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() {
)} - 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 (
-
+
+
+
+
+
);
}