Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 213 additions & 28 deletions src/components/LanguageSelector/LanguageSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -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[] = [
Expand Down Expand Up @@ -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(
<LanguageSelector langs={[languages[0], languages[1]]} />
)
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(
<LanguageSelector langs={[languages[0], languages[1]]} />
)
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(
<LanguageSelector langs={[languagesButton[0], languagesButton[1]]} />
)
fireEvent.click(getByTestId('languageSelectorButton'))
fireEvent.click(getByTestId('languageSelectorButton'))
await user.click(getByTestId('languageSelectorButton'))
await user.click(getByTestId('languageSelectorButton'))
expect(voidButton).toHaveBeenCalledTimes(2)
})
})
Expand Down Expand Up @@ -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(
<LanguageSelector langs={languages} label="Languages" />
)
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(
<LanguageSelector langs={languages} label="Languages" />
)
fireEvent.click(getByTestId('languageSelectorButton'))
await user.click(getByTestId('languageSelectorButton'))
expect(getByTestId(languages[0].attr)).toHaveAttribute(
'href',
languages[0].on_click
Expand All @@ -162,19 +165,22 @@ describe('LanguageSelector component', () => {
)
})

it('are buttons', () => {
it('are buttons', async () => {
const user = userEvent.setup()
const { getByTestId } = render(
<LanguageSelector langs={languagesButton} label="Languages" />
)
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,
Expand All @@ -184,10 +190,10 @@ describe('LanguageSelector component', () => {
<LanguageSelector langs={localLanguagesButton} label="Languages" />
)

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(
Expand All @@ -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(
<LanguageSelector langs={languages} label="Languages" />
)

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',
Expand All @@ -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(
<>
<LanguageSelector langs={languages} label="Languages" />
Expand All @@ -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(
<LanguageSelector langs={languages} label="Languages" />
)

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(
<>
<LanguageSelector langs={languages} label="Languages" />
<button type="button" data-testid="outside">
Outside
</button>
</>
)

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(
<LanguageSelector langs={languages} label="Languages" />
)

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(
<LanguageSelector langs={languages} label="Languages" />
)

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 () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test validates and confirms the { capture: true } addition to the click event listener

const user = userEvent.setup()
const { getByTestId } = render(
<>
<LanguageSelector langs={languages} label="Languages" />
<button type="button" data-testid="outside">
Outside
</button>
</>
)
// 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(
Expand All @@ -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(
<LanguageSelector langs={languages} label="Languages" />
)

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(
<LanguageSelector
langs={languages}
label="Languages"
id="my-custom-id"
/>
)

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(
<>
<LanguageSelector langs={languages} label="Languages" />
<LanguageSelector langs={languages} label="Languages" />
</>
)
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')
})
})
})
17 changes: 7 additions & 10 deletions src/components/LanguageSelector/LanguageSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand All @@ -36,8 +30,11 @@ export const LanguageSelector = ({

const [langIndex, setLangIndex] = useState(false)
if (langs.length > 2) {
const dropdownProps = { label, langs, small, displayLang }
return <LanguageSelectorDropdown {...dropdownProps} className={className} />
const dropdownProps = {
...props,
className: classes,
}
return <LanguageSelectorDropdown {...dropdownProps} />
} else {
if (label) {
console.warn(
Expand Down
Loading