diff --git a/src/components/LanguageSelector/LanguageSelector.test.tsx b/src/components/LanguageSelector/LanguageSelector.test.tsx
index 39c74575e..d08140fc9 100644
--- a/src/components/LanguageSelector/LanguageSelector.test.tsx
+++ b/src/components/LanguageSelector/LanguageSelector.test.tsx
@@ -1,9 +1,7 @@
import React from 'react'
import { fireEvent, render, waitFor } from '@testing-library/react'
-import {
- LanguageSelector,
- LanguageDefinition,
-} from '../LanguageSelector/LanguageSelector'
+import { userEvent } from '@testing-library/user-event'
+import { LanguageSelector, LanguageDefinition } from './LanguageSelector'
const voidLink = '#test'
const languages: LanguageDefinition[] = [
@@ -73,34 +71,37 @@ describe('LanguageSelector component', () => {
})
describe('Given 2 languages', () => {
- it('toggles button label on click', () => {
+ it('toggles button label on click', async () => {
+ const user = userEvent.setup()
const { getByTestId } = render(
)
const button = getByTestId('languageSelectorButton')
expect(button).toHaveTextContent(languages[0].label)
- fireEvent.click(button)
+ await user.click(button)
expect(button).toHaveTextContent(languages[1].label)
- fireEvent.click(button)
+ await user.click(button)
expect(button).toHaveTextContent(languages[0].label)
})
it('works like a link', async () => {
+ const user = userEvent.setup()
const { getByTestId } = render(
)
- fireEvent.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId('languageSelectorButton'))
await waitFor(() => {
expect(window.location.hash).toEqual(voidLink)
})
})
- it('works like a button', () => {
+ it('works like a button', async () => {
+ const user = userEvent.setup()
const { getByTestId } = render(
)
- fireEvent.click(getByTestId('languageSelectorButton'))
- fireEvent.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId('languageSelectorButton'))
expect(voidButton).toHaveBeenCalledTimes(2)
})
})
@@ -129,25 +130,27 @@ describe('LanguageSelector component', () => {
)
})
- it('renders list when opened', () => {
+ it('renders list when opened', async () => {
+ const user = userEvent.setup()
const { getByText, getByTestId } = render(
)
expect(getByText(languages[0].label)).not.toBeVisible()
expect(getByText(languages[1].label)).not.toBeVisible()
expect(getByText(languages[2].label)).not.toBeVisible()
- fireEvent.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId('languageSelectorButton'))
expect(getByText(languages[0].label)).toBeVisible()
expect(getByText(languages[1].label)).toBeVisible()
expect(getByText(languages[2].label)).toBeVisible()
})
describe('its list items', () => {
- it('are links', () => {
+ it('are links', async () => {
+ const user = userEvent.setup()
const { getByTestId } = render(
)
- fireEvent.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId('languageSelectorButton'))
expect(getByTestId(languages[0].attr)).toHaveAttribute(
'href',
languages[0].on_click
@@ -162,19 +165,22 @@ describe('LanguageSelector component', () => {
)
})
- it('are buttons', () => {
+ it('are buttons', async () => {
+ const user = userEvent.setup()
const { getByTestId } = render(
)
- fireEvent.click(getByTestId('languageSelectorButton'))
- fireEvent.click(getByTestId(languagesButton[0].attr))
- fireEvent.click(getByTestId(languagesButton[1].attr))
- fireEvent.click(getByTestId(languagesButton[2].attr))
+ await user.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId(languagesButton[0].attr))
+ await user.click(getByTestId(languagesButton[1].attr))
+ await user.click(getByTestId(languagesButton[2].attr))
expect(voidButton).toHaveBeenCalledTimes(5) //3 here and 2 above
})
})
- it('closes the list after selecting a language', () => {
+ it('closes the list after selecting a language', async () => {
+ const user = userEvent.setup()
+
const onClick = vi.fn()
const localLanguagesButton = languagesButton.map((language) => ({
...language,
@@ -184,10 +190,10 @@ describe('LanguageSelector component', () => {
)
- fireEvent.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId('languageSelectorButton'))
expect(getByTestId(languagesButton[0].attr)).toBeVisible()
- fireEvent.click(getByTestId(languagesButton[0].attr))
+ await user.click(getByTestId(languagesButton[0].attr))
expect(onClick).toHaveBeenCalledTimes(1)
expect(getByTestId('languageSelectorButton')).toHaveAttribute(
@@ -197,15 +203,16 @@ describe('LanguageSelector component', () => {
expect(getByTestId(languagesButton[0].attr)).not.toBeVisible()
})
- it('closes the list after selecting a language link', () => {
+ it('closes the list after selecting a language link', async () => {
+ const user = userEvent.setup()
const { getByTestId } = render(
)
- fireEvent.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId('languageSelectorButton'))
expect(getByTestId(languages[0].attr)).toBeVisible()
- fireEvent.click(getByTestId(languages[0].attr))
+ await user.click(getByTestId(languages[0].attr))
expect(getByTestId('languageSelectorButton')).toHaveAttribute(
'aria-expanded',
@@ -217,7 +224,8 @@ describe('LanguageSelector component', () => {
expect(getByTestId(languages[0].attr)).not.toBeVisible()
})
- it('closes the list after clicking outside the language selector', () => {
+ it('closes the list after clicking outside the language selector', async () => {
+ const user = userEvent.setup()
const { getByTestId } = render(
<>
@@ -227,9 +235,134 @@ describe('LanguageSelector component', () => {
>
)
- fireEvent.click(getByTestId('languageSelectorButton'))
+ await user.click(getByTestId('languageSelectorButton'))
+ expect(getByTestId(languages[0].attr)).toBeVisible()
+
+ await user.click(getByTestId('outside'))
+
+ expect(getByTestId('languageSelectorButton')).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ )
+ expect(getByTestId(languages[0].attr)).not.toBeVisible()
+ })
+
+ it('closes the list after clicking empty space inside the component, without relying on focus', async () => {
+ const user = userEvent.setup()
+ const { getByTestId } = render(
+
+ )
+
+ await user.click(getByTestId('languageSelectorButton'))
expect(getByTestId(languages[0].attr)).toBeVisible()
+ // fireEvent, not userEvent: dispatch a bare click with no focus
+ // movement, as in Safari, so the focusout handler cannot close the
+ // menu and mask a regression in the click listener
+ fireEvent.click(getByTestId('languageSelector'))
+
+ expect(getByTestId('languageSelectorButton')).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ )
+ expect(getByTestId(languages[0].attr)).not.toBeVisible()
+ })
+
+ it('closes the list when focus leaves the language selector', async () => {
+ const user = userEvent.setup()
+ const { getByTestId } = render(
+ <>
+
+
+ >
+ )
+
+ await user.click(getByTestId('languageSelectorButton'))
+ expect(getByTestId(languages[0].attr)).toBeVisible()
+
+ // tab through each language link...
+ for (const _language of languages) {
+ await user.tab()
+ }
+ expect(getByTestId('languageSelectorButton')).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ )
+ // ...then tab out of the component
+ await user.tab()
+
+ expect(getByTestId('outside')).toHaveFocus()
+ expect(getByTestId('languageSelectorButton')).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ )
+ expect(getByTestId(languages[0].attr)).not.toBeVisible()
+ })
+
+ it('closes the list and returns focus to the button when Escape is pressed', async () => {
+ const user = userEvent.setup()
+ const { getByTestId } = render(
+
+ )
+
+ await user.click(getByTestId('languageSelectorButton'))
+ expect(getByTestId(languages[0].attr)).toBeVisible()
+
+ // move focus into the menu so Escape must return it to the button
+ await user.tab()
+ expect(getByTestId(languages[0].attr)).toHaveFocus()
+
+ await user.keyboard('{Escape}')
+
+ expect(getByTestId('languageSelectorButton')).toHaveFocus()
+ expect(getByTestId('languageSelectorButton')).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ )
+ expect(getByTestId(languages[0].attr)).not.toBeVisible()
+ })
+
+ it('closes the list when the toggle button is clicked again', async () => {
+ const user = userEvent.setup()
+ const { getByTestId } = render(
+
+ )
+
+ await user.click(getByTestId('languageSelectorButton'))
+ expect(getByTestId(languages[0].attr)).toBeVisible()
+
+ await user.click(getByTestId('languageSelectorButton'))
+
+ expect(getByTestId('languageSelectorButton')).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ )
+ expect(getByTestId(languages[0].attr)).not.toBeVisible()
+ })
+
+ it('closes the list when an outside click handler stops propagation', async () => {
+ const user = userEvent.setup()
+ const { getByTestId } = render(
+ <>
+
+
+ >
+ )
+ // a widget that swallows bubbling clicks, as third-party widgets often
+ // do; only a capture-phase document listener can still observe the click
+ getByTestId('outside').addEventListener('click', (event) =>
+ event.stopPropagation()
+ )
+
+ await user.click(getByTestId('languageSelectorButton'))
+ expect(getByTestId(languages[0].attr)).toBeVisible()
+
+ // fireEvent, not userEvent: a bare click with no focus movement, so the
+ // focusout handler cannot close the menu and mask a bubble-phase listener
fireEvent.click(getByTestId('outside'))
expect(getByTestId('languageSelectorButton')).toHaveAttribute(
@@ -238,5 +371,57 @@ describe('LanguageSelector component', () => {
)
expect(getByTestId(languages[0].attr)).not.toBeVisible()
})
+
+ it('links the button to the menu with the default id', () => {
+ const { getByTestId } = render(
+
+ )
+
+ expect(getByTestId('languageSelectorButton')).toHaveAttribute(
+ 'aria-controls',
+ 'language-options'
+ )
+ expect(document.getElementById('language-options')).toBeInTheDocument()
+ })
+
+ it('derives a unique menu id from a given id', () => {
+ const { getByTestId } = render(
+
+ )
+
+ expect(getByTestId('languageSelector')).toHaveAttribute(
+ 'id',
+ 'my-custom-id'
+ )
+ expect(getByTestId('languageSelectorButton')).toHaveAttribute(
+ 'aria-controls',
+ 'my-custom-id-language-options'
+ )
+ })
+
+ it('closes an open selector when another selector is opened', async () => {
+ const user = userEvent.setup()
+ const { getAllByTestId } = render(
+ <>
+
+
+ >
+ )
+ const [firstButton, secondButton] = getAllByTestId(
+ 'languageSelectorButton'
+ )
+
+ await user.click(firstButton)
+ expect(firstButton).toHaveAttribute('aria-expanded', 'true')
+
+ await user.click(secondButton)
+
+ expect(firstButton).toHaveAttribute('aria-expanded', 'false')
+ expect(secondButton).toHaveAttribute('aria-expanded', 'true')
+ })
})
})
diff --git a/src/components/LanguageSelector/LanguageSelector.tsx b/src/components/LanguageSelector/LanguageSelector.tsx
index 4d30e97de..fc9d7c1e6 100644
--- a/src/components/LanguageSelector/LanguageSelector.tsx
+++ b/src/components/LanguageSelector/LanguageSelector.tsx
@@ -18,14 +18,8 @@ export type LanguageSelectorProps = {
displayLang?: string
} & JSX.IntrinsicElements['div']
-export const LanguageSelector = ({
- label,
- langs,
- small,
- className,
- displayLang,
- ...divProps
-}: LanguageSelectorProps): JSX.Element => {
+export const LanguageSelector = (props: LanguageSelectorProps): JSX.Element => {
+ const { label, langs, small, className, displayLang, ...divProps } = props
const classes = classnames(
'usa-language-container',
{
@@ -36,8 +30,11 @@ export const LanguageSelector = ({
const [langIndex, setLangIndex] = useState(false)
if (langs.length > 2) {
- const dropdownProps = { label, langs, small, displayLang }
- return
+ const dropdownProps = {
+ ...props,
+ className: classes,
+ }
+ return
} else {
if (label) {
console.warn(
diff --git a/src/components/LanguageSelector/LanguageSelectorButton.tsx b/src/components/LanguageSelector/LanguageSelectorButton.tsx
index ccffcfa55..d6d80399d 100644
--- a/src/components/LanguageSelector/LanguageSelectorButton.tsx
+++ b/src/components/LanguageSelector/LanguageSelectorButton.tsx
@@ -1,4 +1,4 @@
-import React, { type JSX } from 'react'
+import React, { forwardRef, type JSX } from 'react'
import classnames from 'classnames'
export type LanguageSelectorButtonProps = {
@@ -9,33 +9,39 @@ export type LanguageSelectorButtonProps = {
controls?: string
} & JSX.IntrinsicElements['button']
-export const LanguageSelectorButton = ({
- label,
- labelAttr,
- isOpen,
- onToggle,
- className,
- controls,
- ...buttonProps
-}: LanguageSelectorButtonProps): JSX.Element => {
- const classes = classnames('usa-button', 'usa-language__link', className)
- const buttonContents = labelAttr ? (
- {label}
- ) : (
- label
- )
- return (
-
- )
-}
+export const LanguageSelectorButton = forwardRef(
+ (
+ {
+ label,
+ labelAttr,
+ isOpen,
+ onToggle,
+ className,
+ controls,
+ ...buttonProps
+ }: LanguageSelectorButtonProps,
+ ref: React.ForwardedRef
+ ): JSX.Element => {
+ const classes = classnames('usa-button', 'usa-language__link', className)
+ const buttonContents = labelAttr ? (
+ {label}
+ ) : (
+ label
+ )
+ return (
+
+ )
+ }
+)
-export default LanguageSelectorButton
+LanguageSelectorButton.displayName = 'LanguageSelectorButton'
diff --git a/src/components/LanguageSelector/LanguageSelectorDropdown.tsx b/src/components/LanguageSelector/LanguageSelectorDropdown.tsx
index 780c0457a..86c2d13c5 100644
--- a/src/components/LanguageSelector/LanguageSelectorDropdown.tsx
+++ b/src/components/LanguageSelector/LanguageSelectorDropdown.tsx
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { Menu } from '../header/Menu/Menu'
import { LanguageSelectorButton } from './LanguageSelectorButton'
-import classnames from 'classnames'
import { LanguageDefinition, LanguageSelectorProps } from './LanguageSelector'
import { Button } from '../Button/Button'
@@ -47,56 +46,72 @@ const generateMenuItems = (
})
}
-const LanguageSelectorDropdown: React.FC = ({
+const LanguageSelectorDropdown = ({
+ id,
label,
langs,
small,
className,
displayLang,
...divProps
-}) => {
+}: LanguageSelectorProps) => {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef(null)
+ const buttonRef = useRef(null)
useEffect(() => {
if (!isOpen) return
- const closeOnOutsideClick = (event: MouseEvent): void => {
- if (!containerRef.current?.contains(event.target as Node)) {
+ // Mirrors USWDS: any click closes the open menu except a click on the
+ // toggle button itself (menu item clicks close via onSelect).
+ const closeOnClick = (event: MouseEvent): void => {
+ if (!buttonRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}
- document.addEventListener('click', closeOnOutsideClick)
+ const eventListenerOptions = { capture: true }
+ document.addEventListener('click', closeOnClick, eventListenerOptions)
return () => {
- document.removeEventListener('click', closeOnOutsideClick)
+ document.removeEventListener('click', closeOnClick, eventListenerOptions)
}
}, [isOpen])
- const classes = classnames(
- 'usa-language-container',
- {
- [`usa-language--small`]: small !== undefined,
- },
- className
- )
+ const closeOnFocusOut = (event: React.FocusEvent): void => {
+ if (!containerRef.current?.contains(event.relatedTarget as Node)) {
+ setIsOpen(false)
+ }
+ }
+
+ const closeOnEscape = (event: React.KeyboardEvent): void => {
+ if (isOpen && event.key === 'Escape') {
+ buttonRef.current?.focus()
+ setIsOpen(false)
+ }
+ }
+
const displayLabel = langs.find((langDef) => langDef.attr === displayLang)
- const menuID = 'language-options'
+ const menuID = id ? `${id}-language-options` : 'language-options'
const selectLanguage = (): void => {
setIsOpen(false)
}
return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions