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