Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.

Fix new focus issues with nested dialogs

Changed files
+12 -80
src
+6 -76
src/__tests__/useDialogFocus.test.tsx
··· 59 59 it('should not allow the dialog to be tabbable', () => { 60 60 const Dialog = () => { 61 61 const ref = useRef<HTMLUListElement>(null); 62 - useDialogFocus(ref); 62 + const ownerRef = useRef<HTMLInputElement>(null); 63 + useDialogFocus(ref, { ownerRef }); 63 64 return ( 64 65 <div> 65 - <input type="text" name="text" /> 66 + <input type="text" name="text" ref={ownerRef} /> 66 67 <ul ref={ref} role="dialog"> 67 68 <li tabIndex={0}>#1</li> 68 69 <li tabIndex={0}>#2</li> ··· 183 184 const [visible, setVisible] = useState(false); 184 185 const [nested, setNested] = useState(false); 185 186 const ref = useRef<HTMLUListElement>(null); 187 + const ownerRef = useRef<HTMLInputElement>(null); 186 188 187 - useDialogFocus(ref, { disabled: !visible }); 189 + useDialogFocus(ref, { disabled: !visible, ownerRef }); 188 190 189 191 return ( 190 192 <main> 191 - <input type="text" name="text" onFocus={() => setVisible(true)} /> 192 - {visible && ( 193 - <ul ref={ref} role="dialog"> 194 - <li tabIndex={0}>Outer #1</li> 195 - <li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li> 196 - {nested && <InnerDialog />} 197 - </ul> 198 - )} 199 - <button>after</button> 200 - </main> 201 - ); 202 - }; 203 - 204 - mount(<OuterDialog />); 205 - 206 - cy.get('input').first().as('input').focus(); 207 - cy.focused().should('have.property.name', 'text'); 208 - 209 - // select first dialog 210 - cy.realPress('ArrowDown'); 211 - cy.focused().contains('Outer #1'); 212 - cy.realPress('ArrowDown'); 213 - cy.focused().contains('Outer #2'); 214 - 215 - // select second dialog 216 - cy.realPress('ArrowDown'); 217 - cy.focused().contains('Inner #1'); 218 - cy.realPress('ArrowDown'); 219 - cy.focused().contains('Inner #2'); 220 - 221 - // remains in inner dialog 222 - cy.realPress('ArrowDown'); 223 - cy.focused().contains('Inner #1'); 224 - 225 - // tabs to last dialog 226 - cy.realPress(['Shift', 'Tab']); 227 - cy.focused().contains('Outer #2'); 228 - 229 - // arrows bring us back to the inner dialog 230 - cy.realPress('ArrowUp'); 231 - cy.focused().contains('Inner #2'); 232 - 233 - // tab out of dialogs 234 - cy.realPress('Tab'); 235 - cy.focused().contains('after'); 236 - // we can't reenter the dialogs 237 - cy.realPress(['Shift', 'Tab']); 238 - cy.get('@input').should('have.focus'); 239 - }); 240 - 241 - it('supports nested dialogs', () => { 242 - const InnerDialog = () => { 243 - const ref = useRef<HTMLUListElement>(null); 244 - useDialogFocus(ref); 245 - 246 - return ( 247 - <ul ref={ref} role="dialog"> 248 - <li tabIndex={0}>Inner #1</li> 249 - <li tabIndex={0}>Inner #2</li> 250 - </ul> 251 - ); 252 - }; 253 - 254 - const OuterDialog = () => { 255 - const [visible, setVisible] = useState(false); 256 - const [nested, setNested] = useState(false); 257 - const ref = useRef<HTMLUListElement>(null); 258 - 259 - useDialogFocus(ref, { disabled: !visible }); 260 - 261 - return ( 262 - <main> 263 - <input type="text" name="text" onFocus={() => setVisible(true)} /> 193 + <input type="text" name="text" onFocus={() => setVisible(true)} ref={ownerRef} /> 264 194 {visible && ( 265 195 <ul ref={ref} role="dialog"> 266 196 <li tabIndex={0}>Outer #1</li>
+6 -4
src/useDialogFocus.ts
··· 45 45 function onFocus(event: FocusEvent) { 46 46 if (!element || event.defaultPrevented) return; 47 47 48 - const active = document.activeElement as HTMLElement; 49 48 const owner = 50 49 (ownerRef && ownerRef.current) || (selection && selection.element); 51 50 ··· 53 52 willReceiveFocus || 54 53 (hasPriority && owner && contains(event.target, owner)) 55 54 ) { 56 - if (!contains(ref.current, active)) 55 + if (!contains(ref.current, event.relatedTarget)) 57 56 selection = snapshotSelection(owner); 58 57 willReceiveFocus = false; 59 58 return; 60 59 } 61 60 62 61 // Check whether focus is about to move into the container and prevent it 63 - if (contains(ref.current, event.target)) { 62 + if ( 63 + (hasPriority || !contains(ref.current, event.relatedTarget)) && 64 + contains(ref.current, event.target) 65 + ) { 64 66 event.preventDefault(); 65 67 // Get the next focus target of the container 66 68 const focusTarget = getNextFocusTarget(element, !focusMovesForward); ··· 157 159 /^(?:Key|Digit)/.test(event.code) 158 160 ) { 159 161 // Restore selection if a key is pressed on input 160 - event.preventDefault(); 162 + event.stopPropagation(); 161 163 willReceiveFocus = false; 162 164 restoreSelection(selection); 163 165 }