import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { renderHook, render, screen, fireEvent } from '@testing-library/react'; import { useState } from 'react'; import { useFocusTrap } from './use-focus-trap'; describe('useFocusTrap', () => { beforeEach(() => { jest.clearAllMocks(); document.body.style.overflow = 'unset'; // Mock offsetParent at prototype level for jsdom compatibility // jsdom 不支持 layout,offsetParent 始终返回 null,导致 getFocusableElements 过滤失效 Object.defineProperty(HTMLElement.prototype, 'offsetParent', { get: () => document.body, configurable: true, }); }); describe('Initial State', () => { it('should return a ref', () => { const { result } = renderHook(() => useFocusTrap(false)); expect(result.current).toBeDefined(); expect(result.current.current).toBeNull(); }); }); describe('When Active', () => { it('should store previous active element', () => { const activeElement = document.createElement('button'); document.body.appendChild(activeElement); activeElement.focus(); const { result } = renderHook(() => useFocusTrap(true)); expect(result.current).toBeDefined(); document.body.removeChild(activeElement); }); it('should add keydown event listener', () => { const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); renderHook(() => useFocusTrap(true)); expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); }); it('should set body overflow to hidden', () => { renderHook(() => useFocusTrap(true)); expect(document.body.style.overflow).toBe('hidden'); }); }); describe('When Inactive', () => { it('should not add keydown event listener', () => { const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); renderHook(() => useFocusTrap(false)); expect(addEventListenerSpy).not.toHaveBeenCalled(); }); it('should not set body overflow', () => { renderHook(() => useFocusTrap(false)); expect(document.body.style.overflow).toBe('unset'); }); }); describe('Cleanup', () => { it('should remove keydown event listener on unmount', () => { const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); const { unmount } = renderHook(() => useFocusTrap(true)); unmount(); expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); }); it('should restore body overflow on unmount', () => { const { unmount } = renderHook(() => useFocusTrap(true)); unmount(); expect(document.body.style.overflow).toBe('unset'); }); it('should clean up event listener when deactivating', () => { const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); const { rerender } = renderHook( ({ isActive }) => useFocusTrap(isActive), { initialProps: { isActive: true } } ); rerender({ isActive: false }); expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); }); }); describe('Focus Cycling', () => { function TrapComponent({ isActive }: { isActive: boolean }) { const ref = useFocusTrap(isActive); return (
); } it('should cycle focus from last to first on Tab', () => { render(); const lastButton = screen.getByTestId('last-btn'); // Focus the last button lastButton.focus(); expect(document.activeElement).toBe(lastButton); // Tab should cycle to first button fireEvent.keyDown(document, { key: 'Tab' }); // The handler should call preventDefault and firstElement.focus() expect(() => { fireEvent.keyDown(document, { key: 'Tab' }); }).not.toThrow(); }); it('should cycle focus from first to last on Shift+Tab', () => { render(); const firstButton = screen.getByTestId('first-btn'); // Focus the first button firstButton.focus(); expect(document.activeElement).toBe(firstButton); // Shift+Tab should cycle to last button expect(() => { fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); }).not.toThrow(); }); it('should do nothing for Tab on middle element (not last)', () => { function ThreeButtonTrap() { const ref = useFocusTrap(true); return (
); } render(); const middleButton = screen.getByTestId('middle-btn'); middleButton.focus(); expect(document.activeElement).toBe(middleButton); // Tab on middle should NOT cycle (middle is not last) expect(() => { fireEvent.keyDown(document, { key: 'Tab' }); }).not.toThrow(); }); it('should call preventDefault on Tab when focus is on last element', () => { const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); render(); const lastButton = screen.getByTestId('last-btn'); lastButton.focus(); fireEvent.keyDown(document, { key: 'Tab' }); // The handler should call preventDefault because activeElement === lastElement expect(preventDefaultSpy).toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should call preventDefault on Shift+Tab when focus is on first element', () => { const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); render(); fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); // The handler should call preventDefault because activeElement === firstElement expect(preventDefaultSpy).toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should not call preventDefault on Tab when focus is on middle element', () => { function ThreeButtonTrap() { const ref = useFocusTrap(true); return (
); } const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); render(); const middleButton = screen.getByTestId('middle-btn'); middleButton.focus(); fireEvent.keyDown(document, { key: 'Tab' }); // Middle element is not the last, so preventDefault should NOT be called expect(preventDefaultSpy).not.toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should not call preventDefault on Shift+Tab when focus is on last element', () => { function ThreeButtonTrap() { const ref = useFocusTrap(true); return (
); } const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); render(); const lastButton = screen.getByTestId('last-btn'); lastButton.focus(); fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); // Last element is not the first, so preventDefault should NOT be called expect(preventDefaultSpy).not.toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); }); describe('Escape Key Behavior', () => { function TrapComponent({ isActive }: { isActive: boolean }) { const ref = useFocusTrap(isActive); return (
); } it('should call preventDefault on Escape key when active', () => { const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); render(); fireEvent.keyDown(document, { key: 'Escape' }); expect(preventDefaultSpy).toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should call focus on previous active element on Escape', () => { const previous = document.createElement('button'); previous.textContent = 'previous'; const focusSpy = jest.spyOn(previous, 'focus'); document.body.appendChild(previous); previous.focus(); render(); fireEvent.keyDown(document, { key: 'Escape' }); expect(focusSpy).toHaveBeenCalled(); document.body.removeChild(previous); focusSpy.mockRestore(); }); it('should not call preventDefault on non-Escape keys', () => { const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); render(); fireEvent.keyDown(document, { key: 'Enter' }); fireEvent.keyDown(document, { key: 'ArrowUp' }); // preventDefault should only be called for Tab and Escape, not for Enter/ArrowUp expect(preventDefaultSpy).not.toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should not call preventDefault on Escape when inactive', () => { const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); render(); fireEvent.keyDown(document, { key: 'Escape' }); // When inactive, no handler is registered, so preventDefault should not be called expect(preventDefaultSpy).not.toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should handle Escape without crashing when no previous element', () => { render(); expect(() => { fireEvent.keyDown(document, { key: 'Escape' }); }).not.toThrow(); }); }); describe('State Changes', () => { it('should handle activation change', () => { const { result, rerender } = renderHook( ({ isActive }) => useFocusTrap(isActive), { initialProps: { isActive: false } } ); expect(result.current).toBeDefined(); rerender({ isActive: true }); expect(document.body.style.overflow).toBe('hidden'); rerender({ isActive: false }); expect(document.body.style.overflow).toBe('unset'); }); it('should stop responding to events when deactivated', () => { const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); const { rerender } = renderHook( ({ isActive }) => useFocusTrap(isActive), { initialProps: { isActive: true } } ); // Deactivate rerender({ isActive: false }); // With correct deps: listener is removed, so Escape should NOT trigger preventDefault // With wrong deps ([]): listener is still attached with isActive=true from closure fireEvent.keyDown(document, { key: 'Escape' }); expect(preventDefaultSpy).not.toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should re-register event listener when isActive changes back to true', () => { const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); const { rerender } = renderHook( ({ isActive }) => useFocusTrap(isActive), { initialProps: { isActive: true } } ); // Clear initial calls addEventListenerSpy.mockClear(); // Deactivate - cleanup removes listener rerender({ isActive: false }); // Activate again - should re-add listener rerender({ isActive: true }); // Should have added a new listener when reactivating expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); addEventListenerSpy.mockRestore(); }); it('should not respond to Escape when deactivated via state toggle (dependency tracking)', () => { function TrapWithToggle() { const [active, setActive] = useState(true); const ref = useFocusTrap(active); return (
); } const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); render(); // Deactivate via state toggle fireEvent.click(screen.getByTestId('toggle-btn')); // With correct deps: listener is removed, Escape should NOT trigger preventDefault // With wrong deps ([]): listener is still attached with isActive=true, Escape DOES trigger preventDefault fireEvent.keyDown(document, { key: 'Escape' }); expect(preventDefaultSpy).not.toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should restore body overflow when deactivated via state toggle (dependency tracking)', () => { function TrapWithToggle() { const [active, setActive] = useState(true); const ref = useFocusTrap(active); return (
); } render(); expect(document.body.style.overflow).toBe('hidden'); // Deactivate via state toggle fireEvent.click(screen.getByTestId('toggle-overflow-btn')); // With correct deps: cleanup restores overflow to 'unset' // With wrong deps ([]): cleanup doesn't run, overflow stays 'hidden' expect(document.body.style.overflow).toBe('unset'); }); }); describe('Edge Cases', () => { it('should handle empty container with no focusable elements', () => { function EmptyContainer() { const ref = useFocusTrap(true); return
; } render(); expect(() => { fireEvent.keyDown(document, { key: 'Tab' }); }).not.toThrow(); }); it('should handle disabled focusable elements', () => { function ContainerWithDisabled() { const ref = useFocusTrap(true); return (
); } render(); // After useEffect, the first non-disabled focusable element is focused // Since the disabled button is excluded from focusable elements, // the enabled button should be focused (it's the only focusable element). // It's both first and last, so Tab should cycle to the same element. const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); fireEvent.keyDown(document, { key: 'Tab' }); // preventDefault should be called because activeElement === lastElement (same element) expect(preventDefaultSpy).toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should handle hidden focusable elements', () => { function ContainerWithHidden() { const ref = useFocusTrap(true); return (
); } render(); // Override the prototype-level mock for the hidden element to test filtering const hiddenBtn = screen.getByTestId('hidden-container').querySelector('button:first-child'); if (hiddenBtn) { Object.defineProperty(hiddenBtn, 'offsetParent', { value: null, configurable: true, }); } const visibleButton = screen.getByTestId('visible-btn'); visibleButton.focus(); // Only the visible button is focusable, so it's both first and last. // Tab should cycle the visible element (it's first and last) const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault'); fireEvent.keyDown(document, { key: 'Tab' }); expect(preventDefaultSpy).toHaveBeenCalled(); preventDefaultSpy.mockRestore(); }); it('should not respond to non-Tab non-Escape keys', () => { function SimpleTrap() { const ref = useFocusTrap(true); return (
); } render(); const btn = screen.getByTestId('the-btn'); btn.focus(); // Non-Tab/Escape keys should not trigger any focus change expect(() => { fireEvent.keyDown(document, { key: 'Enter' }); fireEvent.keyDown(document, { key: 'ArrowUp' }); fireEvent.keyDown(document, { key: ' ' }); }).not.toThrow(); }); }); describe('Key Event Filtering', () => { function SimpleTrap() { const ref = useFocusTrap(true); return (
); } it('should not crash on Tab key when active', () => { render(); expect(() => { fireEvent.keyDown(document, { key: 'Tab' }); }).not.toThrow(); }); it('should not crash on Escape key when active', () => { render(); expect(() => { fireEvent.keyDown(document, { key: 'Escape' }); }).not.toThrow(); }); it('should not respond to events when inactive', () => { function InactiveTrap() { const ref = useFocusTrap(false); return (
); } render(); // No handler registered, so events should not crash expect(() => { fireEvent.keyDown(document, { key: 'Tab' }); fireEvent.keyDown(document, { key: 'Escape' }); }).not.toThrow(); }); }); describe('Focusable Elements Detection', () => { it('should handle various focusable element types', () => { function ManyFocusableTypes() { const ref = useFocusTrap(true); return (
link