{
/** Shiki `…` markup. Docs CodeBlock owns the outer ``. */
html?: string;
ref?: Ref;
+ /** Ref for the source `` element. */
+ sourceRef?: Ref;
/** Optional caption shown above the code. */
title?: string;
+ /** Class for the scroll region when its parent owns source clipping. */
+ viewportClassName?: string;
+ /** Accessible name for the scroll region. Defaults to the title or "Code". */
+ viewportLabel?: string;
}
/**
@@ -44,41 +70,60 @@ export function CodeBlock({
copyText,
flush = false,
html,
+ sourceRef,
title,
+ viewportClassName,
+ viewportLabel = title ?? 'Code',
...figureProps
}: CodeBlockProps) {
const allowCopy = allowCopyProp !== false && allowCopyProp !== 'false';
- const viewportRef = useRef(null);
- const resizeObserverRef = useRef(null);
- const copyTimeoutRef = useRef(null);
- const [copyStatus, setCopyStatus] = useState('idle');
+ const sourceElementRef = useObjectRef(sourceRef);
+ const [{ copyAttempt, copyStatus, isScrollable }, dispatch] = useReducer(
+ codeBlockReducer,
+ initialState,
+ );
+ const viewportRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ if (!node) return;
+ const update = () => {
+ dispatch({
+ type: 'overflowChanged',
+ isScrollable: isOverflowing(node),
+ });
+ };
+
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(node);
+ if (sourceElementRef.current) observer.observe(sourceElementRef.current);
+ return () => observer.disconnect();
+ },
+ [sourceElementRef],
+ );
useEffect(() => {
- return () => {
- if (copyTimeoutRef.current != null) window.clearTimeout(copyTimeoutRef.current);
- };
- }, []);
+ if (copyStatus === 'idle') return;
+ const timeout = window.setTimeout(
+ () => dispatch({ type: 'copyFeedbackExpired' }),
+ COPY_FEEDBACK_MS,
+ );
+ return () => window.clearTimeout(timeout);
+ // `copyAttempt` restarts the timer when someone copies again during feedback.
+ }, [copyStatus, copyAttempt]);
async function handleCopy() {
const text = resolveCopyText({
code,
copyText,
- viewport: viewportRef.current,
+ sourceElement: sourceElementRef.current,
});
- if (copyTimeoutRef.current != null) window.clearTimeout(copyTimeoutRef.current);
-
try {
await navigator.clipboard.writeText(text);
- setCopyStatus('copied');
+ dispatch({ type: 'copySucceeded' });
} catch {
- setCopyStatus('error');
+ dispatch({ type: 'copyFailed' });
}
-
- copyTimeoutRef.current = window.setTimeout(() => {
- setCopyStatus('idle');
- copyTimeoutRef.current = null;
- }, COPY_FEEDBACK_MS);
}
const showOverlayCopy = allowCopy && title == null;
@@ -114,38 +159,27 @@ export function CodeBlock({
{copyControl}
) : null}
{
- resizeObserverRef.current?.disconnect();
- resizeObserverRef.current = null;
- viewportRef.current = node;
- if (node == null) return;
-
- const updateTabIndex = () => {
- const scrollable =
- node.scrollWidth > node.clientWidth + 1 || node.scrollHeight > node.clientHeight + 1;
- if (scrollable) {
- node.tabIndex = 0;
- node.setAttribute('role', 'region');
- node.setAttribute('aria-label', title ?? 'Code');
- } else {
- node.removeAttribute('tabindex');
- node.removeAttribute('role');
- node.removeAttribute('aria-label');
- }
- };
-
- updateTabIndex();
- const observer = new ResizeObserver(updateTabIndex);
- observer.observe(node);
- resizeObserverRef.current = observer;
- }}
+ aria-label={isScrollable ? viewportLabel : undefined}
+ className={cx(
+ styles.viewport,
+ showOverlayCopy && styles.viewportWithOverlayCopy,
+ viewportClassName,
+ )}
+ ref={viewportRef}
+ role={isScrollable ? 'region' : undefined}
+ tabIndex={isScrollable ? 0 : undefined}
>
{html != null ? (
// Shiki escapes source before the highlight plugin emits this markup.
-
+
) : (
-
{code != null ? {code} : children}
+
+ {code != null ? {code} : children}
+
)}
{allowCopy ? (
@@ -157,22 +191,39 @@ export function CodeBlock({
);
}
+function codeBlockReducer(state: CodeBlockState, event: CodeBlockEvent): CodeBlockState {
+ switch (event.type) {
+ case 'copySucceeded':
+ return { ...state, copyStatus: 'copied', copyAttempt: state.copyAttempt + 1 };
+ case 'copyFailed':
+ return { ...state, copyStatus: 'error', copyAttempt: state.copyAttempt + 1 };
+ case 'copyFeedbackExpired':
+ return { ...state, copyStatus: 'idle' };
+ case 'overflowChanged':
+ if (state.isScrollable === event.isScrollable) return state;
+ return { ...state, isScrollable: event.isScrollable };
+ }
+}
+
+function isOverflowing(node: HTMLElement) {
+ return node.scrollWidth > node.clientWidth + 1 || node.scrollHeight > node.clientHeight + 1;
+}
+
function resolveCopyText({
code,
copyText,
- viewport,
+ sourceElement,
}: {
code: string | undefined;
copyText: string | undefined;
- viewport: HTMLDivElement | null;
+ sourceElement: HTMLPreElement | null;
}): string {
if (copyText != null) return copyText;
if (code != null) return code;
- const pre = viewport?.querySelector('pre');
- if (pre == null) return '';
+ if (sourceElement == null) return '';
- const clone = pre.cloneNode(true);
+ const clone = sourceElement.cloneNode(true);
if (clone instanceof HTMLElement) {
for (const ignored of clone.querySelectorAll('.nd-copy-ignore')) {
ignored.replaceWith('\n');
@@ -180,5 +231,5 @@ function resolveCopyText({
return clone.textContent ?? '';
}
- return pre.textContent ?? '';
+ return sourceElement.textContent ?? '';
}
diff --git a/apps/docs/src/components/example-block.browser.test.tsx b/apps/docs/src/components/example-block.browser.test.tsx
index 5d13b054..18985b0a 100644
--- a/apps/docs/src/components/example-block.browser.test.tsx
+++ b/apps/docs/src/components/example-block.browser.test.tsx
@@ -12,21 +12,27 @@ import {
import { act } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
-import { afterEach, assert, expect, test } from 'vite-plus/test';
+import { afterEach, assert, expect, test, vi } from 'vite-plus/test';
import { commands, page, userEvent } from 'vite-plus/test/context';
-import { ExampleBlock, ExampleLoadingState, ExamplePreview } from './example-block';
+import responsiveLayoutSource from '../examples/box/responsive-layout.tsx?raw';
+import { ExampleBlock, ExampleLoadingState } from './example-block.js';
+import { ExampleCodePreview } from './example-code-preview.js';
+import * as styles from './example-preview.css.js';
+import { ExamplePreview } from './example-preview.js';
import { DocsThemeRoot } from './theme-controls.js';
let container: HTMLElement | undefined;
let root: Root | undefined;
const exampleTitle = 'Combobox Field: Basic';
const loadingLabel = `Loading ${exampleTitle} example`;
+const COPY_BUTTON_NAME_PATTERN = /^(Copy|Copied)$/;
afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
container = undefined;
root = undefined;
+ vi.restoreAllMocks();
});
test('shows a named loading state in a frame that reserves the preview space', () => {
@@ -119,10 +125,17 @@ test('keyboard resize and double-click reset work without losing width on code e
await expect.poll(previewWidth).toBeLessThan(before);
const resized = previewWidth();
- await userEvent.click(page.getByRole('button', { name: 'Show code' }));
+ await expect.poll(() => page.getByRole('button', { name: 'Expand code' }).query()).toBeTruthy();
+ await act(async () => {
+ await userEvent.click(page.getByRole('button', { name: 'Expand code' }));
+ });
+ await expect.element(page.getByRole('button', { name: 'Collapse code' })).toBeVisible();
await expect.poll(previewWidth).toBeCloseTo(resized, 0);
expectGripInsidePreview();
- await userEvent.click(page.getByRole('button', { name: 'Hide code' }));
+ await act(async () => {
+ await userEvent.click(page.getByRole('button', { name: 'Collapse code' }));
+ });
+ await expect.element(page.getByRole('button', { name: 'Expand code' })).toBeVisible();
await expect.poll(previewWidth).toBeCloseTo(resized, 0);
separator().dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
@@ -140,6 +153,16 @@ test('narrow cards use the whole preview width and hide the resize control', asy
expect(document.documentElement.scrollWidth).toBe(document.documentElement.clientWidth);
});
+test('wide cards hide resize chrome below the desktop viewport breakpoint', async () => {
+ await page.viewport(700, 800);
+ renderPreviewHarness({ width: 800 });
+ const resizeSeparator = container?.querySelector('[data-separator]');
+ assert(resizeSeparator, 'expected resize separator');
+ await expect.poll(() => resizeSeparator.getBoundingClientRect().width).toBe(0);
+ expect(previewWidth()).toBeCloseTo(800, 0);
+ expect(previewCanvas().getBoundingClientRect().width).toBeCloseTo(800, 0);
+});
+
test('a resized preview returns to full width when its card becomes narrow', async () => {
await page.viewport(1000, 800);
renderPreviewHarness();
@@ -152,7 +175,7 @@ test('a resized preview returns to full width when its card becomes narrow', asy
expect(previewCanvas().getBoundingClientRect().width).toBeCloseTo(400, 0);
});
-test('the mobile card header scrolls complete controls without page overflow', async () => {
+test('the mobile card header keeps a long title and playground action without page overflow', async () => {
await page.viewport(400, 800);
await renderExampleBlock({
src: 'button/basic',
@@ -160,54 +183,23 @@ test('the mobile card header scrolls complete controls without page overflow', a
width: 360,
});
const titleText = 'Box: Responsive layout with a deliberately long heading';
- const title = page.getByText(titleText).element();
- const playground = page.getByText('Open in playground', { exact: true }).element();
- expect(playground.closest('a, button')).not.toBeNull();
- const showCode = page.getByRole('button', { name: 'Show code' }).element();
- const headerRegion = page.getByRole('region', { name: titleText });
- await expect.poll(() => headerRegion.query()).toBeTruthy();
- const header = headerRegion.element();
- assert(header instanceof HTMLElement, 'expected header region');
- expect(header.tabIndex).toBe(0);
- expect(header.scrollWidth).toBeGreaterThan(header.clientWidth);
- expect(title.getBoundingClientRect().width).toBeGreaterThan(300);
- expect(title.getBoundingClientRect().height).toBeLessThan(30);
- expect(playground.getBoundingClientRect().height).toBeLessThan(40);
- expect(showCode.getBoundingClientRect().height).toBeLessThan(40);
+ expect(page.getByText(titleText)).toBeVisible();
+ expect(page.getByRole('link', { name: 'Open in playground' })).toBeVisible();
+ expect(page.getByRole('button', { name: 'Show code' }).query()).toBeNull();
expect(document.documentElement.scrollWidth).toBe(document.documentElement.clientWidth);
- showCode.focus();
- expect(document.activeElement).toBe(showCode);
- await userEvent.keyboard('{Enter}');
- await expect.poll(() => page.getByRole('button', { name: 'Hide code' }).element()).toBeTruthy();
const code = container?.querySelector('pre');
expect(code).toBeTruthy();
expect(document.documentElement.scrollWidth).toBe(document.documentElement.clientWidth);
});
-test('the frame does not clip the scrollable header focus ring', async () => {
+test('the mobile playground action is keyboard accessible', async () => {
await page.viewport(400, 800);
const titleText = 'Box: Responsive layout with a deliberately long heading';
await renderExampleBlock({ src: 'button/basic', title: titleText, width: 360 });
- const headerRegion = page.getByRole('region', { name: titleText });
- await expect.poll(() => headerRegion.query()).toBeTruthy();
- const header = headerRegion.element();
- assert(header instanceof HTMLElement, 'expected header region');
-
- header.focus({ focusVisible: true });
- expect(document.activeElement).toBe(header);
- expect(header.matches(':focus-visible')).toBe(true);
- const headerStyle = getComputedStyle(header);
- expect(headerStyle.outlineStyle).toBe('solid');
- expect(Number.parseFloat(headerStyle.outlineWidth)).toBeGreaterThan(0);
- expect(headerStyle.outlineOffset).toBe('2px');
-
- const frame = header.parentElement;
- assert(frame, 'expected the example frame');
- const frameStyle = getComputedStyle(frame);
- for (const overflow of [frameStyle.overflow, frameStyle.overflowX, frameStyle.overflowY]) {
- expect(overflow).not.toMatch(/hidden|clip/);
- }
+ const playground = page.getByRole('link', { name: 'Open in playground' }).element();
+ await userEvent.tab();
+ await expect.element(playground).toHaveFocus();
});
test('a missing example stays readable at mobile width', async () => {
@@ -217,6 +209,155 @@ test('a missing example stays readable at mobile width', async () => {
expect(document.documentElement.scrollWidth).toBe(document.documentElement.clientWidth);
});
+test('shows source by default and expands it by keyboard while retaining control focus', async () => {
+ await page.viewport(1000, 800);
+ await renderExampleBlock({ src: 'box/responsive-layout', title: 'Box: Responsive layout' });
+
+ expect(container?.querySelector('pre')).toBeTruthy();
+ expect(page.getByRole('button', { name: 'Show code' }).query()).toBeNull();
+
+ await expect.poll(() => page.getByRole('button', { name: 'Expand code' }).query()).toBeTruthy();
+ const expand = page.getByRole('button', { name: 'Expand code' });
+ expect(expand).toBeVisible();
+ const codeFigure = () => {
+ const figure = container?.querySelector('figure');
+ assert(figure instanceof HTMLElement, 'expected code figure');
+ return figure;
+ };
+ const collapsedHeight = codeFigure().getBoundingClientRect().height;
+ const control = expand.element();
+ const codeId = control.getAttribute('aria-controls');
+ assert(codeId, 'expected the controlled source id');
+ const codeRegion = document.getElementById(codeId);
+ assert(codeRegion?.contains(codeFigure()), 'expected the control to target its source');
+ const regionRect = codeRegion?.getBoundingClientRect();
+ assert(regionRect, 'expected code region bounds');
+ const controlRect = control.getBoundingClientRect();
+ expect(controlRect.bottom).toBeLessThanOrEqual(regionRect.bottom + 1);
+ expect(controlRect.top).toBeGreaterThanOrEqual(regionRect.top - 1);
+ expect(control).toHaveAttribute('aria-expanded', 'false');
+ control.focus();
+
+ await act(async () => {
+ await userEvent.keyboard('{Enter}');
+ });
+ await expect.poll(() => page.getByRole('button', { name: 'Collapse code' }).query()).toBeTruthy();
+ await expect.element(control).toHaveFocus();
+ expect(control).toHaveAttribute('aria-expanded', 'true');
+ expect(control).toHaveAttribute('aria-controls', codeId);
+ await expect
+ .poll(() => codeFigure().getBoundingClientRect().height)
+ .toBeGreaterThan(collapsedHeight);
+
+ await act(async () => {
+ await userEvent.keyboard(' ');
+ });
+ await expect.poll(() => page.getByRole('button', { name: 'Expand code' }).query()).toBeTruthy();
+ await expect.element(control).toHaveFocus();
+ expect(control).toHaveAttribute('aria-expanded', 'false');
+ await expect
+ .poll(() => codeFigure().getBoundingClientRect().height)
+ .toBeCloseTo(collapsedHeight, 0);
+});
+
+test('copies the complete source from both collapsed and expanded previews', async () => {
+ const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
+ await page.viewport(1000, 800);
+ await renderExampleBlock();
+
+ await userEvent.click(page.getByRole('button', { name: 'Copy', exact: true }));
+ expect(writeText).toHaveBeenLastCalledWith(responsiveLayoutSource.trim());
+ await expect.element(page.getByRole('button', { name: 'Copied' })).toBeVisible();
+
+ await act(async () => {
+ await userEvent.click(page.getByRole('button', { name: 'Expand code' }));
+ });
+ await act(async () => {
+ await userEvent.click(page.getByRole('button', { name: COPY_BUTTON_NAME_PATTERN }));
+ });
+ expect(writeText).toHaveBeenCalledTimes(2);
+ expect(writeText).toHaveBeenLastCalledWith(responsiveLayoutSource.trim());
+});
+
+test('keyboard scrolls long lines while collapsed and expanded without revealing clipped lines', async () => {
+ await page.viewport(400, 800);
+ await renderExampleBlock({ width: 360 });
+ const viewport = page.getByRole('region', { name: 'Box: Responsive layout code' }).element();
+ const copy = page.getByRole('button', { name: 'Copy', exact: true }).element();
+ copy.focus();
+ await userEvent.tab();
+ await expect.element(viewport).toHaveFocus();
+ await userEvent.keyboard('{ArrowRight}');
+ await expect.poll(() => viewport.scrollLeft).toBeGreaterThan(0);
+ await userEvent.keyboard('{ArrowDown}');
+ expect(viewport.scrollTop).toBe(0);
+
+ await userEvent.tab();
+ await expect.element(page.getByRole('button', { name: 'Expand code' })).toHaveFocus();
+ await act(async () => {
+ await userEvent.keyboard('{Enter}');
+ });
+ await expect.element(page.getByRole('button', { name: 'Collapse code' })).toBeVisible();
+ await userEvent.tab({ shift: true });
+ await expect.element(viewport).toHaveFocus();
+ viewport.scrollLeft = 0;
+ await userEvent.keyboard('{ArrowRight}');
+ await expect.poll(() => viewport.scrollLeft).toBeGreaterThan(0);
+ expect(viewport.scrollHeight).toBeLessThanOrEqual(viewport.clientHeight + 1);
+ await userEvent.tab({ shift: true });
+ await expect.element(copy).toHaveFocus();
+});
+
+test('omits expand controls when the source already fits the collapsed preview', async () => {
+ await page.viewport(1000, 800);
+ await renderExampleBlock({ src: 'button/basic', title: 'Button: Basic' });
+
+ expect(container?.querySelector('pre')).toBeTruthy();
+ expect(page.getByRole('button', { name: 'Expand code' }).query()).toBeNull();
+ expect(page.getByRole('button', { name: 'Collapse code' }).query()).toBeNull();
+ expect(page.getByRole('button', { name: 'Copy' })).toBeVisible();
+});
+
+test('keeps expanded source collapsible when its typography changes to fit', async () => {
+ await page.viewport(1000, 800);
+ const source = Array.from({ length: 20 }, () => 'const value = 1;').join('\n');
+ container = document.body.appendChild(document.createElement('div'));
+ container.className = `luke-ui-theme ${tactileThemeClassName}`;
+ root = createRoot(container);
+ act(() => {
+ root?.render(
+
+ ${source}`} source={source} title="Source sizing" />
+ ,
+ );
+ });
+ await expect.element(page.getByRole('button', { name: 'Expand code' })).toBeVisible();
+ await act(async () => {
+ await userEvent.click(page.getByRole('button', { name: 'Expand code' }));
+ });
+ const collapse = page.getByRole('button', { name: 'Collapse code' });
+ await expect.element(collapse).toBeVisible();
+ const pre = container.querySelector('pre');
+ assert(pre, 'expected source element');
+ pre.style.fontSize = '1px';
+ pre.style.lineHeight = '1px';
+ await expect.poll(() => pre.getBoundingClientRect().height).toBeLessThan(30);
+ // Let layout observers process the new typography before collapsing.
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ });
+ expect(collapse).toHaveAttribute('aria-expanded', 'true');
+ await act(async () => {
+ await userEvent.click(collapse);
+ });
+ await expect.poll(() => page.getByRole('button', { name: 'Expand code' }).query()).toBeNull();
+ await expect.poll(() => collapse.query()).toBeNull();
+
+ pre.style.removeProperty('font-size');
+ pre.style.removeProperty('line-height');
+ await expect.element(page.getByRole('button', { name: 'Expand code' })).toBeVisible();
+});
+
test('narrowing the preview panel flips a responsive example below its container breakpoint', async () => {
await page.viewport(1000, 800);
// Wide enough that the canvas @container stays ≥768 after the 12px outside
@@ -254,7 +395,7 @@ function renderPreviewHarness({
}: {
width?: number;
withStickyHeader?: boolean;
- /** Fires during commit (ref callback), before ResizeObserver updates cardWidth. */
+ /** Fires during commit (ref callback), before ResizeObserver enables resizing. */
onFirstLayout?: (canvasWidth: number) => void;
} = {}) {
container = document.body.appendChild(document.createElement('div'));
@@ -278,7 +419,7 @@ function renderPreviewHarness({
{
if (!node || !onFirstLayout) return;
- const canvas = node.closest('.example-preview-canvas')?.firstElementChild;
+ const canvas = node.closest(`.${styles.previewCanvas}`)?.firstElementChild;
if (canvas instanceof HTMLElement) {
onFirstLayout(canvas.getBoundingClientRect().width);
}
@@ -354,14 +495,14 @@ function previewWidth() {
}
function previewCanvas() {
- const panelContent = getPreviewPanel().querySelector
('.example-preview-canvas');
+ const panelContent = getPreviewPanel().querySelector(`.${styles.previewCanvas}`);
const canvas = panelContent?.firstElementChild;
assert(canvas instanceof HTMLElement, 'expected preview canvas');
return canvas;
}
function resizeGrip() {
- const grip = separator().querySelector('.example-preview-grip');
+ const grip = separator().querySelector('[data-example-preview-grip]');
assert(grip, 'expected resize grip');
return grip;
}
@@ -382,7 +523,7 @@ function expectGripInsidePreview() {
expect(Math.abs(gripCenterX - dividerCenterX)).toBeLessThanOrEqual(2);
expect(grip.top).toBeGreaterThan(group.top);
expect(grip.bottom).toBeLessThan(group.bottom);
- const card = getPreviewPanel().closest('.not-prose')?.getBoundingClientRect() ?? group;
+ const card = getPreviewPanel().closest('[data-example-frame]')?.getBoundingClientRect() ?? group;
expect(grip.left).toBeGreaterThan(card.left);
expect(grip.right).toBeLessThanOrEqual(card.right);
expect(card.right - grip.right).toBeGreaterThanOrEqual(4);
diff --git a/apps/docs/src/components/example-block.tsx b/apps/docs/src/components/example-block.tsx
index dcfbbd98..d0871f80 100644
--- a/apps/docs/src/components/example-block.tsx
+++ b/apps/docs/src/components/example-block.tsx
@@ -1,32 +1,30 @@
import { Box } from '@luke-ui/react/box';
import { Button } from '@luke-ui/react/button';
-import type { IconName } from '@luke-ui/react/icon';
-import { createIcon, Icon } from '@luke-ui/react/icon';
+import { Icon } from '@luke-ui/react/icon';
import { LoadingSkeleton } from '@luke-ui/react/loading-skeleton';
import { LoadingSpinner } from '@luke-ui/react/loading-spinner';
-import { ScrollFade } from '@luke-ui/react/scroll-fade';
import { Text } from '@luke-ui/react/text';
import { vars } from '@luke-ui/react/theme';
-import { cx } from '@luke-ui/react/utils';
-import type { ComponentType, JSX, ReactNode } from 'react';
-import { Suspense, use, useEffect, useId, useRef, useState } from 'react';
-import type { GroupImperativeHandle } from 'react-resizable-panels';
-import { Group, Panel, Separator } from 'react-resizable-panels';
+import type { ComponentProps, ComponentType, JSX, ReactNode } from 'react';
+import { Suspense, use } from 'react';
import type { HighlightedSource } from '../lib/highlighted-source.js';
import { StoryWrapper } from '../lib/story-wrapper.js';
-import { CodeBlock } from './code-block/code-block.js';
import { DocsLink } from './docs-link.js';
-import { useIsDesktop } from './playground/use-is-desktop.js';
+import { ExampleCodePreview } from './example-code-preview.js';
+import { ExamplePreview } from './example-preview.js';
-// The frame, header, preview, and code block nest one border's gap inside
-// `OUTER_RADIUS`, so their corners stay concentric with the frame's own.
-const OUTER_RADIUS = vars.radius.control;
-const INNER_RADIUS = `max(0px, calc(${OUTER_RADIUS} - 1px))`;
+/** Concentric with the frame's `borderRadius="control"` after the 1px border. */
+const INNER_RADIUS = `max(0px, calc(${vars.radius.control} - 1px))`;
+
+const frameEndRadiusStyle = {
+ borderEndEndRadius: INNER_RADIUS,
+ borderEndStartRadius: INNER_RADIUS,
+} as const;
type ExampleBlockProps = {
src: string;
title: string;
- layout?: 'flow' | 'centered' | 'full-bleed';
+ layout?: ComponentProps['layout'];
};
export function ExampleBlock(props: ExampleBlockProps): JSX.Element {
@@ -37,17 +35,44 @@ export function ExampleBlock(props: ExampleBlockProps): JSX.Element {
);
}
+export function ExampleLoadingState({
+ layout,
+ title,
+}: Pick) {
+ const loadingLabel = `Loading ${title} example`;
+ const isFullBleed = layout === 'full-bleed';
+
+ return (
+ } ariaLabel={loadingLabel} title={title}>
+
+
+
+
+
+
+ );
+}
+
function ExampleContent({ layout, src, title }: ExampleBlockProps): JSX.Element {
const slashIndex = src.indexOf('/');
const component = src.slice(0, slashIndex);
const name = src.slice(slashIndex + 1);
const result = use(loadExample(component, name));
- const [showCode, setShowCode] = useState(false);
- const codeId = useId();
if (!result.ok) {
return (
-
+
Failed to load example {component}/{name}: {result.error.message}
@@ -60,179 +85,76 @@ function ExampleContent({ layout, src, title }: ExampleBlockProps): JSX.Element
return (
- {highlightedSource.playgroundHash != null ? (
-
- ) : null}
- setShowCode((prev) => !prev)}
- />
-
+ highlightedSource.playgroundHash != null ? (
+
+ ) : null
}
title={title}
>
- {showCode ? (
-
- {/* Shiki escapes the source before the Vite plugin generates this HTML. */}
-
-
- ) : null}
-
- );
-}
-
-export function ExampleLoadingState({
- layout,
- title,
-}: Pick) {
- const loadingLabel = `Loading ${title} example`;
- const isFullBleed = layout === 'full-bleed';
-
- return (
- } ariaLabel={loadingLabel} title={title}>
-
-
-
-
-
+ {/* Remount when the source changes so expand state resets without an effect. */}
+
);
}
-// The Tailwind classes in ExamplePreview repeat these values, so change both together:
-// `md:` is DESKTOP_MEDIA_QUERY, `@[640px]/example-preview-card` is MIN_RESIZABLE_CARD_WIDTH,
-// `pe-6` is RESIZE_GUTTER_WIDTH, and `min-inline-3!` is OUTSIDE_STRIP_WIDTH.
-const MIN_RESIZABLE_CARD_WIDTH = 640;
-const MIN_PREVIEW_WIDTH = 320;
-const RESIZE_GUTTER_WIDTH = 24;
-/** Half the grip's inline size, so the grip stays inside the card at full width. */
-const OUTSIDE_STRIP_WIDTH = 12;
-
-// Larger than the playground's `RESIZE_TARGET_MINIMUM_SIZE` so the grip is easier to hit.
-const EXAMPLE_RESIZE_TARGET_MINIMUM_SIZE = { coarse: 32, fine: 32 };
-
-export function ExamplePreview({
- children,
- layout,
- title,
-}: {
+type ExampleFrameProps = {
+ actions?: ReactNode;
+ ariaLabel?: string;
children: ReactNode;
- layout?: ExampleBlockProps['layout'];
title: string;
-}) {
- const isDesktop = useIsDesktop();
- const groupElement = useRef(null);
- const groupHandle = useRef(null);
- const [cardWidth, setCardWidth] = useState(0);
- const previewId = useId();
- const outsideId = useId();
- const isResizable = isDesktop && cardWidth >= MIN_RESIZABLE_CARD_WIDTH;
-
- useEffect(() => {
- const element = groupElement.current;
- if (!element) return;
- const observer = new ResizeObserver(() => setCardWidth(element.getBoundingClientRect().width));
- observer.observe(element);
- return () => observer.disconnect();
- }, []);
-
- useEffect(() => {
- if (!isResizable) groupHandle.current?.setLayout({ [previewId]: 100, [outsideId]: 0 });
- }, [isResizable, outsideId, previewId]);
+};
+function ExampleFrame({ actions, ariaLabel, children, title }: ExampleFrameProps) {
return (
-
-
- {/*
- This is the nearest inline-size container for a responsive
- example, so it narrows against the preview width rather than
- the viewport. The gutter and the separator use container
- queries, not `isResizable`, so the first paint already has
- its final width.
- */}
-
- {children}
-
-
- .example-preview-grip]:border-fd-muted-foreground/80',
- 'data-[separator=active]:[&>.example-preview-grip]:border-fd-muted-foreground',
- 'data-[separator=focus]:[&>.example-preview-grip]:ring-2 data-[separator=focus]:[&>.example-preview-grip]:ring-fd-ring',
- )}
- disabled={!isResizable}
- onDoubleClick={() => {
- groupHandle.current?.setLayout({ [previewId]: 100, [outsideId]: 0 });
+
-
-
-
-
- );
-}
-
-const GripIcon = createIcon({
- path: (
- <>
-
-
-
-
-
-
- >
- ),
-});
-
-function ExamplePreviewResizeGrip() {
- return (
-
-
-
+
+
+ {title}
+
+
+ {actions != null ? (
+
+ {actions}
+
+ ) : null}
+
+
+ {children}
+
+
);
}
@@ -252,109 +174,19 @@ function OpenInPlayground({ hash }: { hash: string }) {
);
}
-function ShowCode({
- codeId,
- isExpanded,
- onPress,
-}: {
- codeId: string;
- isExpanded: boolean;
- onPress: () => void;
-}) {
- return (
- }
- >
- {isExpanded ? 'Hide code' : 'Show code'}
-
- );
-}
-
-// Mirrors `OpenInPlayground` and `ShowCode`'s visuals without mounting a
-// router link or wiring up real interaction — this is an `aria-hidden`,
-// `inert` placeholder, so a plain disabled `Button` is enough for both.
-function ActionPlaceholder({ children, iconName }: { children: ReactNode; iconName: IconName }) {
- return (
- }>
- {children}
-
- );
-}
-
function ExampleLoadingActions() {
return (
- Open in playground
-
-
- Show code
-
-
- );
-}
-
-type ExampleFrameProps = {
- actions?: ReactNode;
- ariaLabel?: string;
- children: ReactNode;
- title: string;
-};
-
-function ExampleFrame({ actions, ariaLabel, children, title }: ExampleFrameProps) {
- const titleId = useId();
- return (
-
-
- }
>
-
- {title}
-
- {actions}
-
-
-
- {children}
-
+ Open in playground
+
+
);
}
diff --git a/apps/docs/src/components/example-code-preview.css.ts b/apps/docs/src/components/example-code-preview.css.ts
new file mode 100644
index 00000000..1e4ae36e
--- /dev/null
+++ b/apps/docs/src/components/example-code-preview.css.ts
@@ -0,0 +1,83 @@
+import { vars } from '@luke-ui/react/theme';
+import { globalStyle, style } from '@vanilla-extract/css';
+
+/** Six caption lines. Viewport padding remains outside the clipped source. */
+const collapsedSourceMaxBlockSize = `calc(${vars.font.caption.lineHeight} * 6)`;
+
+export const codeTransitionType = 'luke-docs-example-code';
+export const codeUpdate = style({});
+
+// Keep the surrounding article out of the code expansion crossfade.
+globalStyle(`:root:active-view-transition-type(${codeTransitionType})::view-transition-old(root)`, {
+ '@layer': {
+ recipes: {
+ display: 'none',
+ },
+ },
+});
+
+globalStyle(
+ [
+ `:root:active-view-transition-type(${codeTransitionType})::view-transition-group(root)`,
+ `:root:active-view-transition-type(${codeTransitionType})::view-transition-new(root)`,
+ ].join(', '),
+ {
+ '@layer': {
+ recipes: {
+ animation: 'none',
+ },
+ },
+ },
+);
+
+globalStyle(
+ [
+ `::view-transition-group(.${codeUpdate})`,
+ `::view-transition-old(.${codeUpdate})`,
+ `::view-transition-new(.${codeUpdate})`,
+ ].join(', '),
+ {
+ '@layer': {
+ recipes: {
+ animationDuration: vars.motion.duration.enter,
+ animationTimingFunction: vars.motion.easing.standard,
+ '@media': {
+ '(prefers-reduced-motion: reduce)': {
+ animationDuration: '0.01ms',
+ },
+ },
+ },
+ },
+ },
+);
+
+/** Reveal the full source when expanded, without CodeBlock's default height cap. */
+export const codeViewport = style({
+ '@layer': {
+ utilities: {
+ maxBlockSize: 'none',
+ },
+ },
+});
+
+export const codeViewportCollapsed = style({});
+export const codeViewportFade = style({});
+
+// Clip the source itself so keyboard scrolling cannot reveal hidden lines.
+globalStyle(`${codeViewportCollapsed} pre`, {
+ '@layer': {
+ recipes: {
+ maxBlockSize: collapsedSourceMaxBlockSize,
+ overflow: 'hidden',
+ },
+ },
+});
+
+// Keep the scroll region's focus ring and copy control outside the text fade.
+globalStyle(`${codeViewportFade} pre`, {
+ '@layer': {
+ recipes: {
+ maskImage: 'linear-gradient(to bottom, #000 0%, #000 55%, transparent 100%)',
+ },
+ },
+});
diff --git a/apps/docs/src/components/example-code-preview.tsx b/apps/docs/src/components/example-code-preview.tsx
new file mode 100644
index 00000000..61f11b54
--- /dev/null
+++ b/apps/docs/src/components/example-code-preview.tsx
@@ -0,0 +1,126 @@
+import { Box } from '@luke-ui/react/box';
+import { Button } from '@luke-ui/react/button';
+import { cx } from '@luke-ui/react/utils';
+import {
+ addTransitionType,
+ startTransition,
+ useId,
+ useLayoutEffect,
+ useReducer,
+ useRef,
+ ViewTransition,
+} from 'react';
+import { CodeBlock } from './code-block/code-block.js';
+import * as styles from './example-code-preview.css.js';
+
+export function ExampleCodePreview({
+ html,
+ source,
+ title,
+}: {
+ html: string;
+ source: string;
+ title: string;
+}) {
+ const [mode, dispatch] = useReducer(sourceReducer, 'unmeasured');
+ const sourceRef = useRef(null);
+ const codeId = useId();
+ const isExpanded = mode === 'expanded';
+ const canExpand = mode === 'collapsed' || isExpanded;
+ const isClipped = !isExpanded;
+
+ useLayoutEffect(() => {
+ const sourceElement = sourceRef.current;
+ if (!sourceElement) return;
+
+ const update = () => {
+ dispatch({
+ isClipped: sourceElement.scrollHeight > sourceElement.clientHeight + 1,
+ type: 'measured',
+ });
+ };
+
+ const observer = new ResizeObserver(update);
+ observer.observe(sourceElement);
+ // Measure on the next frame to avoid a render during effect setup.
+ const frame = requestAnimationFrame(update);
+ return () => {
+ cancelAnimationFrame(frame);
+ observer.disconnect();
+ };
+ }, []);
+
+ return (
+
+
+ {/* Shiki escapes the source before the Vite plugin generates this HTML. */}
+
+ {canExpand ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+type SourceMode = 'unmeasured' | 'fits' | 'collapsed' | 'expanded';
+
+type SourceEvent = { isClipped: boolean; type: 'measured' } | { type: 'toggle' };
+
+function sourceReducer(mode: SourceMode, event: SourceEvent): SourceMode {
+ if (event.type === 'measured') {
+ // Expanded source fits by definition. Keep its collapse control until it is toggled.
+ if (mode === 'expanded') return mode;
+ return event.isClipped ? 'collapsed' : 'fits';
+ }
+
+ switch (mode) {
+ case 'collapsed':
+ return 'expanded';
+ case 'expanded':
+ return 'collapsed';
+ default:
+ return mode;
+ }
+}
diff --git a/apps/docs/src/components/example-preview.css.ts b/apps/docs/src/components/example-preview.css.ts
new file mode 100644
index 00000000..18d24a55
--- /dev/null
+++ b/apps/docs/src/components/example-preview.css.ts
@@ -0,0 +1,108 @@
+import { vars } from '@luke-ui/react/theme';
+import { globalStyle, style } from '@vanilla-extract/css';
+import { DESKTOP_MEDIA_QUERY } from './playground/use-is-desktop.js';
+
+export const MIN_RESIZABLE_CARD_WIDTH = 640;
+const RESIZE_GUTTER_WIDTH = vars.space.sp24;
+const OUTSIDE_STRIP_WIDTH = vars.space.sp12;
+
+const PREVIEW_CARD_CONTAINER = 'example-preview-card';
+const previewCardResizable = `${PREVIEW_CARD_CONTAINER} (inline-size >= ${MIN_RESIZABLE_CARD_WIDTH}px)`;
+
+// Name the card container so shell queries keep measuring the root.
+export const previewGroup = style({
+ '@layer': {
+ recipes: {
+ containerName: PREVIEW_CARD_CONTAINER,
+ containerType: 'inline-size',
+ display: 'flex',
+ isolation: 'isolate',
+ overflow: 'hidden',
+ },
+ },
+});
+
+// Inline panel styles set `min-width: 0`. Keep the outside strip open.
+globalStyle(`${previewGroup} > [data-panel]:last-child`, {
+ '@layer': {
+ recipes: {
+ '@media': {
+ [DESKTOP_MEDIA_QUERY]: {
+ '@container': {
+ [previewCardResizable]: {
+ minInlineSize: `${OUTSIDE_STRIP_WIDTH} !important`,
+ },
+ },
+ },
+ },
+ },
+ },
+});
+
+export const previewCanvas = style({
+ '@layer': {
+ recipes: {
+ containerType: 'inline-size',
+ '@media': {
+ [DESKTOP_MEDIA_QUERY]: {
+ '@container': {
+ [previewCardResizable]: {
+ paddingInlineEnd: RESIZE_GUTTER_WIDTH,
+ },
+ },
+ },
+ },
+ },
+ },
+});
+
+export const previewOutside = style({
+ '@layer': {
+ recipes: {
+ backgroundColor: `color-mix(in oklab, ${vars.color.surface.recessed} 50%, transparent)`,
+ },
+ },
+});
+
+export const previewSeparator = style({
+ '@layer': {
+ recipes: {
+ backgroundColor: vars.color.border.decorative,
+ cursor: 'col-resize',
+ display: 'none',
+ flexShrink: 0,
+ inlineSize: '1px',
+ position: 'relative',
+ zIndex: 10,
+ '@media': {
+ [DESKTOP_MEDIA_QUERY]: {
+ '@container': {
+ [previewCardResizable]: {
+ display: 'block',
+ },
+ },
+ },
+ },
+ },
+ },
+});
+
+export const previewGripState = style({
+ '@layer': {
+ recipes: {
+ borderColor: vars.color.border.decorative,
+ boxShadow: vars.depth.resting,
+ selectors: {
+ [`${previewSeparator}[data-separator=hover] &`]: {
+ borderColor: `color-mix(in oklab, ${vars.color.text.secondary} 80%, transparent)`,
+ },
+ [`${previewSeparator}[data-separator=active] &`]: {
+ borderColor: vars.color.text.secondary,
+ },
+ [`${previewSeparator}[data-separator=focus] &`]: {
+ boxShadow: `0 0 0 2px ${vars.color.border.focus}`,
+ },
+ },
+ },
+ },
+});
diff --git a/apps/docs/src/components/example-preview.tsx b/apps/docs/src/components/example-preview.tsx
new file mode 100644
index 00000000..32089289
--- /dev/null
+++ b/apps/docs/src/components/example-preview.tsx
@@ -0,0 +1,145 @@
+import { Box } from '@luke-ui/react/box';
+import { createIcon } from '@luke-ui/react/icon';
+import { vars } from '@luke-ui/react/theme';
+import type { ComponentProps, ReactNode } from 'react';
+import { useEffect, useId, useRef, useState } from 'react';
+import type { GroupImperativeHandle } from 'react-resizable-panels';
+import { Group, Panel, Separator } from 'react-resizable-panels';
+import { StoryWrapper } from '../lib/story-wrapper.js';
+import * as styles from './example-preview.css.js';
+import { useIsDesktop } from './playground/use-is-desktop.js';
+
+export function ExamplePreview({
+ children,
+ layout,
+ title,
+}: {
+ children: ReactNode;
+ layout?: ComponentProps['layout'];
+ title: string;
+}) {
+ const isDesktop = useIsDesktop();
+ const groupElement = useRef(null);
+ const groupHandle = useRef(null);
+ const [isCardWideEnough, setIsCardWideEnough] = useState(false);
+ const previewId = useId();
+ const outsideId = useId();
+ const isResizable = isDesktop && isCardWideEnough;
+
+ useEffect(() => {
+ const element = groupElement.current;
+ if (!element) return;
+ const observer = new ResizeObserver(([entry]) => {
+ if (!entry) return;
+ setIsCardWideEnough(entry.contentRect.width >= styles.MIN_RESIZABLE_CARD_WIDTH);
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ if (!isResizable)
+ groupHandle.current?.setLayout({
+ [previewId]: 100,
+ [outsideId]: 0,
+ });
+ }, [isResizable, outsideId, previewId]);
+
+ return (
+
+
+ {/* CSS sets the gutter before measurement. Examples query this canvas's width. */}
+
+ {children}
+
+
+ {
+ groupHandle.current?.setLayout({
+ [previewId]: 100,
+ [outsideId]: 0,
+ });
+ }}
+ >
+
+
+
+
+ );
+}
+
+const MIN_PREVIEW_WIDTH = 320;
+// Match sp24 and sp12 in example-preview.css.ts.
+const RESIZE_GUTTER_WIDTH = 24;
+const OUTSIDE_STRIP_WIDTH = 12;
+
+const EXAMPLE_RESIZE_TARGET_MINIMUM_SIZE = { coarse: 32, fine: 32 };
+
+const GripIcon = createIcon({
+ path: (
+ <>
+
+
+
+
+
+
+ >
+ ),
+});
+
+const GRIP_BLOCK_SIZE = '3.75rem';
+const GRIP_INLINE_SIZE = '0.75rem';
+
+function ExamplePreviewResizeGrip() {
+ return (
+
+
+
+ );
+}
diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
index 7392d538..492b0037 100644
--- a/docs/DOCUMENTATION.md
+++ b/docs/DOCUMENTATION.md
@@ -491,6 +491,11 @@ list in `apps/docs/src/lib/docs-playground-specifiers.ts`. The button opens that
Examples that import a relative module, or anything else the preview cannot `require`, omit the
button.
+Each example shows a six-line source preview below its live preview. Longer snippets fade at the
+bottom and have an "Expand code" control to reveal the full source. Copy always copies the full
+source, including while collapsed. Expansion and collapse animate unless reduced motion is
+preferred.
+
User code compiles in the browser with sucrase and can import `react` and any `@luke-ui/react/*`
subpath. The compiler only rejects a missing default export (`undefined` or `null`) and returns
`unknown` rather than a React component type, because it does not depend on React to validate the