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