Skip to content

Commit 736da28

Browse files
committed
feat(CFocusTrap): new component initial release
1 parent fe9f91b commit 736da28

23 files changed

+1638
-39
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import React, { FC, ReactElement, cloneElement, useCallback, useEffect, useRef } from 'react'
2+
import { mergeRefs, isTabbable } from './utils'
3+
import { TABBABLE_SELECTOR } from './const'
4+
5+
export interface CFocusTrapProps {
6+
/**
7+
* Controls whether the focus trap is active or inactive.
8+
* When `true`, focus will be trapped within the child element.
9+
* When `false`, normal focus behavior is restored.
10+
*
11+
* @default true
12+
*/
13+
active?: boolean
14+
15+
/**
16+
* Single React element that renders a DOM node and forwards refs properly.
17+
* The focus trap will be applied to this element and all its focusable descendants.
18+
*
19+
* Requirements:
20+
* - Must be a single ReactElement (not an array or fragment)
21+
* - Must forward the ref to a DOM element
22+
* - Should contain focusable elements for proper trap behavior
23+
*/
24+
children: ReactElement
25+
26+
/**
27+
* Controls whether to focus the first selectable element or the container itself.
28+
* When `true`, focuses the first tabbable element within the container.
29+
* When `false`, focuses the container element directly.
30+
*
31+
* This is useful for containers that should receive focus themselves,
32+
* such as scrollable regions or custom interactive components.
33+
*
34+
* @default false
35+
*/
36+
focusFirstElement?: boolean
37+
38+
/**
39+
* Callback function invoked when the focus trap becomes active.
40+
* Useful for triggering additional accessibility announcements or analytics.
41+
*/
42+
onActivate?: () => void
43+
44+
/**
45+
* Callback function invoked when the focus trap is deactivated.
46+
* Can be used for cleanup, analytics, or triggering state changes.
47+
*/
48+
onDeactivate?: () => void
49+
50+
/**
51+
* Automatically restores focus to the previously focused element when the trap is deactivated.
52+
* This is crucial for accessibility as it maintains the user's place in the document
53+
* when returning from modal dialogs or overlay components.
54+
*
55+
* Recommended to be `true` for modal dialogs and popover components.
56+
*
57+
* @default true
58+
*/
59+
restoreFocus?: boolean
60+
}
61+
62+
export const CFocusTrap: FC<CFocusTrapProps> = ({
63+
active = true,
64+
children,
65+
focusFirstElement = false,
66+
onActivate,
67+
onDeactivate,
68+
restoreFocus = true,
69+
}) => {
70+
const containerRef = useRef<HTMLElement | null>(null)
71+
const prevFocusedRef = useRef<HTMLElement | null>(null)
72+
const addedTabIndexRef = useRef<boolean>(false)
73+
const isActiveRef = useRef<boolean>(false)
74+
const focusingRef = useRef<boolean>(false)
75+
76+
const getTabbables = useCallback((): HTMLElement[] => {
77+
const container = containerRef.current
78+
if (!container) {
79+
return []
80+
}
81+
82+
// eslint-disable-next-line unicorn/prefer-spread
83+
const candidates = Array.from(container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR))
84+
return candidates.filter((el) => isTabbable(el))
85+
}, [])
86+
87+
const focusFirst = useCallback(() => {
88+
const container = containerRef.current
89+
if (!container || focusingRef.current) {
90+
return
91+
}
92+
93+
focusingRef.current = true
94+
95+
const tabbables = getTabbables()
96+
const target = focusFirstElement ? (tabbables[0] ?? container) : container
97+
// Ensure root can receive focus if there are no tabbables
98+
if (target === container && container.getAttribute('tabindex') == null) {
99+
container.setAttribute('tabindex', '-1')
100+
addedTabIndexRef.current = true
101+
}
102+
103+
target.focus({ preventScroll: true })
104+
105+
// Reset the flag after a short delay to allow the focus event to complete
106+
setTimeout(() => {
107+
focusingRef.current = false
108+
}, 0)
109+
}, [getTabbables, focusFirstElement])
110+
111+
useEffect(() => {
112+
const container = containerRef.current
113+
if (!active || !container) {
114+
if (isActiveRef.current) {
115+
// Deactivate cleanup
116+
if (restoreFocus && prevFocusedRef.current && document.contains(prevFocusedRef.current)) {
117+
prevFocusedRef.current.focus({ preventScroll: true })
118+
}
119+
120+
if (addedTabIndexRef.current) {
121+
container?.removeAttribute('tabindex')
122+
addedTabIndexRef.current = false
123+
}
124+
125+
onDeactivate?.()
126+
isActiveRef.current = false
127+
}
128+
129+
return
130+
}
131+
132+
// Activating…
133+
isActiveRef.current = true
134+
onActivate?.()
135+
136+
// Remember focused element BEFORE we move focus into the trap
137+
prevFocusedRef.current = (document.activeElement as HTMLElement) ?? null
138+
139+
// Move focus inside if focus is outside the container
140+
if (!container.contains(document.activeElement)) {
141+
focusFirst()
142+
}
143+
144+
const handleKeyDown = (e: KeyboardEvent) => {
145+
if (e.key !== 'Tab') {
146+
return
147+
}
148+
149+
const tabbables = getTabbables()
150+
const current = document.activeElement as HTMLElement | null
151+
152+
if (tabbables.length === 0) {
153+
container.focus({ preventScroll: true })
154+
e.preventDefault()
155+
return
156+
}
157+
158+
const first = tabbables[0]
159+
const last = tabbables.at(-1)!
160+
161+
if (e.shiftKey) {
162+
if (!current || !container.contains(current) || current === first) {
163+
last.focus({ preventScroll: true })
164+
e.preventDefault()
165+
}
166+
} else {
167+
if (!current || !container.contains(current) || current === last) {
168+
first.focus({ preventScroll: true })
169+
e.preventDefault()
170+
}
171+
}
172+
}
173+
174+
const handleFocusIn = (e: FocusEvent) => {
175+
const target = e.target as Node
176+
if (!container.contains(target) && !focusingRef.current) {
177+
// Redirect stray focus back into the trap
178+
focusFirst()
179+
}
180+
}
181+
182+
document.addEventListener('keydown', handleKeyDown, true)
183+
document.addEventListener('focusin', handleFocusIn, true)
184+
185+
return () => {
186+
document.removeEventListener('keydown', handleKeyDown, true)
187+
document.removeEventListener('focusin', handleFocusIn, true)
188+
189+
// On unmount (also considered deactivation)
190+
if (restoreFocus && prevFocusedRef.current && document.contains(prevFocusedRef.current)) {
191+
prevFocusedRef.current.focus({ preventScroll: true })
192+
}
193+
194+
if (addedTabIndexRef.current) {
195+
container.removeAttribute('tabindex')
196+
addedTabIndexRef.current = false
197+
}
198+
199+
onDeactivate?.()
200+
isActiveRef.current = false
201+
}
202+
}, [active, focusFirst, getTabbables, onActivate, onDeactivate, restoreFocus])
203+
204+
// Attach our ref to the ONLY child — no extra wrappers.
205+
const onlyChild = React.Children.only(children)
206+
const childRef = (onlyChild as ReactElement & { ref?: React.Ref<HTMLElement> }).ref
207+
const mergedRef = mergeRefs(childRef, (node: HTMLElement | null) => {
208+
containerRef.current = node
209+
})
210+
211+
return cloneElement(onlyChild, { ref: mergedRef } as { ref: React.Ref<HTMLElement> })
212+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import * as React from 'react'
2+
import { render, screen, waitFor } from '@testing-library/react'
3+
import '@testing-library/jest-dom'
4+
import { CFocusTrap } from '../CFocusTrap'
5+
6+
// Helper function to create a test component with focusable elements
7+
interface TestComponentProps {
8+
children?: React.ReactNode
9+
[key: string]: any
10+
}
11+
12+
const TestComponent = ({ children, ...props }: TestComponentProps) => (
13+
<CFocusTrap {...props}>
14+
<div data-testid="container">
15+
<button data-testid="first-button">First</button>
16+
<input data-testid="input" type="text" placeholder="Input field" />
17+
<a href="#" data-testid="link">
18+
Link
19+
</a>
20+
<button data-testid="last-button">Last</button>
21+
{children}
22+
</div>
23+
</CFocusTrap>
24+
)
25+
26+
describe('CFocusTrap', () => {
27+
beforeEach(() => {
28+
// Reset document focus before each test
29+
document.body.focus()
30+
})
31+
32+
test('loads and displays CFocusTrap component', () => {
33+
const { container } = render(
34+
<CFocusTrap>
35+
<div data-testid="test-content">Test Content</div>
36+
</CFocusTrap>
37+
)
38+
expect(container).toMatchSnapshot()
39+
expect(screen.getByTestId('test-content')).toBeInTheDocument()
40+
})
41+
42+
test('CFocusTrap with custom props', () => {
43+
const onActivate = jest.fn()
44+
const onDeactivate = jest.fn()
45+
46+
const { container } = render(
47+
<CFocusTrap
48+
active={true}
49+
restoreFocus={false}
50+
focusFirstElement={false}
51+
onActivate={onActivate}
52+
onDeactivate={onDeactivate}
53+
>
54+
<div data-testid="custom-container">Custom Content</div>
55+
</CFocusTrap>
56+
)
57+
58+
expect(container).toMatchSnapshot()
59+
expect(onActivate).toHaveBeenCalledTimes(1)
60+
})
61+
62+
test('focuses container when focusFirstElement is false (default)', async () => {
63+
render(<TestComponent active={true} />)
64+
65+
await waitFor(() => {
66+
expect(screen.getByTestId('container')).toHaveFocus()
67+
})
68+
})
69+
70+
test('does not trap focus when active is false', () => {
71+
render(<TestComponent active={false} />)
72+
73+
// Focus should not be moved to any element
74+
expect(screen.getByTestId('container')).not.toHaveFocus()
75+
expect(screen.getByTestId('first-button')).not.toHaveFocus()
76+
})
77+
78+
test('handles container with no tabbable elements', async () => {
79+
render(
80+
<CFocusTrap active={true}>
81+
<div data-testid="empty-container">No focusable elements</div>
82+
</CFocusTrap>
83+
)
84+
85+
const container = screen.getByTestId('empty-container')
86+
87+
// Container should receive focus and have tabindex="-1" added
88+
await waitFor(() => {
89+
expect(container).toHaveFocus()
90+
expect(container).toHaveAttribute('tabindex', '-1')
91+
})
92+
})
93+
94+
test('calls onActivate callback when trap becomes active', () => {
95+
const onActivate = jest.fn()
96+
97+
render(<TestComponent active={false} onActivate={onActivate} />)
98+
expect(onActivate).not.toHaveBeenCalled()
99+
100+
// Re-render with active=true
101+
render(<TestComponent active={true} onActivate={onActivate} />)
102+
expect(onActivate).toHaveBeenCalledTimes(1)
103+
})
104+
105+
test('calls onDeactivate callback when trap becomes inactive', () => {
106+
const onDeactivate = jest.fn()
107+
108+
const { rerender } = render(<TestComponent active={true} onDeactivate={onDeactivate} />)
109+
expect(onDeactivate).not.toHaveBeenCalled()
110+
111+
// Deactivate the trap
112+
rerender(<TestComponent active={false} onDeactivate={onDeactivate} />)
113+
expect(onDeactivate).toHaveBeenCalledTimes(1)
114+
})
115+
116+
test('cleans up event listeners on unmount', () => {
117+
const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener')
118+
119+
const { unmount } = render(<TestComponent active={true} />)
120+
121+
unmount()
122+
123+
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
124+
expect(removeEventListenerSpy).toHaveBeenCalledWith('focusin', expect.any(Function), true)
125+
126+
removeEventListenerSpy.mockRestore()
127+
})
128+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`CFocusTrap CFocusTrap with custom props 1`] = `
4+
<div>
5+
<div
6+
data-testid="custom-container"
7+
tabindex="-1"
8+
>
9+
Custom Content
10+
</div>
11+
</div>
12+
`;
13+
14+
exports[`CFocusTrap loads and displays CFocusTrap component 1`] = `
15+
<div>
16+
<div
17+
data-testid="test-content"
18+
tabindex="-1"
19+
>
20+
Test Content
21+
</div>
22+
</div>
23+
`;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const FOCUSABLE_TAGS = new Set(['input', 'select', 'textarea', 'button'])
2+
3+
export const TABBABLE_SELECTOR = [
4+
'a[href]',
5+
'area[href]',
6+
'button:not([disabled])',
7+
'input:not([disabled]):not([type="hidden"])',
8+
'select:not([disabled])',
9+
'textarea:not([disabled])',
10+
'summary',
11+
'[tabindex]',
12+
'[contenteditable="true"]',
13+
].join(',')
14+
15+
export { FOCUSABLE_TAGS }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { CFocusTrap } from './CFocusTrap'
2+
3+
export { CFocusTrap }

0 commit comments

Comments
 (0)