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 const ownerRef = useRef<HTMLInputElement>(null); 63 useDialogFocus(ref, { ownerRef }); 64 return ( 65 <div> 66 <input type="text" name="text" ref={ownerRef} /> 67 <ul ref={ref} role="dialog"> 68 <li tabIndex={0}>#1</li> 69 <li tabIndex={0}>#2</li> 70 <li tabIndex={0}>#3</li> 71 </ul> 72 </div> 73 ); 74 }; 75 76 const App = () => { 77 return ( 78 <main> 79 <button>before</button> 80 <Dialog /> 81 <button>after</button> 82 </main> 83 ); 84 }; 85 86 mount(<App />); 87 88 cy.get('input').first().as('input').focus(); 89 cy.focused().should('have.property.name', 'text'); 90 91 // Tabbing should skip over the dialog 92 cy.realPress('Tab'); 93 cy.focused().contains('after'); 94 // Tabbing back should skip over the dialog 95 cy.realPress(['Shift', 'Tab']); 96 cy.get('@input').should('have.focus'); 97 // Tabbing back on the owner shouldn't affect the dialog 98 cy.realPress(['Shift', 'Tab']); 99 cy.focused().contains('before'); 100 // It should still know which element the owner was 101 cy.realPress('Tab'); 102 cy.realPress('ArrowDown'); 103 cy.focused().contains('#1'); 104 // From inside the dialog tabbing should skip out of the dialog 105 cy.realPress('Tab'); 106 cy.focused().contains('after'); 107}); 108 109it('supports being attached to an owner element', () => { 110 const Dialog = () => { 111 const ownerRef = useRef<HTMLInputElement>(null); 112 const ref = useRef<HTMLUListElement>(null); 113 114 useDialogFocus(ref, { ownerRef }); 115 116 return ( 117 <main> 118 <input type="text" name="text" ref={ownerRef} /> 119 <ul ref={ref} role="dialog"> 120 <li tabIndex={0}>#1</li> 121 <li tabIndex={0}>#2</li> 122 <li tabIndex={0}>#3</li> 123 </ul> 124 </main> 125 ); 126 }; 127 128 mount(<Dialog />); 129 130 cy.get('input').first().as('input').focus(); 131 cy.focused().should('have.property.name', 'text'); 132 133 // pressing escape on input shouldn't change focus 134 cy.realPress('Escape'); 135 cy.get('@input').should('have.focus'); 136 137 // pressing arrow down should start focusing the menu 138 cy.get('@input').focus(); 139 cy.realPress('ArrowDown'); 140 cy.focused().contains('#1'); 141 cy.realPress('ArrowDown'); 142 cy.focused().contains('#2'); 143 144 // tabbing should skip over the dialog items 145 cy.realPress(['Shift', 'Tab']); 146 cy.get('@input').should('have.focus'); 147 148 // pressing arrow up should start focusing the last item 149 cy.get('@input').focus(); 150 cy.realPress('ArrowUp'); 151 cy.focused().contains('#3'); 152 153 // pressing enter should start focusing the first item 154 cy.get('@input').focus(); 155 cy.realPress('Enter'); 156 cy.focused().contains('#1'); 157 158 // typing regular values should refocus the owner input 159 cy.realType('test'); 160 cy.get('@input') 161 .should('have.focus') 162 .should('have.value', 'test'); 163 164 // pressing escape should refocus input 165 cy.get('li').first().focus(); 166 cy.realPress('Escape'); 167 cy.get('@input').should('have.focus'); 168}); 169 170it('supports nested dialogs', () => { 171 const InnerDialog = () => { 172 const ref = useRef<HTMLUListElement>(null); 173 useDialogFocus(ref); 174 175 return ( 176 <ul ref={ref} role="dialog"> 177 <li tabIndex={0}>Inner #1</li> 178 <li tabIndex={0}>Inner #2</li> 179 </ul> 180 ); 181 }; 182 183 const OuterDialog = () => { 184 const [visible, setVisible] = useState(false); 185 const [nested, setNested] = useState(false); 186 const ref = useRef<HTMLUListElement>(null); 187 const ownerRef = useRef<HTMLInputElement>(null); 188 189 useDialogFocus(ref, { disabled: !visible, ownerRef }); 190 191 return ( 192 <main> 193 <input type="text" name="text" onFocus={() => setVisible(true)} ref={ownerRef} /> 194 {visible && ( 195 <ul ref={ref} role="dialog"> 196 <li tabIndex={0}>Outer #1</li> 197 <li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li> 198 {nested && <InnerDialog />} 199 </ul> 200 )} 201 <button>after</button> 202 </main> 203 ); 204 }; 205 206 mount(<OuterDialog />); 207 208 cy.get('input').first().as('input').focus(); 209 cy.focused().should('have.property.name', 'text'); 210 211 // select first dialog 212 cy.realPress('ArrowDown'); 213 cy.focused().contains('Outer #1'); 214 cy.realPress('ArrowDown'); 215 cy.focused().contains('Outer #2'); 216 217 // select second dialog 218 cy.realPress('ArrowDown'); 219 cy.focused().contains('Inner #1'); 220 cy.realPress('ArrowDown'); 221 cy.focused().contains('Inner #2'); 222 223 // remains in inner dialog 224 cy.realPress('ArrowDown'); 225 cy.focused().contains('Inner #1'); 226 227 // tabs to last dialog 228 cy.realPress(['Shift', 'Tab']); 229 cy.focused().contains('Outer #2'); 230 231 // arrows bring us back to the inner dialog 232 cy.realPress('ArrowUp'); 233 cy.focused().contains('Inner #2'); 234 235 // tab out of dialogs 236 cy.realPress('Tab'); 237 cy.focused().contains('after'); 238 // we can't reenter the dialogs 239 cy.realPress(['Shift', 'Tab']); 240 cy.get('@input').should('have.focus'); 241}); 242 243it('allows dialogs in semantic order', () => { 244 const Dialog = ({ name }) => { 245 const ownerRef = useRef<HTMLInputElement>(null); 246 const ref = useRef<HTMLUListElement>(null); 247 248 useDialogFocus(ref, { ownerRef }); 249 250 return ( 251 <div> 252 <input type="text" className={name} ref={ownerRef} tabIndex={-1} /> 253 <ul ref={ref} role="dialog"> 254 <li tabIndex={0}>{name} #1</li> 255 <li tabIndex={0}>{name} #2</li> 256 </ul> 257 </div> 258 ); 259 }; 260 261 mount( 262 <main> 263 <Dialog name="First" /> 264 <Dialog name="Second" /> 265 <button>after</button> 266 </main> 267 ); 268 269 cy.get('.First').first().as('first'); 270 cy.get('.Second').first().as('second'); 271 272 // focus first dialog 273 cy.get('@first').focus(); 274 cy.get('.First').first().as('first').focus(); 275 276 // tabs over both subsequent dialogs 277 cy.realPress('Tab'); 278 cy.focused().contains('after'); 279 280 // given a focused first input, doesn't allow the first dialog to be used 281 cy.get('@first').focus(); 282 cy.realPress('ArrowDown'); 283 cy.get('@first').should('have.focus'); 284 285 // given a focused second input, does allow the second dialog to be used 286 cy.get('@second').focus(); 287 cy.realPress('ArrowDown'); 288 cy.focused().contains('Second #1'); 289});