Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import React, { useState, useRef } from 'react'; 2import { mount } from '@cypress/react'; 3 4import { useDialogFocus } from '../useDialogFocus'; 5 6it('allows dialogs to be navigated without an owner', () => { 7 const Dialog = () => { 8 const ref = useRef<HTMLUListElement>(null); 9 useDialogFocus(ref); 10 return ( 11 <ul ref={ref} role="dialog"> 12 <li tabIndex={0}>#1</li> 13 <li tabIndex={0}>#2</li> 14 <li tabIndex={0}>#3</li> 15 </ul> 16 ); 17 }; 18 19 const App = () => { 20 const [hasDialog, setDialog] = useState(false); 21 return ( 22 <main> 23 <input type="text" name="text" onFocus={() => setDialog(true)} /> 24 {hasDialog && <Dialog />} 25 </main> 26 ); 27 }; 28 29 mount(<App />); 30 31 cy.get('input').first().as('input').focus(); 32 cy.focused().should('have.property.name', 'text'); 33 34 // ArrowRight/ArrowLeft shouldn't affect the selection for inputs 35 cy.realPress('ArrowRight'); 36 cy.realPress('ArrowLeft'); 37 cy.get('@input').should('have.focus'); 38 39 // Navigation with arrow keys is normal otherwise 40 cy.realPress('ArrowDown'); 41 cy.realPress('ArrowDown'); 42 cy.focused().contains('#2'); 43 cy.realPress('ArrowRight'); 44 cy.focused().contains('#3'); 45 cy.realPress('ArrowLeft'); 46 cy.focused().contains('#2'); 47 48 // permits special key navigation 49 cy.realPress('Home'); 50 cy.focused().contains('#1'); 51 cy.realPress('End'); 52 cy.focused().contains('#3'); 53 54 // releases focus to original element on escape 55 cy.realPress('Escape'); 56 cy.get('@input').should('have.focus'); 57}); 58 59it('should not allow the dialog to be tabbable', () => { 60 const Dialog = () => { 61 const ref = useRef<HTMLUListElement>(null); 62 useDialogFocus(ref); 63 return ( 64 <ul ref={ref} role="dialog"> 65 <li tabIndex={0}>#1</li> 66 <li tabIndex={0}>#2</li> 67 <li tabIndex={0}>#3</li> 68 </ul> 69 ); 70 }; 71 72 const App = () => { 73 const [hasDialog, setDialog] = useState(false); 74 return ( 75 <main> 76 <button>before</button> 77 <input type="text" name="text" onFocus={() => setDialog(true)} /> 78 {hasDialog && <Dialog />} 79 <button>after</button> 80 </main> 81 ); 82 }; 83 84 mount(<App />); 85 86 cy.get('input').first().as('input').focus(); 87 cy.focused().should('have.property.name', 'text'); 88 89 // Tabbing should skip over the dialog 90 cy.realPress('Tab'); 91 cy.focused().contains('after'); 92 // Tabbing back should skip over the dialog 93 cy.realPress(['Shift', 'Tab']); 94 cy.get('@input').should('have.focus'); 95 // Tabbing back on the owner shouldn't affect the dialog 96 cy.realPress(['Shift', 'Tab']); 97 cy.focused().contains('before'); 98 // It should still know which element the owner was 99 cy.realPress('Tab'); 100 cy.realPress('ArrowDown'); 101 cy.focused().contains('#1'); 102 // From inside the dialog tabbing should skip out of the dialog 103 cy.realPress('Tab'); 104 cy.focused().contains('after'); 105}); 106 107it('supports being attached to an owner element', () => { 108 const Dialog = () => { 109 const ownerRef = useRef<HTMLInputElement>(null); 110 const ref = useRef<HTMLUListElement>(null); 111 112 useDialogFocus(ref, { ownerRef }); 113 114 return ( 115 <main> 116 <input type="text" name="text" ref={ownerRef} /> 117 <ul ref={ref} role="dialog"> 118 <li tabIndex={0}>#1</li> 119 <li tabIndex={0}>#2</li> 120 <li tabIndex={0}>#3</li> 121 </ul> 122 </main> 123 ); 124 }; 125 126 mount(<Dialog />); 127 128 cy.get('input').first().as('input').focus(); 129 cy.focused().should('have.property.name', 'text'); 130 131 // pressing escape on input shouldn't change focus 132 cy.realPress('Escape'); 133 cy.get('@input').should('have.focus'); 134 135 // pressing arrow down should start focusing the menu 136 cy.get('@input').focus(); 137 cy.realPress('ArrowDown'); 138 cy.focused().contains('#1'); 139 cy.realPress('ArrowDown'); 140 cy.focused().contains('#2'); 141 142 // tabbing should skip over the dialog items 143 cy.realPress(['Shift', 'Tab']); 144 cy.get('@input').should('have.focus'); 145 146 // pressing arrow up should start focusing the last item 147 cy.get('@input').focus(); 148 cy.realPress('ArrowUp'); 149 cy.focused().contains('#3'); 150 151 // pressing enter should start focusing the first item 152 cy.get('@input').focus(); 153 cy.realPress('Enter'); 154 cy.focused().contains('#1'); 155 156 // typing regular values should refocus the owner input 157 cy.realType('test'); 158 cy.get('@input') 159 .should('have.focus') 160 .should('have.value', 'test'); 161 162 // pressing escape should refocus input 163 cy.get('li').first().focus(); 164 cy.realPress('Escape'); 165 cy.get('@input').should('have.focus'); 166}); 167 168it('supports nested dialogs', () => { 169 const InnerDialog = () => { 170 const ref = useRef<HTMLUListElement>(null); 171 useDialogFocus(ref); 172 173 return ( 174 <ul ref={ref} role="dialog"> 175 <li tabIndex={0}>Inner #1</li> 176 <li tabIndex={0}>Inner #2</li> 177 </ul> 178 ); 179 }; 180 181 const OuterDialog = () => { 182 const [visible, setVisible] = useState(false); 183 const [nested, setNested] = useState(false); 184 const ref = useRef<HTMLUListElement>(null); 185 186 useDialogFocus(ref, { disabled: !visible }); 187 188 return ( 189 <main> 190 <input type="text" name="text" onFocus={() => setVisible(true)} /> 191 {visible && ( 192 <ul ref={ref} role="dialog"> 193 <li tabIndex={0}>Outer #1</li> 194 <li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li> 195 {nested && <InnerDialog />} 196 </ul> 197 )} 198 <button>after</button> 199 </main> 200 ); 201 }; 202 203 mount(<OuterDialog />); 204 205 cy.get('input').first().as('input').focus(); 206 cy.focused().should('have.property.name', 'text'); 207 208 // select first dialog 209 cy.realPress('ArrowDown'); 210 cy.focused().contains('Outer #1'); 211 cy.realPress('ArrowDown'); 212 cy.focused().contains('Outer #2'); 213 214 // select second dialog 215 cy.realPress('ArrowDown'); 216 cy.focused().contains('Inner #1'); 217 cy.realPress('ArrowDown'); 218 cy.focused().contains('Inner #2'); 219 220 // remains in inner dialog 221 cy.realPress('ArrowDown'); 222 cy.focused().contains('Inner #1'); 223 224 // tabs to last dialog 225 cy.realPress(['Shift', 'Tab']); 226 cy.focused().contains('Outer #2'); 227 228 // arrows bring us back to the inner dialog 229 cy.realPress('ArrowUp'); 230 cy.focused().contains('Inner #2'); 231 232 // tab out of dialogs 233 cy.realPress('Tab'); 234 cy.focused().contains('after'); 235 // we can't reenter the dialogs 236 cy.realPress(['Shift', 'Tab']); 237 cy.get('@input').should('have.focus'); 238}); 239 240it('supports nested dialogs', () => { 241 const InnerDialog = () => { 242 const ref = useRef<HTMLUListElement>(null); 243 useDialogFocus(ref); 244 245 return ( 246 <ul ref={ref} role="dialog"> 247 <li tabIndex={0}>Inner #1</li> 248 <li tabIndex={0}>Inner #2</li> 249 </ul> 250 ); 251 }; 252 253 const OuterDialog = () => { 254 const [visible, setVisible] = useState(false); 255 const [nested, setNested] = useState(false); 256 const ref = useRef<HTMLUListElement>(null); 257 258 useDialogFocus(ref, { disabled: !visible }); 259 260 return ( 261 <main> 262 <input type="text" name="text" onFocus={() => setVisible(true)} /> 263 {visible && ( 264 <ul ref={ref} role="dialog"> 265 <li tabIndex={0}>Outer #1</li> 266 <li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li> 267 {nested && <InnerDialog />} 268 </ul> 269 )} 270 <button>after</button> 271 </main> 272 ); 273 }; 274 275 mount(<OuterDialog />); 276 277 cy.get('input').first().as('input').focus(); 278 cy.focused().should('have.property.name', 'text'); 279 280 // select first dialog 281 cy.realPress('ArrowDown'); 282 cy.focused().contains('Outer #1'); 283 cy.realPress('ArrowDown'); 284 cy.focused().contains('Outer #2'); 285 286 // select second dialog 287 cy.realPress('ArrowDown'); 288 cy.focused().contains('Inner #1'); 289 cy.realPress('ArrowDown'); 290 cy.focused().contains('Inner #2'); 291 292 // remains in inner dialog 293 cy.realPress('ArrowDown'); 294 cy.focused().contains('Inner #1'); 295 296 // tabs to last dialog 297 cy.realPress(['Shift', 'Tab']); 298 cy.focused().contains('Outer #2'); 299 300 // arrows bring us back to the inner dialog 301 cy.realPress('ArrowUp'); 302 cy.focused().contains('Inner #2'); 303 304 // tab out of dialogs 305 cy.realPress('Tab'); 306 cy.focused().contains('after'); 307 // we can't reenter the dialogs 308 cy.realPress(['Shift', 'Tab']); 309 cy.get('@input').should('have.focus'); 310}); 311 312it('allows dialogs in semantic order', () => { 313 const Dialog = ({ name }) => { 314 const ownerRef = useRef<HTMLInputElement>(null); 315 const ref = useRef<HTMLUListElement>(null); 316 317 useDialogFocus(ref, { ownerRef }); 318 319 return ( 320 <div> 321 <input type="text" className={name} ref={ownerRef} tabIndex={-1} /> 322 <ul ref={ref} role="dialog"> 323 <li tabIndex={0}>{name} #1</li> 324 <li tabIndex={0}>{name} #2</li> 325 </ul> 326 </div> 327 ); 328 }; 329 330 mount( 331 <main> 332 <Dialog name="First" /> 333 <Dialog name="Second" /> 334 <button>after</button> 335 </main> 336 ); 337 338 cy.get('.First').first().as('first'); 339 cy.get('.Second').first().as('second'); 340 341 // focus first dialog 342 cy.get('@first').focus(); 343 cy.get('.First').first().as('first').focus(); 344 345 // tabs over both subsequent dialogs 346 cy.realPress('Tab'); 347 cy.focused().contains('after'); 348 349 // given a focused first input, doesn't allow the first dialog to be used 350 cy.get('@first').focus(); 351 cy.realPress('ArrowDown'); 352 cy.get('@first').should('have.focus'); 353 354 // given a focused second input, does allow the second dialog to be used 355 cy.get('@second').focus(); 356 cy.realPress('ArrowDown'); 357 cy.focused().contains('Second #1'); 358});