-
Notifications
You must be signed in to change notification settings - Fork 5
[Component] Integrate DS Select + Multiselect #206
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
Merged
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
44670e6
feat: [Select] Single
meissadia 3014e0a
feat: [Select] Multi
meissadia 759a799
test: [Select] Add unit tests
meissadia e189ac7
fix: Move Select to Draft state
meissadia 733390b
feat: Allow consumer to directly use SelectMulti or SelectSingle
meissadia 68dcb53
Merge branch 'main' into 197-ds-select
meissadia 90f7fb4
deps: Update @cfpb/* to v0.34.0
meissadia 6a4cea8
Merge branch 'main' into 197-ds-select
meissadia 4b9201a
test: Skip Multiselect test until we can figure out how to resolve th…
meissadia e196c2e
deps: Upgrade vitest to 1.2.1
meissadia d7828a2
fix: Update Multiselect tests now that we can rely on the DS to rende…
meissadia bb49a26
fix: Update Multiselect to use the DS Multiselect's rendering of sele…
meissadia f8ed66b
fix: Config
meissadia 4c82bff
Merge branch 'main' into 197-ds-select
meissadia ab2beaf
fix: Resolve "Unknown file extension .svg
meissadia d36f8e3
Merge branch 'main' into 197-ds-select
meissadia 76031f4
Update yarn.lock
meissadia b5b2aba
[Select] Code cleanup
meissadia baff2f9
Merge branch 'main' into 197-ds-select
meissadia 7f880ae
deps: update yarn.lock
meissadia f105274
[Select] Remove intro paragraph in favor of hosting it in the customi…
meissadia 147bca6
[Select] Separate stories for Single into its own file
meissadia db2caad
[Select] Separate stories for Multiple into its own file
meissadia 45d1d93
[Select] Add custom Overiew
meissadia a632a8e
fix: [Selects]
meissadia c9a90c8
Merge branch 'main' into 197-ds-select
meissadia 6cdaa74
fix: [Selects] Update labels to match the displayed state
meissadia d7522f8
fix: [Selects] Update shared Overview's 'Types' heading
meissadia 242dfe7
fix: [Selects] Revert story name and label for Multiple
meissadia 110f4cb
fix: [Selects] Update language
meissadia cc3f625
fix: [Selects] Update unit tests
meissadia 640ea2b
fix: [Selects] Render Multiselect story with a min height to allow ea…
meissadia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 hidden or 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 hidden or 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 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 | ||
} | ||
}; |
This file contains hidden or 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,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([]); | ||
}); | ||
}); |
This file contains hidden or 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,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; |
This file contains hidden or 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,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> | ||
}; |
This file contains hidden or 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,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> | ||
); | ||
}; |
This file contains hidden or 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,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> |
This file contains hidden or 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,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> | ||
</> | ||
); | ||
}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.