Skip to content

Commit 6eec183

Browse files
committed
feat(CFocusTrap): new component initial release
1 parent 131e562 commit 6eec183

File tree

17 files changed

+1483
-45
lines changed

17 files changed

+1483
-45
lines changed
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import {
2+
cloneVNode,
3+
defineComponent,
4+
ref,
5+
watch,
6+
onMounted,
7+
onUnmounted,
8+
type Ref,
9+
type PropType,
10+
} from 'vue'
11+
import { focusableChildren } from './utils'
12+
13+
const CFocusTrap = defineComponent({
14+
name: 'CFocusTrap',
15+
props: {
16+
/**
17+
* Controls whether the focus trap is active or inactive.
18+
* When `true`, focus will be trapped within the child element.
19+
* When `false`, normal focus behavior is restored.
20+
*/
21+
active: {
22+
type: Boolean,
23+
default: true,
24+
},
25+
26+
/**
27+
* Additional container elements to include in the focus trap.
28+
* Useful for floating elements like tooltips or popovers that are
29+
* rendered outside the main container but should be part of the trap.
30+
*/
31+
additionalContainer: {
32+
type: Object as PropType<Ref<HTMLElement | null>>,
33+
default: undefined,
34+
},
35+
36+
/**
37+
* Controls whether to focus the first selectable element or the container itself.
38+
* When `true`, focuses the first tabbable element within the container.
39+
* When `false`, focuses the container element directly.
40+
*
41+
* This is useful for containers that should receive focus themselves,
42+
* such as scrollable regions or custom interactive components.
43+
*/
44+
focusFirstElement: {
45+
type: Boolean,
46+
default: false,
47+
},
48+
49+
/**
50+
* Automatically restores focus to the previously focused element when the trap is deactivated.
51+
* This is crucial for accessibility as it maintains the user's place in the document
52+
* when returning from modal dialogs or overlay components.
53+
*
54+
* Recommended to be `true` for modal dialogs and popover components.
55+
*/
56+
restoreFocus: {
57+
type: Boolean,
58+
default: true,
59+
},
60+
},
61+
emits: {
62+
/**
63+
* Emitted when the focus trap becomes active.
64+
* Useful for triggering additional accessibility announcements or analytics.
65+
*/
66+
activate: () => true,
67+
/**
68+
* Emitted when the focus trap is deactivated.
69+
* Can be used for cleanup, analytics, or triggering state changes.
70+
*/
71+
deactivate: () => true,
72+
},
73+
setup(props, { emit, slots, expose }) {
74+
const containerRef = ref<HTMLElement | null>(null)
75+
const prevFocusedRef = ref<HTMLElement | null>(null)
76+
const isActiveRef = ref<boolean>(false)
77+
const lastTabNavDirectionRef = ref<'forward' | 'backward'>('forward')
78+
const tabEventSourceRef = ref<HTMLElement | null>(null)
79+
80+
let handleKeyDown: ((event: KeyboardEvent) => void) | null = null
81+
let handleFocusIn: ((event: FocusEvent) => void) | null = null
82+
83+
const activateTrap = () => {
84+
const container = containerRef.value
85+
const additionalContainer = props.additionalContainer?.value || null
86+
87+
if (!container) {
88+
return
89+
}
90+
91+
prevFocusedRef.value = document.activeElement as HTMLElement | null
92+
93+
// Activating...
94+
isActiveRef.value = true
95+
96+
// Set initial focus
97+
if (props.focusFirstElement) {
98+
const elements = focusableChildren(container)
99+
if (elements.length > 0) {
100+
elements[0].focus({ preventScroll: true })
101+
} else {
102+
// Fallback to container if no focusable elements
103+
container.focus({ preventScroll: true })
104+
}
105+
} else {
106+
container.focus({ preventScroll: true })
107+
}
108+
109+
emit('activate')
110+
111+
// Create event handlers
112+
handleFocusIn = (event: FocusEvent) => {
113+
// Only handle focus events from tab navigation
114+
if (containerRef.value !== tabEventSourceRef.value) {
115+
return
116+
}
117+
118+
const target = event.target as Node
119+
120+
// Allow focus within container
121+
if (target === document || target === container || container.contains(target)) {
122+
return
123+
}
124+
125+
// Allow focus within additional elements
126+
if (
127+
additionalContainer &&
128+
(target === additionalContainer || additionalContainer.contains(target))
129+
) {
130+
return
131+
}
132+
133+
// Focus escaped, bring it back
134+
const elements = focusableChildren(container)
135+
136+
if (elements.length === 0) {
137+
container.focus({ preventScroll: true })
138+
} else if (lastTabNavDirectionRef.value === 'backward') {
139+
elements.at(-1)?.focus({ preventScroll: true })
140+
} else {
141+
elements[0].focus({ preventScroll: true })
142+
}
143+
}
144+
145+
handleKeyDown = (event: KeyboardEvent) => {
146+
if (event.key !== 'Tab') {
147+
return
148+
}
149+
150+
tabEventSourceRef.value = container
151+
lastTabNavDirectionRef.value = event.shiftKey ? 'backward' : 'forward'
152+
153+
if (!additionalContainer) {
154+
return
155+
}
156+
157+
const containerElements = focusableChildren(container)
158+
const additionalElements = focusableChildren(additionalContainer)
159+
160+
if (containerElements.length === 0 && additionalElements.length === 0) {
161+
// No focusable elements, prevent tab
162+
event.preventDefault()
163+
return
164+
}
165+
166+
const activeElement = document.activeElement as HTMLElement
167+
const isInContainer = containerElements.includes(activeElement)
168+
const isInAdditional = additionalElements.includes(activeElement)
169+
170+
// Handle tab navigation between container and additional elements
171+
if (isInContainer) {
172+
const index = containerElements.indexOf(activeElement)
173+
174+
if (
175+
!event.shiftKey &&
176+
index === containerElements.length - 1 &&
177+
additionalElements.length > 0
178+
) {
179+
// Tab forward from last container element to first additional element
180+
event.preventDefault()
181+
additionalElements[0].focus({ preventScroll: true })
182+
} else if (event.shiftKey && index === 0 && additionalElements.length > 0) {
183+
// Tab backward from first container element to last additional element
184+
event.preventDefault()
185+
additionalElements.at(-1)?.focus({ preventScroll: true })
186+
}
187+
} else if (isInAdditional) {
188+
const index = additionalElements.indexOf(activeElement)
189+
190+
if (
191+
!event.shiftKey &&
192+
index === additionalElements.length - 1 &&
193+
containerElements.length > 0
194+
) {
195+
// Tab forward from last additional element to first container element
196+
event.preventDefault()
197+
containerElements[0].focus({ preventScroll: true })
198+
} else if (event.shiftKey && index === 0 && containerElements.length > 0) {
199+
// Tab backward from first additional element to last container element
200+
event.preventDefault()
201+
containerElements.at(-1)?.focus({ preventScroll: true })
202+
}
203+
}
204+
}
205+
206+
// Add event listeners
207+
container.addEventListener('keydown', handleKeyDown, true)
208+
if (additionalContainer) {
209+
additionalContainer.addEventListener('keydown', handleKeyDown, true)
210+
}
211+
document.addEventListener('focusin', handleFocusIn, true)
212+
}
213+
214+
const deactivateTrap = () => {
215+
if (!isActiveRef.value) {
216+
return
217+
}
218+
219+
// Cleanup event listeners
220+
const container = containerRef.value
221+
const additionalContainer = props.additionalContainer?.value || null
222+
223+
if (container && handleKeyDown) {
224+
container.removeEventListener('keydown', handleKeyDown, true)
225+
}
226+
if (additionalContainer && handleKeyDown) {
227+
additionalContainer.removeEventListener('keydown', handleKeyDown, true)
228+
}
229+
if (handleFocusIn) {
230+
document.removeEventListener('focusin', handleFocusIn, true)
231+
}
232+
233+
// Restore focus
234+
if (props.restoreFocus && prevFocusedRef.value?.isConnected) {
235+
prevFocusedRef.value.focus({ preventScroll: true })
236+
}
237+
238+
emit('deactivate')
239+
isActiveRef.value = false
240+
prevFocusedRef.value = null
241+
}
242+
243+
watch(
244+
() => props.active,
245+
(newActive) => {
246+
if (newActive && containerRef.value) {
247+
activateTrap()
248+
} else {
249+
deactivateTrap()
250+
}
251+
},
252+
{ immediate: false }
253+
)
254+
255+
watch(
256+
() => props.additionalContainer?.value,
257+
() => {
258+
if (props.active && isActiveRef.value) {
259+
// Reactivate to update event listeners
260+
deactivateTrap()
261+
activateTrap()
262+
}
263+
}
264+
)
265+
266+
onMounted(() => {
267+
if (props.active && containerRef.value) {
268+
activateTrap()
269+
}
270+
})
271+
272+
onUnmounted(() => {
273+
deactivateTrap()
274+
})
275+
276+
// Expose containerRef for parent components
277+
expose({
278+
containerRef,
279+
})
280+
281+
return () =>
282+
slots.default?.().map((slot) =>
283+
cloneVNode(slot, {
284+
ref: (el) => {
285+
containerRef.value = el as HTMLElement | null
286+
},
287+
})
288+
)
289+
},
290+
})
291+
292+
export { CFocusTrap }
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { App } from 'vue'
2+
import { CFocusTrap } from './CFocusTrap'
3+
4+
const CFocusTrapPlugin = {
5+
install: (app: App): void => {
6+
app.component(CFocusTrap.name as string, CFocusTrap)
7+
},
8+
}
9+
10+
export { CFocusTrapPlugin, CFocusTrap }
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Gets all focusable child elements within a container.
3+
* Uses a comprehensive selector to find elements that can receive focus.
4+
* @param element - The container element to search within
5+
* @returns Array of focusable HTML elements
6+
*/
7+
export const focusableChildren = (element: HTMLElement): HTMLElement[] => {
8+
const focusableSelectors = [
9+
'a[href]',
10+
'button:not([disabled])',
11+
'input:not([disabled])',
12+
'textarea:not([disabled])',
13+
'select:not([disabled])',
14+
'details',
15+
'[tabindex]:not([tabindex="-1"])',
16+
'[contenteditable="true"]',
17+
].join(',')
18+
19+
const elements = [...element.querySelectorAll<HTMLElement>(focusableSelectors)] as HTMLElement[]
20+
21+
return elements.filter((el) => !isDisabled(el) && isVisible(el))
22+
}
23+
24+
/**
25+
* Checks if an element is disabled.
26+
* Considers various ways an element can be disabled including CSS classes and attributes.
27+
* @param element - The HTML element to check
28+
* @returns True if the element is disabled, false otherwise
29+
*/
30+
export const isDisabled = (element: HTMLElement): boolean => {
31+
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
32+
return true
33+
}
34+
35+
if (element.classList.contains('disabled')) {
36+
return true
37+
}
38+
39+
if ('disabled' in element && typeof element.disabled === 'boolean') {
40+
return element.disabled
41+
}
42+
43+
return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false'
44+
}
45+
46+
/**
47+
* Type guard to check if an object is an Element.
48+
* Handles edge cases including jQuery objects.
49+
* @param object - The object to check
50+
* @returns True if the object is an Element, false otherwise
51+
*/
52+
export const isElement = (object: unknown): object is Element => {
53+
if (!object || typeof object !== 'object') {
54+
return false
55+
}
56+
57+
return 'nodeType' in object && typeof object.nodeType === 'number'
58+
}
59+
60+
/**
61+
* Checks if an element is visible in the DOM.
62+
* Considers client rects and computed visibility styles, handling edge cases like details elements.
63+
* @param element - The HTML element to check for visibility
64+
* @returns True if the element is visible, false otherwise
65+
*/
66+
export const isVisible = (element: HTMLElement): boolean => {
67+
if (!isElement(element) || element.getClientRects().length === 0) {
68+
return false
69+
}
70+
71+
const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible'
72+
73+
// Handle `details` element as its content may falsely appear visible when it is closed
74+
const closedDetails = element.closest('details:not([open])')
75+
76+
if (!closedDetails) {
77+
return elementIsVisible
78+
}
79+
80+
if (closedDetails !== element) {
81+
const summary = element.closest('summary')
82+
83+
// Check if summary is a direct child of the closed details
84+
if (summary?.parentNode !== closedDetails) {
85+
return false
86+
}
87+
}
88+
89+
return elementIsVisible
90+
}

0 commit comments

Comments
 (0)