-
Notifications
You must be signed in to change notification settings - Fork 87
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(v2): add Dropdown/SingleSelect (Combobox variant) component (#3421)
* style: update Menu styles, add Combobox custom styles * chore: add downshift, match-sorter, and react-highlight-words packages used for creating comboboxes * feat(SelectContext): context encapsulates single/multi variants * feat: add useItems hook to normalize initial items * feat(SingleSelectProvider): add provider for sharing context * feat: add placeholder SelectCombobox/Menu components for rendering * feat: add placeholder SingleSelect component and story * feat: add SelectCombobox and its subcomponents * feat: add SelectMenu renderer * feat: filter item according to value in SingleSelectProvider * fix(DropdownItem): correctly ascertain whether item is selected * feat(SingleSelect): clear selected item when input is empty * feat: update props passed into SelectContext and SingleSelectProvider * feat: add clearable state to SelectCombobox * ref(SelectCombobox): use context values wherever applicable * ref: move SelectCombobox subcomponents into directory * feat: allow ref forwarding to input * feat: add SelectPopover component and wrap Combobox components * feat(SelectContext(: add focused state (and handler) will be useful for multiselect, to track focus * feat(SingleSelect): add stories * fix: a11y issues raised by axe * ref: remove isString import and use typeof check * fix: remove key props when match sorting, add pure string story key props were causing pure string inputs to not get filtered * fix: add missing type import in Dropdown types * feat(SelectContext): only allow strings for `nothingFoundLabel` prop * feat: update comments to highlight existence of defaults * ref: rename elements subfolder to components idk why i did that, must have wanted to be edgy * fix: use different matchsorter options depending on item type * feat: remove misleading comment * ref: move SingleSelectProvider into SingleSelect folder * feat: update Menu and Combo styling to be importable (without casting) * feat: update ComboboxClearButton according to design * ref: rename Combobox theme to SingleSelect to better fit component * style: add white bg to menu list * feat: no need to clone items in useItems * fix: use value as source of truth when item is selected * fix: correct match sort, use value match only if label does not exist
- Loading branch information
Showing
26 changed files
with
1,204 additions
and
46 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
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,53 @@ | ||
import { createContext, useContext } from 'react' | ||
import { CSSObject, FormControlOptions } from '@chakra-ui/react' | ||
import { UseComboboxPropGetters, UseComboboxState } from 'downshift' | ||
|
||
import { ComboboxItem } from './types' | ||
|
||
export interface SharedSelectContextReturnProps< | ||
Item extends ComboboxItem = ComboboxItem, | ||
> { | ||
/** Set to true to enable search, defaults to `true` */ | ||
isSearchable?: boolean | ||
/** Set to true to allow clearing of input, defaults to `true` */ | ||
isClearable?: boolean | ||
/** Nothing found label. Defaults to "No matching results" */ | ||
nothingFoundLabel?: string | ||
/** aria-label for clear button. Defaults to "Clear dropdown" */ | ||
clearButtonLabel?: string | ||
/** Placeholder to show in the input field. Defaults to "Select an option". */ | ||
placeholder?: string | ||
/** ID of input itself, for a11y purposes */ | ||
name: string | ||
/** Item data used to render items in dropdown */ | ||
items: Item[] | ||
} | ||
|
||
interface SelectContextReturn<Item extends ComboboxItem = ComboboxItem> | ||
extends UseComboboxPropGetters<Item>, | ||
UseComboboxState<Item>, | ||
Required<SharedSelectContextReturnProps<Item>>, | ||
FormControlOptions { | ||
isItemSelected: (item: ComboboxItem) => boolean | ||
toggleMenu: () => void | ||
selectItem: (item: Item) => void | ||
styles: Record<string, CSSObject> | ||
isFocused: boolean | ||
setIsFocused: (isFocused: boolean) => void | ||
} | ||
|
||
export const SelectContext = createContext<SelectContextReturn | undefined>( | ||
undefined, | ||
) | ||
|
||
export const useSelectContext = () => { | ||
const context = useContext(SelectContext) | ||
|
||
if (context === undefined) { | ||
throw new Error( | ||
`useSelectContext must be used within a SelectContextProvider`, | ||
) | ||
} | ||
|
||
return context | ||
} |
192 changes: 192 additions & 0 deletions
192
frontend/src/components/Dropdown/SingleSelect/SingleSelect.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,192 @@ | ||
import { useCallback, useMemo } from 'react' | ||
import { Controller, useForm } from 'react-hook-form' | ||
import { BiHeading, BiRadioCircleMarked } from 'react-icons/bi' | ||
import { FormControl } from '@chakra-ui/react' | ||
import { useArgs } from '@storybook/client-api' | ||
import { Meta, Story } from '@storybook/react' | ||
|
||
import Button from '~components/Button' | ||
import FormErrorMessage from '~components/FormControl/FormErrorMessage' | ||
import FormLabel from '~components/FormControl/FormLabel' | ||
|
||
import { ComboboxItem } from '../types' | ||
import { itemToLabelString, itemToValue } from '../utils/itemUtils' | ||
|
||
import { SingleSelect, SingleSelectProps } from './SingleSelect' | ||
|
||
const INITIAL_COMBOBOX_ITEMS: ComboboxItem[] = [ | ||
{ | ||
value: 'A', | ||
label: 'A', | ||
description: 'Not to be confused with B', | ||
}, | ||
{ | ||
value: 'B', | ||
label: 'B', | ||
description: 'Not to be confused with A', | ||
disabled: true, | ||
}, | ||
{ | ||
value: 'Bat', | ||
label: 'Bat', | ||
}, | ||
{ | ||
value: 'Multiple words and strings', | ||
label: 'Multiple words and strings', | ||
}, | ||
{ | ||
value: 'C', | ||
label: 'C', | ||
}, | ||
{ | ||
value: 'D', | ||
label: 'D', | ||
}, | ||
{ | ||
value: 'A1', | ||
label: 'A1', | ||
}, | ||
{ | ||
value: 'B2', | ||
label: 'B2', | ||
}, | ||
{ | ||
value: 'Bat3', | ||
label: 'Bat3', | ||
}, | ||
{ | ||
value: 'C4', | ||
label: 'C4', | ||
}, | ||
{ | ||
value: 'D5', | ||
label: 'D5', | ||
disabled: true, | ||
}, | ||
] | ||
|
||
export default { | ||
title: 'Components/SingleSelect', | ||
component: SingleSelect, | ||
decorators: [], | ||
args: { | ||
items: INITIAL_COMBOBOX_ITEMS, | ||
value: '', | ||
}, | ||
} as Meta | ||
|
||
const Template: Story<SingleSelectProps> = (args) => { | ||
const [{ value = '' }, updateArgs] = useArgs() | ||
const onChange = (value: string) => updateArgs({ value }) | ||
return <SingleSelect {...args} value={value} onChange={onChange} /> | ||
} | ||
|
||
export const Default = Template.bind({}) | ||
|
||
export const NotClearable = Template.bind({}) | ||
NotClearable.args = { | ||
isClearable: false, | ||
} | ||
|
||
export const HasValueSelected = Template.bind({}) | ||
HasValueSelected.args = { | ||
value: itemToLabelString(INITIAL_COMBOBOX_ITEMS[0]), | ||
defaultIsOpen: true, | ||
} | ||
|
||
export const StringValues = Template.bind({}) | ||
StringValues.args = { | ||
items: ['this only has only string values', 'this is cool'], | ||
value: 'this', | ||
defaultIsOpen: true, | ||
} | ||
|
||
export const WithIconSelected = Template.bind({}) | ||
WithIconSelected.args = { | ||
items: [ | ||
{ | ||
value: 'Radio button', | ||
icon: BiRadioCircleMarked, | ||
description: 'This is an option with an icon', | ||
}, | ||
{ | ||
value: 'Radio button button', | ||
icon: BiRadioCircleMarked, | ||
description: 'To show highlight effect between active and inactive', | ||
}, | ||
{ | ||
value: 'Section', | ||
icon: BiHeading, | ||
description: 'This is another option with an icon', | ||
}, | ||
], | ||
value: 'Radio button', | ||
defaultIsOpen: true, | ||
isDisabled: false, | ||
} | ||
|
||
export const WithHalfFilledValue = Template.bind({}) | ||
WithHalfFilledValue.args = { | ||
value: 'Multiple words and', | ||
defaultIsOpen: true, | ||
} | ||
|
||
export const Invalid = Template.bind({}) | ||
Invalid.args = { | ||
isInvalid: true, | ||
} | ||
|
||
export const Disabled = Template.bind({}) | ||
Disabled.args = { | ||
isDisabled: true, | ||
} | ||
|
||
export const Playground: Story<SingleSelectProps> = ({ items, isReadOnly }) => { | ||
const name = 'Dropdown' | ||
const { | ||
handleSubmit, | ||
formState: { errors }, | ||
control, | ||
} = useForm({ | ||
defaultValues: { | ||
[name]: '', | ||
}, | ||
}) | ||
|
||
const itemValues = useMemo(() => items.map((i) => itemToValue(i)), [items]) | ||
|
||
const onSubmit = useCallback((data: unknown) => { | ||
alert(JSON.stringify(data)) | ||
}, []) | ||
|
||
return ( | ||
<form onSubmit={handleSubmit(onSubmit)} noValidate> | ||
<FormControl | ||
isRequired | ||
isInvalid={!!errors[name]} | ||
isReadOnly={isReadOnly} | ||
> | ||
<FormLabel>Best fruit</FormLabel> | ||
<Controller | ||
control={control} | ||
name={name} | ||
rules={{ | ||
required: 'Dropdown selection is required', | ||
validate: (value) => { | ||
return ( | ||
itemValues.includes(value) || | ||
'Entered value is not valid dropdown option' | ||
) | ||
}, | ||
}} | ||
render={({ field }) => <SingleSelect items={items} {...field} />} | ||
/> | ||
<FormErrorMessage>{errors[name]?.message}</FormErrorMessage> | ||
</FormControl> | ||
<Button type="submit">Submit</Button> | ||
</form> | ||
) | ||
} | ||
Playground.args = { | ||
isReadOnly: false, | ||
} |
Oops, something went wrong.