style(scroll-bar): align design (#33751)

This commit is contained in:
yyh 2026-03-19 17:51:55 +08:00 committed by GitHub
parent 7019395a28
commit 8bbaa862f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 239 additions and 17 deletions

View File

@ -8,6 +8,7 @@ import {
ScrollAreaThumb,
ScrollAreaViewport,
} from '../index'
import styles from '../index.module.css'
const renderScrollArea = (options: {
rootClassName?: string
@ -72,20 +73,19 @@ describe('scroll-area wrapper', () => {
const thumb = screen.getByTestId('scroll-area-vertical-thumb')
expect(scrollbar).toHaveAttribute('data-orientation', 'vertical')
expect(scrollbar).toHaveClass(styles.scrollbar)
expect(scrollbar).toHaveClass(
'flex',
'overflow-clip',
'p-1',
'touch-none',
'select-none',
'opacity-0',
'opacity-100',
'transition-opacity',
'motion-reduce:transition-none',
'pointer-events-none',
'data-[hovering]:pointer-events-auto',
'data-[hovering]:opacity-100',
'data-[scrolling]:pointer-events-auto',
'data-[scrolling]:opacity-100',
'hover:pointer-events-auto',
'hover:opacity-100',
'data-[orientation=vertical]:absolute',
'data-[orientation=vertical]:inset-y-0',
'data-[orientation=vertical]:w-3',
@ -97,7 +97,6 @@ describe('scroll-area wrapper', () => {
'rounded-[4px]',
'bg-state-base-handle',
'transition-[background-color]',
'hover:bg-state-base-handle-hover',
'motion-reduce:transition-none',
'data-[orientation=vertical]:w-1',
)
@ -112,20 +111,19 @@ describe('scroll-area wrapper', () => {
const thumb = screen.getByTestId('scroll-area-horizontal-thumb')
expect(scrollbar).toHaveAttribute('data-orientation', 'horizontal')
expect(scrollbar).toHaveClass(styles.scrollbar)
expect(scrollbar).toHaveClass(
'flex',
'overflow-clip',
'p-1',
'touch-none',
'select-none',
'opacity-0',
'opacity-100',
'transition-opacity',
'motion-reduce:transition-none',
'pointer-events-none',
'data-[hovering]:pointer-events-auto',
'data-[hovering]:opacity-100',
'data-[scrolling]:pointer-events-auto',
'data-[scrolling]:opacity-100',
'hover:pointer-events-auto',
'hover:opacity-100',
'data-[orientation=horizontal]:absolute',
'data-[orientation=horizontal]:inset-x-0',
'data-[orientation=horizontal]:h-3',
@ -137,7 +135,6 @@ describe('scroll-area wrapper', () => {
'rounded-[4px]',
'bg-state-base-handle',
'transition-[background-color]',
'hover:bg-state-base-handle-hover',
'motion-reduce:transition-none',
'data-[orientation=horizontal]:h-1',
)

View File

@ -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;
}
}

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { ReactNode } from 'react'
import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
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.',
}))
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 },
@ -255,6 +266,112 @@ const HorizontalRailPane = () => (
</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 = () => (
<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]')}>
@ -561,3 +678,35 @@ export const PrimitiveComposition: Story = {
</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>
),
}

View File

@ -3,6 +3,7 @@
import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import styles from './index.module.css'
export const ScrollArea = 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 const scrollAreaScrollbarClassName = cn(
'flex touch-none select-none opacity-0 transition-opacity motion-reduce:transition-none',
'pointer-events-none data-[hovering]:pointer-events-auto data-[hovering]:opacity-100',
'data-[scrolling]:pointer-events-auto data-[scrolling]:opacity-100',
'hover:pointer-events-auto hover:opacity-100',
styles.scrollbar,
'flex touch-none select-none overflow-clip p-1 opacity-100 transition-opacity motion-reduce:transition-none',
'pointer-events-none data-[hovering]:pointer-events-auto',
'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=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:items-center',
)
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=horizontal]:h-1',
)