Movatterモバイル変換


[0]ホーム

URL:


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

Commit005fdfb

Browse files
committed
feat(CFocusTrap): new component initial release
1 parentbd81422 commit005fdfb

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-
importReact,{FC,ReactElement,cloneElement,useCallback,useEffect,useRef}from'react'
2-
import{mergeRefs,isTabbable}from'./utils'
3-
import{TABBABLE_SELECTOR}from'./const'
1+
importReact,{FC,ReactElement,cloneElement,useEffect,useRef}from'react'
2+
import{mergeRefs,focusableChildren}from'./utils'
43

54
exportinterfaceCFocusTrapProps{
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
exportconstCFocusTrap: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
constcontainerRef=useRef<HTMLElement|null>(null)
7178
constprevFocusedRef=useRef<HTMLElement|null>(null)
72-
constaddedTabIndexRef=useRef<boolean>(false)
7379
constisActiveRef=useRef<boolean>(false)
74-
constfocusingRef=useRef<boolean>(false)
75-
76-
constgetTabbables=useCallback(():HTMLElement[]=>{
77-
constcontainer=containerRef.current
78-
if(!container){
79-
return[]
80-
}
81-
82-
// eslint-disable-next-line unicorn/prefer-spread
83-
constcandidates=Array.from(container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR))
84-
returncandidates.filter((el)=>isTabbable(el))
85-
},[])
86-
87-
constfocusFirst=useCallback(()=>{
88-
constcontainer=containerRef.current
89-
if(!container||focusingRef.current){
90-
return
91-
}
92-
93-
focusingRef.current=true
94-
95-
consttabbables=getTabbables()
96-
consttarget=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+
constlastTabNavDirectionRef=useRef<'forward'|'backward'>('forward')
81+
consttabEventSourceRef=useRef<HTMLElement|null>(null)
11082

11183
useEffect(()=>{
11284
constcontainer=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.activeElementasHTMLElement|null
104+
132105
// Activating…
133106
isActiveRef.current=true
107+
108+
// Set initial focus
109+
if(focusFirstElement){
110+
constelements=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.activeElementasHTMLElement)??null
123+
consthandleFocusIn=(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+
consttarget=event.targetasNode
143130

144-
consthandleKeyDown=(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-
consttabbables=getTabbables()
150-
constcurrent=document.activeElementasHTMLElement|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+
constelements=focusableChildren(container)
146+
147+
if(elements.length===0){
153148
container.focus({preventScroll:true})
154-
e.preventDefault()
149+
}elseif(lastTabNavDirectionRef.current==='backward'){
150+
elements.at(-1)?.focus({preventScroll:true})
151+
}else{
152+
elements[0].focus({preventScroll:true})
153+
}
154+
}
155+
156+
consthandleKeyDown=(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-
constfirst=tabbables[0]
159-
constlast=tabbables.at(-1)!
168+
constcontainerElements=focusableChildren(container)
169+
constadditionalElements=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+
constactiveElement=document.activeElementasHTMLElement
178+
constisInContainer=containerElements.includes(activeElement)
179+
constisInAdditional=additionalElements.includes(activeElement)
180+
181+
// Handle tab navigation between container and additional elements
182+
if(isInContainer){
183+
constindex=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+
}elseif(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+
}elseif(isInAdditional){
199+
constindex=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+
}elseif(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-
consthandleFocusIn=(e:FocusEvent)=>{
175-
consttarget=e.targetasNode
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
constonlyChild=React.Children.only(children)
206-
constchildRef=(onlyChildasReactElement&{ref?:React.Ref<HTMLElement>}).ref
248+
constchildRef=(onlyChildasReact.ReactElement&{ref?:React.Ref<HTMLElement>}).ref
207249
constmergedRef=mergeRefs(childRef,(node:HTMLElement|null)=>{
208250
containerRef.current=node
209251
})

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp