-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #272 from MetroStar/language-selector
Add Language Selector Component to Comet USWDS
- Loading branch information
Showing
6 changed files
with
306 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from './language-selector'; |
56 changes: 56 additions & 0 deletions
56
packages/comet-uswds/src/components/language-selector/language-selector.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import { StoryFn, Meta } from '@storybook/react'; | ||
import { LanguageSelector } from '../../index'; | ||
import { LanguageSelectorProps } from './language-selector'; | ||
|
||
const meta: Meta<typeof LanguageSelector> = { | ||
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<typeof LanguageSelector> = (args: LanguageSelectorProps) => ( | ||
<LanguageSelector {...args} /> | ||
); | ||
|
||
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', | ||
}; |
118 changes: 118 additions & 0 deletions
118
packages/comet-uswds/src/components/language-selector/language-selector.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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( | ||
<LanguageSelector id="selector" items={[{ label: 'English', attr: 'en' }]} />, | ||
); | ||
expect(await axe(container)).toHaveNoViolations(); | ||
}); | ||
|
||
test('should render a default language selector', async () => { | ||
const { container } = render( | ||
<LanguageSelector | ||
id="selector" | ||
items={[ | ||
{ label: 'English', attr: 'en' }, | ||
{ label: 'Español', attr: 'es' }, | ||
]} | ||
/>, | ||
); | ||
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( | ||
<LanguageSelector | ||
id="selector" | ||
items={[ | ||
{ label: 'English', attr: 'en' }, | ||
{ label: 'Español', attr: 'es' }, | ||
{ label: 'Français', attr: 'fr' }, | ||
]} | ||
/>, | ||
); | ||
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( | ||
<LanguageSelector | ||
id="selector" | ||
items={[{ label: 'English', attr: 'en' }]} | ||
variant="unstyled" | ||
/>, | ||
); | ||
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( | ||
<LanguageSelector id="selector" items={[{ label: 'English', attr: 'en' }]} size="small" />, | ||
); | ||
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( | ||
<LanguageSelector | ||
id="selector" | ||
items={[ | ||
{ label: 'English', attr: 'en', onChange: handleChange }, | ||
{ label: 'Español', attr: 'es', onChange: handleChange }, | ||
]} | ||
/>, | ||
); | ||
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( | ||
<LanguageSelector | ||
id="selector" | ||
items={[ | ||
{ label: 'English', attr: 'en', onChange: handleChange }, | ||
{ label: 'Español', localLabel: 'Spanish', attr: 'es', onChange: handleChange }, | ||
{ label: 'Français', localLabel: 'French', attr: 'fr', onChange: handleChange }, | ||
]} | ||
/>, | ||
); | ||
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); | ||
}); | ||
} | ||
}); | ||
} | ||
}); | ||
}); |
129 changes: 129 additions & 0 deletions
129
packages/comet-uswds/src/components/language-selector/language-selector.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<number>(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 ( | ||
<div id={id} className={languageSelectorClasses}> | ||
<button | ||
type="button" | ||
className={buttonClasses} | ||
role="button" | ||
onClick={() => { | ||
if (current == items.length - 1) { | ||
setCurrent(0); | ||
} else { | ||
setCurrent((prev) => prev + 1); | ||
} | ||
|
||
if (items[current].onChange) { | ||
items[current].onChange(); | ||
} | ||
}} | ||
> | ||
<span lang={items[current].attr}>{items[current].label}</span> | ||
</button> | ||
</div> | ||
); | ||
} | ||
|
||
// Ensure language selector JS is loaded | ||
const languageSelectorRef = useRef<HTMLDivElement>(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 ( | ||
<div id={id} className={languageSelectorClasses} ref={languageSelectorRef}> | ||
<ul className="usa-language__primary usa-accordion"> | ||
<li className="usa-language__primary-item"> | ||
<button | ||
type="button" | ||
className={buttonClasses} | ||
role="button" | ||
aria-expanded="false" | ||
aria-controls="language-options" | ||
> | ||
Languages | ||
</button> | ||
<ul id="language-options" className="usa-language__submenu" hidden> | ||
{items.map((item, index) => ( | ||
<li key={index} className="usa-language__submenu-item"> | ||
<a | ||
href="#" | ||
onClick={(event) => { | ||
event.preventDefault(); | ||
if (item.onChange) { | ||
item.onChange(); | ||
} | ||
}} | ||
> | ||
<span lang={item.attr}> | ||
<strong>{item.label}</strong> | ||
{item.localLabel ? ` (${item.localLabel})` : <></>} | ||
</span> | ||
</a> | ||
</li> | ||
))} | ||
</ul> | ||
</li> | ||
</ul> | ||
</div> | ||
); | ||
}; | ||
|
||
export default LanguageSelector; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters