Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Component] Integrate DS Select + Multiselect #206

Merged
merged 32 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
44670e6
feat: [Select] Single
meissadia Oct 31, 2023
3014e0a
feat: [Select] Multi
meissadia Nov 7, 2023
759a799
test: [Select] Add unit tests
meissadia Nov 7, 2023
e189ac7
fix: Move Select to Draft state
meissadia Nov 17, 2023
733390b
feat: Allow consumer to directly use SelectMulti or SelectSingle
meissadia Nov 17, 2023
68dcb53
Merge branch 'main' into 197-ds-select
meissadia Nov 20, 2023
90f7fb4
deps: Update @cfpb/* to v0.34.0
meissadia Dec 8, 2023
6a4cea8
Merge branch 'main' into 197-ds-select
meissadia Dec 8, 2023
4b9201a
test: Skip Multiselect test until we can figure out how to resolve th…
meissadia Dec 21, 2023
e196c2e
deps: Upgrade vitest to 1.2.1
meissadia Jan 19, 2024
d7828a2
fix: Update Multiselect tests now that we can rely on the DS to rende…
meissadia Jan 22, 2024
bb49a26
fix: Update Multiselect to use the DS Multiselect's rendering of sele…
meissadia Jan 22, 2024
f8ed66b
fix: Config
meissadia Jan 23, 2024
4c82bff
Merge branch 'main' into 197-ds-select
meissadia Jan 23, 2024
ab2beaf
fix: Resolve "Unknown file extension .svg
meissadia Jan 25, 2024
d36f8e3
Merge branch 'main' into 197-ds-select
meissadia Jan 25, 2024
76031f4
Update yarn.lock
meissadia Jan 26, 2024
b5b2aba
[Select] Code cleanup
meissadia Jan 29, 2024
baff2f9
Merge branch 'main' into 197-ds-select
meissadia Jan 30, 2024
7f880ae
deps: update yarn.lock
meissadia Jan 31, 2024
f105274
[Select] Remove intro paragraph in favor of hosting it in the customi…
meissadia Jan 31, 2024
147bca6
[Select] Separate stories for Single into its own file
meissadia Jan 31, 2024
db2caad
[Select] Separate stories for Multiple into its own file
meissadia Jan 31, 2024
45d1d93
[Select] Add custom Overiew
meissadia Jan 31, 2024
a632a8e
fix: [Selects]
meissadia Jan 31, 2024
c9a90c8
Merge branch 'main' into 197-ds-select
meissadia Jan 31, 2024
6cdaa74
fix: [Selects] Update labels to match the displayed state
meissadia Jan 31, 2024
d7522f8
fix: [Selects] Update shared Overview's 'Types' heading
meissadia Jan 31, 2024
242dfe7
fix: [Selects] Revert story name and label for Multiple
meissadia Jan 31, 2024
110f4cb
fix: [Selects] Update language
meissadia Jan 31, 2024
cc3f625
fix: [Selects] Update unit tests
meissadia Jan 31, 2024
640ea2b
fix: [Selects] Render Multiselect story with a min height to allow ea…
meissadia Feb 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@
"@types/testing-library__jest-dom": "5.14.5",
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",
"@vitejs/plugin-react": "2.2.0",
"@vitest/coverage-istanbul": "0.25.2",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-istanbul": "^1.2.1",
"autoprefixer": "10.4.13",
"babel-loader": "^8.3.0",
"chromatic": "^6.19.9",
Expand Down Expand Up @@ -127,7 +127,7 @@
"vite-plugin-turbosnap": "^1.0.3",
"vite-svg-loader": "^4.0.0",
"vite-tsconfig-paths": "3.5.2",
"vitest": "0.25.2",
"vitest": "^1.2.1",
"whatwg-fetch": "3.6.2",
"workbox-build": "6.5.4",
"workbox-window": "6.5.4"
Expand Down
4 changes: 1 addition & 3 deletions src/components/Checkbox/Checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ describe('Checkbox', () => {

act(() => checkbox.click());

// Change handler is called with updated input value
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(onChange.calls[0][0].target.checked).toEqual(true);
expect(onChange).toHaveBeenCalled();

// Accessbility attributes updated
expect(checkbox.getAttribute(attributeAria)).toMatch('true');
Expand Down
56 changes: 56 additions & 0 deletions src/components/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Select } from '~/src/index';
import { SingleSelectOptions } from './testUtils';

const meta: Meta<typeof Select> = {
title: 'Components (Draft)/Selects/Single select',
tags: ['autodocs'],
component: Select,
argTypes: {
disabled: { control: 'boolean' },
isMulti: { control: 'boolean' }
}
};

export default meta;

type Story = StoryObj<typeof meta>;

export const SingleSelect: Story = {
meissadia marked this conversation as resolved.
Show resolved Hide resolved
name: 'Enabled',
args: {
id: 'singleSelect',
label: 'Enabled',
options: SingleSelectOptions
}
};

export const SingleSelectHover: Story = {
name: 'Hover',
args: {
id: 'singleSelect',
label: 'Hover',
options: SingleSelectOptions,
className: 'hover'
}
};

export const SingleSelectFocus: Story = {
name: 'Focus',
args: {
id: 'singleSelect',
label: 'Focus',
options: SingleSelectOptions,
className: 'focus'
}
};

export const SingleSelectDisabled: Story = {
name: 'Disabled',
args: {
id: 'singleSelect',
label: 'Disabled',
options: SingleSelectOptions,
disabled: true
}
};
107 changes: 107 additions & 0 deletions src/components/Select/Select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { jest } from '@storybook/jest';
import '@testing-library/jest-dom';
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Select } from './Select';
import { DemoOptions } from './testUtils';

describe('<SelectSingle />', () => {
it('renders Single select with default value', () => {
render(<Select id='single' options={DemoOptions} />);
expect(screen.getByRole('combobox')).toHaveValue('option0');
expect(screen.getByRole('option', { name: 'Option 0' }).selected).toBe(
true
);
});

it('Handles Single selection change', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

render(
<Select
id='single-change'
label='Single Select'
options={DemoOptions}
defaultValue='option1'
onChange={onChange}
/>
);

await user.selectOptions(screen.getByRole('combobox'), 'option3');
expect(screen.getByRole('combobox')).toHaveValue('option3');
expect(onChange).toHaveBeenCalledWith(DemoOptions[3]);
});
});

describe('<SelectMulti />', () => {
it('Is interactable', async () => {
const id = 'multi';
const label = 'MultiLabel';
const maxSelections = 2;
const user = userEvent.setup();
const onChange = jest.fn();

render(
<Select
id={id}
options={DemoOptions}
label={label}
isMulti
maxSelections={maxSelections}
onChange={onChange}
/>
);

// Has correct placeholder text based on maxSelections
const placeholder = `Select up to ${maxSelections}`;
const input = screen.getByPlaceholderText(placeholder);
expect(input).toBeInTheDocument();

// Initial Select has nothing selected
expect(onChange).toHaveBeenCalledWith([]);

// Selection limit has not been reached
// eslint-disable-next-line testing-library/no-node-access
expect(document.querySelectorAll('.u-max-selections').length).toBe(0);

// Allows selection of multiple options, up to the limit
await act(async () => {
await user.click(screen.getByLabelText('Option 1'));
await user.click(screen.getByLabelText('Option 5'));
});

// Change handler is called with the expected content
expect(onChange).toHaveBeenCalledWith([
{ ...DemoOptions[1], selected: true },
{ ...DemoOptions[5], selected: true }
]);

// Tags are rendered for the selected options
const AllButtons = screen.getAllByRole(`button`);
expect(within(AllButtons[0]).getByText(`Option 1`)).toBeInTheDocument();
expect(within(AllButtons[1]).getByText(`Option 5`)).toBeInTheDocument();
expect(AllButtons.length).toBe(2);

/* TODO: Better verification that maxSelections is enforced.
* We are relying on the DS implementation of Multiselect which uses CSS
* to show/hide options, but the options' <li> remain in the DOM. To Vitest,
* these elements, even when CSS is set to `display: none`,
* are still "visible".
*
* For now, I'm just checking that the `u-max-selections` class is applied.
*/
// eslint-disable-next-line testing-library/no-node-access
expect(document.querySelectorAll('.u-max-selections').length).toBe(1);

