Skip to content

Commit

Permalink
feat(v2): add Dropdown/SingleSelect (Combobox variant) component (#3421)
Browse files Browse the repository at this point in the history
* 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
karrui authored Feb 15, 2022
1 parent 501ed05 commit 18bccd6
Show file tree
Hide file tree
Showing 26 changed files with 1,204 additions and 46 deletions.
48 changes: 37 additions & 11 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@
"dayjs": "^1.10.7",
"dayzed": "^3.2.2",
"dedent": "^0.7.0",
"downshift": "^6.1.7",
"file-saver": "^2.0.5",
"focus-visible": "^5.2.0",
"framer-motion": "^5.4.5",
"immer": "^9.0.6",
"jszip": "^3.7.1",
"libphonenumber-js": "^1.9.44",
"lodash": "^4.17.21",
"match-sorter": "^6.3.1",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2",
"react-dropzone": "^11.4.2",
"react-focus-lock": "^2.7.1",
"react-highlight-words": "^0.17.0",
"react-hook-form": "^7.21.2",
"react-icons": "^4.3.1",
"react-markdown": "^7.1.1",
Expand Down Expand Up @@ -108,6 +111,7 @@
"@types/react": "^17.0.37",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.11",
"@types/react-highlight-words": "^0.16.4",
"@types/react-router-dom": "^5.3.2",
"@types/react-table": "^7.7.9",
"@types/storybook-react-router": "^1.0.1",
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/components/Dropdown/SelectContext.tsx
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 frontend/src/components/Dropdown/SingleSelect/SingleSelect.stories.tsx
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,
}
Loading

0 comments on commit 18bccd6

Please sign in to comment.