diff --git a/src/components/LanguageSelector/LanguageSelector.stories.tsx b/src/components/LanguageSelector/LanguageSelector.stories.tsx
new file mode 100644
index 0000000000..d09ab6dd61
--- /dev/null
+++ b/src/components/LanguageSelector/LanguageSelector.stories.tsx
@@ -0,0 +1,162 @@
+import React from 'react'
+import { LanguageSelector, LanguageDefinition } from './LanguageSelector'
+
+export default {
+ title: 'Components/LanguageSelector',
+ component: LanguageSelector,
+ argTypes: {
+ small: { control: 'boolean' },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component: `
+### USWDS 3.0 LanguageSelector component
+
+Source: https://designsystem.digital.gov/components/language-selector/
+`,
+ },
+ },
+ },
+}
+type StorybookArguments = {
+ small?: boolean
+}
+const voidLink = '#test'
+const languagesLink: LanguageDefinition[] = [
+ {
+ label: 'العربية',
+ label_local: 'Arabic',
+ attr: 'ar',
+ on_click: voidLink,
+ },
+ {
+ label: '简体字',
+ label_local: 'Chinese - Simplified',
+ attr: 'zh',
+ on_click: voidLink,
+ },
+ {
+ label: 'English',
+ attr: 'en',
+ on_click: voidLink,
+ },
+ {
+ label: 'Español',
+ label_local: 'Spanish',
+ attr: 'es',
+ on_click: voidLink,
+ },
+ {
+ label: 'Français',
+ label_local: 'French',
+ attr: 'fr',
+ on_click: voidLink,
+ },
+ {
+ label: 'Italiano',
+ label_local: 'Italian',
+ attr: 'it',
+ on_click: voidLink,
+ },
+ {
+ label: 'Pусский',
+ label_local: 'Russian',
+ attr: 'ru',
+ on_click: voidLink,
+ },
+]
+
+const voidButton = () => console.log('click')
+const languagesButton: LanguageDefinition[] = [
+ {
+ label: 'العربية',
+ label_local: 'Arabic',
+ attr: 'ar',
+ on_click: voidButton,
+ },
+ {
+ label: '简体字',
+ label_local: 'Chinese - Simplified',
+ attr: 'zh',
+ on_click: voidButton,
+ },
+ {
+ label: 'English',
+ attr: 'en',
+ on_click: voidButton,
+ },
+ {
+ label: 'Español',
+ label_local: 'Spanish',
+ attr: 'es',
+ on_click: voidButton,
+ },
+ {
+ label: 'Français',
+ label_local: 'French',
+ attr: 'fr',
+ on_click: voidButton,
+ },
+ {
+ label: 'Italiano',
+ label_local: 'Italian',
+ attr: 'it',
+ on_click: voidButton,
+ },
+ {
+ label: 'Pусский',
+ label_local: 'Russian',
+ attr: 'ru',
+ on_click: voidButton,
+ },
+]
+
+export const TwoLanguagesAsALink = (
+ argTypes: StorybookArguments
+): React.ReactElement => (
+
+)
+
+export const TwoLanguagesAsAButton = (
+ argTypes: StorybookArguments
+): React.ReactElement => (
+
+)
+
+export const MoreThanTwoLanguagesAsALink = (
+ argTypes: StorybookArguments
+): React.ReactElement => (
+
+)
+
+export const MoreThanTwoLanguagesAsAButton = (
+ argTypes: StorybookArguments
+): React.ReactElement => (
+
+)
+
+export const CustomClass = (
+ argTypes: StorybookArguments
+): React.ReactElement => (
+
+)
diff --git a/src/components/LanguageSelector/LanguageSelector.test.tsx b/src/components/LanguageSelector/LanguageSelector.test.tsx
new file mode 100644
index 0000000000..7fe223fe60
--- /dev/null
+++ b/src/components/LanguageSelector/LanguageSelector.test.tsx
@@ -0,0 +1,163 @@
+import React from 'react'
+import { fireEvent, render, waitFor } from '@testing-library/react'
+import {
+ LanguageSelector,
+ LanguageDefinition,
+} from '../LanguageSelector/LanguageSelector'
+
+const voidLink = '#test'
+const languages: LanguageDefinition[] = [
+ {
+ label: 'العربية',
+ label_local: 'Arabic',
+ attr: 'ar',
+ on_click: voidLink,
+ },
+ {
+ label: '简体字',
+ label_local: 'Chinese - Simplified',
+ attr: 'zh',
+ on_click: voidLink,
+ },
+ {
+ label: 'English',
+ attr: 'en',
+ on_click: voidLink,
+ },
+]
+
+const voidButton = jest.fn()
+const languagesButton: LanguageDefinition[] = [
+ {
+ label: 'العربية',
+ label_local: 'Arabic',
+ attr: 'ar',
+ on_click: voidButton,
+ },
+ {
+ label: '简体字',
+ label_local: 'Chinese - Simplified',
+ attr: 'zh',
+ on_click: voidButton,
+ },
+ {
+ label: 'English',
+ attr: 'en',
+ on_click: voidButton,
+ },
+]
+
+describe('LanguageSelector component', () => {
+ it('renders without errors', () => {
+ const { getByTestId } = render()
+ expect(getByTestId('languageSelector')).toBeInTheDocument()
+ })
+
+ it('renders custom styles', () => {
+ const { getByTestId } = render(
+
+ )
+ expect(getByTestId('languageSelector')).toHaveClass('custom-class')
+ })
+
+ it('renders small', () => {
+ const { getByTestId } = render()
+ expect(getByTestId('languageSelector')).toHaveClass('usa-language--small')
+ })
+
+ it('is auto-labelled with the first language in the list', () => {
+ const { getByTestId } = render()
+ expect(getByTestId('languageSelectorButton')).toHaveTextContent(
+ languages[0].label
+ )
+ })
+
+ describe('Given 2 languages', () => {
+ it('toggles button label on click', () => {
+ const { getByTestId } = render(
+
+ )
+ const button = getByTestId('languageSelectorButton')
+ expect(button).toHaveTextContent(languages[0].label)
+ fireEvent.click(button)
+ expect(button).toHaveTextContent(languages[1].label)
+ fireEvent.click(button)
+ expect(button).toHaveTextContent(languages[0].label)
+ })
+
+ it('works like a link', async () => {
+ const { getByTestId } = render(
+
+ )
+ fireEvent.click(getByTestId('languageSelectorButton'))
+ await waitFor(() => {
+ expect(window.location.hash).toEqual(voidLink)
+ })
+ })
+
+ it('works like a button', () => {
+ const { getByTestId } = render(
+
+ )
+ fireEvent.click(getByTestId('languageSelectorButton'))
+ fireEvent.click(getByTestId('languageSelectorButton'))
+ expect(voidButton).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('Given >2 languages', () => {
+ it('displays the given label', () => {
+ const { getByTestId } = render(
+
+ )
+ expect(getByTestId('languageSelectorButton')).toHaveTextContent(
+ 'Languages'
+ )
+ })
+
+ it('renders list when opened', () => {
+ 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'))
+ 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', () => {
+ const { getByTestId } = render(
+
+ )
+ fireEvent.click(getByTestId('languageSelectorButton'))
+ expect(getByTestId(languages[0].attr)).toHaveAttribute(
+ 'href',
+ languages[0].on_click
+ )
+ expect(getByTestId(languages[1].attr)).toHaveAttribute(
+ 'href',
+ languages[0].on_click
+ )
+ expect(getByTestId(languages[2].attr)).toHaveAttribute(
+ 'href',
+ languages[0].on_click
+ )
+ })
+
+ it('are buttons', () => {
+ 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))
+ expect(voidButton).toHaveBeenCalledTimes(5) //3 here and 2 above
+ })
+ })
+ })
+})
diff --git a/src/components/LanguageSelector/LanguageSelector.tsx b/src/components/LanguageSelector/LanguageSelector.tsx
new file mode 100644
index 0000000000..976c4141db
--- /dev/null
+++ b/src/components/LanguageSelector/LanguageSelector.tsx
@@ -0,0 +1,121 @@
+import React, { useState } from 'react'
+import classnames from 'classnames'
+import { Menu } from '../header/Menu/Menu'
+import { LanguageSelectorButton } from './LanguageSelectorButton'
+import { Button } from '../Button/Button'
+
+export type LanguageDefinition = {
+ label: string
+ label_local?: string
+ attr: string
+ on_click: string | (() => void)
+}
+
+type LanguageSelectorProps = {
+ label?: string
+ langs: LanguageDefinition[]
+ small?: boolean
+ className?: string
+}
+
+export const LanguageSelector = ({
+ label,
+ langs,
+ small,
+ className,
+ ...divProps
+}: LanguageSelectorProps &
+ JSX.IntrinsicElements['div']): React.ReactElement => {
+ const classes = classnames(
+ 'usa-language-container',
+ {
+ [`usa-language--small`]: small !== undefined,
+ },
+ className
+ )
+
+ const [isOpen, setIsOpen] = useState(false)
+ const [langIndex, setLangIndex] = useState(false)
+ if (langs.length > 2) {
+ const items = []
+ for (let i = 0; i < langs.length; i++) {
+ // eslint-disable-next-line security/detect-object-injection
+ const lang: LanguageDefinition = langs[i]
+ if (typeof lang.on_click === 'string') {
+ items.push(
+
+
+ {lang.label}
+
+ {lang.label_local && ` (${lang.label_local})`}
+
+ )
+ } else {
+ items.push(
+
+ )
+ }
+ }
+ return (
+
+
+ -
+ {
+ setIsOpen((prevIsOpen) => !prevIsOpen)
+ }}
+ />
+
+
+
+
+ )
+ } else {
+ if (label) {
+ console.warn(
+ "LanguageSelector's label is not used when only two languages are available."
+ )
+ }
+ const curLang = langs[Number(langIndex)]
+ const onClickString: string =
+ typeof curLang.on_click === 'string' ? curLang.on_click : ''
+ const onClick =
+ typeof curLang.on_click === 'string'
+ ? () => {
+ window.location.assign(onClickString)
+ }
+ : curLang.on_click
+ return (
+
+ {
+ onClick()
+ setLangIndex((prevLangIndex) => !prevLangIndex)
+ }}
+ />
+
+ )
+ }
+}
+
+export default LanguageSelector
diff --git a/src/components/LanguageSelector/LanguageSelectorButton.tsx b/src/components/LanguageSelector/LanguageSelectorButton.tsx
new file mode 100644
index 0000000000..23bda36374
--- /dev/null
+++ b/src/components/LanguageSelector/LanguageSelectorButton.tsx
@@ -0,0 +1,40 @@
+import React from 'react'
+import classnames from 'classnames'
+
+type LanguageSelectorButtonProps = {
+ label: string
+ labelAttr?: string
+ isOpen?: boolean
+ onToggle: () => void
+}
+
+export const LanguageSelectorButton = ({
+ label,
+ labelAttr,
+ isOpen,
+ onToggle,
+ className,
+ ...buttonProps
+}: LanguageSelectorButtonProps &
+ JSX.IntrinsicElements['button']): React.ReactElement => {
+ const classes = classnames('usa-button', 'usa-language__link', className)
+ const buttonContents = labelAttr ? (
+ {label}
+ ) : (
+ label
+ )
+ return (
+
+ )
+}
+
+export default LanguageSelectorButton
diff --git a/src/components/header/Menu/Menu.test.tsx b/src/components/header/Menu/Menu.test.tsx
index e3b6d88e94..4a70adf35a 100644
--- a/src/components/header/Menu/Menu.test.tsx
+++ b/src/components/header/Menu/Menu.test.tsx
@@ -32,4 +32,18 @@ describe('Menu component', () => {
expect(getByText('Simple link one')).toBeInTheDocument()
expect(getByText('Simple link two')).toBeInTheDocument()
})
+
+ it('defaults to subnav type', () => {
+ const { container } = render()
+ expect(container.querySelector('.usa-nav__submenu')).toBeInTheDocument()
+ })
+
+ it('renders given NavList type', () => {
+ const { container } = render(
+
+ )
+ expect(
+ container.querySelector('.usa-language__submenu-item')
+ ).toBeInTheDocument()
+ })
})
diff --git a/src/components/header/Menu/Menu.tsx b/src/components/header/Menu/Menu.tsx
index 92231491cf..39cc54fded 100644
--- a/src/components/header/Menu/Menu.tsx
+++ b/src/components/header/Menu/Menu.tsx
@@ -4,19 +4,27 @@ import { NavList, NavListProps } from '../NavList/NavList'
type MenuProps = {
items: React.ReactNode[]
isOpen: boolean
+ type?:
+ | 'primary'
+ | 'secondary'
+ | 'subnav'
+ | 'megamenu'
+ | 'footerSecondary'
+ | 'language'
}
export const Menu = ({
className,
items,
isOpen,
+ type,
...navListProps
}: MenuProps & NavListProps): React.ReactElement => {
return (
diff --git a/src/components/header/NavList/NavList.test.tsx b/src/components/header/NavList/NavList.test.tsx
index aae53058a1..a3dee54ab4 100644
--- a/src/components/header/NavList/NavList.test.tsx
+++ b/src/components/header/NavList/NavList.test.tsx
@@ -31,6 +31,7 @@ describe('NavList component', () => {
['subnav', '.usa-nav__submenu'],
['megamenu', '.usa-nav__submenu-list'],
['footerSecondary', '.usa-list'],
+ ['language', '.usa-language__submenu-item'],
])('prefers applies type classes %s', (typeString, expectedClass) => {
const type = typeString as
| 'primary'
@@ -38,6 +39,7 @@ describe('NavList component', () => {
| 'subnav'
| 'megamenu'
| 'footerSecondary'
+ | 'language'
const { container } = render()
expect(container.querySelector(expectedClass)).toBeInTheDocument()
})
diff --git a/src/components/header/NavList/NavList.tsx b/src/components/header/NavList/NavList.tsx
index c4f1b16b46..9126be8893 100644
--- a/src/components/header/NavList/NavList.tsx
+++ b/src/components/header/NavList/NavList.tsx
@@ -3,7 +3,13 @@ import classnames from 'classnames'
type CustomNavListProps = {
items: React.ReactNode[]
- type?: 'primary' | 'secondary' | 'subnav' | 'megamenu' | 'footerSecondary'
+ type?:
+ | 'primary'
+ | 'secondary'
+ | 'subnav'
+ | 'megamenu'
+ | 'footerSecondary'
+ | 'language'
}
export type NavListProps = CustomNavListProps & JSX.IntrinsicElements['ul']
@@ -19,6 +25,7 @@ export const NavList = ({
const isSubnav = type === 'subnav'
const isMegamenu = type === 'megamenu'
const isFooterSecondary = type === 'footerSecondary'
+ const isLanguage = type === 'language'
const ulClasses = classnames(
{
@@ -27,6 +34,7 @@ export const NavList = ({
'usa-nav__submenu': isSubnav,
'usa-nav__submenu-list': isMegamenu,
'usa-list usa-list--unstyled': isFooterSecondary,
+ 'usa-language__submenu': isLanguage,
},
className
)
@@ -36,6 +44,7 @@ export const NavList = ({
'usa-nav__secondary-item': isSecondary,
'usa-nav__submenu-item': isSubnav || isMegamenu,
'usa-footer__secondary-link': isFooterSecondary,
+ 'usa-language__submenu-item': isLanguage,
})
return (
diff --git a/src/index.ts b/src/index.ts
index dede489259..87a0f4f0e9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -62,6 +62,8 @@ export { InputGroup } from './components/forms/InputGroup/InputGroup'
export { InputPrefix } from './components/forms/InputPrefix/InputPrefix'
export { InputSuffix } from './components/forms/InputSuffix/InputSuffix'
export { Label } from './components/forms/Label/Label'
+export { LanguageSelector } from './components/LanguageSelector/LanguageSelector'
+export { LanguageSelectorButton } from './components/LanguageSelector/LanguageSelectorButton'
export { Radio } from './components/forms/Radio/Radio'
export { RangeInput } from './components/forms/RangeInput/RangeInput'
export { Select } from './components/forms/Select/Select'
diff --git a/src/styles/index.scss b/src/styles/index.scss
index 5840ccb09a..9b48ddbdd2 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -15,3 +15,23 @@
.usa-search [type='search']::-webkit-search-cancel-button {
display: none;
}
+
+// This just allows buttons as well as links in the LanguageSelector. A more ideal coding for this would just be to `@extend a` here instead of copy-pasting it, but that caused a lint error
+// It can be removed when https://github.com/uswds/uswds/issues/5409 is fixed
+
+.usa-language__submenu .usa-language__submenu-item button {
+ color: #fff;
+ display: block;
+ line-height: 1.3;
+ padding: 0.5rem;
+ text-decoration: none;
+ width: 100%; /* this is something that was actually missing when we were doing the extend because button widths hve different default widths than block element links */
+ &:focus {
+ outline-offset: units('neg-05');
+ }
+
+ &:hover {
+ color: color('white');
+ text-decoration: underline;
+ }
+}