// Allows deselection of options
await act(async () => {
await user.click(screen.getByLabelText('Option 1'));
await user.click(screen.getByLabelText('Option 5'));
});

const NoButtons = screen.queryAllByRole(`button`);
expect(NoButtons.length).toBe(0);
expect(onChange).toHaveBeenCalledWith([]);
});
});
34 changes: 34 additions & 0 deletions src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { SelectMulti } from './SelectMulti';
import { SelectSingle } from './SelectSingle';

export interface SelectOption {
value: string;
label: string;
selected?: boolean;
}

export interface SelectProperties {
disabled?: boolean;
id: string;
isMulti?: boolean;
label?: string;
onChange?: (selected: SelectOption | SelectOption[] | undefined) => void;
options: SelectOption[];
maxSelections?: number;
className?: string;
}

/**
* Source: https://cfpb.github.io/design-system/components/selects
*/
export const Select = ({
isMulti = false,
onChange = (): null => null,
...properties
}: SelectProperties): JSX.Element => {
if (isMulti) return <SelectMulti {...{ onChange, ...properties }} />;

return <SelectSingle {...{ onChange, ...properties }} />;
};

export default Select;
27 changes: 27 additions & 0 deletions src/components/Select/SelectMulti.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Select } from '~/src/index';
import { MultipleSelectOptions } from './testUtils';

