// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest'; import { act, useEffect, useRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nProvider } from '../../i18n'; import { ThemeProvider } from '../../themeContext'; import { DialogShell } from './DialogShell'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let container: HTMLDivElement | null = null; let root: Root | null = null; function mount(node: React.ReactNode) { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); act(() => { root!.render( {node} , ); }); } function press(key: string, options: KeyboardEventInit = {}) { act(() => { document.dispatchEvent( new KeyboardEvent('keydown', { key, cancelable: true, ...options }), ); }); } afterEach(() => { act(() => root?.unmount()); container?.remove(); root = null; container = null; }); function AutofocusChild() { const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); return ; } describe('DialogShell', () => { it('hides the fullscreen toggle by default', () => { mount( {}}>
content
, ); // DialogShell portals into document.body, so query there, not the root. expect(document.querySelector('button[aria-pressed]')).toBeNull(); }); it('shows a fullscreen toggle when allowFullscreen and toggles its state', () => { mount( {}}>
content
, ); const toggle = document.querySelector( 'button[aria-pressed]', ) as HTMLElement | null; expect(toggle).toBeTruthy(); expect(toggle!.getAttribute('aria-pressed')).toBe('false'); act(() => { toggle!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(toggle!.getAttribute('aria-pressed')).toBe('true'); }); it('closes on Escape', () => { const onClose = vi.fn(); mount( , ); press('Escape'); expect(onClose).toHaveBeenCalledTimes(1); }); it('ignores Escape that belongs to an IME composition', () => { const onClose = vi.fn(); mount( , ); // Chrome/Firefox: Escape cancelling a composition reports isComposing. press('Escape', { isComposing: true }); expect(onClose).not.toHaveBeenCalled(); // WebKit: compositionend fires first, so only keyCode 229 marks the key. act(() => { const imeEscape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, }); Object.defineProperty(imeEscape, 'keyCode', { value: 229 }); document.dispatchEvent(imeEscape); }); expect(onClose).not.toHaveBeenCalled(); // A genuine Escape still closes. press('Escape'); expect(onClose).toHaveBeenCalledTimes(1); }); it('keeps focus on the panel when Tab is pressed with nothing focusable inside', () => { mount( , ); const panel = document.querySelector('[role="dialog"]')!; // Simulate content whose focusables all went away (e.g. everything became // disabled/hidden while an action runs). document.querySelector('[data-dialog-close]')!.remove(); document.querySelector('[data-testid="inner"]')!.remove(); panel.focus(); act(() => { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true, }), ); }); // Tab must not escape to the page behind; focus stays parked on the panel. expect(document.activeElement).toBe(panel); }); it('lets a dialog control consume Escape instead of closing', () => { const onClose = vi.fn(); mount( { // e.g. an inline editor cancelling its edit on Escape. if (event.key === 'Escape') event.preventDefault(); }} /> , ); const input = document.querySelector('input')!; input.focus(); act(() => { input.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true, }), ); }); expect(onClose).not.toHaveBeenCalled(); }); it('only the topmost of stacked shells handles Escape', () => { const onCloseBottom = vi.fn(); const onCloseTop = vi.fn(); mount( <> , ); // One Escape peels off one layer — the top one — not both at once. press('Escape'); expect(onCloseTop).toHaveBeenCalledTimes(1); expect(onCloseBottom).not.toHaveBeenCalled(); }); it('keeps focus inside the top shell if a lower shell unmounts first', () => { const opener = document.createElement('button'); document.body.appendChild(opener); opener.focus(); function Harness({ showBottom }: { showBottom: boolean }) { return ( <> {showBottom ? ( ) : null} ); } mount(); const topButton = document.querySelector( '[data-testid="top-focus"]', )!; expect(document.activeElement).toBe(topButton); act(() => { root!.render( , ); }); // Focus must stay inside the remaining top shell, not jump back behind it. expect(document.activeElement).toBe(topButton); opener.remove(); }); it('restores focus to the remaining top shell when the lower shell unmounts after focus moved', () => { const opener = document.createElement('button'); document.body.appendChild(opener); opener.focus(); function Harness({ showBottom }: { showBottom: boolean }) { return ( <> {showBottom ? ( ) : null} ); } mount(); const topButton = document.querySelector( '[data-testid="top-focus"]', )!; document.querySelector('button:not([data-testid])')!.focus(); act(() => { root!.render( , ); }); expect(document.activeElement).toBe(topButton); opener.remove(); }); it('closes when the backdrop is clicked but not when the panel is clicked', () => { const onClose = vi.fn(); mount( , ); const backdrop = document.querySelector( '[data-keyboard-scope]', ); const panel = document.querySelector('[role="dialog"]'); expect(backdrop).toBeTruthy(); act(() => { panel!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); panel!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onClose).not.toHaveBeenCalled(); act(() => { backdrop!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); backdrop!.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); backdrop!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onClose).toHaveBeenCalledTimes(1); }); it('does not close when a drag starts in the panel and ends on the backdrop', () => { const onClose = vi.fn(); mount( , ); const backdrop = document.querySelector( '[data-keyboard-scope]', )!; const panel = document.querySelector('[role="dialog"]')!; act(() => { panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onClose).not.toHaveBeenCalled(); }); it('does not close when a press starts on the backdrop and ends on the panel', () => { const onClose = vi.fn(); mount( , ); const backdrop = document.querySelector( '[data-keyboard-scope]', )!; const panel = document.querySelector('[role="dialog"]')!; act(() => { backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); panel.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); // Browsers synthesize click on the nearest common ancestor for mismatched // press/release targets; here that's effectively the backdrop. backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(onClose).not.toHaveBeenCalled(); }); it('moves focus into the dialog on open', () => { mount( , ); const first = document.querySelector('[data-testid="first"]'); expect(document.activeElement).toBe(first); }); it('restores focus to the opener on close', () => { const opener = document.createElement('button'); document.body.appendChild(opener); opener.focus(); expect(document.activeElement).toBe(opener); mount( , ); // Focus moved into the dialog. expect(document.activeElement).not.toBe(opener); act(() => root?.unmount()); root = null; expect(document.activeElement).toBe(opener); opener.remove(); }); it('restores focus to the opener even if a child autofocuses first', () => { const opener = document.createElement('button'); document.body.appendChild(opener); opener.focus(); expect(document.activeElement).toBe(opener); mount( , ); const input = document.querySelector('input'); expect(document.activeElement).toBe(input); act(() => root?.unmount()); root = null; expect(document.activeElement).toBe(opener); opener.remove(); }); it('traps Tab within the dialog, wrapping at both ends', () => { mount( , ); // Focusables in DOM order: [close button, inner button]. const close = document.querySelector('[data-dialog-close]')!; const last = document.querySelector('[data-testid="last"]')!; // Tab from the last focusable wraps to the first (the close button). last.focus(); act(() => { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), ); }); expect(document.activeElement).toBe(close); // Shift+Tab from the first focusable wraps to the last. close.focus(); act(() => { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, }), ); }); expect(document.activeElement).toBe(last); }); it('pulls focus into the dialog when Tab is pressed while the panel holds focus', () => { mount( , ); const panel = document.querySelector('[role="dialog"]')!; const close = document.querySelector('[data-dialog-close]')!; const last = document.querySelector('[data-testid="last"]')!; // Focus sits on the panel itself (roving-list fallback). Tab pulls it in. panel.focus(); act(() => { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), ); }); expect(document.activeElement).toBe(close); // Shift+Tab from the panel pulls in from the end instead. panel.focus(); act(() => { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, }), ); }); expect(document.activeElement).toBe(last); }); });