11import React from 'react'
22import { fireEvent , render , waitFor } from '@testing-library/react'
3- import {
4- LanguageSelector ,
5- LanguageDefinition ,
6- } from '../LanguageSelector/LanguageSelector'
3+ import { userEvent } from '@testing-library/user-event'
4+ import { LanguageSelector , LanguageDefinition } from './LanguageSelector'
75
86const voidLink = '#test'
97const languages : LanguageDefinition [ ] = [
@@ -73,34 +71,37 @@ describe('LanguageSelector component', () => {
7371 } )
7472
7573 describe ( 'Given 2 languages' , ( ) => {
76- it ( 'toggles button label on click' , ( ) => {
74+ it ( 'toggles button label on click' , async ( ) => {
75+ const user = userEvent . setup ( )
7776 const { getByTestId } = render (
7877 < LanguageSelector langs = { [ languages [ 0 ] , languages [ 1 ] ] } />
7978 )
8079 const button = getByTestId ( 'languageSelectorButton' )
8180 expect ( button ) . toHaveTextContent ( languages [ 0 ] . label )
82- fireEvent . click ( button )
81+ await user . click ( button )
8382 expect ( button ) . toHaveTextContent ( languages [ 1 ] . label )
84- fireEvent . click ( button )
83+ await user . click ( button )
8584 expect ( button ) . toHaveTextContent ( languages [ 0 ] . label )
8685 } )
8786
8887 it ( 'works like a link' , async ( ) => {
88+ const user = userEvent . setup ( )
8989 const { getByTestId } = render (
9090 < LanguageSelector langs = { [ languages [ 0 ] , languages [ 1 ] ] } />
9191 )
92- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
92+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
9393 await waitFor ( ( ) => {
9494 expect ( window . location . hash ) . toEqual ( voidLink )
9595 } )
9696 } )
9797
98- it ( 'works like a button' , ( ) => {
98+ it ( 'works like a button' , async ( ) => {
99+ const user = userEvent . setup ( )
99100 const { getByTestId } = render (
100101 < LanguageSelector langs = { [ languagesButton [ 0 ] , languagesButton [ 1 ] ] } />
101102 )
102- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
103- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
103+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
104+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
104105 expect ( voidButton ) . toHaveBeenCalledTimes ( 2 )
105106 } )
106107 } )
@@ -129,25 +130,27 @@ describe('LanguageSelector component', () => {
129130 )
130131 } )
131132
132- it ( 'renders list when opened' , ( ) => {
133+ it ( 'renders list when opened' , async ( ) => {
134+ const user = userEvent . setup ( )
133135 const { getByText, getByTestId } = render (
134136 < LanguageSelector langs = { languages } label = "Languages" />
135137 )
136138 expect ( getByText ( languages [ 0 ] . label ) ) . not . toBeVisible ( )
137139 expect ( getByText ( languages [ 1 ] . label ) ) . not . toBeVisible ( )
138140 expect ( getByText ( languages [ 2 ] . label ) ) . not . toBeVisible ( )
139- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
141+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
140142 expect ( getByText ( languages [ 0 ] . label ) ) . toBeVisible ( )
141143 expect ( getByText ( languages [ 1 ] . label ) ) . toBeVisible ( )
142144 expect ( getByText ( languages [ 2 ] . label ) ) . toBeVisible ( )
143145 } )
144146
145147 describe ( 'its list items' , ( ) => {
146- it ( 'are links' , ( ) => {
148+ it ( 'are links' , async ( ) => {
149+ const user = userEvent . setup ( )
147150 const { getByTestId } = render (
148151 < LanguageSelector langs = { languages } label = "Languages" />
149152 )
150- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
153+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
151154 expect ( getByTestId ( languages [ 0 ] . attr ) ) . toHaveAttribute (
152155 'href' ,
153156 languages [ 0 ] . on_click
@@ -162,19 +165,22 @@ describe('LanguageSelector component', () => {
162165 )
163166 } )
164167
165- it ( 'are buttons' , ( ) => {
168+ it ( 'are buttons' , async ( ) => {
169+ const user = userEvent . setup ( )
166170 const { getByTestId } = render (
167171 < LanguageSelector langs = { languagesButton } label = "Languages" />
168172 )
169- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
170- fireEvent . click ( getByTestId ( languagesButton [ 0 ] . attr ) )
171- fireEvent . click ( getByTestId ( languagesButton [ 1 ] . attr ) )
172- fireEvent . click ( getByTestId ( languagesButton [ 2 ] . attr ) )
173+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
174+ await user . click ( getByTestId ( languagesButton [ 0 ] . attr ) )
175+ await user . click ( getByTestId ( languagesButton [ 1 ] . attr ) )
176+ await user . click ( getByTestId ( languagesButton [ 2 ] . attr ) )
173177 expect ( voidButton ) . toHaveBeenCalledTimes ( 5 ) //3 here and 2 above
174178 } )
175179 } )
176180
177- it ( 'closes the list after selecting a language' , ( ) => {
181+ it ( 'closes the list after selecting a language' , async ( ) => {
182+ const user = userEvent . setup ( )
183+
178184 const onClick = vi . fn ( )
179185 const localLanguagesButton = languagesButton . map ( ( language ) => ( {
180186 ...language ,
@@ -184,10 +190,10 @@ describe('LanguageSelector component', () => {
184190 < LanguageSelector langs = { localLanguagesButton } label = "Languages" />
185191 )
186192
187- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
193+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
188194 expect ( getByTestId ( languagesButton [ 0 ] . attr ) ) . toBeVisible ( )
189195
190- fireEvent . click ( getByTestId ( languagesButton [ 0 ] . attr ) )
196+ await user . click ( getByTestId ( languagesButton [ 0 ] . attr ) )
191197
192198 expect ( onClick ) . toHaveBeenCalledTimes ( 1 )
193199 expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
@@ -197,15 +203,16 @@ describe('LanguageSelector component', () => {
197203 expect ( getByTestId ( languagesButton [ 0 ] . attr ) ) . not . toBeVisible ( )
198204 } )
199205
200- it ( 'closes the list after selecting a language link' , ( ) => {
206+ it ( 'closes the list after selecting a language link' , async ( ) => {
207+ const user = userEvent . setup ( )
201208 const { getByTestId } = render (
202209 < LanguageSelector langs = { languages } label = "Languages" />
203210 )
204211
205- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
212+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
206213 expect ( getByTestId ( languages [ 0 ] . attr ) ) . toBeVisible ( )
207214
208- fireEvent . click ( getByTestId ( languages [ 0 ] . attr ) )
215+ await user . click ( getByTestId ( languages [ 0 ] . attr ) )
209216
210217 expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
211218 'aria-expanded' ,
@@ -217,7 +224,8 @@ describe('LanguageSelector component', () => {
217224 expect ( getByTestId ( languages [ 0 ] . attr ) ) . not . toBeVisible ( )
218225 } )
219226
220- it ( 'closes the list after clicking outside the language selector' , ( ) => {
227+ it ( 'closes the list after clicking outside the language selector' , async ( ) => {
228+ const user = userEvent . setup ( )
221229 const { getByTestId } = render (
222230 < >
223231 < LanguageSelector langs = { languages } label = "Languages" />
@@ -227,9 +235,134 @@ describe('LanguageSelector component', () => {
227235 </ >
228236 )
229237
230- fireEvent . click ( getByTestId ( 'languageSelectorButton' ) )
238+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
239+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . toBeVisible ( )
240+
241+ await user . click ( getByTestId ( 'outside' ) )
242+
243+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
244+ 'aria-expanded' ,
245+ 'false'
246+ )
247+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . not . toBeVisible ( )
248+ } )
249+
250+ it ( 'closes the list after clicking empty space inside the component, without relying on focus' , async ( ) => {
251+ const user = userEvent . setup ( )
252+ const { getByTestId } = render (
253+ < LanguageSelector langs = { languages } label = "Languages" />
254+ )
255+
256+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
231257 expect ( getByTestId ( languages [ 0 ] . attr ) ) . toBeVisible ( )
232258
259+ // fireEvent, not userEvent: dispatch a bare click with no focus
260+ // movement, as in Safari, so the focusout handler cannot close the
261+ // menu and mask a regression in the click listener
262+ fireEvent . click ( getByTestId ( 'languageSelector' ) )
263+
264+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
265+ 'aria-expanded' ,
266+ 'false'
267+ )
268+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . not . toBeVisible ( )
269+ } )
270+
271+ it ( 'closes the list when focus leaves the language selector' , async ( ) => {
272+ const user = userEvent . setup ( )
273+ const { getByTestId } = render (
274+ < >
275+ < LanguageSelector langs = { languages } label = "Languages" />
276+ < button type = "button" data-testid = "outside" >
277+ Outside
278+ </ button >
279+ </ >
280+ )
281+
282+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
283+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . toBeVisible ( )
284+
285+ // tab through each language link...
286+ for ( const _language of languages ) {
287+ await user . tab ( )
288+ }
289+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
290+ 'aria-expanded' ,
291+ 'true'
292+ )
293+ // ...then tab out of the component
294+ await user . tab ( )
295+
296+ expect ( getByTestId ( 'outside' ) ) . toHaveFocus ( )
297+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
298+ 'aria-expanded' ,
299+ 'false'
300+ )
301+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . not . toBeVisible ( )
302+ } )
303+
304+ it ( 'closes the list and returns focus to the button when Escape is pressed' , async ( ) => {
305+ const user = userEvent . setup ( )
306+ const { getByTestId } = render (
307+ < LanguageSelector langs = { languages } label = "Languages" />
308+ )
309+
310+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
311+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . toBeVisible ( )
312+
313+ // move focus into the menu so Escape must return it to the button
314+ await user . tab ( )
315+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . toHaveFocus ( )
316+
317+ await user . keyboard ( '{Escape}' )
318+
319+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveFocus ( )
320+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
321+ 'aria-expanded' ,
322+ 'false'
323+ )
324+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . not . toBeVisible ( )
325+ } )
326+
327+ it ( 'closes the list when the toggle button is clicked again' , async ( ) => {
328+ const user = userEvent . setup ( )
329+ const { getByTestId } = render (
330+ < LanguageSelector langs = { languages } label = "Languages" />
331+ )
332+
333+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
334+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . toBeVisible ( )
335+
336+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
337+
338+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
339+ 'aria-expanded' ,
340+ 'false'
341+ )
342+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . not . toBeVisible ( )
343+ } )
344+
345+ it ( 'closes the list when an outside click handler stops propagation' , async ( ) => {
346+ const user = userEvent . setup ( )
347+ const { getByTestId } = render (
348+ < >
349+ < LanguageSelector langs = { languages } label = "Languages" />
350+ < button type = "button" data-testid = "outside" >
351+ Outside
352+ </ button >
353+ </ >
354+ )
355+ // a widget that swallows bubbling clicks, as third-party widgets often
356+ // do; only a capture-phase document listener can still observe the click
357+ getByTestId ( 'outside' ) . addEventListener ( 'click' , ( event ) =>
358+ event . stopPropagation ( )
359+ )
360+
361+ await user . click ( getByTestId ( 'languageSelectorButton' ) )
362+ expect ( getByTestId ( languages [ 0 ] . attr ) ) . toBeVisible ( )
363+
364+ // fireEvent, not userEvent: a bare click with no focus movement, so the
365+ // focusout handler cannot close the menu and mask a bubble-phase listener
233366 fireEvent . click ( getByTestId ( 'outside' ) )
234367
235368 expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
@@ -238,5 +371,57 @@ describe('LanguageSelector component', () => {
238371 )
239372 expect ( getByTestId ( languages [ 0 ] . attr ) ) . not . toBeVisible ( )
240373 } )
374+
375+ it ( 'links the button to the menu with the default id' , ( ) => {
376+ const { getByTestId } = render (
377+ < LanguageSelector langs = { languages } label = "Languages" />
378+ )
379+
380+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
381+ 'aria-controls' ,
382+ 'language-options'
383+ )
384+ expect ( document . getElementById ( 'language-options' ) ) . toBeInTheDocument ( )
385+ } )
386+
387+ it ( 'derives a unique menu id from a given id' , ( ) => {
388+ const { getByTestId } = render (
389+ < LanguageSelector
390+ langs = { languages }
391+ label = "Languages"
392+ id = "my-custom-id"
393+ />
394+ )
395+
396+ expect ( getByTestId ( 'languageSelector' ) ) . toHaveAttribute (
397+ 'id' ,
398+ 'my-custom-id'
399+ )
400+ expect ( getByTestId ( 'languageSelectorButton' ) ) . toHaveAttribute (
401+ 'aria-controls' ,
402+ 'my-custom-id-language-options'
403+ )
404+ } )
405+
406+ it ( 'closes an open selector when another selector is opened' , async ( ) => {
407+ const user = userEvent . setup ( )
408+ const { getAllByTestId } = render (
409+ < >
410+ < LanguageSelector langs = { languages } label = "Languages" />
411+ < LanguageSelector langs = { languages } label = "Languages" />
412+ </ >
413+ )
414+ const [ firstButton , secondButton ] = getAllByTestId (
415+ 'languageSelectorButton'
416+ )
417+
418+ await user . click ( firstButton )
419+ expect ( firstButton ) . toHaveAttribute ( 'aria-expanded' , 'true' )
420+
421+ await user . click ( secondButton )
422+
423+ expect ( firstButton ) . toHaveAttribute ( 'aria-expanded' , 'false' )
424+ expect ( secondButton ) . toHaveAttribute ( 'aria-expanded' , 'true' )
425+ } )
241426 } )
242427} )
0 commit comments