From 576573bcf863a9d3a43c0752204b72a330b93e29 Mon Sep 17 00:00:00 2001 From: Andrew Nelson Date: Wed, 26 Jul 2023 14:03:46 -0700 Subject: [PATCH 01/13] added language selector --- .../LanguageSelector.stories.tsx | 87 +++++++++++++++++ .../LanguageSelector.test.tsx | 91 ++++++++++++++++++ .../LanguageSelector/LanguageSelector.tsx | 96 +++++++++++++++++++ .../LanguageSelectorButton.tsx | 36 +++++++ src/components/header/Menu/Menu.test.tsx | 14 +++ src/components/header/Menu/Menu.tsx | 10 +- .../header/NavList/NavList.test.tsx | 2 + src/components/header/NavList/NavList.tsx | 11 ++- 8 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 src/components/LanguageSelector/LanguageSelector.stories.tsx create mode 100644 src/components/LanguageSelector/LanguageSelector.test.tsx create mode 100644 src/components/LanguageSelector/LanguageSelector.tsx create mode 100644 src/components/LanguageSelector/LanguageSelectorButton.tsx diff --git a/src/components/LanguageSelector/LanguageSelector.stories.tsx b/src/components/LanguageSelector/LanguageSelector.stories.tsx new file mode 100644 index 0000000000..0498fb7643 --- /dev/null +++ b/src/components/LanguageSelector/LanguageSelector.stories.tsx @@ -0,0 +1,87 @@ +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 = 'javascript:void()' +const languages: LanguageDefinition[] = [ + { + label: 'العربية', + label_en: 'Arabic', + attr: 'ar', + on_click: voidLink, + }, + { + label: '简体字', + label_en: 'Chinese - Simplified', + attr: 'zh', + on_click: voidLink, + }, + { + label: 'English', + attr: 'en', + on_click: voidLink, + }, + { + label: 'Español', + label_en: 'Spanish', + attr: 'es', + on_click: voidLink, + }, + { + label: 'Français', + label_en: 'French', + attr: 'fr', + on_click: voidLink, + }, + { + label: 'Italiano', + label_en: 'Italian', + attr: 'it', + on_click: voidLink, + }, + { + label: 'Pусский', + label_en: 'Russian', + attr: 'ru', + on_click: voidLink, + }, +] + +export const TwoLanguages = ( + argTypes: StorybookArguments +): React.ReactElement => ( + +) + +export const MoreThanTwoLanguages = ( + 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..91c41c204b --- /dev/null +++ b/src/components/LanguageSelector/LanguageSelector.test.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import { fireEvent, render } from '@testing-library/react' +import { + LanguageSelector, + LanguageDefinition, +} from '../LanguageSelector/LanguageSelector' + +const voidLink = 'javascript:void()' +const languages: LanguageDefinition[] = [ + { + label: 'العربية', + label_en: 'Arabic', + attr: 'ar', + on_click: voidLink, + }, + { + label: '简体字', + label_en: 'Chinese - Simplified', + attr: 'zh', + on_click: voidLink, + }, + { + label: 'English', + attr: 'en', + on_click: voidLink, + }, +] + +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) + }) + }) + + 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() + }) + }) +}) diff --git a/src/components/LanguageSelector/LanguageSelector.tsx b/src/components/LanguageSelector/LanguageSelector.tsx new file mode 100644 index 0000000000..670d84be75 --- /dev/null +++ b/src/components/LanguageSelector/LanguageSelector.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react' +import classnames from 'classnames' +import { Menu } from '../header/Menu/Menu' +import { LanguageSelectorButton } from './LanguageSelectorButton' + +export type LanguageDefinition = { + label: string + label_en?: string + attr: string + on_click: string +} + +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 + ) + + if (langs.length > 2) { + const [isOpen, setIsOpen] = useState(false) + const items = [] + for (let i = 0; i < langs.length; i++) { + // eslint-disable-next-line security/detect-object-injection + const lang: LanguageDefinition = langs[i] + items.push( + + + {lang.label} + + {lang.label_en && ` (${lang.label_en})`} + + ) + } + return ( +
+
    +
  • + { + setIsOpen((prevIsOpen) => !prevIsOpen) + }} + /> + +
  • +
+
+ ) + } else { + if (label) + console.warn( + "LanguageSelector's label is not used when only two languages are available." + ) + const [langIndex, setLangIndex] = useState(false) + const curLang = langs[Number(langIndex)] + const curLangNotEn = + curLang.attr && curLang.attr !== 'en' ? curLang.attr : undefined + return ( +
+ { + 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..b267b998ce --- /dev/null +++ b/src/components/LanguageSelector/LanguageSelectorButton.tsx @@ -0,0 +1,36 @@ +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 labelNotEn = labelAttr && {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 (