Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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)

AltStyle によって変換されたページ (->オリジナル) /