mirror of https://github.com/langgenius/dify.git
style(scroll-bar): align design (#33751)
This commit is contained in:
parent
7019395a28
commit
8bbaa862f2
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ScrollAreaThumb,
|
ScrollAreaThumb,
|
||||||
ScrollAreaViewport,
|
ScrollAreaViewport,
|
||||||
} from '../index'
|
} from '../index'
|
||||||
|
import styles from '../index.module.css'
|
||||||
|
|
||||||
const renderScrollArea = (options: {
|
const renderScrollArea = (options: {
|
||||||
rootClassName?: string
|
rootClassName?: string
|
||||||
|
|
@ -72,20 +73,19 @@ describe('scroll-area wrapper', () => {
|
||||||
const thumb = screen.getByTestId('scroll-area-vertical-thumb')
|
const thumb = screen.getByTestId('scroll-area-vertical-thumb')
|
||||||
|
|
||||||
expect(scrollbar).toHaveAttribute('data-orientation', 'vertical')
|
expect(scrollbar).toHaveAttribute('data-orientation', 'vertical')
|
||||||
|
expect(scrollbar).toHaveClass(styles.scrollbar)
|
||||||
expect(scrollbar).toHaveClass(
|
expect(scrollbar).toHaveClass(
|
||||||
'flex',
|
'flex',
|
||||||
|
'overflow-clip',
|
||||||
|
'p-1',
|
||||||
'touch-none',
|
'touch-none',
|
||||||
'select-none',
|
'select-none',
|
||||||
'opacity-0',
|
'opacity-100',
|
||||||
'transition-opacity',
|
'transition-opacity',
|
||||||
'motion-reduce:transition-none',
|
'motion-reduce:transition-none',
|
||||||
'pointer-events-none',
|
'pointer-events-none',
|
||||||
'data-[hovering]:pointer-events-auto',
|
'data-[hovering]:pointer-events-auto',
|
||||||
'data-[hovering]:opacity-100',
|
|
||||||
'data-[scrolling]:pointer-events-auto',
|
'data-[scrolling]:pointer-events-auto',
|
||||||
'data-[scrolling]:opacity-100',
|
|
||||||
'hover:pointer-events-auto',
|
|
||||||
'hover:opacity-100',
|
|
||||||
'data-[orientation=vertical]:absolute',
|
'data-[orientation=vertical]:absolute',
|
||||||
'data-[orientation=vertical]:inset-y-0',
|
'data-[orientation=vertical]:inset-y-0',
|
||||||
'data-[orientation=vertical]:w-3',
|
'data-[orientation=vertical]:w-3',
|
||||||
|
|
@ -97,7 +97,6 @@ describe('scroll-area wrapper', () => {
|
||||||
'rounded-[4px]',
|
'rounded-[4px]',
|
||||||
'bg-state-base-handle',
|
'bg-state-base-handle',
|
||||||
'transition-[background-color]',
|
'transition-[background-color]',
|
||||||
'hover:bg-state-base-handle-hover',
|
|
||||||
'motion-reduce:transition-none',
|
'motion-reduce:transition-none',
|
||||||
'data-[orientation=vertical]:w-1',
|
'data-[orientation=vertical]:w-1',
|
||||||
)
|
)
|
||||||
|
|
@ -112,20 +111,19 @@ describe('scroll-area wrapper', () => {
|
||||||
const thumb = screen.getByTestId('scroll-area-horizontal-thumb')
|
const thumb = screen.getByTestId('scroll-area-horizontal-thumb')
|
||||||
|
|
||||||
expect(scrollbar).toHaveAttribute('data-orientation', 'horizontal')
|
expect(scrollbar).toHaveAttribute('data-orientation', 'horizontal')
|
||||||
|
expect(scrollbar).toHaveClass(styles.scrollbar)
|
||||||
expect(scrollbar).toHaveClass(
|
expect(scrollbar).toHaveClass(
|
||||||
'flex',
|
'flex',
|
||||||
|
'overflow-clip',
|
||||||
|
'p-1',
|
||||||
'touch-none',
|
'touch-none',
|
||||||
'select-none',
|
'select-none',
|
||||||
'opacity-0',
|
'opacity-100',
|
||||||
'transition-opacity',
|
'transition-opacity',
|
||||||
'motion-reduce:transition-none',
|
'motion-reduce:transition-none',
|
||||||
'pointer-events-none',
|
'pointer-events-none',
|
||||||
'data-[hovering]:pointer-events-auto',
|
'data-[hovering]:pointer-events-auto',
|
||||||
'data-[hovering]:opacity-100',
|
|
||||||
'data-[scrolling]:pointer-events-auto',
|
'data-[scrolling]:pointer-events-auto',
|
||||||
'data-[scrolling]:opacity-100',
|
|
||||||
'hover:pointer-events-auto',
|
|
||||||
'hover:opacity-100',
|
|
||||||
'data-[orientation=horizontal]:absolute',
|
'data-[orientation=horizontal]:absolute',
|
||||||
'data-[orientation=horizontal]:inset-x-0',
|
'data-[orientation=horizontal]:inset-x-0',
|
||||||
'data-[orientation=horizontal]:h-3',
|
'data-[orientation=horizontal]:h-3',
|
||||||
|
|
@ -137,7 +135,6 @@ describe('scroll-area wrapper', () => {
|
||||||
'rounded-[4px]',
|
'rounded-[4px]',
|
||||||
'bg-state-base-handle',
|
'bg-state-base-handle',
|
||||||
'transition-[background-color]',
|
'transition-[background-color]',
|
||||||
'hover:bg-state-base-handle-hover',
|
|
||||||
'motion-reduce:transition-none',
|
'motion-reduce:transition-none',
|
||||||
'data-[orientation=horizontal]:h-1',
|
'data-[orientation=horizontal]:h-1',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
.scrollbar::before,
|
||||||
|
.scrollbar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 9999px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-orientation='vertical']::before {
|
||||||
|
left: 50%;
|
||||||
|
top: 4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 12px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(to bottom, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-orientation='vertical']::after {
|
||||||
|
left: 50%;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 12px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(to top, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-orientation='horizontal']::before {
|
||||||
|
top: 50%;
|
||||||
|
left: 4px;
|
||||||
|
width: 12px;
|
||||||
|
height: 4px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: linear-gradient(to right, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-orientation='horizontal']::after {
|
||||||
|
top: 50%;
|
||||||
|
right: 4px;
|
||||||
|
width: 12px;
|
||||||
|
height: 4px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: linear-gradient(to left, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-orientation='vertical']:not([data-overflow-y-start])::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-orientation='vertical']:not([data-overflow-y-end])::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-orientation='horizontal']:not([data-overflow-x-start])::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-orientation='horizontal']:not([data-overflow-x-end])::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar[data-hovering] > [data-orientation],
|
||||||
|
.scrollbar[data-scrolling] > [data-orientation],
|
||||||
|
.scrollbar > [data-orientation]:active {
|
||||||
|
background-color: var(--scroll-area-thumb-bg-active, var(--color-state-base-handle-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.scrollbar::before,
|
||||||
|
.scrollbar::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import * as React from 'react'
|
||||||
import AppIcon from '@/app/components/base/app-icon'
|
import AppIcon from '@/app/components/base/app-icon'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
import {
|
import {
|
||||||
|
|
@ -78,6 +79,16 @@ const activityRows = Array.from({ length: 14 }, (_, index) => ({
|
||||||
body: 'A short line of copy to mimic dense operational feeds in settings and debug panels.',
|
body: 'A short line of copy to mimic dense operational feeds in settings and debug panels.',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const scrollbarShowcaseRows = Array.from({ length: 18 }, (_, index) => ({
|
||||||
|
title: `Scroll checkpoint ${index + 1}`,
|
||||||
|
body: 'Dedicated story content so the scrollbar can be inspected without sticky headers, masks, or clipped shells.',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const horizontalShowcaseCards = Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
title: `Lane ${index + 1}`,
|
||||||
|
body: 'Horizontal scrollbar reference without edge hints.',
|
||||||
|
}))
|
||||||
|
|
||||||
const webAppsRows = [
|
const webAppsRows = [
|
||||||
{ id: 'invoice-copilot', name: 'Invoice Copilot', meta: 'Pinned', icon: '🧾', iconBackground: '#FFEAD5', selected: true, pinned: true },
|
{ id: 'invoice-copilot', name: 'Invoice Copilot', meta: 'Pinned', icon: '🧾', iconBackground: '#FFEAD5', selected: true, pinned: true },
|
||||||
{ id: 'rag-ops', name: 'RAG Ops Console', meta: 'Ops', icon: '🛰️', iconBackground: '#E0F2FE', selected: false, pinned: true },
|
{ id: 'rag-ops', name: 'RAG Ops Console', meta: 'Ops', icon: '🛰️', iconBackground: '#E0F2FE', selected: false, pinned: true },
|
||||||
|
|
@ -255,6 +266,112 @@ const HorizontalRailPane = () => (
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ScrollbarStatePane = ({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
initialPosition,
|
||||||
|
}: {
|
||||||
|
eyebrow: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
initialPosition: 'top' | 'middle' | 'bottom'
|
||||||
|
}) => {
|
||||||
|
const viewportId = React.useId()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let frameA = 0
|
||||||
|
let frameB = 0
|
||||||
|
|
||||||
|
const syncScrollPosition = () => {
|
||||||
|
const viewport = document.getElementById(viewportId)
|
||||||
|
|
||||||
|
if (!(viewport instanceof HTMLDivElement))
|
||||||
|
return
|
||||||
|
|
||||||
|
const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||||
|
|
||||||
|
if (initialPosition === 'top')
|
||||||
|
viewport.scrollTop = 0
|
||||||
|
|
||||||
|
if (initialPosition === 'middle')
|
||||||
|
viewport.scrollTop = maxScrollTop / 2
|
||||||
|
|
||||||
|
if (initialPosition === 'bottom')
|
||||||
|
viewport.scrollTop = maxScrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
frameA = requestAnimationFrame(() => {
|
||||||
|
frameB = requestAnimationFrame(syncScrollPosition)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frameA)
|
||||||
|
cancelAnimationFrame(frameB)
|
||||||
|
}
|
||||||
|
}, [initialPosition, viewportId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className={labelClassName}>{eyebrow}</div>
|
||||||
|
<div className="text-text-primary system-md-semibold">{title}</div>
|
||||||
|
<p className="text-text-secondary system-sm-regular">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 min-w-0 rounded-[24px] border border-divider-subtle bg-components-panel-bg p-3">
|
||||||
|
<ScrollArea className="h-[320px] p-1">
|
||||||
|
<ScrollAreaViewport id={viewportId} className="rounded-[20px] bg-components-panel-bg">
|
||||||
|
<ScrollAreaContent className="min-w-0 space-y-2 p-4 pr-6">
|
||||||
|
{scrollbarShowcaseRows.map(item => (
|
||||||
|
<article key={item.title} className="min-w-0 rounded-xl border border-divider-subtle bg-components-panel-bg-alt p-3">
|
||||||
|
<div className="truncate text-text-primary system-sm-semibold">{item.title}</div>
|
||||||
|
<div className="mt-1 break-words text-text-secondary system-sm-regular">{item.body}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</ScrollAreaContent>
|
||||||
|
</ScrollAreaViewport>
|
||||||
|
<ScrollAreaScrollbar className={insetScrollbarClassName}>
|
||||||
|
<ScrollAreaThumb />
|
||||||
|
</ScrollAreaScrollbar>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const HorizontalScrollbarShowcasePane = () => (
|
||||||
|
<div className="min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className={labelClassName}>Horizontal</div>
|
||||||
|
<div className="text-text-primary system-md-semibold">Horizontal track reference</div>
|
||||||
|
<p className="text-text-secondary system-sm-regular">Current design delivery defines the horizontal scrollbar body, but not a horizontal edge hint.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 min-w-0 rounded-[24px] border border-divider-subtle bg-components-panel-bg p-3">
|
||||||
|
<ScrollArea className="h-[240px] p-1">
|
||||||
|
<ScrollAreaViewport className="rounded-[20px] bg-components-panel-bg">
|
||||||
|
<ScrollAreaContent className="min-h-full min-w-max space-y-4 p-4 pb-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-text-primary system-sm-semibold">Horizontal scrollbar</div>
|
||||||
|
<div className="text-text-secondary system-sm-regular">A clean horizontal pane to inspect thickness, padding, and thumb behavior without extra masks.</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{horizontalShowcaseCards.map(card => (
|
||||||
|
<article key={card.title} className="flex h-[120px] w-[220px] shrink-0 flex-col justify-between rounded-2xl border border-divider-subtle bg-components-panel-bg-alt p-4">
|
||||||
|
<div className="text-text-primary system-sm-semibold">{card.title}</div>
|
||||||
|
<div className="text-text-secondary system-sm-regular">{card.body}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollAreaContent>
|
||||||
|
</ScrollAreaViewport>
|
||||||
|
<ScrollAreaScrollbar orientation="horizontal" className={insetScrollbarClassName}>
|
||||||
|
<ScrollAreaThumb />
|
||||||
|
</ScrollAreaScrollbar>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const OverlayPane = () => (
|
const OverlayPane = () => (
|
||||||
<div className="flex h-[420px] min-w-0 items-center justify-center rounded-[28px] bg-[radial-gradient(circle_at_top,_rgba(21,90,239,0.12),_transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.03),transparent)] p-6">
|
<div className="flex h-[420px] min-w-0 items-center justify-center rounded-[28px] bg-[radial-gradient(circle_at_top,_rgba(21,90,239,0.12),_transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.03),transparent)] p-6">
|
||||||
<div className={cn(blurPanelClassName, 'w-full max-w-[360px]')}>
|
<div className={cn(blurPanelClassName, 'w-full max-w-[360px]')}>
|
||||||
|
|
@ -561,3 +678,35 @@ export const PrimitiveComposition: Story = {
|
||||||
</StoryCard>
|
</StoryCard>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ScrollbarDelivery: Story = {
|
||||||
|
render: () => (
|
||||||
|
<StoryCard
|
||||||
|
eyebrow="Scrollbar"
|
||||||
|
title="Dedicated scrollbar delivery review"
|
||||||
|
description="Three vertical panes pin the viewport to top, middle, and bottom so the edge hint can be inspected without sticky headers, viewport masks, or clipped shells. A separate horizontal pane shows the current non-edge-hint track."
|
||||||
|
>
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<ScrollbarStatePane
|
||||||
|
eyebrow="Top"
|
||||||
|
title="At top edge"
|
||||||
|
description="Top edge hint should sit exactly on the handle area edge."
|
||||||
|
initialPosition="top"
|
||||||
|
/>
|
||||||
|
<ScrollbarStatePane
|
||||||
|
eyebrow="Middle"
|
||||||
|
title="Away from edges"
|
||||||
|
description="No edge hint should be visible when the viewport is not pinned to either end."
|
||||||
|
initialPosition="middle"
|
||||||
|
/>
|
||||||
|
<ScrollbarStatePane
|
||||||
|
eyebrow="Bottom"
|
||||||
|
title="At bottom edge"
|
||||||
|
description="Bottom edge hint should sit exactly on the handle area edge."
|
||||||
|
initialPosition="bottom"
|
||||||
|
/>
|
||||||
|
<HorizontalScrollbarShowcasePane />
|
||||||
|
</div>
|
||||||
|
</StoryCard>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'
|
import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { cn } from '@/utils/classnames'
|
import { cn } from '@/utils/classnames'
|
||||||
|
import styles from './index.module.css'
|
||||||
|
|
||||||
export const ScrollArea = BaseScrollArea.Root
|
export const ScrollArea = BaseScrollArea.Root
|
||||||
export type ScrollAreaRootProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Root>
|
export type ScrollAreaRootProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Root>
|
||||||
|
|
@ -11,16 +12,16 @@ export const ScrollAreaContent = BaseScrollArea.Content
|
||||||
export type ScrollAreaContentProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Content>
|
export type ScrollAreaContentProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Content>
|
||||||
|
|
||||||
export const scrollAreaScrollbarClassName = cn(
|
export const scrollAreaScrollbarClassName = cn(
|
||||||
'flex touch-none select-none opacity-0 transition-opacity motion-reduce:transition-none',
|
styles.scrollbar,
|
||||||
'pointer-events-none data-[hovering]:pointer-events-auto data-[hovering]:opacity-100',
|
'flex touch-none select-none overflow-clip p-1 opacity-100 transition-opacity motion-reduce:transition-none',
|
||||||
'data-[scrolling]:pointer-events-auto data-[scrolling]:opacity-100',
|
'pointer-events-none data-[hovering]:pointer-events-auto',
|
||||||
'hover:pointer-events-auto hover:opacity-100',
|
'data-[scrolling]:pointer-events-auto',
|
||||||
'data-[orientation=vertical]:absolute data-[orientation=vertical]:inset-y-0 data-[orientation=vertical]:w-3 data-[orientation=vertical]:justify-center',
|
'data-[orientation=vertical]:absolute data-[orientation=vertical]:inset-y-0 data-[orientation=vertical]:w-3 data-[orientation=vertical]:justify-center',
|
||||||
'data-[orientation=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:items-center',
|
'data-[orientation=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:items-center',
|
||||||
)
|
)
|
||||||
|
|
||||||
export const scrollAreaThumbClassName = cn(
|
export const scrollAreaThumbClassName = cn(
|
||||||
'shrink-0 rounded-[4px] bg-state-base-handle transition-[background-color] hover:bg-state-base-handle-hover motion-reduce:transition-none',
|
'shrink-0 rounded-[4px] bg-state-base-handle transition-[background-color] motion-reduce:transition-none',
|
||||||
'data-[orientation=vertical]:w-1',
|
'data-[orientation=vertical]:w-1',
|
||||||
'data-[orientation=horizontal]:h-1',
|
'data-[orientation=horizontal]:h-1',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue