From 70a68f0a86e5e5ed32db0bb33c28cb34c25de4dc Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Thu, 19 Mar 2026 19:54:16 +0800
Subject: [PATCH] refactor: simplify the scroll area API for sidebar layouts
(#33761)
---
.../ui/scroll-area/__tests__/index.spec.tsx | 41 ++++++++++++++--
.../base/ui/scroll-area/index.stories.tsx | 46 +++++++++---------
.../components/base/ui/scroll-area/index.tsx | 44 ++++++++++++++++-
.../explore/sidebar/__tests__/index.spec.tsx | 16 +++++++
web/app/components/explore/sidebar/index.tsx | 47 ++++++++-----------
5 files changed, 138 insertions(+), 56 deletions(-)
diff --git a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx
index e506fe59d0..b4524a971e 100644
--- a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx
+++ b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx
@@ -4,6 +4,7 @@ import {
ScrollArea,
ScrollAreaContent,
ScrollAreaCorner,
+ ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
@@ -19,7 +20,7 @@ const renderScrollArea = (options: {
horizontalThumbClassName?: string
} = {}) => {
return render(
-
+
Scrollable content
@@ -43,7 +44,7 @@ const renderScrollArea = (options: {
className={options.horizontalThumbClassName}
/>
- ,
+ ,
)
}
@@ -62,6 +63,38 @@ describe('scroll-area wrapper', () => {
expect(screen.getByTestId('scroll-area-horizontal-thumb')).toBeInTheDocument()
})
})
+
+ it('should render the convenience wrapper and apply slot props', async () => {
+ render(
+ <>
+
Installed apps
+
+ Scrollable content
+
+ >,
+ )
+
+ await waitFor(() => {
+ const root = screen.getByTestId('scroll-area-wrapper-root')
+ const viewport = screen.getByRole('region', { name: 'Installed apps' })
+ const content = screen.getByText('Scrollable content').parentElement
+
+ expect(root).toBeInTheDocument()
+ expect(viewport).toHaveClass('custom-viewport-class')
+ expect(viewport).toHaveAccessibleName('Installed apps')
+ expect(content).toHaveClass('custom-content-class')
+ expect(screen.getByText('Scrollable content')).toBeInTheDocument()
+ })
+ })
})
describe('Scrollbar', () => {
@@ -219,7 +252,7 @@ describe('scroll-area wrapper', () => {
try {
render(
-
+
Scrollable content
@@ -236,7 +269,7 @@ describe('scroll-area wrapper', () => {
- ,
+ ,
)
await waitFor(() => {
diff --git a/web/app/components/base/ui/scroll-area/index.stories.tsx b/web/app/components/base/ui/scroll-area/index.stories.tsx
index 465e534921..4a97610c19 100644
--- a/web/app/components/base/ui/scroll-area/index.stories.tsx
+++ b/web/app/components/base/ui/scroll-area/index.stories.tsx
@@ -4,9 +4,9 @@ import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
import {
- ScrollArea,
ScrollAreaContent,
ScrollAreaCorner,
+ ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
@@ -14,7 +14,7 @@ import {
const meta = {
title: 'Base/Layout/ScrollArea',
- component: ScrollArea,
+ component: ScrollAreaRoot,
parameters: {
layout: 'padded',
docs: {
@@ -24,7 +24,7 @@ const meta = {
},
},
tags: ['autodocs'],
-} satisfies Meta
+} satisfies Meta
export default meta
type Story = StoryObj
@@ -135,7 +135,7 @@ const StoryCard = ({
const VerticalPanelPane = () => (
-
+
@@ -161,13 +161,13 @@ const VerticalPanelPane = () => (
-
+
)
const StickyListPane = () => (
-
+
@@ -200,7 +200,7 @@ const StickyListPane = () => (
-
+
)
@@ -216,7 +216,7 @@ const WorkbenchPane = ({
className?: string
}) => (
-
+
@@ -229,13 +229,13 @@ const WorkbenchPane = ({
-
+
)
const HorizontalRailPane = () => (
-
+
@@ -262,7 +262,7 @@ const HorizontalRailPane = () => (
-
+
)
@@ -319,7 +319,7 @@ const ScrollbarStatePane = ({
{description}
-
+
{scrollbarShowcaseRows.map(item => (
@@ -333,7 +333,7 @@ const ScrollbarStatePane = ({
-
+
)
@@ -347,7 +347,7 @@ const HorizontalScrollbarShowcasePane = () => (
Current design delivery defines the horizontal scrollbar body, but not a horizontal edge hint.
-
+
@@ -367,7 +367,7 @@ const HorizontalScrollbarShowcasePane = () => (
-
+
)
@@ -375,7 +375,7 @@ const HorizontalScrollbarShowcasePane = () => (
const OverlayPane = () => (
-
+
@@ -400,14 +400,14 @@ const OverlayPane = () => (
-
+
)
const CornerPane = () => (
-
+
@@ -443,7 +443,7 @@ const CornerPane = () => (
-
+
)
@@ -475,7 +475,7 @@ const ExploreSidebarWebAppsPane = () => {
-
+
{webAppsRows.map((item, index) => (
@@ -519,7 +519,7 @@ const ExploreSidebarWebAppsPane = () => {
-
+
@@ -654,7 +654,7 @@ export const PrimitiveComposition: Story = {
description="A stripped-down example for teams that want to start from the base API and add their own shell classes around it. The outer shell adds inset padding so the tracks sit inside the rounded surface instead of colliding with the panel corners."
>
-
+
{Array.from({ length: 8 }, (_, index) => (
@@ -673,7 +673,7 @@ export const PrimitiveComposition: Story = {
-
+
),
diff --git a/web/app/components/base/ui/scroll-area/index.tsx b/web/app/components/base/ui/scroll-area/index.tsx
index 840cb86021..b0f85f78d4 100644
--- a/web/app/components/base/ui/scroll-area/index.tsx
+++ b/web/app/components/base/ui/scroll-area/index.tsx
@@ -5,12 +5,26 @@ import * as React from 'react'
import { cn } from '@/utils/classnames'
import styles from './index.module.css'
-export const ScrollArea = BaseScrollArea.Root
+export const ScrollAreaRoot = BaseScrollArea.Root
export type ScrollAreaRootProps = React.ComponentPropsWithRef
export const ScrollAreaContent = BaseScrollArea.Content
export type ScrollAreaContentProps = React.ComponentPropsWithRef
+export type ScrollAreaSlotClassNames = {
+ viewport?: string
+ content?: string
+ scrollbar?: string
+}
+
+export type ScrollAreaProps = Omit & {
+ children: React.ReactNode
+ orientation?: 'vertical' | 'horizontal'
+ slotClassNames?: ScrollAreaSlotClassNames
+ label?: string
+ labelledBy?: string
+}
+
export const scrollAreaScrollbarClassName = cn(
styles.scrollbar,
'flex touch-none select-none overflow-clip p-1 opacity-100 transition-opacity motion-reduce:transition-none',
@@ -88,3 +102,31 @@ export function ScrollAreaCorner({
/>
)
}
+
+export function ScrollArea({
+ children,
+ className,
+ orientation = 'vertical',
+ slotClassNames,
+ label,
+ labelledBy,
+ ...props
+}: ScrollAreaProps) {
+ return (
+
+
+
+ {children}
+
+
+
+
+
+
+ )
+}
diff --git a/web/app/components/explore/sidebar/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx
index e29a12a17f..bf5486fdb7 100644
--- a/web/app/components/explore/sidebar/__tests__/index.spec.tsx
+++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx
@@ -93,6 +93,13 @@ describe('SideBar', () => {
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
})
+ it('should expose an accessible name for the discovery link when the text is hidden', () => {
+ mockMediaType = MediaType.mobile
+ renderSideBar()
+
+ expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
+ })
+
it('should render workspace items when installed apps exist', () => {
mockInstalledApps = [createInstalledApp()]
renderSideBar()
@@ -136,6 +143,15 @@ describe('SideBar', () => {
const dividers = container.querySelectorAll('[class*="divider"], hr')
expect(dividers.length).toBeGreaterThan(0)
})
+
+ it('should render a button for toggling the sidebar and update its accessible name', () => {
+ renderSideBar()
+
+ const toggleButton = screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' })
+ fireEvent.click(toggleButton)
+
+ expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
+ })
})
describe('User Interactions', () => {
diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx
index 032430909d..38dfa956a1 100644
--- a/web/app/components/explore/sidebar/index.tsx
+++ b/web/app/components/explore/sidebar/index.tsx
@@ -13,13 +13,7 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
-import {
- ScrollArea,
- ScrollAreaContent,
- ScrollAreaScrollbar,
- ScrollAreaThumb,
- ScrollAreaViewport,
-} from '@/app/components/base/ui/scroll-area'
+import { ScrollArea } from '@/app/components/base/ui/scroll-area'
import { toast } from '@/app/components/base/ui/toast'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
@@ -30,11 +24,9 @@ import Item from './app-nav-item'
import NoApps from './no-apps'
const expandedSidebarScrollAreaClassNames = {
- root: 'h-full',
- viewport: 'overscroll-contain',
content: 'space-y-0.5',
scrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:[margin-inline-end:-0.75rem]',
- thumb: 'rounded-full',
+ viewport: 'overscroll-contain',
} as const
const SideBar = () => {
@@ -104,10 +96,11 @@ const SideBar = () => {
-
+
{!isMobile && !isFold &&
{t('sidebar.title', { ns: 'explore' })}
}
@@ -126,19 +119,12 @@ const SideBar = () => {
{shouldUseExpandedScrollArea
? (
-
-
-
- {installedAppItems}
-
-
-
-
-
+
+ {installedAppItems}
)
@@ -154,13 +140,18 @@ const SideBar = () => {
{!isMobile && (
-
+
{isFold
- ?
+ ?
: (
-
+
)}
-
+
)}