import { renderHook, act } from '@testing-library/react'; import { useDialogFocusTrap } from '../useDialogFocusTrap'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const makeContainerRef = (el: HTMLElement) => ({ current: el as HTMLElement | null }); // Execute rAF callbacks synchronously so focus side-effects in the hook are // visible immediately without needing fake timers. beforeEach(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0; }); jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); // --------------------------------------------------------------------------- // Suites // --------------------------------------------------------------------------- describe('useDialogFocusTrap – inactive', () => { it('does not attach a keydown listener on document', () => { const container = document.createElement('div'); document.body.appendChild(container); const spy = jest.spyOn(document, 'addEventListener'); renderHook(() => useDialogFocusTrap({ active: false, containerRef: makeContainerRef(container) }), ); expect(spy).not.toHaveBeenCalledWith('keydown', expect.any(Function)); document.body.removeChild(container); }); it('does not change focus when inactive', () => { const outsideBtn = document.createElement('button'); document.body.appendChild(outsideBtn); outsideBtn.focus(); const container = document.createElement('div'); document.body.appendChild(container); renderHook(() => useDialogFocusTrap({ active: false, containerRef: makeContainerRef(container) }), ); expect(document.activeElement).toBe(outsideBtn); document.body.removeChild(outsideBtn); document.body.removeChild(container); }); }); describe('useDialogFocusTrap – active', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); container.setAttribute('tabindex', '-1'); document.body.appendChild(container); }); afterEach(() => { // Guard: element may have been removed by a test already if (document.body.contains(container)) { document.body.removeChild(container); } }); // ── Initial focus ────────────────────────────────────────────────────────── it('focuses the first focusable element inside the container on activation', () => { const btn = document.createElement('button'); container.appendChild(btn); renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); expect(document.activeElement).toBe(btn); }); it('focuses the container itself when it contains no focusable elements', () => { const focusSpy = jest.spyOn(container, 'focus'); renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); expect(focusSpy).toHaveBeenCalled(); }); it('skips elements with aria-hidden="true"', () => { const btn1 = document.createElement('button'); btn1.setAttribute('aria-hidden', 'true'); const btn2 = document.createElement('button'); container.appendChild(btn1); container.appendChild(btn2); renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); // btn1 is hidden from AT, so btn2 should receive focus first expect(document.activeElement).toBe(btn2); }); // ── Tab key trap ─────────────────────────────────────────────────────────── it('wraps Tab forward: last → first focusable element', () => { const btn1 = document.createElement('button'); const btn2 = document.createElement('button'); container.appendChild(btn1); container.appendChild(btn2); renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); act(() => { btn2.focus(); }); expect(document.activeElement).toBe(btn2); const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }); const preventDefaultSpy = jest.spyOn(tabEvent, 'preventDefault'); act(() => { document.dispatchEvent(tabEvent); }); expect(preventDefaultSpy).toHaveBeenCalled(); expect(document.activeElement).toBe(btn1); }); it('wraps Shift+Tab backward: first → last focusable element', () => { const btn1 = document.createElement('button'); const btn2 = document.createElement('button'); container.appendChild(btn1); container.appendChild(btn2); renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); act(() => { btn1.focus(); }); expect(document.activeElement).toBe(btn1); const shiftTabEvent = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }); const preventDefaultSpy = jest.spyOn(shiftTabEvent, 'preventDefault'); act(() => { document.dispatchEvent(shiftTabEvent); }); expect(preventDefaultSpy).toHaveBeenCalled(); expect(document.activeElement).toBe(btn2); }); it('ignores non-Tab keydown events', () => { const btn1 = document.createElement('button'); const btn2 = document.createElement('button'); container.appendChild(btn1); container.appendChild(btn2); renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); act(() => { btn1.focus(); }); const escEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); const preventDefaultSpy = jest.spyOn(escEvent, 'preventDefault'); act(() => { document.dispatchEvent(escEvent); }); // Focus should stay on btn1 — Escape is not handled by the trap expect(document.activeElement).toBe(btn1); expect(preventDefaultSpy).not.toHaveBeenCalled(); }); it('Tab with no focusable elements focuses the container root', () => { const focusSpy = jest.spyOn(container, 'focus'); renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }); const preventDefaultSpy = jest.spyOn(tabEvent, 'preventDefault'); act(() => { document.dispatchEvent(tabEvent); }); expect(preventDefaultSpy).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); }); // ── focusin outside redirect ─────────────────────────────────────────────── it('redirects focus back inside when an element outside the dialog gains focus', () => { const btn = document.createElement('button'); container.appendChild(btn); const outsideBtn = document.createElement('button'); document.body.appendChild(outsideBtn); renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); // Cause focus to leave the dialog — jsdom fires "focusin" on document act(() => { outsideBtn.focus(); }); expect(document.activeElement).toBe(btn); document.body.removeChild(outsideBtn); }); // ── Focus restoration on cleanup ────────────────────────────────────────── it('restores focus to the previously focused element when the hook cleans up', () => { const previousBtn = document.createElement('button'); document.body.appendChild(previousBtn); previousBtn.focus(); const btn = document.createElement('button'); container.appendChild(btn); const { unmount } = renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); // Trap should have shifted focus to btn expect(document.activeElement).toBe(btn); act(() => { unmount(); }); expect(document.activeElement).toBe(previousBtn); document.body.removeChild(previousBtn); }); it('does not throw if the previously focused element was removed from the DOM before cleanup', () => { const btn = document.createElement('button'); container.appendChild(btn); const { unmount } = renderHook(() => useDialogFocusTrap({ active: true, containerRef: makeContainerRef(container) }), ); // No element was focused beforehand → previouslyFocused is body/null, // which is always in the document, so unmount must never throw. expect(() => { act(() => { unmount(); }); }).not.toThrow(); }); });