Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/docs-tests/docs/sidebar-scroll.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
16 changes: 16 additions & 0 deletions apps/docs/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
89 changes: 47 additions & 42 deletions apps/docs/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -412,50 +414,53 @@ export function SearchBar() {
</p>
)}
</div>
<ul
id="search-results"
ref={listRef}
role="listbox"
style={{
listStyle: 'none',
margin: 0,
padding: 0,
maxHeight: '50vh',
overflowY: 'auto',
}}
>
{results.map((result, resultIndex) => (
<li
aria-selected={resultIndex === selectedIndex}
id={`search-result-${resultIndex}`}
key={result.url}
onClick={() => navigate(result.url)}
onMouseEnter={() => setSelectedIndex(resultIndex)}
role="option"
style={{
padding: '0.625rem 1rem',
cursor: 'pointer',
background: resultIndex === selectedIndex ? 'var(--bg-muted)' : 'transparent',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ fontWeight: 500, color: 'var(--fg)' }}>{result.title}</div>
<div
// Pagefind escapes indexed text and adds <mark>; fallback
// excerpts are this repo's own frontmatter descriptions.
// First-party static content — accepted, not re-sanitized.
// react-doctor-disable-next-line react-doctor/dangerous-html-sink
dangerouslySetInnerHTML={{ __html: result.excerpt }}
{/* The results scroll inside the shared ScrollArea; the list keeps
the listbox role so it still contains only options. Its height
cap is the .search-results-viewport rule in globals.css. */}
<ScrollArea viewportClassName="search-results-viewport">
<ul
id="search-results"
ref={listRef}
role="listbox"
style={{
listStyle: 'none',
margin: 0,
padding: 0,
}}
>
{results.map((result, resultIndex) => (
<li
aria-selected={resultIndex === selectedIndex}
id={`search-result-${resultIndex}`}
key={result.url}
onClick={() => navigate(result.url)}
onMouseEnter={() => setSelectedIndex(resultIndex)}
role="option"
style={{
fontSize: '0.8125rem',
color: 'var(--fg-muted)',
marginTop: '0.25rem',
lineHeight: 1.4,
padding: '0.625rem 1rem',
cursor: 'pointer',
background: resultIndex === selectedIndex ? 'var(--bg-muted)' : 'transparent',
borderBottom: '1px solid var(--border)',
}}
/>
</li>
))}
</ul>
>
<div style={{ fontWeight: 500, color: 'var(--fg)' }}>{result.title}</div>
<div
// Pagefind escapes indexed text and adds <mark>; fallback
// excerpts are this repo's own frontmatter descriptions.
// First-party static content — accepted, not re-sanitized.
// react-doctor-disable-next-line react-doctor/dangerous-html-sink
dangerouslySetInnerHTML={{ __html: result.excerpt }}
style={{
fontSize: '0.8125rem',
color: 'var(--fg-muted)',
marginTop: '0.25rem',
lineHeight: 1.4,
}}
/>
</li>
))}
</ul>
</ScrollArea>
</dialog>
</>
);
Expand Down
11 changes: 10 additions & 1 deletion apps/docs/src/components/code-block/code-block.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 15 additions & 1 deletion apps/docs/src/components/code-block/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 <pre> 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 (
<div className={styles.codeBlock}>
<div dangerouslySetInnerHTML={{ __html: html }} />
<ScrollArea orientation="horizontal" viewportClassName={styles.viewport}>
<div dangerouslySetInnerHTML={{ __html: html }} />
</ScrollArea>
</div>
);
}
29 changes: 12 additions & 17 deletions apps/docs/src/components/controls/DemoLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -34,18 +33,14 @@ export function DemoLayout({ controls, children }: { controls: ReactNode; childr
<div className={styles.layout}>
<div>{children}</div>
<aside className={styles.controls}>
<ScrollArea.Root
<ScrollArea
className={styles.scroller}
overflowEdgeThreshold={{ yEnd: FADE_THRESHOLD_PX }}
overlay={<div aria-hidden="true" className={styles.fade} />}
viewportClassName={styles.viewport}
>
<ScrollArea.Viewport className={styles.viewport}>
<ScrollArea.Content>{controls}</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar className={styles.scrollbar}>
<ScrollArea.Thumb className={styles.thumb} />
</ScrollArea.Scrollbar>
<div aria-hidden="true" className={styles.fade} />
</ScrollArea.Root>
{controls}
</ScrollArea>
</aside>
</div>
);
Expand Down
39 changes: 7 additions & 32 deletions apps/docs/src/components/controls/demo-layout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions apps/docs/src/components/docs-sidebar/docs-sidebar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading