import assert from 'node:assert/strict'; import test from 'node:test'; import { createModalFocusGuard } from './modal-focus-guard'; type Listener = (event?: unknown) => void; function createRoot(contains: boolean) { const listeners: Array<{ type: string; listener: Listener }> = []; return { contains: () => contains, addEventListener: (type: string, listener: Listener) => { listeners.push({ type, listener }); }, removeEventListener: (type: string, listener: Listener) => { const index = listeners.findIndex( (entry) => entry.type === type && entry.listener === listener, ); if (index >= 0) listeners.splice(index, 1); }, listeners, }; } type Harness = { guard: ReturnType; root: ReturnType; focusMainWindowCalls: () => number; focused: () => string[]; documentListeners: () => string[]; windowListeners: () => string[]; handlerFor: (scope: 'document' | 'window', type: string) => Listener | undefined; setActiveElement: (value: unknown) => void; advanceClock: (ms: number) => void; runTimers: () => void; clearedTimers: () => number[]; pendingTimerCount: () => number; restore: () => void; }; function createHarness( options: { isOpen?: () => boolean; isModalLayer?: boolean; contains?: boolean; preferredVisible?: boolean; preferredAcceptsFocus?: boolean; extraPreferred?: boolean; fallback?: 'element' | null; } = {}, ): Harness { const previous = (['window', 'document', 'HTMLElement', 'Element'] as const).map( (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, ); class TestElement {} for (const name of ['HTMLElement', 'Element'] as const) { Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: TestElement, }); } let focusMainWindowCalls = 0; let now = 1_000; const realDateNow = Date.now; Date.now = () => now; const focused: string[] = []; const documentListeners: Array<{ type: string; listener: Listener }> = []; const windowListeners: Array<{ type: string; listener: Listener }> = []; const register = (registry: Array<{ type: string; listener: Listener }>) => ({ add: (type: string, listener: Listener) => { registry.push({ type, listener }); }, remove: (type: string, listener: Listener) => { const index = registry.findIndex( (entry) => entry.type === type && entry.listener === listener, ); if (index >= 0) registry.splice(index, 1); }, }); const windowRegistry = register(windowListeners); const documentRegistry = register(documentListeners); const timers = new Map void>(); let nextTimerId = 0; const clearedTimers: number[] = []; let activeElement: unknown = null; const root = createRoot(options.contains ?? false); const preferred = Object.assign(new TestElement(), { // A position:fixed element has a null offsetParent but still has rects. offsetParent: null, getClientRects: () => (options.preferredVisible === false ? [] : [{ width: 10, height: 10 }]), focus: () => { focused.push('preferred'); // A rendered-but-unfocusable target (e.g. disabled) never becomes active. if (options.preferredAcceptsFocus !== false) activeElement = preferred; }, }); const secondPreferred = Object.assign(new TestElement(), { getClientRects: () => [{ width: 10, height: 10 }], focus: () => { focused.push('second'); activeElement = secondPreferred; }, }); const fallback = options.fallback === null ? null : Object.assign(new TestElement(), { focus: () => { focused.push('fallback'); activeElement = fallback; }, }); Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: { electronAPI: { focusMainWindow: async () => { focusMainWindowCalls += 1; }, }, focus: () => { focused.push('window'); }, addEventListener: windowRegistry.add, removeEventListener: windowRegistry.remove, setTimeout: (callback: () => void) => { nextTimerId += 1; timers.set(nextTimerId, callback); return nextTimerId; }, clearTimeout: (id: number) => { clearedTimers.push(id); timers.delete(id); }, }, }); Object.defineProperty(globalThis, 'document', { configurable: true, writable: true, value: { get activeElement() { return activeElement; }, addEventListener: documentRegistry.add, removeEventListener: documentRegistry.remove, }, }); const guard = createModalFocusGuard({ isOpen: options.isOpen ?? (() => true), getModalRoot: () => root as unknown as Element, getPreferredFocusTargets: () => (options.extraPreferred ? [preferred, secondPreferred] : [preferred]) as unknown as HTMLElement[], getFallbackFocusTarget: () => fallback as unknown as Element | null, isModalLayer: options.isModalLayer ?? true, }); return { guard, root, focusMainWindowCalls: () => focusMainWindowCalls, focused: () => focused, documentListeners: () => documentListeners.map((entry) => entry.type), windowListeners: () => windowListeners.map((entry) => entry.type), handlerFor: (scope: 'document' | 'window', type: string) => (scope === 'document' ? documentListeners : windowListeners).find( (entry) => entry.type === type, )?.listener, setActiveElement: (value: unknown) => { activeElement = value; }, advanceClock: (ms: number) => { now += ms; }, runTimers: () => { const pending = [...timers.values()]; timers.clear(); for (const callback of pending) callback(); }, clearedTimers: () => clearedTimers, pendingTimerCount: () => timers.size, restore: () => { Date.now = realDateNow; for (const [name, descriptor] of previous) { if (descriptor) { Object.defineProperty(globalThis, name, descriptor); } else { delete (globalThis as Record)[name]; } } }, }; } test('modal focus guard attaches once and detaches every listener', () => { const harness = createHarness(); try { harness.guard.attach(); harness.guard.attach(); assert.deepEqual(harness.documentListeners(), ['focusin']); assert.deepEqual(harness.windowListeners(), ['blur', 'focus']); assert.deepEqual( harness.root.listeners.map((entry) => entry.type), ['pointerdown', 'click'], ); harness.guard.detach(); assert.deepEqual(harness.documentListeners(), []); assert.deepEqual(harness.windowListeners(), []); assert.deepEqual(harness.root.listeners, []); } finally { harness.restore(); } }); test('modal focus guard pulls focus back when focusin lands outside the modal', () => { const harness = createHarness(); try { harness.guard.attach(); const focusin = harness.handlerFor('document', 'focusin'); assert.ok(focusin, 'attach registers a focusin handler'); // focusin is not cancelable, so recovery is the only observable effect. focusin?.({ target: {} }); assert.deepEqual(harness.focused(), ['preferred']); } finally { harness.restore(); } }); test('modal focus guard ignores focusin while the modal is closed', () => { const harness = createHarness({ isOpen: () => false }); try { harness.guard.attach(); harness.handlerFor('document', 'focusin')?.({ target: {} }); assert.deepEqual(harness.focused(), []); } finally { harness.restore(); } }); test('modal focus guard restores focus to the first rendered target', () => { // The target is position:fixed (null offsetParent) yet visible, so it must // still win over the fallback. const harness = createHarness(); try { harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), ['preferred']); } finally { harness.restore(); } }); test('modal focus guard tries the next target when one refuses focus', () => { const harness = createHarness({ preferredAcceptsFocus: false, extraPreferred: true }); try { assert.equal(harness.guard.focusFallbackTarget(), true); assert.deepEqual(harness.focused(), ['preferred', 'second']); } finally { harness.restore(); } }); test('modal focus guard reaches the fallback when no preferred target takes focus', () => { const harness = createHarness({ preferredAcceptsFocus: false }); try { assert.equal(harness.guard.focusFallbackTarget(), true); assert.deepEqual(harness.focused(), ['preferred', 'fallback']); } finally { harness.restore(); } }); test('modal focus guard falls back when no preferred target is rendered', () => { const harness = createHarness({ preferredVisible: false }); try { harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), ['fallback']); } finally { harness.restore(); } }); test('modal focus guard focuses the window when nothing else can take focus', () => { const harness = createHarness({ preferredVisible: false, fallback: null }); try { assert.equal(harness.guard.focusFallbackTarget(), false); assert.deepEqual(harness.focused(), ['window']); } finally { harness.restore(); } }); test('modal focus guard leaves focus alone while it is already inside the modal', () => { const harness = createHarness({ contains: true }); try { harness.setActiveElement( Object.create((globalThis as { Element: { prototype: object } }).Element.prototype), ); harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), []); } finally { harness.restore(); } }); test('modal focus guard does nothing while the modal is closed', () => { const harness = createHarness({ isOpen: () => false }); try { harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), []); } finally { harness.restore(); } }); test('modal focus guard clears recovery state on detach so a reopen is not blocked', () => { const harness = createHarness(); try { harness.guard.attach(); harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), ['preferred']); assert.equal(harness.pendingTimerCount(), 1, 'recovery armed the debounce timer'); // Close while recovery is still in flight. harness.guard.detach(); assert.equal(harness.clearedTimers().length, 1, 'the pending debounce timer is cancelled'); assert.equal(harness.pendingTimerCount(), 0); // Immediate reopen: recovery must work straight away, not 120 ms later. harness.guard.attach(); harness.setActiveElement(null); harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), ['preferred', 'preferred']); } finally { harness.restore(); } }); test('modal focus guard debounces recovery so a focus fight cannot spin', () => { const harness = createHarness(); try { harness.guard.enforceModalFocus(); harness.setActiveElement(null); // Re-entry guard: the recovery timer has not fired yet. harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), ['preferred']); // Timer cleared the re-entry flag, but the debounce window still holds. harness.runTimers(); harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), ['preferred'], 'debounce still blocks the retry'); // Past the window, recovery resumes. harness.advanceClock(200); harness.setActiveElement(null); harness.guard.enforceModalFocus(); assert.deepEqual(harness.focused(), ['preferred', 'preferred']); } finally { harness.restore(); } }); test('modal focus guard asks the main window for focus off the modal layer only', () => { const onModalLayer = createHarness({ isModalLayer: true }); try { onModalLayer.guard.requestOverlayFocus(); assert.equal(onModalLayer.focusMainWindowCalls(), 0); } finally { onModalLayer.restore(); } const onOverlayLayer = createHarness({ isModalLayer: false }); try { onOverlayLayer.guard.requestOverlayFocus(); assert.equal(onOverlayLayer.focusMainWindowCalls(), 1); } finally { onOverlayLayer.restore(); } });