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 (