diff --git a/apps/docs/src/components/code-block/code-block.tsx b/apps/docs/src/components/code-block/code-block.tsx index c6c79e62..7c937a93 100644 --- a/apps/docs/src/components/code-block/code-block.tsx +++ b/apps/docs/src/components/code-block/code-block.tsx @@ -41,6 +41,8 @@ export interface CodeBlockProps extends Omit { children?: ReactNode; /** Plain source. Prefer this over children when the text is a string constant. */ code?: string; + /** Ref for the copy button, when the copy control is shown. */ + copyButtonRef?: Ref; /** Exact clipboard payload when rendered markup should not define what is copied. */ copyText?: string; /** Drop outer margin and frame chrome so the block can sit flush under an example frame. */ @@ -67,6 +69,7 @@ export function CodeBlock({ children, className, code, + copyButtonRef, copyText, flush = false, html, @@ -132,6 +135,7 @@ export function CodeBlock({ aria-label={copyStatus === 'copied' ? 'Copied' : 'Copy'} icon={copyStatus === 'copied' ? 'check' : 'copy'} onPress={handleCopy} + ref={copyButtonRef} prominence="low" size="small" tone="neutral" diff --git a/apps/docs/src/components/example-block.browser.test.tsx b/apps/docs/src/components/example-block.browser.test.tsx index 18985b0a..fda51ada 100644 --- a/apps/docs/src/components/example-block.browser.test.tsx +++ b/apps/docs/src/components/example-block.browser.test.tsx @@ -28,6 +28,7 @@ const loadingLabel = `Loading ${exampleTitle} example`; const COPY_BUTTON_NAME_PATTERN = /^(Copy|Copied)$/; afterEach(() => { + vi.unstubAllGlobals(); if (root) act(() => root?.unmount()); container?.remove(); container = undefined; @@ -265,7 +266,9 @@ test('copies the complete source from both collapsed and expanded previews', asy await page.viewport(1000, 800); await renderExampleBlock(); - await userEvent.click(page.getByRole('button', { name: 'Copy', exact: true })); + await act(async () => { + await userEvent.click(page.getByRole('button', { name: 'Copy', exact: true })); + }); expect(writeText).toHaveBeenLastCalledWith(responsiveLayoutSource.trim()); await expect.element(page.getByRole('button', { name: 'Copied' })).toBeVisible(); @@ -342,10 +345,6 @@ test('keeps expanded source collapsible when its typography changes to fit', asy 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); @@ -358,6 +357,143 @@ test('keeps expanded source collapsible when its typography changes to fit', asy await expect.element(page.getByRole('button', { name: 'Expand code' })).toBeVisible(); }); +for (const { key, name } of [ + { key: '{Enter}', name: 'Enter' }, + { key: ' ', name: 'Space' }, +]) { + test(`collapsing clipped source with ${name} keeps focus on the toggle`, async () => { + await renderSourcePreview(); + const expand = page.getByRole('button', { name: 'Expand code' }); + await expect.element(expand).toBeVisible(); + expand.element().focus(); + await pressKey(key); + const collapse = page.getByRole('button', { name: 'Collapse code' }); + await expect.element(collapse).toHaveFocus(); + + await pressKey(key); + await expect.element(page.getByRole('button', { name: 'Expand code' })).toHaveFocus(); + }); + + test(`collapsing source that now fits with ${name} moves focus to Copy`, async () => { + await renderSourcePreview(); + const expand = page.getByRole('button', { name: 'Expand code' }); + await expect.element(expand).toBeVisible(); + expand.element().focus(); + await pressKey(key); + await expect.element(page.getByRole('button', { name: 'Collapse code' })).toHaveFocus(); + shrinkTypography(sourceElement()); + await expect.poll(() => sourceElement().getBoundingClientRect().height).toBeLessThan(30); + + await pressKey(key); + await expect.poll(() => page.getByRole('button', { name: 'Collapse code' }).query()).toBeNull(); + await expect.poll(() => page.getByRole('button', { name: 'Expand code' }).query()).toBeNull(); + await expect + .element(page.getByRole('button', { name: COPY_BUTTON_NAME_PATTERN })) + .toHaveFocus(); + }); + + for (const returns of [false, true]) { + test(`collapse with ${name} respects focus ${returns ? 'returning' : 'moving away'} before commit`, async () => { + await renderSourcePreview({ withNextControl: true }); + const expand = page.getByRole('button', { name: 'Expand code' }); + await expect.element(expand).toBeVisible(); + expand.element().focus(); + await pressKey(key); + const collapse = page.getByRole('button', { name: 'Collapse code' }); + await expect.element(collapse).toHaveFocus(); + shrinkTypography(sourceElement()); + await expect.poll(() => sourceElement().getBoundingClientRect().height).toBeLessThan(30); + + const focusChanges: Array = []; + const control = collapse.element(); + control.addEventListener('blur', () => { + focusChanges.push(`blur ${control.getAttribute('aria-expanded')}`); + }); + control.addEventListener('focus', () => { + focusChanges.push(`focus ${control.getAttribute('aria-expanded')}`); + }); + const next = page.getByRole('button', { name: 'Next example' }).element(); + // Redirect focus during the native activation event, before another browser task can run. + // Keep React's scheduler active rather than deferring the collapse with act. + control.addEventListener( + 'keyup', + () => { + next.focus(); + if (returns) control.focus(); + }, + { once: true }, + ); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', false); + await userEvent.keyboard(key); + expect(focusChanges.slice(0, returns ? 2 : 1)).toEqual( + returns ? ['blur true', 'focus true'] : ['blur true'], + ); + await expect.poll(() => collapse.query()).toBeNull(); + const destination = returns + ? page.getByRole('button', { name: COPY_BUTTON_NAME_PATTERN }) + : page.getByRole('button', { name: 'Next example' }); + await expect.element(destination).toHaveFocus(); + }); + } +} + +test('typography changes without interaction do not move focus', async () => { + await renderSourcePreview(); + await expect.element(page.getByRole('button', { name: 'Expand code' })).toBeVisible(); + await userEvent.click(page.getByRole('button', { name: 'Expand code' })); + const collapse = page.getByRole('button', { name: 'Collapse code' }); + await expect.element(collapse).toBeVisible(); + collapse.element().focus(); + const pre = sourceElement(); + + // The source now fits, but the expanded toggle stays until someone collapses it. + shrinkTypography(pre); + await expect.poll(() => pre.getBoundingClientRect().height).toBeLessThan(30); + await expect.element(collapse).toHaveFocus(); + + // Collapsing removes the toggle and hands focus to Copy. Growing the source again must leave it + // there while the expand control comes back. + await pressKey('{Enter}'); + await expect.poll(() => collapse.query()).toBeNull(); + const copy = page.getByRole('button', { name: COPY_BUTTON_NAME_PATTERN }); + await expect.element(copy).toHaveFocus(); + pre.style.removeProperty('font-size'); + pre.style.removeProperty('line-height'); + await expect.element(page.getByRole('button', { name: 'Expand code' })).toBeVisible(); + await expect.element(copy).toHaveFocus(); +}); + +test('a surviving collapse does not restore focus after a later typography change', async () => { + await renderSourcePreview({ withNextControl: true }); + const expand = page.getByRole('button', { name: 'Expand code' }); + await expect.element(expand).toBeVisible(); + expand.element().focus(); + await pressKey('{Enter}'); + await expect.element(page.getByRole('button', { name: 'Collapse code' })).toHaveFocus(); + await pressKey(' '); + await expect.element(expand).toHaveFocus(); + + await userEvent.tab(); + await expect.element(page.getByRole('button', { name: 'Next example' })).toHaveFocus(); + await userEvent.tab({ shift: true }); + await expect.element(expand).toHaveFocus(); + shrinkTypography(sourceElement()); + await expect.poll(() => expand.query()).toBeNull(); + expect(document.activeElement).toBe(document.body); +}); + +test('resizing the viewport does not move focus away from Copy', async () => { + await renderSourcePreview(); + await expect.element(page.getByRole('button', { name: 'Expand code' })).toBeVisible(); + const copy = page.getByRole('button', { name: COPY_BUTTON_NAME_PATTERN }); + copy.element().focus(); + await page.viewport(600, 800); + await expect.element(page.getByRole('button', { name: 'Expand code' })).toBeVisible(); + await expect.element(copy).toHaveFocus(); + await page.viewport(1000, 800); + await expect.element(copy).toHaveFocus(); +}); + 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 @@ -372,6 +508,39 @@ test('narrowing the preview panel flips a responsive example below its container await expect.poll(flexDirection).toBe('column'); }); +async function renderSourcePreview({ withNextControl = false } = {}) { + 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" /> + {withNextControl ? : null} + , + ); + }); +} + +function sourceElement() { + const pre = container?.querySelector('pre'); + assert(pre, 'expected source element'); + return pre; +} + +function shrinkTypography(pre: HTMLElement) { + pre.style.fontSize = '1px'; + pre.style.lineHeight = '1px'; +} + +async function pressKey(key: string) { + await act(async () => { + await userEvent.keyboard(key); + }); +} + function renderExample(title: string) { container = document.body.appendChild(document.createElement('div')); container.className = `luke-ui-theme ${tactileThemeClassName}`; diff --git a/apps/docs/src/components/example-code-preview.tsx b/apps/docs/src/components/example-code-preview.tsx index 4795e228..13bffd2f 100644 --- a/apps/docs/src/components/example-code-preview.tsx +++ b/apps/docs/src/components/example-code-preview.tsx @@ -22,8 +22,9 @@ export function ExampleCodePreview({ source: string; title: string; }) { - const [mode, dispatch] = useReducer(sourceReducer, 'unmeasured'); + const [{ focus, mode }, dispatch] = useReducer(sourceReducer, initialSourceState); const sourceRef = useRef(null); + const copyButtonRef = useRef(null); const codeId = useId(); const isExpanded = mode === 'expanded'; const canExpand = mode === 'collapsed' || isExpanded; @@ -34,10 +35,8 @@ export function ExampleCodePreview({ if (!sourceElement) return; const update = () => { - dispatch({ - isClipped: sourceElement.scrollHeight > sourceElement.clientHeight + 1, - type: 'measured', - }); + const isClipped = measureIsClipped(sourceElement); + if (isClipped != null) dispatch({ isClipped, type: 'measured' }); }; const observer = new ResizeObserver(update); @@ -50,6 +49,24 @@ export function ExampleCodePreview({ }; }, []); + // Collapsed styles can land without a size change (for example after + // shrinking typography while expanded). Remeasure once they apply. + useLayoutEffect(() => { + if (mode !== 'collapsed') return; + const sourceElement = sourceRef.current; + if (!sourceElement) return; + + const isClipped = measureIsClipped(sourceElement); + if (isClipped != null) dispatch({ isClipped, type: 'measured' }); + }, [mode]); + + // The reducer asks for this only after a focused collapse control has unmounted. + useLayoutEffect(() => { + if (focus !== 'copy') return; + copyButtonRef.current?.focus(); + dispatch({ type: 'focusRestored' }); + }, [focus]); + return ( {/* Shiki escapes the source before the Vite plugin generates this HTML. */} { + // React ignores the blur from unmounting, so this only sees focus moving away. + onBlur={() => dispatch({ type: 'toggleBlurred' })} + onFocus={() => dispatch({ type: 'toggleFocused' })} + onPress={(event) => { + // Read focus at press time. A view transition can move it to body before effects run. + const hadFocus = event.target === document.activeElement; startTransition(() => { addTransitionType(styles.codeTransitionType); - dispatch({ type: 'toggle' }); + dispatch(isExpanded ? { hadFocus, type: 'collapse' } : { type: 'expand' }); }); }} size="small" @@ -104,23 +127,57 @@ export function ExampleCodePreview({ ); } -type SourceMode = 'unmeasured' | 'fits' | 'collapsed' | 'expanded'; +/** + * Whether the collapsed clip cuts off source lines. Returns `undefined` while the clip is not + * applied (for example when a resize callback fires for the expanded DOM just as a collapse is + * queued), because that measurement says nothing about the collapsed source. + */ +function measureIsClipped(sourceElement: HTMLElement) { + if (getComputedStyle(sourceElement).overflowY !== 'hidden') return undefined; + return sourceElement.scrollHeight > sourceElement.clientHeight + 1; +} -type SourceEvent = { isClipped: boolean; type: 'measured' } | { type: 'toggle' }; +interface SourceState { + mode: 'unmeasured' | 'fits' | 'collapsed' | 'expanded'; + /** + * Only a pending collapse can request restoration. Remember blur until measurement so focus + * returning before commit can reinstate the request. + */ + focus: 'none' | 'copy-if-fits' | 'collapse-blurred' | 'copy'; +} -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'; - } +type SourceEvent = + | { isClipped: boolean; type: 'measured' } + | { type: 'expand' } + | { hadFocus: boolean; type: 'collapse' } + | { type: 'toggleBlurred' } + | { type: 'toggleFocused' } + | { type: 'focusRestored' }; + +const initialSourceState: SourceState = { focus: 'none', mode: 'unmeasured' }; - switch (mode) { - case 'collapsed': - return 'expanded'; - case 'expanded': - return 'collapsed'; - default: - return mode; +function sourceReducer(state: SourceState, event: SourceEvent): SourceState { + switch (event.type) { + case 'measured': { + // Expanded source fits by definition. Keep its collapse control until it is collapsed. + if (state.mode === 'expanded') return state; + const mode = event.isClipped ? 'collapsed' : 'fits'; + let focus = state.focus; + if (event.isClipped || focus === 'collapse-blurred') focus = 'none'; + else if (focus === 'copy-if-fits') focus = 'copy'; + return mode === state.mode && focus === state.focus ? state : { focus, mode }; + } + case 'expand': + return state.mode === 'collapsed' ? { focus: 'none', mode: 'expanded' } : state; + case 'collapse': + return state.mode === 'expanded' + ? { focus: event.hadFocus ? 'copy-if-fits' : 'none', mode: 'collapsed' } + : state; + case 'toggleBlurred': + return state.focus === 'copy-if-fits' ? { ...state, focus: 'collapse-blurred' } : state; + case 'toggleFocused': + return state.focus === 'collapse-blurred' ? { ...state, focus: 'copy-if-fits' } : state; + case 'focusRestored': + return state.focus === 'copy' ? { ...state, focus: 'none' } : state; } }