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