From b981fb3ada5f741084bcbb5b9c8e404639cc0f90 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Fri, 9 Oct 2026 18:05:23 +1100 Subject: [PATCH] Improve ExampleBlock --- .../code-block/code-block.browser.test.tsx | 93 ++++- .../components/code-block/code-block.css.ts | 4 +- .../src/components/code-block/code-block.tsx | 157 +++++--- .../components/example-block.browser.test.tsx | 237 ++++++++--- apps/docs/src/components/example-block.tsx | 376 +++++------------- .../components/example-code-preview.css.ts | 83 ++++ .../src/components/example-code-preview.tsx | 126 ++++++ .../src/components/example-preview.css.ts | 108 +++++ apps/docs/src/components/example-preview.tsx | 145 +++++++ docs/DOCUMENTATION.md | 5 + 10 files changed, 959 insertions(+), 375 deletions(-) create mode 100644 apps/docs/src/components/example-code-preview.css.ts create mode 100644 apps/docs/src/components/example-code-preview.tsx create mode 100644 apps/docs/src/components/example-preview.css.ts create mode 100644 apps/docs/src/components/example-preview.tsx diff --git a/apps/docs/src/components/code-block/code-block.browser.test.tsx b/apps/docs/src/components/code-block/code-block.browser.test.tsx index 70043234..df48bca1 100644 --- a/apps/docs/src/components/code-block/code-block.browser.test.tsx +++ b/apps/docs/src/components/code-block/code-block.browser.test.tsx @@ -3,7 +3,7 @@ import '@luke-ui/react/themes/tactile/stylesheet.css'; import { themeClassName as tactileThemeClassName } from '@luke-ui/react/themes/tactile'; import axe from 'axe-core'; import type { ReactNode } from 'react'; -import { act } from 'react'; +import { act, createRef } from 'react'; import type { Root } from 'react-dom/client'; import { createRoot } from 'react-dom/client'; import { afterEach, expect, test, vi } from 'vite-plus/test'; @@ -66,6 +66,93 @@ test('copies copyText instead of rendered highlighted text', async () => { expect(page.getByText('rendered').element()).toBeTruthy(); }); +test('updates scroll accessibility when the mounted source and label change', async () => { + await page.viewport(400, 800); + renderCodeBlock(); + const source = container?.querySelector('pre'); + assert(source != null, 'Expected a source element'); + const viewport = source.parentElement; + assert(viewport != null, 'Expected a scroll viewport'); + expect(page.getByRole('region').query()).toBeNull(); + + rerenderCodeBlock(); + await expect.element(page.getByRole('region', { name: 'Source' })).toBeVisible(); + expect(viewport).toHaveAttribute('tabindex', '0'); + expect(container?.querySelector('pre')).toBe(source); + + rerenderCodeBlock( + , + ); + await expect.element(page.getByRole('region', { name: 'Updated source' })).toBeVisible(); + expect(page.getByRole('region', { name: 'Source', exact: true }).query()).toBeNull(); + + rerenderCodeBlock(); + await expect.poll(() => page.getByRole('region').query()).toBeNull(); + expect(viewport).not.toHaveAttribute('tabindex'); + expect(viewport).not.toHaveAttribute('aria-label'); +}); + +test('updates scroll accessibility when the viewport narrows and widens', async () => { + await page.viewport(1000, 800); + renderCodeBlock(); + expect(page.getByRole('region').query()).toBeNull(); + + await page.viewport(320, 800); + const viewport = page.getByRole('region', { name: 'Source' }); + await expect.element(viewport).toBeVisible(); + expect(viewport).toHaveAttribute('tabindex', '0'); + + await page.viewport(1000, 800); + await expect.poll(() => viewport.query()).toBeNull(); +}); + +test('composes source refs through content changes, ref replacement, and unmount', () => { + const cleanup = vi.fn<() => void>(); + const callbackRef = vi.fn<(node: HTMLPreElement | null) => (() => void) | void>((node) => { + if (node) return cleanup; + }); + const figureRef = createRef(); + const objectRef = createRef(); + renderCodeBlock(); + const source = container?.querySelector('pre'); + assert(source != null, 'Expected a source element'); + expect(callbackRef).toHaveBeenCalledExactlyOnceWith(source); + expect(figureRef.current).toBe(source.closest('figure')); + + rerenderCodeBlock( + , + ); + expect(source).toHaveTextContent('second'); + expect(callbackRef).toHaveBeenCalledTimes(1); + expect(cleanup).not.toHaveBeenCalled(); + + rerenderCodeBlock(); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(objectRef.current).toBe(source); + expect(source).toHaveTextContent('third'); + + act(() => root?.unmount()); + root = undefined; + expect(objectRef.current).toBeNull(); + expect(figureRef.current).toBeNull(); +}); + +test('copies child source while excluding ignored spans', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + renderCodeBlock( + + + firstannotationsecond + + , + ); + + await act(async () => { + await userEvent.click(page.getByRole('button', { name: 'Copy' })); + }); + expect(writeText).toHaveBeenCalledWith('first\nsecond'); +}); + test('scrolls Shiki line spans across the full figure width under overlay copy', async () => { await page.viewport(320, 720); @@ -212,6 +299,10 @@ function renderCodeBlock(node: ReactNode) { container = document.body.appendChild(document.createElement('div')); container.className = `luke-ui-theme ${tactileThemeClassName}`; root = createRoot(container); + rerenderCodeBlock(node); +} + +function rerenderCodeBlock(node: ReactNode) { act(() => { root?.render({node}); }); diff --git a/apps/docs/src/components/code-block/code-block.css.ts b/apps/docs/src/components/code-block/code-block.css.ts index 8ba1f03e..3f3a2501 100644 --- a/apps/docs/src/components/code-block/code-block.css.ts +++ b/apps/docs/src/components/code-block/code-block.css.ts @@ -38,8 +38,9 @@ export const flush = style({ '@layer': { recipes: { borderInlineWidth: 0, - borderRadius: 0, + borderRadius: 'inherit', borderBlockEndWidth: 0, + overflow: 'visible', }, }, }); @@ -95,6 +96,7 @@ export const actions = style({ export const overlayActions = style({ '@layer': { recipes: { + backgroundColor: vars.color.surface.recessed, insetBlockStart: `calc(${vars.space.sp12} + (${vars.font.caption.lineHeight} / 2) - (${vars.controlSize.small} / 2))`, insetInlineEnd: vars.space.sp8, position: 'absolute', diff --git a/apps/docs/src/components/code-block/code-block.tsx b/apps/docs/src/components/code-block/code-block.tsx index 366c0efb..c6c79e62 100644 --- a/apps/docs/src/components/code-block/code-block.tsx +++ b/apps/docs/src/components/code-block/code-block.tsx @@ -1,16 +1,36 @@ import { IconButton } from '@luke-ui/react/icon-button'; import { cx } from '@luke-ui/react/utils'; import { VisuallyHidden } from '@luke-ui/react/visually-hidden'; +import { useObjectRef } from '@react-aria/utils'; import type { ComponentPropsWithoutRef, ReactNode, Ref } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useReducer } from 'react'; import * as styles from './code-block.css.js'; type FigureProps = ComponentPropsWithoutRef<'figure'>; type CopyStatus = 'idle' | 'copied' | 'error'; +interface CodeBlockState { + copyStatus: CopyStatus; + /** Counts copy attempts so copying again during feedback restarts the timer. */ + copyAttempt: number; + isScrollable: boolean; +} + +type CodeBlockEvent = + | { type: 'copySucceeded' } + | { type: 'copyFailed' } + | { type: 'copyFeedbackExpired' } + | { type: 'overflowChanged'; isScrollable: boolean }; + const COPY_FEEDBACK_MS = 1500; +const initialState: CodeBlockState = { + copyStatus: 'idle', + copyAttempt: 0, + isScrollable: false, +}; + export interface CodeBlockProps extends Omit { /** * Shows the copy control. MDX may pass the string `"true"` / `"false"`. @@ -28,8 +48,14 @@ export interface CodeBlockProps extends Omit { /** 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} -
-
- - -
- ); -} - -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 ( - - ); -} - -// 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 ( - - ); -} - 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 -- 2.51.2