Skip to content

Commit

Permalink
[Component] Integrate DS Select + Multiselect (#206)
Browse files Browse the repository at this point in the history
* feat: [Select] Single

* feat: [Select] Multi

* test: [Select] Add unit tests

* fix: Move Select to Draft state

* feat: Allow consumer to directly use SelectMulti or SelectSingle

* deps: Update @cfpb/* to v0.34.0
  • Loading branch information
meissadia committed Feb 1, 2024
1 parent edc2705 commit 5fcb4bc
Show file tree
Hide file tree
Showing 14 changed files with 2,954 additions and 2,585 deletions.
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 = {
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 { MultipleSelectOptions, SingleSelectOptions } from './testUtils';

describe('<SelectSingle />', () => {
it('renders Single select with default value', () => {
render(<Select id='single' options={SingleSelectOptions} />);
expect(screen.getByRole('combobox')).toHaveValue('option1');
expect(screen.getByRole('option', { name: 'Option 1' }).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={SingleSelectOptions}
defaultValue='option1'
onChange={onChange}
/>
);

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

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={MultipleSelectOptions}
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 4'));
});

// Change handler is called with the expected content
expect(onChange).toHaveBeenCalledWith([
{ ...MultipleSelectOptions[0], selected: true },
{ ...MultipleSelectOptions[3], 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 4`)).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 4'));
});

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;
30 changes: 30 additions & 0 deletions src/components/Select/SelectMulti.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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
},
render: (arguments_) => <div style={{ minHeight: '200px' }}>
<Select {...arguments_} />
</div>
};
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

0 comments on commit 5fcb4bc

Please sign in to comment.