diff --git a/apps/docs/src/components/example-block.browser.test.tsx b/apps/docs/src/components/example-block.browser.test.tsx index cc95f1aa..2b71db94 100644 --- a/apps/docs/src/components/example-block.browser.test.tsx +++ b/apps/docs/src/components/example-block.browser.test.tsx @@ -55,6 +55,30 @@ test('resizes a desktop preview from its external grip down to its minimum width expect(document.documentElement.scrollWidth).toBe(document.documentElement.clientWidth); }); +test('keeps the preview resize grip below a sticky page header', async () => { + await page.viewport(1000, 800); + renderPreviewHarness({ withStickyHeader: true }); + + const header = container?.querySelector('header'); + if (!header) throw new Error('expected the sticky page header'); + const separator = page.getByRole('separator', { name: 'Resize harness preview' }).element(); + + separator.scrollIntoView({ block: 'center' }); + const separatorCenterY = + separator.getBoundingClientRect().top + separator.getBoundingClientRect().height / 2; + const headerBefore = header.getBoundingClientRect(); + window.scrollBy(0, separatorCenterY - (headerBefore.top + headerBefore.height / 2)); + + const headerBox = header.getBoundingClientRect(); + const separatorBox = separator.getBoundingClientRect(); + const overlapX = separatorBox.right + 12; + const overlapY = headerBox.top + headerBox.height / 2; + + expect(overlapY).toBeGreaterThanOrEqual(headerBox.top); + expect(overlapY).toBeLessThanOrEqual(headerBox.bottom); + expect(document.elementFromPoint(overlapX, overlapY)).toBe(header); +}); + test('narrowing the preview panel flips a responsive example below its container breakpoint', async () => { await page.viewport(1000, 800); await renderExampleBlock(); @@ -82,19 +106,31 @@ function renderExample(title: string) { // Renders `ExamplePreview` directly with a static child instead of going // through `ExampleBlock`'s lazily-loaded example module, so the resize // mechanics under test do not depend on a Suspense boundary resolving. -function renderPreviewHarness() { +function renderPreviewHarness({ withStickyHeader = false }: { withStickyHeader?: boolean } = {}) { container = document.body.appendChild(document.createElement('div')); container.className = `luke-ui-theme ${tactileThemeClassName}`; // Leaves headroom to the right of the viewport for the grip, which sits // outside the panel's own edge. - container.style.inlineSize = '800px'; + container.style.inlineSize = withStickyHeader ? '100%' : '800px'; root = createRoot(container); act(() => { root?.render( - -
- +
+ {withStickyHeader ? ( +
+ Page header +
+ ) : null} +
+
+ +
+ +
+
+ {withStickyHeader ?
: null} +
, ); }); diff --git a/apps/docs/src/components/example-block.tsx b/apps/docs/src/components/example-block.tsx index ff2ccdbc..d280ab80 100644 --- a/apps/docs/src/components/example-block.tsx +++ b/apps/docs/src/components/example-block.tsx @@ -135,7 +135,8 @@ export function ExamplePreview({ return ( diff --git a/apps/docs/src/components/playground/color-mode-toggle.tsx b/apps/docs/src/components/playground/color-mode-toggle.tsx index 18e0d60d..baf8bcb5 100644 --- a/apps/docs/src/components/playground/color-mode-toggle.tsx +++ b/apps/docs/src/components/playground/color-mode-toggle.tsx @@ -10,12 +10,14 @@ const COLOR_MODES = [ type ColorMode = (typeof COLOR_MODES)[number]['value']; +/** Lets someone choose the light, dark, or system colour mode. */ export function ColorModeToggle() { const { setTheme } = useTheme(); const colorMode = useHydratedColorModeSelection(); return ( = { }; type IconToggleButtonGroupProps = { + appearance?: ToggleButtonAppearance; label: string; onChange: (value: Value) => void; options: ReadonlyArray>; @@ -24,17 +27,23 @@ type TextToggleItem = { value: Value; }; +type ToggleButtonAppearance = Extract; + type TextToggleButtonGroupProps = { + appearance?: ToggleButtonAppearance; label: string; onChange: (value: Value) => void; options: ReadonlyArray>; value: Value; }; -const GROUP_CLASS_NAME = 'flex items-center gap-2 bg-fd-secondary p-0.5'; - -/** A round icon-only pill group, for choices with well-known glyphs such as light/dark/system. */ +/** + * A round icon-only pill group for choices with well-known glyphs such as light, dark, and system. + * It uses the `subtle` button appearance by default. Set `appearance` to `ghost` for controls on a + * shared surface. + */ export function IconToggleButtonGroup({ + appearance = 'subtle', label, onChange, options, @@ -43,7 +52,7 @@ export function IconToggleButtonGroup({ return ( ({ {options.map(({ icon, label: optionLabel, value: optionValue }) => ( ({ } /** - * A round pill group with visible text labels, for choices without an established glyph, such as - * a named theme identity. Matches `IconToggleButtonGroup` in height, radius, and focus treatment. + * A round pill group with visible text labels for choices without an established glyph, such as a + * named theme identity. It uses the `subtle` button appearance by default. Set `appearance` to + * `ghost` for controls on a shared surface. Matches `IconToggleButtonGroup` in height, radius, and + * focus treatment. */ export function TextToggleButtonGroup({ + appearance = 'subtle', label, onChange, options, @@ -78,7 +90,7 @@ export function TextToggleButtonGroup({ return ( ({ > {options.map(({ label: optionLabel, value: optionValue }) => ( ({ ); } +const GROUP_CLASS_NAME = 'flex items-center gap-2'; +const GROUP_WELL_CLASS_NAME = 'bg-fd-secondary p-0.5'; + +function groupClassName(appearance: ToggleButtonAppearance) { + return cx(GROUP_CLASS_NAME, appearance === 'subtle' && GROUP_WELL_CLASS_NAME); +} + +function toggleButtonClassName(appearance: ToggleButtonAppearance) { + return buttonRecipe({ + appearance, + size: 'small', + tone: 'neutral', + }); +} + type RenderToggleButton = ComponentProps['render']; /** diff --git a/apps/docs/src/components/theme-controls.tsx b/apps/docs/src/components/theme-controls.tsx index d68acc9b..a3181b94 100644 --- a/apps/docs/src/components/theme-controls.tsx +++ b/apps/docs/src/components/theme-controls.tsx @@ -68,6 +68,7 @@ export function ThemeControls({ className, ...props }: ComponentProps<'div'>) { return (
* { .luke-ui-theme #nd-notebook-layout article h1:first-of-type { line-height: 36px; } + +/* + * Contain in-flow stacking (example resize grips, code copy controls) so + * descendants cannot paint over the sticky header. Isolation rather than a + * higher header z-index. + */ +.luke-ui-theme #nd-notebook-layout article { + isolation: isolate; +} diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 1af6b74f..fb46a46f 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -388,7 +388,8 @@ the wordmark, the primary destinations, search, and the appearance controls. The `/`, the landing page. Docs opens `/docs/installation`. Components opens `/components`. The destination list and its active-route matching live in `apps/docs/src/lib/site-destinations.ts`, so the nav and the docs layout navigate to the same places. Appearance controls belong to the nav on -every surface, not to the docs sidebar footer. +every surface, not to the docs sidebar footer. They use flush ghost toggles so they sit on the +header's translucent background instead of painting an opaque well. The landing page at `/` renders `SiteNav` with no docs sidebar. It has no active destination. @@ -405,6 +406,10 @@ they never appear twice. It also keeps the bar on one row at exactly `h-14`, whi Surfaces with no sidebar keep the destinations at every width, moving them to a second nav row below `md`. +The notebook article and each example frame use `isolation: isolate` so in-flow stacking, such as +example resize grips, cannot paint over the sticky header. Do not raise the header `z-index` to +compete with page content. + ## Playground The docs site has a live playground at `/playground`: a Monaco editor with TypeScript IntelliSense