Files
novalon-website/src/hooks/use-focus-trap.test.tsx
T
zhangxiang 7c0af54897 test(hooks): enhance focus-trap mutation score to 85.25% and finalize release acceptance
- Increase use-focus-trap mutation score from 42.62% to 85.25% (exceeds 50% target)
- Add 40 new test cases: focus cycling, Edge Cases, Focusable Elements Detection
- Fix jsdom offsetParent limitation via prototype-level mock
- Remove temporary debug file (__debug.test.tsx)
- Update test-strategy-plan.md to v1.9 with mutation score and debt tracking
- Update README.md with final acceptance progress
- Quality gates: TypeScript 0 errors, ESLint 0 errors, 121 suites/1549 tests passed
- Coverage: 73.62% stmts / 82.52% branches (all thresholds met)
2026-08-02 18:42:45 +08:00

631 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 不支持 layoutoffsetParent 始终返回 null,导致 getFocusableElements 过滤失效
Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
get: () => document.body,
configurable: true,
});
});
describe('Initial State', () => {
it('should return a ref', () => {
const { result } = renderHook(() => useFocusTrap<HTMLDivElement>(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<HTMLDivElement>(true));
expect(result.current).toBeDefined();
document.body.removeChild(activeElement);
});
it('should add keydown event listener', () => {
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
renderHook(() => useFocusTrap<HTMLDivElement>(true));
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
});
it('should set body overflow to hidden', () => {
renderHook(() => useFocusTrap<HTMLDivElement>(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<HTMLDivElement>(false));
expect(addEventListenerSpy).not.toHaveBeenCalled();
});
it('should not set body overflow', () => {
renderHook(() => useFocusTrap<HTMLDivElement>(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<HTMLDivElement>(true));
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
});
it('should restore body overflow on unmount', () => {
const { unmount } = renderHook(() => useFocusTrap<HTMLDivElement>(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<HTMLDivElement>(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<HTMLDivElement>(isActive);
return (
<div>
<button>outside</button>
<div ref={ref} data-testid="trap">
<button data-testid="first-btn">first</button>
<button data-testid="last-btn">last</button>
</div>
</div>
);
}
it('should cycle focus from last to first on Tab', () => {
render(<TrapComponent isActive />);
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(<TrapComponent isActive />);
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<HTMLDivElement>(true);
return (
<div>
<button>outside</button>
<div ref={ref} data-testid="trap-3btn">
<button data-testid="first-btn">first</button>
<button data-testid="middle-btn">middle</button>
<button data-testid="last-btn">last</button>
</div>
</div>
);
}
render(<ThreeButtonTrap />);
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(<TrapComponent isActive />);
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(<TrapComponent isActive />);
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<HTMLDivElement>(true);
return (
<div>
<button>outside</button>
<div ref={ref} data-testid="trap-3btn">
<button data-testid="first-btn">first</button>
<button data-testid="middle-btn">middle</button>
<button data-testid="last-btn">last</button>
</div>
</div>
);
}
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
render(<ThreeButtonTrap />);
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<HTMLDivElement>(true);
return (
<div>
<button>outside</button>
<div ref={ref} data-testid="trap-3btn">
<button data-testid="first-btn">first</button>
<button data-testid="middle-btn">middle</button>
<button data-testid="last-btn">last</button>
</div>
</div>
);
}
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
render(<ThreeButtonTrap />);
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<HTMLDivElement>(isActive);
return (
<div>
<div ref={ref}>
<button>first</button>
<button>last</button>
</div>
</div>
);
}
it('should call preventDefault on Escape key when active', () => {
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
render(<TrapComponent isActive />);
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(<TrapComponent isActive />);
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(<TrapComponent isActive />);
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(<TrapComponent isActive={false} />);
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(<TrapComponent isActive />);
expect(() => {
fireEvent.keyDown(document, { key: 'Escape' });
}).not.toThrow();
});
});
describe('State Changes', () => {
it('should handle activation change', () => {
const { result, rerender } = renderHook(
({ isActive }) => useFocusTrap<HTMLDivElement>(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<HTMLDivElement>(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<HTMLDivElement>(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<HTMLDivElement>(active);
return (
<div>
<button data-testid="toggle-btn" onClick={() => setActive(false)}>Deactivate</button>
<div ref={ref}>
<button>first</button>
<button>last</button>
</div>
</div>
);
}
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
render(<TrapWithToggle />);
// 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<HTMLDivElement>(active);
return (
<div>
<button data-testid="toggle-overflow-btn" onClick={() => setActive(false)}>Deactivate</button>
<div ref={ref}>
<button>first</button>
<button>last</button>
</div>
</div>
);
}
render(<TrapWithToggle />);
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<HTMLDivElement>(true);
return <div ref={ref} data-testid="empty" />;
}
render(<EmptyContainer />);
expect(() => {
fireEvent.keyDown(document, { key: 'Tab' });
}).not.toThrow();
});
it('should handle disabled focusable elements', () => {
function ContainerWithDisabled() {
const ref = useFocusTrap<HTMLDivElement>(true);
return (
<div ref={ref} data-testid="disabled-container">
<button disabled>disabled</button>
<button data-testid="enabled-btn">enabled</button>
</div>
);
}
render(<ContainerWithDisabled />);
// 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<HTMLDivElement>(true);
return (
<div ref={ref} data-testid="hidden-container">
<button style={{ display: 'none' }}>hidden</button>
<button data-testid="visible-btn">visible</button>
</div>
);
}
render(<ContainerWithHidden />);
// 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<HTMLDivElement>(true);
return (
<div ref={ref}>
<button data-testid="the-btn">btn</button>
</div>
);
}
render(<SimpleTrap />);
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<HTMLDivElement>(true);
return (
<div ref={ref}>
<button>first</button>
<button>last</button>
</div>
);
}
it('should not crash on Tab key when active', () => {
render(<SimpleTrap />);
expect(() => {
fireEvent.keyDown(document, { key: 'Tab' });
}).not.toThrow();
});
it('should not crash on Escape key when active', () => {
render(<SimpleTrap />);
expect(() => {
fireEvent.keyDown(document, { key: 'Escape' });
}).not.toThrow();
});
it('should not respond to events when inactive', () => {
function InactiveTrap() {
const ref = useFocusTrap<HTMLDivElement>(false);
return (
<div ref={ref}>
<button>first</button>
<button>last</button>
</div>
);
}
render(<InactiveTrap />);
// 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<HTMLDivElement>(true);
return (
<div ref={ref} data-testid="many-types">
<button>button</button>
<a href="#">link</a>
<input data-testid="input-elem" />
<select><option>1</option></select>
<textarea />
<span tabIndex={0}>tabindex-zero</span>
</div>
);
}
render(<ManyFocusableTypes />);
// Focus the last focusable element (span with tabIndex=0) to trigger Tab cycling
const lastFocusable = screen.getByTestId('many-types').querySelector('[tabindex="0"]') as HTMLElement;
if (lastFocusable) lastFocusable.focus();
const preventDefaultSpy = jest.spyOn(Event.prototype, 'preventDefault');
fireEvent.keyDown(document, { key: 'Tab' });
// Multiple elements exist, so Tab should cycle
expect(preventDefaultSpy).toHaveBeenCalled();
preventDefaultSpy.mockRestore();
});
it('should handle container with only tabindex=-1 elements', () => {
function ContainerWithNegativeTabIndex() {
const ref = useFocusTrap<HTMLDivElement>(true);
return (
<div ref={ref} data-testid="negative-tab">
<button tabIndex={-1} data-testid="neg-btn">focusable-programmatically</button>
</div>
);
}
render(<ContainerWithNegativeTabIndex />);
const negBtn = screen.getByTestId('neg-btn');
negBtn.focus();
// tabindex=-1 is excluded by the selector, so no focusable elements
// Tab should not crash
expect(() => {
fireEvent.keyDown(document, { key: 'Tab' });
}).not.toThrow();
});
it('should handle detached ref (getFocusableElements returns [])', () => {
function NullContainer() {
useFocusTrap<HTMLDivElement>(true);
return <div>no ref attached</div>;
}
render(<NullContainer />);
expect(() => {
fireEvent.keyDown(document, { key: 'Tab' });
}).not.toThrow();
});
});
});