Skip to content

Commit 005fdfb

Browse files
committed
feat(CFocusTrap): new component initial release
1 parent bd81422 commit 005fdfb

File tree

6 files changed

+442
-157
lines changed

6 files changed

+442
-157
lines changed

packages/coreui-react/src/components/focus-trap/CFocusTrap.tsx

Lines changed: 128 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import React, { FC, ReactElement, cloneElement, useCallback, useEffect, useRef } from 'react'
2-
import { mergeRefs, isTabbable } from './utils'
3-
import { TABBABLE_SELECTOR } from './const'
1+
import React, { FC, ReactElement, cloneElement, useEffect, useRef } from 'react'
2+
import { mergeRefs, focusableChildren } from './utils'
43

54
export interface CFocusTrapProps {
65
/**
@@ -12,6 +11,13 @@ export interface CFocusTrapProps {
1211
*/
1312
active?: boolean
1413

14+
/**
15+
* Additional container elements to include in the focus trap.
16+
* Useful for floating elements like tooltips or popovers that are
17+
* rendered outside the main container but should be part of the trap.
18+
*/
19+
additionalContainer?: React.RefObject<HTMLElement | null>
20+
1521
/**
1622
* Single React element that renders a DOM node and forwards refs properly.
1723
* The focus trap will be applied to this element and all its focusable descendants.
@@ -61,6 +67,7 @@ export interface CFocusTrapProps {
6167

6268
export const CFocusTrap: FC<CFocusTrapProps> = ({
6369
active = true,
70+
additionalContainer,
6471
children,
6572
focusFirstElement = false,
6673
onActivate,
@@ -69,141 +76,176 @@ export const CFocusTrap: FC<CFocusTrapProps> = ({
6976
}) => {
7077
const containerRef = useRef<HTMLElement | null>(null)
7178
const prevFocusedRef = useRef<HTMLElement | null>(null)
72-
const addedTabIndexRef = useRef<boolean>(false)
7379
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])
80+
const lastTabNavDirectionRef = useRef<'forward' | 'backward'>('forward')
81+
const tabEventSourceRef = useRef<HTMLElement | null>(null)
11082

11183
useEffect(() => {
11284
const container = containerRef.current
85+
const _additionalContainer = additionalContainer?.current || null
86+
11387
if (!active || !container) {
11488
if (isActiveRef.current) {
11589
// Deactivate cleanup
116-
if (restoreFocus && prevFocusedRef.current && document.contains(prevFocusedRef.current)) {
90+
if (restoreFocus && prevFocusedRef.current?.isConnected) {
11791
prevFocusedRef.current.focus({ preventScroll: true })
11892
}
11993

120-
if (addedTabIndexRef.current) {
121-
container?.removeAttribute('tabindex')
122-
addedTabIndexRef.current = false
123-
}
124-
12594
onDeactivate?.()
12695
isActiveRef.current = false
96+
prevFocusedRef.current = null
12797
}
12898

12999
return
130100
}
131101

102+
// Remember focused element BEFORE we move focus into the trap
103+
prevFocusedRef.current = document.activeElement as HTMLElement | null
104+
132105
// Activating…
133106
isActiveRef.current = true
107+
108+
// Set initial focus
109+
if (focusFirstElement) {
110+
const elements = focusableChildren(container)
111+
if (elements.length > 0) {
112+
elements[0].focus({ preventScroll: true })
113+
} else {
114+
// Fallback to container if no focusable elements
115+
container.focus({ preventScroll: true })
116+
}
117+
} else {
118+
container.focus({ preventScroll: true })
119+
}
120+
134121
onActivate?.()
135122

136-
// Remember focused element BEFORE we move focus into the trap
137-
prevFocusedRef.current = (document.activeElement as HTMLElement) ?? null
123+
const handleFocusIn = (event: FocusEvent) => {
124+
// Only handle focus events from tab navigation
125+
if (containerRef.current !== tabEventSourceRef.current) {
126+
return
127+
}
138128

139-
// Move focus inside if focus is outside the container
140-
if (!container.contains(document.activeElement)) {
141-
focusFirst()
142-
}
129+
const target = event.target as Node
143130

144-
const handleKeyDown = (e: KeyboardEvent) => {
145-
if (e.key !== 'Tab') {
131+
// Allow focus within container
132+
if (target === document || target === container || container.contains(target)) {
146133
return
147134
}
148135

149-
const tabbables = getTabbables()
150-
const current = document.activeElement as HTMLElement | null
136+
// Allow focus within additional elements
137+
if (
138+
_additionalContainer &&
139+
(target === _additionalContainer || _additionalContainer.contains(target))
140+
) {
141+
return
142+
}
151143

152-
if (tabbables.length === 0) {
144+
// Focus escaped, bring it back
145+
const elements = focusableChildren(container)
146+
147+
if (elements.length === 0) {
153148
container.focus({ preventScroll: true })
154-
e.preventDefault()
149+
} else if (lastTabNavDirectionRef.current === 'backward') {
150+
elements.at(-1)?.focus({ preventScroll: true })
151+
} else {
152+
elements[0].focus({ preventScroll: true })
153+
}
154+
}
155+
156+
const handleKeyDown = (event: KeyboardEvent) => {
157+
if (event.key !== 'Tab') {
158+
return
159+
}
160+
161+
tabEventSourceRef.current = container
162+
lastTabNavDirectionRef.current = event.shiftKey ? 'backward' : 'forward'
163+
164+
if (!_additionalContainer) {
155165
return
156166
}
157167

158-
const first = tabbables[0]
159-
const last = tabbables.at(-1)!
168+
const containerElements = focusableChildren(container)
169+
const additionalElements = focusableChildren(_additionalContainer)
160170

161-
if (e.shiftKey) {
162-
if (!current || !container.contains(current) || current === first) {
163-
last.focus({ preventScroll: true })
164-
e.preventDefault()
171+
if (containerElements.length === 0 && additionalElements.length === 0) {
172+
// No focusable elements, prevent tab
173+
event.preventDefault()
174+
return
175+
}
176+
177+
const activeElement = document.activeElement as HTMLElement
178+
const isInContainer = containerElements.includes(activeElement)
179+
const isInAdditional = additionalElements.includes(activeElement)
180+
181+
// Handle tab navigation between container and additional elements
182+
if (isInContainer) {
183+
const index = containerElements.indexOf(activeElement)
184+
185+
if (
186+
!event.shiftKey &&
187+
index === containerElements.length - 1 &&
188+
additionalElements.length > 0
189+
) {
190+
// Tab forward from last container element to first additional element
191+
event.preventDefault()
192+
additionalElements[0].focus({ preventScroll: true })
193+
} else if (event.shiftKey && index === 0 && additionalElements.length > 0) {
194+
// Tab backward from first container element to last additional element
195+
event.preventDefault()
196+
additionalElements.at(-1)?.focus({ preventScroll: true })
165197
}
166-
} else {
167-
if (!current || !container.contains(current) || current === last) {
168-
first.focus({ preventScroll: true })
169-
e.preventDefault()
198+
} else if (isInAdditional) {
199+
const index = additionalElements.indexOf(activeElement)
200+
201+
if (
202+
!event.shiftKey &&
203+
index === additionalElements.length - 1 &&
204+
containerElements.length > 0
205+
) {
206+
// Tab forward from last additional element to first container element
207+
event.preventDefault()
208+
containerElements[0].focus({ preventScroll: true })
209+
} else if (event.shiftKey && index === 0 && containerElements.length > 0) {
210+
// Tab backward from first additional element to last container element
211+
event.preventDefault()
212+
containerElements.at(-1)?.focus({ preventScroll: true })
170213
}
171214
}
172215
}
173216

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-
}
217+
// Add event listeners
218+
container.addEventListener('keydown', handleKeyDown, true)
219+
if (_additionalContainer) {
220+
_additionalContainer.addEventListener('keydown', handleKeyDown, true)
180221
}
181-
182-
document.addEventListener('keydown', handleKeyDown, true)
183222
document.addEventListener('focusin', handleFocusIn, true)
184223

224+
// Cleanup function
185225
return () => {
186-
document.removeEventListener('keydown', handleKeyDown, true)
226+
container.removeEventListener('keydown', handleKeyDown, true)
227+
if (_additionalContainer) {
228+
_additionalContainer.removeEventListener('keydown', handleKeyDown, true)
229+
}
187230
document.removeEventListener('focusin', handleFocusIn, true)
188231

189232
// On unmount (also considered deactivation)
190-
if (restoreFocus && prevFocusedRef.current && document.contains(prevFocusedRef.current)) {
233+
if (restoreFocus && prevFocusedRef.current?.isConnected) {
191234
prevFocusedRef.current.focus({ preventScroll: true })
192235
}
193236

194-
if (addedTabIndexRef.current) {
195-
container.removeAttribute('tabindex')
196-
addedTabIndexRef.current = false
237+
if (isActiveRef.current) {
238+
onDeactivate?.()
239+
isActiveRef.current = false
197240
}
198241

199-
onDeactivate?.()
200-
isActiveRef.current = false
242+
prevFocusedRef.current = null
201243
}
202-
}, [active, focusFirst, getTabbables, onActivate, onDeactivate, restoreFocus])
244+
}, [active, additionalContainer, focusFirstElement, onActivate, onDeactivate, restoreFocus])
203245

204-
// Attach our ref to the ONLY child — no extra wrappers.
246+
// Attach our ref to the ONLY child — no extra wrappers
205247
const onlyChild = React.Children.only(children)
206-
const childRef = (onlyChild as ReactElement & { ref?: React.Ref<HTMLElement> }).ref
248+
const childRef = (onlyChild as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref
207249
const mergedRef = mergeRefs(childRef, (node: HTMLElement | null) => {
208250
containerRef.current = node
209251
})

0 commit comments

Comments
 (0)