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 <div> 65 <input type="text" name="text" /> 66 <ul ref={ref} role="dialog"> 67 <li tabIndex={0}>#1</li> 68 <li tabIndex={0}>#2</li> 69 <li tabIndex={0}>#3</li> 70 </ul> 71 </div> 72 ); 73 }; 74 75 const App = () => { 76 return ( 77 <main> 78 <button>before</button> 79 <Dialog /> 80 <button>after</button> 81 </main> 82 ); 83 }; 84 85 mount(<App />); 86 87 cy.get('input').first().as('input').focus(); 88 cy.focused().should('have.property.name', 'text'); 89 90 // Tabbing should skip over the dialog 91 cy.realPress('Tab'); 92 cy.focused().contains('after'); 93 // Tabbing back should skip over the dialog 94 cy.realPress(['Shift', 'Tab']); 95 cy.get('@input').should('have.focus'); 96 // Tabbing back on the owner shouldn't affect the dialog 97 cy.realPress(['Shift', 'Tab']); 98 cy.focused().contains('before'); 99 // It should still know which element the owner was 100 cy.realPress('Tab'); 101 cy.realPress('ArrowDown'); 102 cy.focused().contains('#1'); 103 // From inside the dialog tabbing should skip out of the dialog 104 cy.realPress('Tab'); 105 cy.focused().contains('after'); 106}); 107 108it('supports being attached to an owner element', () => { 109 const Dialog = () => { 110 const ownerRef = useRef<HTMLInputElement>(null); 111 const ref = useRef<HTMLUListElement>(null); 112 113 useDialogFocus(ref, { ownerRef }); 114 115 return ( 116 <main> 117 <input type="text" name="text" ref={ownerRef} /> 118 <ul ref={ref} role="dialog"> 119 <li tabIndex={0}>#1</li> 120 <li tabIndex={0}>#2</li> 121 <li tabIndex={0}>#3</li> 122 </ul> 123 </main> 124 ); 125 }; 126 127 mount(<Dialog />); 128 129 cy.get('input').first().as('input').focus(); 130 cy.focused().should('have.property.name', 'text'); 131 132 // pressing escape on input shouldn't change focus 133 cy.realPress('Escape'); 134 cy.get('@input').should('have.focus'); 135 136 // pressing arrow down should start focusing the menu 137 cy.get('@input').focus(); 138 cy.realPress('ArrowDown'); 139 cy.focused().contains('#1'); 140 cy.realPress('ArrowDown'); 141 cy.focused().contains('#2'); 142 143 // tabbing should skip over the dialog items 144 cy.realPress(['Shift', 'Tab']); 145 cy.get('@input').should('have.focus'); 146 147 // pressing arrow up should start focusing the last item 148 cy.get('@input').focus(); 149 cy.realPress('ArrowUp'); 150 cy.focused().contains('#3'); 151 152 // pressing enter should start focusing the first item 153 cy.get('@input').focus(); 154 cy.realPress('Enter'); 155 cy.focused().contains('#1'); 156 157 // typing regular values should refocus the owner input 158 cy.realType('test'); 159 cy.get('@input') 160 .should('have.focus') 161 .should('have.value', 'test'); 162 163 // pressing escape should refocus input 164 cy.get('li').first().focus(); 165 cy.realPress('Escape'); 166 cy.get('@input').should('have.focus'); 167}); 168 169it('supports nested dialogs', () => { 170 const InnerDialog = () => { 171 const ref = useRef<HTMLUListElement>(null); 172 useDialogFocus(ref); 173 174 return ( 175 <ul ref={ref} role="dialog"> 176 <li tabIndex={0}>Inner #1</li> 177 <li tabIndex={0}>Inner #2</li> 178 </ul> 179 ); 180 }; 181 182 const OuterDialog = () => { 183 const [visible, setVisible] = useState(false); 184 const [nested, setNested] = useState(false); 185 const ref = useRef<HTMLUListElement>(null); 186 187 useDialogFocus(ref, { disabled: !visible }); 188 189 return ( 190 <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 241it('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)} /> 264 {visible && ( 265 <ul ref={ref} role="dialog"> 266 <li tabIndex={0}>Outer #1</li> 267 <li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li> 268 {nested && <InnerDialog />} 269 </ul> 270 )} 271 <button>after</button> 272 </main> 273 ); 274 }; 275 276 mount(<OuterDialog />); 277 278 cy.get('input').first().as('input').focus(); 279 cy.focused().should('have.property.name', 'text'); 280 281 // select first dialog 282 cy.realPress('ArrowDown'); 283 cy.focused().contains('Outer #1'); 284 cy.realPress('ArrowDown'); 285 cy.focused().contains('Outer #2'); 286 287 // select second dialog 288 cy.realPress('ArrowDown'); 289 cy.focused().contains('Inner #1'); 290 cy.realPress('ArrowDown'); 291 cy.focused().contains('Inner #2'); 292 293 // remains in inner dialog 294 cy.realPress('ArrowDown'); 295 cy.focused().contains('Inner #1'); 296 297 // tabs to last dialog 298 cy.realPress(['Shift', 'Tab']); 299 cy.focused().contains('Outer #2'); 300 301 // arrows bring us back to the inner dialog 302 cy.realPress('ArrowUp'); 303 cy.focused().contains('Inner #2'); 304 305 // tab out of dialogs 306 cy.realPress('Tab'); 307 cy.focused().contains('after'); 308 // we can't reenter the dialogs 309 cy.realPress(['Shift', 'Tab']); 310 cy.get('@input').should('have.focus'); 311}); 312 313it('allows dialogs in semantic order', () => { 314 const Dialog = ({ name }) => { 315 const ownerRef = useRef<HTMLInputElement>(null); 316 const ref = useRef<HTMLUListElement>(null); 317 318 useDialogFocus(ref, { ownerRef }); 319 320 return ( 321 <div> 322 <input type="text" className={name} ref={ownerRef} tabIndex={-1} /> 323 <ul ref={ref} role="dialog"> 324 <li tabIndex={0}>{name} #1</li> 325 <li tabIndex={0}>{name} #2</li> 326 </ul> 327 </div> 328 ); 329 }; 330 331 mount( 332 <main> 333 <Dialog name="First" /> 334 <Dialog name="Second" /> 335 <button>after</button> 336 </main> 337 ); 338 339 cy.get('.First').first().as('first'); 340 cy.get('.Second').first().as('second'); 341 342 // focus first dialog 343 cy.get('@first').focus(); 344 cy.get('.First').first().as('first').focus(); 345 346 // tabs over both subsequent dialogs 347 cy.realPress('Tab'); 348 cy.focused().contains('after'); 349 350 // given a focused first input, doesn't allow the first dialog to be used 351 cy.get('@first').focus(); 352 cy.realPress('ArrowDown'); 353 cy.get('@first').should('have.focus'); 354 355 // given a focused second input, does allow the second dialog to be used 356 cy.get('@second').focus(); 357 cy.realPress('ArrowDown'); 358 cy.focused().contains('Second #1'); 359});