From 47b58a34efac176fef247a45eba5e5cd0aac4d91 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:49:10 +0800 Subject: [PATCH] fix(ui): align scroll area focus styles (#37204) --- packages/dify-ui/package.json | 2 +- .../src/scroll-area/__tests__/index.spec.tsx | 8 +- .../dify-ui/src/scroll-area/index.stories.tsx | 909 +++++------------- packages/dify-ui/src/scroll-area/index.tsx | 4 +- 4 files changed, 272 insertions(+), 651 deletions(-) diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index a18ad04355..3a9995f867 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -147,7 +147,7 @@ } }, "scripts": { - "storybook": "storybook dev", + "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", "test": "vp test", "test:watch": "vp test --watch", diff --git a/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx b/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx index 8e23ecebfb..ee0f391138 100644 --- a/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/scroll-area/__tests__/index.spec.tsx @@ -190,10 +190,10 @@ describe('scroll-area wrapper', () => { 'size-full', 'min-h-0', 'min-w-0', - 'outline-hidden', - 'focus-visible:ring-2', - 'focus-visible:ring-inset', - 'focus-visible:ring-state-accent-solid', + 'focus-visible:outline-2', + 'focus-visible:-outline-offset-1', + 'focus-visible:outline-solid', + 'focus-visible:outline-state-accent-solid', 'custom-viewport-class', ) }) diff --git a/packages/dify-ui/src/scroll-area/index.stories.tsx b/packages/dify-ui/src/scroll-area/index.stories.tsx index 433817948f..bd3a1cef16 100644 --- a/packages/dify-ui/src/scroll-area/index.stories.tsx +++ b/packages/dify-ui/src/scroll-area/index.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ReactNode } from 'react' import * as React from 'react' import { ScrollAreaContent, @@ -18,7 +17,7 @@ const meta = { layout: 'padded', docs: { description: { - component: 'Compound scroll container built on Base UI ScrollArea. These stories focus on panel-style compositions that already exist throughout Dify: dense sidebars, sticky list headers, multi-pane workbenches, horizontal rails, and overlay surfaces. Scrollbar placement should be adjusted by consumer spacing classes such as margin-based overrides instead of right/bottom positioning utilities.', + component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens, panel surfaces, and scrollbar spacing.', }, }, }, @@ -28,685 +27,307 @@ const meta = { export default meta type Story = StoryObj -const panelClassName = 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5' -const blurPanelClassName = 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl shadow-shadow-shadow-7 backdrop-blur-[6px]' -const labelClassName = 'text-text-tertiary system-xs-medium-uppercase tracking-[0.14em]' -const titleClassName = 'system-sm-semibold text-text-primary' -const bodyClassName = 'system-sm-regular text-text-secondary' -const insetScrollAreaClassName = 'h-full p-1' -const insetViewportClassName = 'rounded-[20px] bg-components-panel-bg' -const insetScrollbarClassName = 'data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1 data-[orientation=horizontal]:mx-1 data-[orientation=horizontal]:mb-1' -const storyButtonClassName = 'flex w-full items-center justify-between gap-3 rounded-xl border border-divider-subtle bg-components-panel-bg-alt px-3 py-2.5 text-left text-text-secondary transition-colors hover:bg-state-base-hover focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none' -const sidebarScrollAreaClassName = 'h-full' -const sidebarViewportClassName = 'overscroll-contain' -const sidebarContentClassName = 'space-y-0.5' -const sidebarScrollbarClassName = 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:-me-3' -const appNavButtonClassName = 'group flex h-8 w-full items-center justify-between gap-3 rounded-lg px-2 text-left transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none' -const appNavMetaClassName = 'shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 text-text-quaternary system-2xs-medium-uppercase tracking-[0.08em]' +const scrollFadeRootClassName = cn( + 'has-[>_:first-child:focus-visible]:outline-2', + 'has-[>_:first-child:focus-visible]:outline-offset-0', + 'has-[>_:first-child:focus-visible]:outline-state-accent-solid', +) +const rootClassName = 'relative min-h-0 min-w-0' +const viewportClassName = 'h-full max-h-full max-w-full rounded-xl border border-divider-subtle bg-components-panel-bg' +const fadeViewportClassName = cn( + 'h-full max-h-full max-w-full rounded-xl bg-components-panel-bg outline-none focus-visible:outline-none', + 'mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat', +) +const scrollbarClassName = cn( + 'data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1', + 'data-[orientation=horizontal]:mx-1 data-[orientation=horizontal]:mb-1', +) +const verticalContentClassName = 'w-full max-w-full min-w-0' +const verticalContentStyle = { minWidth: 0 } satisfies React.CSSProperties +const panelClassName = 'min-w-0 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5' +const pageClassName = 'min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5' +const labelClassName = 'system-xs-medium-uppercase text-text-tertiary' +const headingClassName = 'system-md-semibold text-text-primary' -const releaseRows = [ - { title: 'Agent refactor', meta: 'Updated 2 hours ago', status: 'Ready' }, - { title: 'Retriever tuning', meta: 'Updated yesterday', status: 'Review' }, - { title: 'Workflow replay', meta: 'Updated 3 days ago', status: 'Draft' }, - { title: 'Sandbox policy', meta: 'Updated this week', status: 'Ready' }, - { title: 'SSE diagnostics', meta: 'Updated last week', status: 'Blocked' }, - { title: 'Model routing', meta: 'Updated 9 days ago', status: 'Review' }, - { title: 'Chunk overlap', meta: 'Updated 11 days ago', status: 'Draft' }, - { title: 'Vector warmup', meta: 'Updated 2 weeks ago', status: 'Ready' }, +const appRows = [ + { name: 'Invoice Copilot', meta: 'Pinned', icon: 'i-ri-file-list-3-line', selected: true, pinned: true }, + { name: 'RAG Ops Console', meta: 'Ops', icon: 'i-ri-database-2-line', selected: false, pinned: true }, + { name: 'Knowledge Studio', meta: 'Docs', icon: 'i-ri-book-open-line', selected: false, pinned: true }, + { name: 'Workflow Studio', meta: 'Build', icon: 'i-ri-flow-chart', selected: false, pinned: true }, + { name: 'Agent Playground', meta: 'Lab', icon: 'i-ri-robot-2-line', selected: false, pinned: false }, + { name: 'Sales Briefing', meta: 'Team', icon: 'i-ri-presentation-line', selected: false, pinned: false }, + { name: 'Support Triage', meta: 'Queue', icon: 'i-ri-customer-service-2-line', selected: false, pinned: false }, + { name: 'Legal Review', meta: 'Beta', icon: 'i-ri-scales-3-line', selected: false, pinned: false }, + { name: 'Release Watcher', meta: 'Feed', icon: 'i-ri-rocket-line', selected: false, pinned: false }, + { name: 'Security Radar', meta: 'Risk', icon: 'i-ri-shield-check-line', selected: false, pinned: false }, + { name: 'Partner Portal', meta: 'Ext', icon: 'i-ri-handshake-line', selected: false, pinned: false }, + { name: 'QA Replays', meta: 'Debug', icon: 'i-ri-replay-line', selected: false, pinned: false }, ] as const -const queueRows = [ - { id: 'PLG-142', title: 'Plugin catalog sync', note: 'Waiting for moderation result' }, - { id: 'OPS-088', title: 'Billing alert fallback', note: 'Last retry finished 12 minutes ago' }, - { id: 'RAG-511', title: 'Embedding migration', note: '16 datasets still pending' }, - { id: 'AGT-204', title: 'Multi-agent tracing', note: 'QA is verifying edge cases' }, - { id: 'UI-390', title: 'Prompt editor polish', note: 'Needs token density pass' }, - { id: 'WEB-072', title: 'Marketplace empty state', note: 'Waiting for design review' }, +const articleParagraphs = [ + 'Vernacular architecture is building done outside any academic tradition, and without professional guidance. It is not a particular architectural movement or style, but rather a broad category, encompassing a wide range and variety of building types, with differing methods of construction, from around the world, both historical and extant and classical and modern.', + 'This type of architecture usually serves immediate, local needs, is constrained by the materials available in its particular region, and reflects local traditions and cultural practices. The study of vernacular architecture does not examine formally schooled architects, but instead the design skills and tradition of local builders.', + 'A scroll area follows the same principle in an interface. The viewport owns scrolling, the content stays inside its measured width, and the scrollbar remains a visual affordance rather than a second layout system.', ] as const -const horizontalCards = [ - { title: 'Claude Opus', detail: 'Reasoning-heavy preset' }, - { title: 'GPT-5.4', detail: 'Balanced orchestration lane' }, - { title: 'Gemini 2.5', detail: 'Multimodal fallback' }, - { title: 'Qwen Max', detail: 'Regional deployment' }, - { title: 'DeepSeek R1', detail: 'High-throughput analysis' }, - { title: 'Llama 4', detail: 'Cost-sensitive routing' }, -] as const +const gridCells = Array.from({ length: 100 }, (_, index) => index + 1) -const activityRows = Array.from({ length: 14 }, (_, index) => ({ - title: `Workspace activity ${index + 1}`, - 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 = [ - { 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: 'knowledge-studio', name: 'Knowledge Studio', meta: 'Docs', icon: 'πŸ“š', iconBackground: '#FEF3C7', selected: false, pinned: true }, - { id: 'workflow-studio', name: 'Workflow Studio', meta: 'Build', icon: '🧩', iconBackground: '#E0E7FF', selected: false, pinned: true }, - { id: 'growth-briefs', name: 'Growth Briefs', meta: 'Brief', icon: 'πŸ“£', iconBackground: '#FCE7F3', selected: false, pinned: true }, - { id: 'agent-playground', name: 'Agent Playground', meta: 'Lab', icon: 'πŸ§ͺ', iconBackground: '#DCFCE7', selected: false, pinned: false }, - { id: 'sales-briefing', name: 'Sales Briefing', meta: 'Team', icon: 'πŸ“ˆ', iconBackground: '#FCE7F3', selected: false, pinned: false }, - { id: 'support-triage', name: 'Support Triage', meta: 'Queue', icon: '🎧', iconBackground: '#EDE9FE', selected: false, pinned: false }, - { id: 'legal-review', name: 'Legal Review', meta: 'Beta', icon: 'βš–οΈ', iconBackground: '#FDE68A', selected: false, pinned: false }, - { id: 'release-watcher', name: 'Release Watcher', meta: 'Feed', icon: 'πŸš€', iconBackground: '#DBEAFE', selected: false, pinned: false }, - { id: 'research-hub', name: 'Research Hub', meta: 'Notes', icon: 'πŸ”Ž', iconBackground: '#E0F2FE', selected: false, pinned: false }, - { id: 'field-enablement', name: 'Field Enablement', meta: 'Team', icon: '🧭', iconBackground: '#DCFCE7', selected: false, pinned: false }, - { id: 'brand-monitor', name: 'Brand Monitor', meta: 'Watch', icon: 'πŸͺ„', iconBackground: '#F3E8FF', selected: false, pinned: false }, - { id: 'finance-ops', name: 'Finance Ops Desk', meta: 'Ops', icon: 'πŸ’³', iconBackground: '#FEF3C7', selected: false, pinned: false }, - { id: 'security-radar', name: 'Security Radar', meta: 'Risk', icon: 'πŸ›‘οΈ', iconBackground: '#FEE2E2', selected: false, pinned: false }, - { id: 'partner-portal', name: 'Partner Portal', meta: 'Ext', icon: '🀝', iconBackground: '#DBEAFE', selected: false, pinned: false }, - { id: 'qa-replays', name: 'QA Replays', meta: 'Debug', icon: '🎞️', iconBackground: '#EDE9FE', selected: false, pinned: false }, - { id: 'roadmap-notes', name: 'Roadmap Notes', meta: 'Plan', icon: 'πŸ—ΊοΈ', iconBackground: '#FFEAD5', selected: false, pinned: false }, -] as const - -const StoryCard = ({ +function StorySection({ eyebrow, title, description, - className, children, + className, }: { eyebrow: string title: string description: string + children: React.ReactNode className?: string - children: ReactNode -}) => ( -
-
-
{eyebrow}
-

{title}

-

{description}

-
- {children} -
-) - -const VerticalPanelPane = () => ( -
- - - -
-
Release board
-
Weekly checkpoints
-

A simple vertical panel with the default scrollbar skin and no business-specific overrides.

-
- {releaseRows.map(item => ( -
-
-
-

{item.title}

-

{item.meta}

-
- - {item.status} - -
-
- ))} -
-
- - - -
-
-) - -const StickyListPane = () => ( -
- - - -
-
Sticky header
-
-
-
Operational queue
-

The scrollbar is still the shared dify-ui primitive, while the pane adds sticky structure and a viewport mask.

-
- - 24 items - -
-
-
- {queueRows.map(item => ( -
-
-
-
{item.title}
-
{item.note}
-
- {item.id} -
-
- ))} -
-
-
- - - -
-
-) - -const WorkbenchPane = ({ - title, - eyebrow, - children, - className, -}: { - title: string - eyebrow: string - children: ReactNode - className?: string -}) => ( -
- - - -
-
{eyebrow}
-
{title}
-
- {children} -
-
- - - -
-
-) - -const HorizontalRailPane = () => ( -
- - - -
-
Horizontal rail
-
Model lanes
-

This pane keeps the default track behavior and only changes the surface layout around it.

-
-
- {horizontalCards.map(card => ( -
-
- - - -
{card.title}
-
{card.detail}
-
-
Drag cards into orchestration groups.
-
- ))} -
-
-
- - - -
-
-) - -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 ( -
+
{eyebrow}
-
{title}
-

{description}

+

{title}

+

{description}

-
- - - - {scrollbarShowcaseRows.map(item => ( -
-
{item.title}
-
{item.body}
-
+
+ {children} +
+
+ ) +} + +function VerticalContent({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return ( + + {children} + + ) +} + +export const Anatomy: Story = { + render: () => ( + +
+ + + + {articleParagraphs.map(paragraph => ( +

+ {paragraph} +

))} - +
- +
-
- ) + + ), } -const HorizontalScrollbarShowcasePane = () => ( -
-
-
Horizontal
-
Horizontal track reference
-

Current design delivery defines the horizontal scrollbar body, but not a horizontal edge hint.

-
-
- - - -
-
Horizontal scrollbar
-
A clean horizontal pane to inspect thickness, padding, and thumb behavior without extra masks.
-
-
- {horizontalShowcaseCards.map(card => ( -
-
{card.title}
-
{card.body}
-
+export const Vertical: Story = { + render: () => ( + +
+ + + +
+
Article
+
Scrollable text region
+
+ {Array.from({ length: 4 }, (_, groupIndex) => ( + articleParagraphs.map(paragraph => ( +

+ {paragraph} +

+ )) ))} -
- - - - - - -
-
-) - -const OverlayPane = () => ( -
-
- - - -
-
Overlay palette
-
Quick actions
-
- {activityRows.map(item => ( -
-
- - - -
-
{item.title}
-
{item.body}
-
-
-
- ))} -
-
- - - -
-
-
-) - -const CornerPane = () => ( -
- - - -
-
-
Corner surface
-
Bi-directional inspector canvas
-

Both axes overflow here so the corner becomes visible as a deliberate seam between the two tracks.

-
- - Always visible - -
-
- {Array.from({ length: 12 }, (_, index) => ( -
-
- Cell - {' '} - {index + 1} -
-

- Wide-and-tall content to force both scrollbars and show the corner treatment clearly. -

-
- ))} -
-
-
- - - - - - - -
-
-) - -const ExploreSidebarWebAppsPane = () => { - const pinnedAppsCount = webAppsRows.filter(item => item.pinned).length - - return ( -
-
-
-
-
- -
-
- Explore -
-
-
- -
-
-

- Web Apps -

- - {webAppsRows.length} - -
- -
- - - - {webAppsRows.map((item, index) => ( -
- - {index === pinnedAppsCount - 1 && index !== webAppsRows.length - 1 && ( -
- )} -
- ))} - - - - - - -
-
-
-
- ) -} - -export const VerticalPanels: Story = { - render: () => ( - -
- - -
-
- ), -} - -export const ThreePaneWorkbench: Story = { - render: () => ( - -
- -
- {releaseRows.map(item => ( - - ))} -
-
- -
- {Array.from({ length: 7 }, (_, index) => ( -
-
-
- Section - {' '} - {index + 1} -
- - Active - -
-

- This pane is intentionally long so the default vertical scrollbar sits over a larger editorial surface. -

-
- ))} -
-
- -
- {queueRows.map(item => ( -
-
{item.id}
-
{item.title}
-
{item.note}
-
- ))} -
-
-
-
- ), -} - -export const HorizontalAndOverlay: Story = { - render: () => ( -
- - - - - - -
- ), -} - -export const CornerSurface: Story = { - render: () => ( - -
- -
-
- ), -} - -export const ExploreSidebarWebApps: Story = { - render: () => ( - -
- -
-
- ), -} - -export const PrimitiveComposition: Story = { - render: () => ( - -
- - - - {Array.from({ length: 8 }, (_, index) => ( -
- Primitive row - {' '} - {index + 1} -
- ))} -
+
- + - +
+
+ + ), +} + +export const ScrollFade: Story = { + render: () => ( + +
+ + + + {Array.from({ length: 5 }, (_, groupIndex) => ( + articleParagraphs.map(paragraph => ( +

+ {paragraph} +

+ )) + ))} +
+
+ + + +
+
+
+ ), +} + +export const Horizontal: Story = { + render: () => ( + +
+ + + +
+ {gridCells.slice(0, 18).map(cell => ( +
+ {cell} +
+ ))} +
+
+
+ + + +
+
+
+ ), +} + +export const BothAxes: Story = { + render: () => ( + +
+ + + +
+ {gridCells.map(cell => ( +
+ {cell} +
+ ))} +
+
+
+ + + +
-
+ ), } -export const ScrollbarDelivery: Story = { - render: () => ( - -
- - - - -
-
- ), +export const AppSidebar: Story = { + render: () => { + const pinnedCount = appRows.filter(row => row.pinned).length + + return ( + +
+
+
+ + Explore +
+
+ Web apps + {appRows.length} +
+
+ + + + {appRows.map((row, index) => ( +
+ + {index === pinnedCount - 1 && index !== appRows.length - 1 && ( +
+ )} +
+ ))} + + + + + + +
+
+
+ + ) + }, } diff --git a/packages/dify-ui/src/scroll-area/index.tsx b/packages/dify-ui/src/scroll-area/index.tsx index 5519123db1..4e9f9ff01b 100644 --- a/packages/dify-ui/src/scroll-area/index.tsx +++ b/packages/dify-ui/src/scroll-area/index.tsx @@ -41,8 +41,8 @@ const scrollAreaThumbClassName = cn( ) const scrollAreaViewportClassName = cn( - 'size-full min-h-0 min-w-0 outline-hidden', - 'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset', + 'size-full min-h-0 min-w-0', + 'focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-solid focus-visible:outline-state-accent-solid', ) const scrollAreaCornerClassName = 'bg-transparent'