Skip to content

Commit f4c7f6a

Browse files
authored
feat: Close Language Selector on pressing escape and on blur (#3534)
1 parent 6415f8f commit f4c7f6a

4 files changed

Lines changed: 288 additions & 85 deletions

File tree

src/components/LanguageSelector/LanguageSelector.test.tsx

Lines changed: 213 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import React from 'react'
22
import { 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

86
const voidLink = '#test'
97
const 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
})

src/components/LanguageSelector/LanguageSelector.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,8 @@ export type LanguageSelectorProps = {
1818
displayLang?: string
1919
} & JSX.IntrinsicElements['div']
2020

21-
export const LanguageSelector = ({
22-
label,
23-
langs,
24-
small,
25-
className,
26-
displayLang,
27-
...divProps
28-
}: LanguageSelectorProps): JSX.Element => {
21+
export const LanguageSelector = (props: LanguageSelectorProps): JSX.Element => {
22+
const { label, langs, small, className, displayLang, ...divProps } = props
2923
const classes = classnames(
3024
'usa-language-container',
3125
{
@@ -36,8 +30,11 @@ export const LanguageSelector = ({
3630

3731
const [langIndex, setLangIndex] = useState(false)
3832
if (langs.length > 2) {
39-
const dropdownProps = { label, langs, small, displayLang }
40-
return <LanguageSelectorDropdown {...dropdownProps} className={className} />
33+
const dropdownProps = {
34+
...props,
35+
className: classes,
36+
}
37+
return <LanguageSelectorDropdown {...dropdownProps} />
4138
} else {
4239
if (label) {
4340
console.warn(

0 commit comments

Comments
 (0)