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 2781a5844f..e506fe59d0 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 @@ -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', ) diff --git a/web/app/components/base/ui/scroll-area/index.module.css b/web/app/components/base/ui/scroll-area/index.module.css new file mode 100644 index 0000000000..a81fd3d3c2 --- /dev/null +++ b/web/app/components/base/ui/scroll-area/index.module.css @@ -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; + } +} 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 8eb655a151..465e534921 100644 --- a/web/app/components/base/ui/scroll-area/index.stories.tsx +++ b/web/app/components/base/ui/scroll-area/index.stories.tsx @@ -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 = () => ( ) +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 ( +
{description}
+Current design delivery defines the horizontal scrollbar body, but not a horizontal edge hint.
+