From cb5ee5450badbc70f3c9fae35db48cd7eff24c57 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 2 Oct 2024 18:52:06 -0400 Subject: [PATCH 1/2] Add language selector component to comet-uswds. Add stories and unit tests for coverage. Add to exports. --- packages/comet-uswds/src/components/index.ts | 1 + .../src/components/language-selector/index.ts | 1 + .../language-selector.stories.tsx | 56 ++++++++ .../language-selector.test.tsx | 118 ++++++++++++++++ .../language-selector/language-selector.tsx | 129 ++++++++++++++++++ packages/comet-uswds/src/uswds/types.d.ts | 1 + 6 files changed, 306 insertions(+) create mode 100644 packages/comet-uswds/src/components/language-selector/index.ts create mode 100644 packages/comet-uswds/src/components/language-selector/language-selector.stories.tsx create mode 100644 packages/comet-uswds/src/components/language-selector/language-selector.test.tsx create mode 100644 packages/comet-uswds/src/components/language-selector/language-selector.tsx diff --git a/packages/comet-uswds/src/components/index.ts b/packages/comet-uswds/src/components/index.ts index eb624bde..2d0d7000 100644 --- a/packages/comet-uswds/src/components/index.ts +++ b/packages/comet-uswds/src/components/index.ts @@ -22,6 +22,7 @@ export { default as HelperText } from './helper-text'; export { default as Icon } from './icon'; export { default as TextInput } from './text-input'; export { default as Label } from './label'; +export { default as LanguageSelector } from './language-selector'; export { default as List } from './list'; export type { ListItem } from './list'; export { default as MemorableDate } from './memorable-date'; diff --git a/packages/comet-uswds/src/components/language-selector/index.ts b/packages/comet-uswds/src/components/language-selector/index.ts new file mode 100644 index 00000000..e05994bb --- /dev/null +++ b/packages/comet-uswds/src/components/language-selector/index.ts @@ -0,0 +1 @@ +export { default } from './language-selector'; diff --git a/packages/comet-uswds/src/components/language-selector/language-selector.stories.tsx b/packages/comet-uswds/src/components/language-selector/language-selector.stories.tsx new file mode 100644 index 00000000..d4f377d2 --- /dev/null +++ b/packages/comet-uswds/src/components/language-selector/language-selector.stories.tsx @@ -0,0 +1,56 @@ +import { StoryFn, Meta } from '@storybook/react'; +import { LanguageSelector } from '../../index'; +import { LanguageSelectorProps } from './language-selector'; + +const meta: Meta = { + title: 'USWDS/Language Selector', + component: LanguageSelector, + argTypes: { + id: { required: true }, + variant: { control: { type: 'select', options: ['default', 'unstyled'] } }, + size: { control: { type: 'select', options: ['default', 'small'] } }, + }, +}; +export default meta; + +const handleChange = (attr: string) => { + // eslint-disable-next-line no-console + console.log(`${attr} selected`); +}; + +const Template: StoryFn = (args: LanguageSelectorProps) => ( + +); + +export const Default = Template.bind({}); +Default.args = { + id: 'selector-1', + items: [ + { label: 'English', attr: 'en', onChange: () => handleChange('en') }, + { label: 'Español', attr: 'es', onChange: () => handleChange('es') }, + ], + variant: 'default', + size: 'default', +}; + +export const ThreeOrMore = Template.bind({}); +ThreeOrMore.args = { + id: 'selector-2', + items: [ + { label: 'English', attr: 'en', onChange: () => handleChange('en') }, + { + label: 'Español', + localLabel: 'Spanish', + attr: 'es', + onChange: () => handleChange('es'), + }, + { + label: 'Français', + localLabel: 'French', + attr: 'fr', + onChange: () => handleChange('fr'), + }, + ], + variant: 'default', + size: 'small', +}; diff --git a/packages/comet-uswds/src/components/language-selector/language-selector.test.tsx b/packages/comet-uswds/src/components/language-selector/language-selector.test.tsx new file mode 100644 index 00000000..897df337 --- /dev/null +++ b/packages/comet-uswds/src/components/language-selector/language-selector.test.tsx @@ -0,0 +1,118 @@ +import { render, waitFor } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import LanguageSelector from './language-selector'; + +describe('LanguageSelector', () => { + test('should render with no accessibility violations', async () => { + const { container } = render( + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + test('should render a default language selector', async () => { + const { container } = render( + , + ); + expect(container.querySelector('#selector')).toHaveClass('usa-language-container'); + expect(container.querySelector('#selector button')).toHaveTextContent('English'); + }); + + test('should render a language selector with 3 options', async () => { + const { container } = render( + , + ); + expect(container.querySelector('#selector')).toHaveClass('usa-language-container'); + expect(container.querySelector('#selector button')).toHaveTextContent('Languages'); + }); + + test('should render an unstyled language selector', async () => { + const { container } = render( + , + ); + expect(container.querySelector('#selector')).toHaveClass('usa-language-container'); + expect(container.querySelector('button')).toHaveClass('usa-button--unstyled'); + }); + + test('should render a small language selector', async () => { + const { container } = render( + , + ); + expect(container.querySelector('#selector')).toHaveClass('usa-language-container'); + expect(container.querySelector('#selector')).toHaveClass('usa-language--small'); + }); + + test('should render a language selector with a custom onChange handler for default items', async () => { + const handleChange = vi.fn(); + const { container } = render( + , + ); + const button = container.querySelector('button'); + if (button) { + button.click(); + await waitFor(async () => { + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + button.click(); + await waitFor(async () => { + expect(handleChange).toHaveBeenCalledTimes(2); + }); + + button.click(); + await waitFor(async () => { + expect(handleChange).toHaveBeenCalledTimes(3); + }); + } + }); + + test('should render a language selector with a custom onChange handler for selector with 3 items', async () => { + const handleChange = vi.fn(); + const { container } = render( + , + ); + const button = container.querySelector('button'); + if (button) { + button.click(); + await waitFor(async () => { + const anchor = container.querySelector('a'); + if (anchor) { + anchor.click(); + await waitFor(async () => { + expect(handleChange).toHaveBeenCalledTimes(1); + }); + } + }); + } + }); +}); diff --git a/packages/comet-uswds/src/components/language-selector/language-selector.tsx b/packages/comet-uswds/src/components/language-selector/language-selector.tsx new file mode 100644 index 00000000..fe979581 --- /dev/null +++ b/packages/comet-uswds/src/components/language-selector/language-selector.tsx @@ -0,0 +1,129 @@ +import { useEffect, useRef, useState } from 'react'; +import languageSelector from '@uswds/uswds/js/usa-language-selector'; +import classnames from 'classnames'; + +export type LanguageOption = { + label: string; + localLabel?: string; + attr: string; + onChange?: () => void; +}; + +export interface LanguageSelectorProps { + /** + * The unique identifier for this component + */ + id: string; + /** + * The variant of the language selector to display + */ + variant?: 'default' | 'unstyled'; + /** + * The size of the language selector + */ + size?: 'default' | 'small'; + /** + * The list of language options to display + */ + items: LanguageOption[]; +} + +/** + * The consistent placement, interface, and behavior of the language selection component allows users to easily find and + * access content in the language the user is most comfortable in. + */ +export const LanguageSelector = ({ + id, + items, + variant = 'default', + size = 'default', +}: LanguageSelectorProps): React.ReactElement => { + const [current, setCurrent] = useState(0); + + const languageSelectorClasses = classnames('usa-language-container', { + 'usa-language--small': size === 'small', + }); + const buttonClasses = classnames('usa-button', { + 'usa-language__link': items.length >= 3, + 'usa-button--unstyled': variant === 'unstyled', + }); + + // If there are less than 3 items, render as a button that toggles the options + if (items.length < 3) { + return ( +
+ +
+ ); + } + + // Ensure language selector JS is loaded + const languageSelectorRef = useRef(null); + useEffect(() => { + const accordionElement = languageSelectorRef.current; + languageSelector.on(accordionElement); + + // Ensure cleanup after the effect + return () => { + languageSelector.off(accordionElement); + }; + }); + + // If there are 3 or more items, render as an accordion + return ( + + ); +}; + +export default LanguageSelector; diff --git a/packages/comet-uswds/src/uswds/types.d.ts b/packages/comet-uswds/src/uswds/types.d.ts index 18588b1e..88616555 100644 --- a/packages/comet-uswds/src/uswds/types.d.ts +++ b/packages/comet-uswds/src/uswds/types.d.ts @@ -8,6 +8,7 @@ declare module '@uswds/uswds/js/usa-date-range-picker'; declare module '@uswds/uswds/js/usa-file-input'; declare module '@uswds/uswds/js/usa-header'; declare module '@uswds/uswds/js/usa-input-mask'; +declare module '@uswds/uswds/js/usa-language-selector'; declare module '@uswds/uswds/js/usa-modal'; declare module '@uswds/uswds/js/usa-nav'; declare module '@uswds/uswds/js/usa-table'; From f63c0742d6bb29647ba5c29fad118f60d37f4ce8 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Thu, 3 Oct 2024 10:27:00 -0400 Subject: [PATCH 2/2] Add dependency array to use effect. --- .../src/components/language-selector/language-selector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/comet-uswds/src/components/language-selector/language-selector.tsx b/packages/comet-uswds/src/components/language-selector/language-selector.tsx index fe979581..17e350c9 100644 --- a/packages/comet-uswds/src/components/language-selector/language-selector.tsx +++ b/packages/comet-uswds/src/components/language-selector/language-selector.tsx @@ -84,7 +84,7 @@ export const LanguageSelector = ({ return () => { languageSelector.off(accordionElement); }; - }); + }, []); // If there are 3 or more items, render as an accordion return (