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});