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
54export  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
6268export  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