|
14 | 14 | * limitations under the License. |
15 | 15 | */ |
16 | 16 |
|
17 | | -import { useEffect, useRef } from 'react'; |
| 17 | +import { useCallback, useEffect, useRef } from 'react'; |
18 | 18 |
|
19 | 19 | /** |
20 | 20 | * gets fired on each keydown and executes the provided callback. |
@@ -71,59 +71,132 @@ export const useFocusTrap = ( |
71 | 71 | ref: React.RefObject<HTMLElement>, |
72 | 72 | active: boolean, |
73 | 73 | ) => { |
| 74 | + const autofocusDelay = 50; |
| 75 | + |
74 | 76 | const previouslyFocused = useRef<HTMLElement | null>(null); |
| 77 | + const trapEnabled = useRef(false); |
| 78 | + const autofocusTimer = useRef<number | null>(null); |
| 79 | + const cancelledAutofocus = useRef(false); |
75 | 80 |
|
76 | | - // Focus trap handler |
77 | | - const handleKeyDown = (e: KeyboardEvent) => { |
78 | | - if (!active || !ref.current || e.key !== 'Tab') { |
79 | | - return; |
80 | | - } |
81 | | - const node = ref.current; |
82 | | - const focusables = node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR); |
83 | | - if (!focusables.length) { |
84 | | - return; |
85 | | - } |
| 81 | + const handleKeyDown = useCallback( |
| 82 | + (e: KeyboardEvent) => { |
| 83 | + if (!trapEnabled.current || !ref.current || e.key !== 'Tab') { |
| 84 | + return; |
| 85 | + } |
86 | 86 |
|
87 | | - const first = focusables[0]; |
88 | | - const last = focusables[focusables.length - 1]; |
| 87 | + const focusables = Array.from( |
| 88 | + ref.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) |
| 89 | + ).filter((el) => el.offsetParent !== null); // skip hidden |
89 | 90 |
|
90 | | - if (e.shiftKey && document.activeElement === first) { |
91 | | - e.preventDefault(); |
92 | | - last?.focus(); |
93 | | - } else if (!e.shiftKey && document.activeElement === last) { |
94 | | - e.preventDefault(); |
95 | | - first?.focus(); |
96 | | - } |
97 | | - }; |
| 91 | + if (focusables.length === 0) { |
| 92 | + return; |
| 93 | + } |
98 | 94 |
|
99 | | - useKeydown(handleKeyDown); |
| 95 | + const first = focusables[0]; |
| 96 | + const last = focusables[focusables.length - 1]; |
| 97 | + const current = document.activeElement as HTMLElement; |
| 98 | + |
| 99 | + if (e.shiftKey && current && current === first) { |
| 100 | + e.preventDefault(); |
| 101 | + last?.focus(); |
| 102 | + } else if (!e.shiftKey && current && current === last) { |
| 103 | + e.preventDefault(); |
| 104 | + first?.focus(); |
| 105 | + } |
| 106 | + }, |
| 107 | + [ref] |
| 108 | + ); |
100 | 109 |
|
101 | | - // Manage mount/unmount lifecycle |
102 | 110 | useEffect(() => { |
| 111 | + // cleanup from previous runs |
| 112 | + if (autofocusTimer.current) { |
| 113 | + window.clearTimeout(autofocusTimer.current); |
| 114 | + autofocusTimer.current = null; |
| 115 | + } |
| 116 | + cancelledAutofocus.current = false; |
| 117 | + |
103 | 118 | if (!active || !ref.current) { |
| 119 | + trapEnabled.current = false; |
104 | 120 | return; |
105 | 121 | } |
106 | 122 |
|
107 | | - // Save previously focused element |
| 123 | + const node = ref.current; |
| 124 | + trapEnabled.current = true; |
108 | 125 | previouslyFocused.current = document.activeElement as HTMLElement; |
109 | 126 |
|
110 | | - // Autofocus first element, but only if nothing inside already has focus |
111 | | - if (!ref.current.contains(document.activeElement)) { |
112 | | - const firstFocusable = ( |
113 | | - ref.current.querySelector<HTMLElement>('[autofocus]:not(:disabled)') |
114 | | - ?? ref.current.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) |
115 | | - ); |
116 | | - firstFocusable?.focus({ preventScroll: true }); |
| 127 | + // If focus is already inside, don't schedule autofocus. |
| 128 | + if (node.contains(document.activeElement)) { |
| 129 | + // no autofocus needed |
| 130 | + } else { |
| 131 | + // If any focus enters the dialog while we're waiting, cancel the autofocus. |
| 132 | + const onFocusIn = (e: FocusEvent) => { |
| 133 | + if (node.contains(e.target as Node)) { |
| 134 | + cancelledAutofocus.current = true; |
| 135 | + if (autofocusTimer.current) { |
| 136 | + window.clearTimeout(autofocusTimer.current); |
| 137 | + autofocusTimer.current = null; |
| 138 | + } |
| 139 | + } |
| 140 | + }; |
| 141 | + |
| 142 | + document.addEventListener('focusin', onFocusIn, true); |
| 143 | + |
| 144 | + // Delay longer than the 20ms your Dialog uses, default 50ms. |
| 145 | + autofocusTimer.current = window.setTimeout(() => { |
| 146 | + autofocusTimer.current = null; |
| 147 | + if (cancelledAutofocus.current) { |
| 148 | + return; |
| 149 | + } |
| 150 | + |
| 151 | + // final guard: if still nothing inside has focus, focus first focusable |
| 152 | + if (!node.contains(document.activeElement)) { |
| 153 | + const firstFocusable = |
| 154 | + node.querySelector<HTMLElement>('[autofocus]:not(:disabled)') ?? |
| 155 | + node.querySelector<HTMLElement>(FOCUSABLE_SELECTOR); |
| 156 | + firstFocusable?.focus({ preventScroll: true }); |
| 157 | + } |
| 158 | + }, autofocusDelay); |
| 159 | + |
| 160 | + // remove focusin listener when effect cleanup runs |
| 161 | + // (we'll remove it in the effect return) |
| 162 | + return () => { |
| 163 | + document.removeEventListener('focusin', onFocusIn, true); |
| 164 | + }; |
117 | 165 | } |
118 | 166 |
|
| 167 | + // If we didn't return above (i.e. no focusin listener set), continue to set up keydown below. |
| 168 | + // Set up keydown listener on document |
| 169 | + const onKeyDown = (e: KeyboardEvent) => handleKeyDown(e); |
| 170 | + document.addEventListener('keydown', onKeyDown); |
| 171 | + |
| 172 | + return () => { |
| 173 | + // cleanup in-case we returned earlier (also safe) |
| 174 | + document.removeEventListener('keydown', onKeyDown); |
| 175 | + }; |
| 176 | + }, [active, ref, autofocusDelay, handleKeyDown]); |
| 177 | + |
| 178 | + // global keydown listener must be attached separately too so it's always active while trapEnabled |
| 179 | + useEffect(() => { |
| 180 | + const onKeyDown = (e: KeyboardEvent) => handleKeyDown(e); |
| 181 | + document.addEventListener('keydown', onKeyDown); |
| 182 | + return () => document.removeEventListener('keydown', onKeyDown); |
| 183 | + }, [handleKeyDown]); |
| 184 | + |
| 185 | + // cleanup on deactivate: restore previous focus, clear timers/listeners |
| 186 | + useEffect(() => { |
119 | 187 | return () => { |
120 | | - // Restore focus if element is still in DOM |
121 | | - if ( |
122 | | - previouslyFocused.current && |
123 | | - document.body.contains(previouslyFocused.current) |
124 | | - ) { |
125 | | - previouslyFocused.current.focus(); |
| 188 | + trapEnabled.current = false; |
| 189 | + |
| 190 | + if (autofocusTimer.current) { |
| 191 | + window.clearTimeout(autofocusTimer.current); |
| 192 | + autofocusTimer.current = null; |
| 193 | + } |
| 194 | + |
| 195 | + const prev = previouslyFocused.current; |
| 196 | + if (prev && document.body.contains(prev)) { |
| 197 | + // restore focus |
| 198 | + prev.focus(); |
126 | 199 | } |
127 | 200 | }; |
128 | | - }, [ref, active]); |
| 201 | + }, []); |
129 | 202 | }; |
0 commit comments