const meta: Meta<typeof Select> = {
title: 'Components (Draft)/Selects/Multiselect',
component: Select,
tags: ['autodocs'],
argTypes: {
disabled: { control: 'boolean' },
isMulti: { control: 'boolean' }
}
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
id: 'multiSelect',
label: 'Label',
isMulti: true,
options: MultipleSelectOptions,
disabled: true
}
};
74 changes: 74 additions & 0 deletions src/components/Select/SelectMulti.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Lots of rules disabled because we're using DS code that is plain JS, not TS
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */

import { Multiselect } from '@cfpb/cfpb-forms';
import { useEffect, useRef, useState } from 'react';
import { noOp } from '~/src/utils/noOp';
import type { SelectProperties } from './Select';
import { buildOptions } from './selectUtils';

const MAX_SELECTIONS = 5;

export const SelectMulti = ({
id,
options,
label,
onChange = noOp,
maxSelections = MAX_SELECTIONS,
...properties
}: SelectProperties): JSX.Element => {
const [selectedIndicies, setSelectedIndicies] = useState([]);
const inputReference = useRef(null);

// Initialize and configure DS Multiselect
useEffect(() => {
const ms = new Multiselect(inputReference.current);
const newSelect = ms.init({ maxSelections, renderTags: true });

const onUpdate = (): void => {
const modelSelected = newSelect.getModel().getSelectedIndices();
setSelectedIndicies([...modelSelected]);
};

const EVT_SELECT = 'selectionsupdated';
newSelect.addEventListener(EVT_SELECT, onUpdate);

return () => newSelect.removeEventListener(EVT_SELECT, onUpdate);
}, [maxSelections]);

// Notify parent on change of selected options
useEffect(() => {
// Map our simplified tracking state to actual Option objects
const selectedValues = selectedIndicies.map(index => ({
...options[index],
selected: true
}));

onChange(selectedValues);
}, [selectedIndicies, onChange, options]);

return (
<div
className='m-form-field m-form-field__select'
id={`multi-wrapper-${id}`}
>
<label className='a-label a-label__heading' htmlFor={id}>
{label}
</label>
<select
id={id}
data-testid={id}
ref={inputReference}
multiple
placeholder={`Select up to ${maxSelections}`}
data-open
{...properties}
>
{buildOptions(options)}
</select>
</div>
);
};
20 changes: 20 additions & 0 deletions src/components/Select/SelectOverview.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Meta } from '@storybook/addon-docs'
import { Heading, Link, List, ListItem, Paragraph } from '~/src/index'

<Meta title='Components (Draft)/Selects/Overview' />

<Heading type='1'>Selects</Heading>

<Paragraph>Selects allow users to make a single selection or multiple selections from a finite list of options. They are not always the best choice from a usability perspective; see the <Link href='https://cfpb.github.io/design-system/components/selects#use-cases'>use cases documentation</Link> for more details.</Paragraph>

<Paragraph>Source: <Link href='https://cfpb.github.io/design-system/components/selects'>https://cfpb.github.io/design-system/components/selects</Link></Paragraph>

<br />

<div className="sb-unstyled">
<Heading type='4'>Types</Heading>
<List>
<ListItem><Link href='/?path=/docs/components-draft-selects-single-select--overview'>Single select</Link></ListItem>
<ListItem><Link href='/?path=/docs/components-draft-selects-multiselect--overview'>Multiselect</Link></ListItem>
</List>
</div>
34 changes: 34 additions & 0 deletions src/components/Select/SelectSingle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ChangeEvent } from 'react';
import { noOp } from '~/src/utils/noOp';
import type { SelectOption, SelectProperties } from './Select';
import { buildOptions, findOptionByValue } from './selectUtils';

export const SelectSingle = ({
id,
options,
label,
onChange = noOp,
maxSelections,
...properties
}: SelectProperties): JSX.Element => {
const onSelect = (
event: ChangeEvent<HTMLSelectElement>
): SelectOption | undefined => {
const selected = findOptionByValue(options, event.target.value);
onChange(selected); // Notify parent component of changes
return selected;
};

return (
<>
<label className='a-label a-label__heading' htmlFor={id}>
{label}
</label>
<div className='a-select'>
<select id={id} data-testid={id} {...properties} onChange={onSelect}>
{buildOptions(options)}
</select>
</div>
</>
);
};
Loading
Loading