refactor: simplify the scroll area API for sidebar layouts (#33761)

This commit is contained in:
yyh
2026-03-19 19:54:16 +08:00
committed by GitHub
parent bb1a6f8a57
commit 70a68f0a86
5 changed files with 138 additions and 56 deletions

View File

@ -4,6 +4,7 @@ import {
ScrollArea,
ScrollAreaContent,
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
@ -19,7 +20,7 @@ const renderScrollArea = (options: {
horizontalThumbClassName?: string
} = {}) => {
return render(
<ScrollArea className={options.rootClassName ?? 'h-40 w-40'} data-testid="scroll-area-root">
<ScrollAreaRoot className={options.rootClassName ?? 'h-40 w-40'} data-testid="scroll-area-root">
<ScrollAreaViewport data-testid="scroll-area-viewport" className={options.viewportClassName}>
<ScrollAreaContent data-testid="scroll-area-content">
<div className="h-48 w-48">Scrollable content</div>
@ -43,7 +44,7 @@ const renderScrollArea = (options: {
className={options.horizontalThumbClassName}
/>
</ScrollAreaScrollbar>
</ScrollArea>,
</ScrollAreaRoot>,
)
}
@ -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(
<>
<p id="installed-apps-label">Installed apps</p>
<ScrollArea
className="h-40 w-40"
slotClassNames={{
content: 'custom-content-class',
scrollbar: 'custom-scrollbar-class',
viewport: 'custom-viewport-class',
}}
labelledBy="installed-apps-label"
data-testid="scroll-area-wrapper-root"
>
<div className="h-48 w-20">Scrollable content</div>
</ScrollArea>
</>,
)
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(
<ScrollArea className="h-40 w-40" data-testid="scroll-area-root">
<ScrollAreaRoot className="h-40 w-40" data-testid="scroll-area-root">
<ScrollAreaViewport data-testid="scroll-area-viewport">
<ScrollAreaContent data-testid="scroll-area-content">
<div className="h-48 w-48">Scrollable content</div>
@ -236,7 +269,7 @@ describe('scroll-area wrapper', () => {
<ScrollAreaThumb data-testid="scroll-area-horizontal-thumb" />
</ScrollAreaScrollbar>
<ScrollAreaCorner data-testid="scroll-area-corner" />
</ScrollArea>,
</ScrollAreaRoot>,
)
await waitFor(() => {

View File

@ -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<typeof ScrollArea>
} satisfies Meta<typeof ScrollAreaRoot>
export default meta
type Story = StoryObj<typeof meta>
@ -135,7 +135,7 @@ const StoryCard = ({
const VerticalPanelPane = () => (
<div className={cn(panelClassName, 'h-[360px]')}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollAreaViewport className={insetViewportClassName}>
<ScrollAreaContent className="space-y-3 p-4 pr-6">
<div className="space-y-1">
@ -161,13 +161,13 @@ const VerticalPanelPane = () => (
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</ScrollAreaRoot>
</div>
)
const StickyListPane = () => (
<div className={cn(panelClassName, 'h-[360px]')}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollAreaViewport className={cn(insetViewportClassName, '[mask-image:linear-gradient(to_bottom,transparent_0px,black_10px,black_calc(100%-14px),transparent_100%)]')}>
<ScrollAreaContent className="min-h-full">
<div className="sticky top-0 z-10 border-b border-divider-subtle bg-components-panel-bg px-4 pb-3 pt-4">
@ -200,7 +200,7 @@ const StickyListPane = () => (
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
</ScrollArea>
</ScrollAreaRoot>
</div>
)
@ -216,7 +216,7 @@ const WorkbenchPane = ({
className?: string
}) => (
<div className={cn(panelClassName, 'min-h-0', className)}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollAreaViewport className={insetViewportClassName}>
<ScrollAreaContent className="space-y-3 p-4 pr-6">
<div className="space-y-1">
@ -229,13 +229,13 @@ const WorkbenchPane = ({
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</ScrollAreaRoot>
</div>
)
const HorizontalRailPane = () => (
<div className={cn(panelClassName, 'h-[272px] min-w-0 max-w-full')}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollAreaViewport className={insetViewportClassName}>
<ScrollAreaContent className="min-h-full min-w-max space-y-4 p-4 pb-6">
<div className="space-y-1">
@ -262,7 +262,7 @@ const HorizontalRailPane = () => (
<ScrollAreaScrollbar orientation="horizontal" className={insetScrollbarClassName}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
</ScrollArea>
</ScrollAreaRoot>
</div>
)
@ -319,7 +319,7 @@ const ScrollbarStatePane = ({
<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">
<ScrollAreaRoot 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 => (
@ -333,7 +333,7 @@ const ScrollbarStatePane = ({
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</ScrollAreaRoot>
</div>
</div>
)
@ -347,7 +347,7 @@ const HorizontalScrollbarShowcasePane = () => (
<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">
<ScrollAreaRoot 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">
@ -367,7 +367,7 @@ const HorizontalScrollbarShowcasePane = () => (
<ScrollAreaScrollbar orientation="horizontal" className={insetScrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</ScrollAreaRoot>
</div>
</div>
)
@ -375,7 +375,7 @@ const HorizontalScrollbarShowcasePane = () => (
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={cn(blurPanelClassName, 'w-full max-w-[360px]')}>
<ScrollArea className="h-[320px] p-1">
<ScrollAreaRoot className="h-[320px] p-1">
<ScrollAreaViewport className="overscroll-contain rounded-[20px] bg-components-panel-bg-blur">
<ScrollAreaContent className="space-y-2 p-3 pr-6">
<div className="sticky top-0 z-10 rounded-xl border border-divider-subtle bg-components-panel-bg-blur px-3 py-3 backdrop-blur-[6px]">
@ -400,14 +400,14 @@ const OverlayPane = () => (
<ScrollAreaScrollbar className={insetScrollbarClassName}>
<ScrollAreaThumb className="rounded-full bg-state-base-handle hover:bg-state-base-handle-hover" />
</ScrollAreaScrollbar>
</ScrollArea>
</ScrollAreaRoot>
</div>
</div>
)
const CornerPane = () => (
<div className={cn(panelClassName, 'h-[320px] w-full max-w-[440px]')}>
<ScrollArea className={cn(insetScrollAreaClassName, 'overflow-hidden')}>
<ScrollAreaRoot className={cn(insetScrollAreaClassName, 'overflow-hidden')}>
<ScrollAreaViewport className={cn(insetViewportClassName, 'bg-[linear-gradient(180deg,var(--color-components-panel-bg),var(--color-components-panel-bg-alt))]')}>
<ScrollAreaContent className="min-h-[420px] min-w-[620px] space-y-4 p-4">
<div className="flex items-start justify-between gap-6">
@ -443,7 +443,7 @@ const CornerPane = () => (
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
<ScrollAreaCorner className="bg-[linear-gradient(180deg,var(--color-components-panel-bg),var(--color-components-panel-bg-alt))]" />
</ScrollArea>
</ScrollAreaRoot>
</div>
)
@ -475,7 +475,7 @@ const ExploreSidebarWebAppsPane = () => {
</div>
<div className="h-[304px]">
<ScrollArea className={sidebarScrollAreaClassName}>
<ScrollAreaRoot className={sidebarScrollAreaClassName}>
<ScrollAreaViewport className={sidebarViewportClassName}>
<ScrollAreaContent className={sidebarContentClassName}>
{webAppsRows.map((item, index) => (
@ -519,7 +519,7 @@ const ExploreSidebarWebAppsPane = () => {
<ScrollAreaScrollbar className={sidebarScrollbarClassName}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
</ScrollArea>
</ScrollAreaRoot>
</div>
</div>
</div>
@ -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."
>
<div className={cn(panelClassName, 'h-[260px] max-w-[420px]')}>
<ScrollArea className={insetScrollAreaClassName}>
<ScrollAreaRoot className={insetScrollAreaClassName}>
<ScrollAreaViewport className={insetViewportClassName}>
<ScrollAreaContent className="min-w-[560px] space-y-3 p-4 pr-6">
{Array.from({ length: 8 }, (_, index) => (
@ -673,7 +673,7 @@ export const PrimitiveComposition: Story = {
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollArea>
</ScrollAreaRoot>
</div>
</StoryCard>
),

View File

@ -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<typeof BaseScrollArea.Root>
export const ScrollAreaContent = BaseScrollArea.Content
export type ScrollAreaContentProps = React.ComponentPropsWithRef<typeof BaseScrollArea.Content>
export type ScrollAreaSlotClassNames = {
viewport?: string
content?: string
scrollbar?: string
}
export type ScrollAreaProps = Omit<ScrollAreaRootProps, 'children'> & {
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 (
<ScrollAreaRoot className={className} {...props}>
<ScrollAreaViewport
aria-label={label}
aria-labelledby={labelledBy}
className={slotClassNames?.viewport}
role={label || labelledBy ? 'region' : undefined}
>
<ScrollAreaContent className={slotClassNames?.content}>
{children}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation={orientation} className={slotClassNames?.scrollbar}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)
}

View File

@ -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', () => {

View File

@ -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 = () => {
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
<Link
href="/explore/apps"
aria-label={isMobile || isFold ? t('sidebar.title', { ns: 'explore' }) : undefined}
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 items-center gap-2 rounded-lg px-1 mobile:w-fit mobile:justify-center pc:w-full pc:justify-start')}
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
<span className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
<span aria-hidden="true" className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
</div>
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-regular')}>{t('sidebar.title', { ns: 'explore' })}</div>}
</Link>
@ -126,19 +119,12 @@ const SideBar = () => {
{shouldUseExpandedScrollArea
? (
<div className="min-h-0 flex-1">
<ScrollArea className={expandedSidebarScrollAreaClassNames.root}>
<ScrollAreaViewport
aria-labelledby={webAppsLabelId}
className={expandedSidebarScrollAreaClassNames.viewport}
role="region"
>
<ScrollAreaContent className={expandedSidebarScrollAreaClassNames.content}>
{installedAppItems}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={expandedSidebarScrollAreaClassNames.scrollbar}>
<ScrollAreaThumb className={expandedSidebarScrollAreaClassNames.thumb} />
</ScrollAreaScrollbar>
<ScrollArea
className="h-full"
slotClassNames={expandedSidebarScrollAreaClassNames}
labelledBy={webAppsLabelId}
>
{installedAppItems}
</ScrollArea>
</div>
)
@ -154,13 +140,18 @@ const SideBar = () => {
{!isMobile && (
<div className="mt-auto flex pb-3 pt-3">
<div className="flex size-8 cursor-pointer items-center justify-center text-text-tertiary" onClick={toggleIsFold}>
<button
type="button"
aria-label={isFold ? t('sidebar.expandSidebar', { ns: 'layout' }) : t('sidebar.collapseSidebar', { ns: 'layout' })}
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover"
onClick={toggleIsFold}
>
{isFold
? <span className="i-ri-expand-right-line" />
? <span aria-hidden="true" className="i-ri-expand-right-line" />
: (
<span className="i-ri-layout-left-2-line" />
<span aria-hidden="true" className="i-ri-layout-left-2-line" />
)}
</div>
</button>
</div>
)}