Skip to content

Commit

Permalink
Merge pull request #272 from MetroStar/language-selector
Browse files Browse the repository at this point in the history
Add Language Selector Component to Comet USWDS
  • Loading branch information
jbouder authored Oct 3, 2024
2 parents cce0d45 + f63c074 commit 5c9b7c8
Show file tree
Hide file tree
Showing 6 changed files with 306 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/comet-uswds/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './language-selector';
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',
};
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);
});
}
});
}
});
});
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;
1 change: 1 addition & 0 deletions packages/comet-uswds/src/uswds/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit 5c9b7c8

Please sign in to comment.