diff --git a/apps/docs-tests/docs/sidebar-scroll.spec.ts b/apps/docs-tests/docs/sidebar-scroll.spec.ts new file mode 100644 index 00000000..fa479cc7 --- /dev/null +++ b/apps/docs-tests/docs/sidebar-scroll.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; + +/** + * The docs sidebar is sticky under a header and banner that scroll away, so + * a reader reaches its lower groups by scrolling the window until the nav + * pins. Navigating from a row must not undo that: the group the reader + * clicked in stays on screen, and the new page still opens at its top. + */ + +const sidebar = 'nav[aria-label="Docs"]'; + +// Short enough that the sidebar's last group starts below the fold and the +// nav has to pin before the last row comes into view. +test.use({ viewport: { width: 1280, height: 720 } }); + +test('a sidebar click keeps the clicked group on screen', async ({ page }) => { + await page.goto('/components/aurora'); + await page.waitForLoadState('networkidle'); + + const lastRow = page.locator(`${sidebar} a`).last(); + const target = await lastRow.getAttribute('href'); + + await lastRow.scrollIntoViewIfNeeded(); + await expect(lastRow).toBeInViewport(); + await lastRow.click(); + await expect(page).toHaveURL(target!); + + await expect(lastRow).toHaveAttribute('aria-current', 'page'); + await expect(lastRow).toBeInViewport(); + await expect(page.locator('main h1')).toBeInViewport(); + await expect(page.locator('[data-shader-demo]')).toBeInViewport({ ratio: 0.5 }); +}); + +test('a sidebar click from deep in a page opens the new page at its top', async ({ page }) => { + await page.goto('/components/aurora'); + await page.waitForLoadState('networkidle'); + + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + const firstRow = page.locator(`${sidebar} a`).first(); + const target = await firstRow.getAttribute('href'); + + await expect(firstRow).toBeInViewport(); + await firstRow.click(); + await expect(page).toHaveURL(target!); + + await expect(firstRow).toHaveAttribute('aria-current', 'page'); + await expect(firstRow).toBeInViewport(); + await expect(page.locator('main h1')).toBeInViewport(); + await expect(page.locator('[data-shader-demo]')).toBeInViewport({ ratio: 0.5 }); +}); diff --git a/apps/docs/src/app/globals.css b/apps/docs/src/app/globals.css index 58da76af..e38e3219 100644 --- a/apps/docs/src/app/globals.css +++ b/apps/docs/src/app/globals.css @@ -139,8 +139,15 @@ --code-comment: var(--gray-200); } +/* The window's scroll bar, in the same thin grey the inner scroll areas + draw (components/scroll-area): a gray-600 thumb on a transparent track. + These two properties are the whole styling, since a browser that honors + them ignores the ::-webkit-scrollbar pseudo-elements, and macOS overlay + bars on a trackpad hide themselves regardless. */ html { color-scheme: dark; + scrollbar-width: thin; + scrollbar-color: var(--gray-600) transparent; } html, @@ -231,3 +238,12 @@ a:hover { .search-dialog::backdrop { background: rgba(0, 0, 0, 0.5); } + +/* The search results' scroll viewport (SearchBar wraps the listbox in the + shared ScrollArea). Half the viewport is as tall as the list gets before + it scrolls. Two classes on purpose: the ScrollArea's own viewport rule + sets max-height to inherit, and at equal specificity that module rule + loads later and would win. */ +.search-dialog .search-results-viewport { + max-height: 50vh; +} diff --git a/apps/docs/src/components/SearchBar.tsx b/apps/docs/src/components/SearchBar.tsx index 338ae404..84c772db 100644 --- a/apps/docs/src/components/SearchBar.tsx +++ b/apps/docs/src/components/SearchBar.tsx @@ -3,6 +3,8 @@ import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { ScrollArea } from '@/components/scroll-area/scroll-area'; + interface SearchResult { url: string; title: string; @@ -412,50 +414,53 @@ export function SearchBar() {

)} - + ); diff --git a/apps/docs/src/components/code-block/code-block.module.css b/apps/docs/src/components/code-block/code-block.module.css index aad1fd22..4a1af9dc 100644 --- a/apps/docs/src/components/code-block/code-block.module.css +++ b/apps/docs/src/components/code-block/code-block.module.css @@ -7,10 +7,19 @@ margin: var(--spacing-4) 0; } +/* The viewport does the sideways scrolling and the clipping, so the radius + sits here, where it rounds the clipped corners of a wide block too. */ +.viewport { + border-radius: var(--radius-xl); +} + +/* Wide enough for its longest line and never narrower than the block, so + the theme's background covers the code however far it scrolls. */ .codeBlock pre { + width: max-content; + min-width: 100%; padding: var(--spacing-4); border-radius: var(--radius-xl); font-size: var(--font-size-sm); line-height: var(--leading-5); - overflow-x: auto; } diff --git a/apps/docs/src/components/code-block/code-block.tsx b/apps/docs/src/components/code-block/code-block.tsx index 55b08d48..8864b5d4 100644 --- a/apps/docs/src/components/code-block/code-block.tsx +++ b/apps/docs/src/components/code-block/code-block.tsx @@ -3,8 +3,10 @@ * string into styled HTML at build time (the site is a static export), so no * highlighting JavaScript ships to the client. The colors are `var(--code-*)` * references from lib/code-theme.ts, resolved by the tokens in globals.css. + * A line wider than the block scrolls sideways inside the shared ScrollArea. * Used by the component pages' Usage section and props table. */ +import { ScrollArea } from '@/components/scroll-area/scroll-area'; import { CODE_THEME_NAME } from '@/lib/code-theme'; import { type CodeLang, getHighlighter } from '@/lib/shiki'; @@ -20,11 +22,23 @@ export async function CodeBlock({ source, lang = 'tsx' }: CodeBlockProps) { const html = highlighter.codeToHtml(source, { lang, theme: CODE_THEME_NAME, + // Shiki makes every
 a tab stop so a keyboard can scroll it. The
+    // ScrollArea's viewport already is one whenever the code overflows, so
+    // keeping Shiki's would give each block two.
+    transformers: [
+      {
+        pre(node) {
+          delete node.properties.tabindex;
+        },
+      },
+    ],
   });
 
   return (
     
-
+ +
+
); } diff --git a/apps/docs/src/components/controls/DemoLayout.tsx b/apps/docs/src/components/controls/DemoLayout.tsx index e5593c26..8e689e8a 100644 --- a/apps/docs/src/components/controls/DemoLayout.tsx +++ b/apps/docs/src/components/controls/DemoLayout.tsx @@ -4,17 +4,16 @@ * The two-column frame every component demo page uses: shader on the left in * a column capped at 4xl, controls on the right in a sticky 2xs column that * scrolls on its own once the panel outgrows the shader, and stacks below - * the shader under 1024px. The column is a Base UI Scroll Area, which hides - * the native scrollbar and draws its own thin one that fades in while the - * reader scrolls, and a fade over the column's bottom edge says there are - * more controls below. Both key off state Base UI stamps on the root as - * data attributes, so there is no measuring here. The shader child keeps - * its own [data-shader-demo] wrapper, which is what the Playwright visual - * suite sizes against. + * the shader under 1024px. The column is the site's shared ScrollArea, and a + * fade over the column's bottom edge says there are more controls below, + * keyed off the overflow state Base UI stamps on the scroll area's root, so + * there is no measuring here. The shader child keeps its own + * [data-shader-demo] wrapper, which is what the Playwright visual suite + * sizes against. */ import type { ReactNode } from 'react'; -import { ScrollArea } from '@base-ui/react/scroll-area'; +import { ScrollArea } from '@/components/scroll-area/scroll-area'; import styles from './demo-layout.module.css'; @@ -34,18 +33,14 @@ export function DemoLayout({ controls, children }: { controls: ReactNode; childr
{children}
); diff --git a/apps/docs/src/components/controls/demo-layout.module.css b/apps/docs/src/components/controls/demo-layout.module.css index 8004deb1..291e2766 100644 --- a/apps/docs/src/components/controls/demo-layout.module.css +++ b/apps/docs/src/components/controls/demo-layout.module.css @@ -33,47 +33,22 @@ max-height: min(calc(var(--stage-width) * 2 / 3), calc(100vh - var(--spacing-12))); } -/* The scroll area root: the column's height cap flows down to the viewport - through `inherit`, and the scrollbar and fade position against this box. */ +/* The scroll area root, which the fade positions against. The height cap + and the bar itself come from the shared ScrollArea's own styles; this + class exists so the fade rule below and the mobile override can name it. */ .scroller { - position: relative; max-height: inherit; } -/* The element that scrolls. Base UI hides the native scrollbar here. It - carries the panel's corner radius so the panel's sticky title row, which - sits at this element's top edge once the panel has scrolled, is clipped - to the same rounded corners the panel itself has. */ +/* The element that scrolls. It carries the panel's corner radius so the + panel's sticky title row, which sits at this element's top edge once the + panel has scrolled, is clipped to the same rounded corners the panel + itself has. */ .viewport { - max-height: inherit; - overflow-y: auto; border-radius: var(--radius-xl); overscroll-behavior: contain; } -/* The custom scrollbar: an 8px track inset from the panel's edge and its - rounded corners, shown only while the reader scrolls (Base UI keeps - data-scrolling on for half a second after the last scroll) or while the - pointer is on the bar, so it can be grabbed. Otherwise it fades out and - the controls have the panel's full width. */ -.scrollbar { - z-index: 2; - width: var(--spacing-2); - margin: var(--spacing-1); - opacity: 0; - transition: opacity var(--fade-sm) var(--ease-hover); -} - -.scroller[data-scrolling] .scrollbar, -.scrollbar:hover { - opacity: 1; -} - -.thumb { - background: var(--gray-600); - border-radius: var(--radius-full); -} - /* The mock's "reveal": 64px of the panel's own color fading upward over its bottom edge, telling the reader there are more controls below. Base UI sets data-overflow-y-end on the root while there is something left to diff --git a/apps/docs/src/components/docs-sidebar/docs-sidebar.module.css b/apps/docs/src/components/docs-sidebar/docs-sidebar.module.css index e69bff64..68b7493c 100644 --- a/apps/docs/src/components/docs-sidebar/docs-sidebar.module.css +++ b/apps/docs/src/components/docs-sidebar/docs-sidebar.module.css @@ -4,15 +4,19 @@ with 8px, and rows with 2px. Every header and row shares one 36px box: 12px on the left, 8px elsewhere, 6px corners. */ -/* Sticky so the sidebar stays put while a long page scrolls, and scrolls - itself once the tree outgrows the viewport. The shell's grid gives it the - 2xs column; the padding is the mock's aside inset. */ +/* Sticky so the sidebar stays put while a long page scrolls. The shell's + grid gives it the 2xs column, and the 100vh cap flows into the ScrollArea + inside, which scrolls the tree once it outgrows the viewport. */ .sidebar { position: sticky; top: 0; align-self: start; max-height: 100vh; - overflow-y: auto; +} + +/* The tree itself: tiers stacked with a 16px gap, on the mock's aside + inset. The scroll bar overlays the right-hand inset. */ +.tree { display: flex; flex-direction: column; gap: var(--spacing-4); diff --git a/apps/docs/src/components/docs-sidebar/docs-sidebar.tsx b/apps/docs/src/components/docs-sidebar/docs-sidebar.tsx index 2a8a8b15..383444f7 100644 --- a/apps/docs/src/components/docs-sidebar/docs-sidebar.tsx +++ b/apps/docs/src/components/docs-sidebar/docs-sidebar.tsx @@ -5,23 +5,66 @@ * over rows, with the current page's row highlighted in lime. It renders * whatever tree the docs shell hands it, so on a component page the top * level is the taxonomy tiers and on a guide it is the section's groups. - * A client component only because the active row comes from the pathname. + * A client component because the active row comes from the pathname and + * because a row click has to pin the sidebar before the page changes. */ import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { type MouseEvent, useRef } from 'react'; +import { ScrollArea } from '@/components/scroll-area/scroll-area'; import type { ResolvedNavGroup, ResolvedNavItem } from '@/content/types'; import styles from './docs-sidebar.module.css'; +type RowClickHandler = (event: MouseEvent) => void; + +interface GroupProps { + group: ResolvedNavGroup; + /** Every row's click handler, the sidebar pin. */ + onRowClick: RowClickHandler; + pathname: string; +} + export function DocsSidebar({ tree }: { tree: ResolvedNavGroup[] }) { const pathname = usePathname(); + const navRef = useRef(null); + + // The sidebar is sticky under a header and banner that scroll away, so a + // reader reaches its lower groups by scrolling the window until the nav + // pins at the top of the viewport. Next's default scroll-to-top on + // navigation would then drop the nav back under the banner and push those + // groups below the fold again (SHA-130). So the rows opt out of that + // scroll, and this handler, which every row runs on the old page before + // the router swaps in the new one, brings the window up to the point where + // the nav pins. A reader who was past the banner lands with the sidebar + // exactly where it was and the new page's breadcrumbs at the top; a reader + // who was not sees nothing move. Doing it here rather than after the route + // changes leaves back and forward to the browser's own scroll restoration. + // A modifier-key click opens a new tab and leaves this page alone. Enter + // on a focused row fires a click event too, so a keyboard reader gets the + // same pin. + function pinSidebar(event: MouseEvent) { + const opensHere = + event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; + const shell = navRef.current?.parentElement; + + if (!opensHere || !shell) return; + + const shellTop = shell.getBoundingClientRect().top + window.scrollY; + + if (window.scrollY > shellTop) window.scrollTo({ top: shellTop, behavior: 'instant' }); + } return ( - ); } diff --git a/apps/docs/src/components/scroll-area/scroll-area.module.css b/apps/docs/src/components/scroll-area/scroll-area.module.css new file mode 100644 index 00000000..9d42c29a --- /dev/null +++ b/apps/docs/src/components/scroll-area/scroll-area.module.css @@ -0,0 +1,53 @@ +/* The shared scroll container's look: the root and viewport take whatever + height cap their parent has, and the bar is the control panel's thin one. + Base UI positions the bar absolutely against the root and sizes the thumb + itself, so this file only decides how thick the bar is, where it sits, + and when it shows. */ + +/* The root is what the bar and any overlay position against. max-height + flows down from the parent through inherit rather than being set here, so + a sticky column caps the scroll region without measuring it. */ +.root { + position: relative; + max-height: inherit; +} + +/* The element that scrolls. Base UI sets its overflow and hides the native + scrollbar; the cap comes down from the root. */ +.viewport { + max-height: inherit; +} + +/* An 8px bar inset 4px from the edge, shown only while the reader scrolls + (Base UI keeps data-scrolling on for half a second after the last scroll) + or while the pointer is on the bar, so it can be grabbed. Otherwise it + fades out and the content has the region's full width. */ +.scrollbar { + z-index: 2; + margin: var(--spacing-1); + opacity: 0; + transition: opacity var(--fade-sm) var(--ease-hover); +} + +.vertical { + width: var(--spacing-2); +} + +.horizontal { + height: var(--spacing-2); +} + +.root[data-scrolling] .scrollbar, +.scrollbar:hover { + opacity: 1; +} + +/* Base UI sets the thumb's length inline, its height on a vertical bar and + its width on a horizontal one. The other dimension fills the bar, so the + thumb is as thick as the bar whichever way it runs. */ +.thumb { + width: 100%; + height: 100%; + background: var(--gray-600); + border-radius: var(--radius-full); +} diff --git a/apps/docs/src/components/scroll-area/scroll-area.tsx b/apps/docs/src/components/scroll-area/scroll-area.tsx new file mode 100644 index 00000000..6112913b --- /dev/null +++ b/apps/docs/src/components/scroll-area/scroll-area.tsx @@ -0,0 +1,68 @@ +'use client'; + +/** + * The site's scroll container: a Base UI Scroll Area that hides the native + * scrollbar and draws the thin one the control panel introduced, an 8px bar + * that fades in while the reader scrolls or hovers it and is gone the rest + * of the time. Every inner scroll region on the site uses it (the docs + * sidebar, the control panel, code blocks, search results, the guide + * table of contents), so they all scroll the same way. Base UI stamps + * scroll state on the root as data attributes, which is what the fade and + * any overlay a consumer adds key off. + */ +import type { ReactNode } from 'react'; + +import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'; + +import styles from './scroll-area.module.css'; + +interface ScrollAreaProps { + /** Which way the content scrolls, and so which bar is drawn. Defaults to vertical. */ + orientation?: 'vertical' | 'horizontal'; + /** Class for the root, the box the bar and any overlay position against. */ + className?: string; + /** Class for the viewport, the element that actually scrolls. */ + viewportClassName?: string; + /** + * Passed through to Base UI: how far from an edge, in px, still counts + * as reaching it before the root's data-overflow-* attributes clear. + */ + overflowEdgeThreshold?: number | Partial>; + /** + * Drawn inside the root but outside the viewport, so it sits over the + * content without scrolling with it. The control panel's bottom fade. + */ + overlay?: ReactNode; + children: ReactNode; +} + +export function ScrollArea({ + orientation = 'vertical', + className, + viewportClassName, + overflowEdgeThreshold, + overlay, + children, +}: ScrollAreaProps) { + return ( + + + {children} + + + + + {overlay} + + ); +} + +function join(...classes: Array) { + return classes.filter(Boolean).join(' '); +} diff --git a/apps/docs/src/content/mdx.tsx b/apps/docs/src/content/mdx.tsx index 9590170e..e8f8d5fa 100644 --- a/apps/docs/src/content/mdx.tsx +++ b/apps/docs/src/content/mdx.tsx @@ -1,5 +1,7 @@ import type { HTMLAttributes, ReactNode } from 'react'; +import { ScrollArea } from '@/components/scroll-area/scroll-area'; + function Callout({ children }: { children: ReactNode }) { return (
is as wide as its longest line and +// never narrower than the block, so its background covers a line that +// scrolls sideways. function Pre(props: HTMLAttributes) { return ( -
+    
+ +
+      
+    
); }