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
165it('supports nested dialogs', () => {
166 const InnerDialog = ({ ownerRef }) => {
167 const ref = useRef<HTMLUListElement>(null);
168 useDialogFocus(ref, { ownerRef });
169
170 return (
171 <ul ref={ref} role="dialog">
172 <li tabIndex={0}>Inner #1</li>
173 <li tabIndex={0}>Inner #2</li>
174 </ul>
175 );
176 };
177
178 const OuterDialog = () => {
179 const [visible, setVisible] = useState(false);
180 const [nested, setNested] = useState(false);
181 const ref = useRef<HTMLUListElement>(null);
182 const ownerRef = useRef<HTMLInputElement>(null);
183
184 useDialogFocus(ref, { disabled: !visible, ownerRef });
185
186 return (
187 <main>
188 <input type="text" name="text" onFocus={() => setVisible(true)} ref={ownerRef} />
189 {visible && (
190 <ul ref={ref} role="dialog">
191 <li tabIndex={0}>Outer #1</li>
192 <li tabIndex={0} onFocus={() => setNested(true)}>Outer #2</li>
193 {nested && <InnerDialog ownerRef={ref} />}
194 </ul>
195 )}
196 <button>after</button>
197 </main>
198 );
199 };
200
201 mount(<OuterDialog />);
202
203 cy.get('input').first().as('input').focus();
204 cy.focused().should('have.property.name', 'text');
205
206 // select first dialog
207 cy.realPress('ArrowDown');
208 cy.focused().contains('Outer #1');
209 cy.realPress('ArrowDown');
210 cy.focused().contains('Outer #2');
211
212 // select second dialog
213 cy.realPress('ArrowDown');
214 cy.focused().contains('Inner #1');
215 cy.realPress('ArrowDown');
216 cy.focused().contains('Inner #2');
217
218 // remains in inner dialog
219 cy.realPress('ArrowDown');
220 cy.focused().contains('Inner #1');
221
222 // tabs to last dialog
223 cy.realPress(['Shift', 'Tab']);
224 cy.get('@input').should('have.focus');
225
226 // tab out of dialogs
227 cy.realPress('Tab');
228 cy.focused().contains('after');
229});
230
231it('allows dialogs in semantic order', () => {
232 const Dialog = ({ name }) => {
233 const ownerRef = useRef<HTMLInputElement>(null);
234 const ref = useRef<HTMLUListElement>(null);
235
236 useDialogFocus(ref, { ownerRef });
237
238 return (
239 <div>
240 <input type="text" className={name} ref={ownerRef} tabIndex={-1} />
241 <ul ref={ref} role="dialog">
242 <li tabIndex={0}>{name} #1</li>
243 <li tabIndex={0}>{name} #2</li>
244 </ul>
245 </div>
246 );
247 };
248
249 mount(
250 <main>
251 <Dialog name="First" />
252 <Dialog name="Second" />
253 <button>after</button>
254 </main>
255 );
256
257 cy.get('.First').first().as('first');
258 cy.get('.Second').first().as('second');
259
260 // focus first dialog
261 cy.get('@first').focus();
262 cy.get('.First').first().as('first').focus();
263
264 // tabs over both subsequent dialogs
265 cy.realPress('Tab');
266 cy.focused().contains('after');
267
268 // given a focused first input, doesn't allow the first dialog to be used
269 cy.get('@first').focus();
270 cy.realPress('ArrowDown');
271 cy.get('@first').should('have.focus');
272
273 // given a focused second input, does allow the second dialog to be used
274 cy.get('@second').focus();
275 cy.realPress('ArrowDown');
276 cy.focused().contains('Second #1');
277});