Skip to content

Commit

Permalink
feat(v2): add DropdownField component, update SingleSelect component (#…
Browse files Browse the repository at this point in the history
…3440)

* feat: add createDropdownValidationRules fn

* feat: add DropdownField component and stories

* feat: add field id as ChakraUI's FieldControl component to propagate

* feat(SingleSelectProvider): add default labelId and inputId

remove a11y errors, allow targeting via label when used with FieldContainer

* feat: allow override of comboboxProps, update clearButtonLabel default

* feat: rename dropdown stories' label title

* feat: render dropdown fields in public form page

* test(DropdownField): add unit tests

* feat: show selected in input but clear input so dropdown is refreshed

* feat: set defaultHighlightedIndex to reset on selection

* test: fix test cases due to new behaviour

* feat: add aria-describedby to input for selection option a11y
  • Loading branch information
karrui authored Feb 18, 2022
1 parent 18bccd6 commit 36656d7
Show file tree
Hide file tree
Showing 16 changed files with 466 additions and 55 deletions.
6 changes: 6 additions & 0 deletions frontend/src/components/Dropdown/SelectContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export interface SharedSelectContextReturnProps<
name: string
/** Item data used to render items in dropdown */
items: Item[]
/** aria-describedby to be attached to the combobox input, if any. */
inputAria?: {
id: string
label: string
}
}

interface SelectContextReturn<Item extends ComboboxItem = ComboboxItem>
Expand All @@ -34,6 +39,7 @@ interface SelectContextReturn<Item extends ComboboxItem = ComboboxItem>
styles: Record<string, CSSObject>
isFocused: boolean
setIsFocused: (isFocused: boolean) => void
resetInputValue: () => void
}

export const SelectContext = createContext<SelectContextReturn | undefined>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,16 @@ NotClearable.args = {
export const HasValueSelected = Template.bind({})
HasValueSelected.args = {
value: itemToLabelString(INITIAL_COMBOBOX_ITEMS[0]),
defaultIsOpen: true,
initialIsOpen: true,
}

export const StringValues = Template.bind({})
StringValues.args = {
items: ['this only has only string values', 'this is cool'],
value: 'this',
defaultIsOpen: true,
comboboxProps: {
initialInputValue: 'this',
},
initialIsOpen: true,
}

export const WithIconSelected = Template.bind({})
Expand All @@ -121,14 +123,16 @@ WithIconSelected.args = {
},
],
value: 'Radio button',
defaultIsOpen: true,
initialIsOpen: true,
isDisabled: false,
}

export const WithHalfFilledValue = Template.bind({})
WithHalfFilledValue.args = {
value: 'Multiple words and',
defaultIsOpen: true,
comboboxProps: {
initialInputValue: 'Multiple words and',
},
initialIsOpen: true,
}

export const Invalid = Template.bind({})
Expand Down Expand Up @@ -162,6 +166,7 @@ export const Playground: Story<SingleSelectProps> = ({ items, isReadOnly }) => {
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<FormControl
id={name}
isRequired
isInvalid={!!errors[name]}
isReadOnly={isReadOnly}
Expand Down
110 changes: 88 additions & 22 deletions frontend/src/components/Dropdown/SingleSelect/SingleSelectProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react'
import { FormControlOptions, useMultiStyleConfig } from '@chakra-ui/react'
import { useCombobox } from 'downshift'
import { useCombobox, UseComboboxProps } from 'downshift'

import { useItems } from '../hooks/useItems'
import { SelectContext, SharedSelectContextReturnProps } from '../SelectContext'
Expand All @@ -12,14 +12,21 @@ export interface SingleSelectProviderProps<
Item extends ComboboxItem = ComboboxItem,
> extends SharedSelectContextReturnProps<Item>,
FormControlOptions {
/** Controlled input value */
/** Controlled selected value */
value: string
/** Controlled input onChange handler */
/** Controlled selected item onChange handler */
onChange: (value: string) => void
/** Function based on which items in dropdown are filtered. Default filter filters by fuzzy match. */
filter?(items: Item[], value: string): Item[]
/** Initial dropdown opened state. Defaults to `false`. */
defaultIsOpen?: boolean
/** Initial dropdown opened state. */
initialIsOpen?: boolean
/** Props to override default useComboboxProps, if any. */
comboboxProps?: Partial<UseComboboxProps<Item>>
/** aria-describedby to be attached to the combobox input, if any. */
inputAria?: {
id: string
label: string
}
children: React.ReactNode
}
export const SingleSelectProvider = ({
Expand All @@ -30,29 +37,42 @@ export const SingleSelectProvider = ({
filter = defaultFilter,
nothingFoundLabel = 'No matching results',
placeholder = 'Select an option',
clearButtonLabel = 'Clear dropdown',
clearButtonLabel = 'Clear dropdown input',
isClearable = true,
isSearchable = true,
defaultIsOpen,
initialIsOpen,
isInvalid,
isReadOnly,
isDisabled,
isRequired,
children,
inputAria: inputAriaProp,
comboboxProps = {},
}: SingleSelectProviderProps): JSX.Element => {
const { items, getItemByValue } = useItems({ rawItems })
const [isFocused, setIsFocused] = useState(false)

const filteredItems = useMemo(
() => (value ? filter(items, value) : items),
[filter, value, items],
const getFilteredItems = useCallback(
(filterValue?: string) =>
filterValue ? filter(items, filterValue) : items,
[filter, items],
)
const [filteredItems, setFilteredItems] = useState(
getFilteredItems(
comboboxProps.initialInputValue ?? comboboxProps.inputValue,
),
)

const getDefaultSelectedValue = useCallback(
const getInitialSelectedValue = useCallback(
() => getItemByValue(value)?.item ?? null,
[getItemByValue, value],
)

const resetItems = useCallback(
() => setFilteredItems(getFilteredItems()),
[getFilteredItems],
)

const {
toggleMenu,
isOpen,
Expand All @@ -65,21 +85,49 @@ export const SingleSelectProvider = ({
highlightedIndex,
selectItem,
selectedItem,
inputValue,
setInputValue,
} = useCombobox({
labelId: `${name}-label`,
inputId: name,
defaultInputValue: '',
defaultHighlightedIndex: 0,
items: filteredItems,
inputValue: value,
defaultIsOpen,
onInputValueChange: ({ inputValue }) => {
if (!inputValue) {
selectItem(null)
}
onChange(inputValue ?? '')
},
defaultSelectedItem: getDefaultSelectedValue(),
initialIsOpen,
initialSelectedItem: getInitialSelectedValue(),
itemToString: itemToValue,
onSelectedItemChange: ({ selectedItem }) => {
onChange(itemToValue(selectedItem))
},
onStateChange: ({ inputValue, type }) => {
switch (type) {
case useCombobox.stateChangeTypes.FunctionSetInputValue:
case useCombobox.stateChangeTypes.InputChange:
setFilteredItems(getFilteredItems(inputValue))
break
default:
return
}
},
stateReducer: (_state, { changes, type }) => {
switch (type) {
case useCombobox.stateChangeTypes.FunctionSelectItem:
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.InputBlur:
case useCombobox.stateChangeTypes.ItemClick: {
resetItems()
return {
...changes,
// Clear inputValue on item selection
inputValue: '',
isOpen: false,
}
}
default:
return changes
}
},
...comboboxProps,
})

const isItemSelected = useCallback(
Expand All @@ -89,7 +137,23 @@ export const SingleSelectProvider = ({
[selectedItem],
)

const styles = useMultiStyleConfig('SingleSelect', { isClearable })
const resetInputValue = useCallback(() => setInputValue(''), [setInputValue])

const styles = useMultiStyleConfig('SingleSelect', {
isClearable,
})

const inputAria = useMemo(() => {
if (inputAriaProp) return inputAriaProp
let label = 'No option selected'
if (selectedItem) {
label = `Option ${itemToValue(selectedItem)}, selected`
}
return {
id: `${name}-current-selection`,
label,
}
}, [inputAriaProp, name, selectedItem])

return (
<SelectContext.Provider
Expand All @@ -108,7 +172,7 @@ export const SingleSelectProvider = ({
highlightedIndex,
items: filteredItems,
nothingFoundLabel,
inputValue: value,
inputValue,
isSearchable,
isClearable,
isInvalid,
Expand All @@ -121,6 +185,8 @@ export const SingleSelectProvider = ({
styles,
isFocused,
setIsFocused,
resetInputValue,
inputAria,
}}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const ComboboxClearButton = (): JSX.Element | null => {

return (
<chakra.button
// Prevent form submission from triggering this button.
type="button"
disabled={isDisabled}
aria-label={clearButtonLabel}
onClick={handleClearSelection}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { forwardRef, useMemo } from 'react'
import { Flex, InputGroup } from '@chakra-ui/react'
import {
Flex,
Icon,
InputGroup,
Stack,
Text,
VisuallyHidden,
} from '@chakra-ui/react'

import Input from '~components/Input'

import { useSelectContext } from '../../SelectContext'
import { itemToIcon } from '../../utils/itemUtils'
import { itemToIcon, itemToLabelString } from '../../utils/itemUtils'

import { ComboboxClearButton } from './ComboboxClearButton'
import { LabelIcon } from './LabelIcon'
import { ToggleChevron } from './ToggleChevron'

export const SelectCombobox = forwardRef<HTMLInputElement>(
Expand All @@ -22,20 +28,30 @@ export const SelectCombobox = forwardRef<HTMLInputElement>(
isSearchable,
isReadOnly,
isInvalid,
inputValue,
isRequired,
placeholder,
setIsFocused,
isOpen,
resetInputValue,
inputAria,
} = useSelectContext()

const selectedItemIcon = useMemo(
() => itemToIcon(selectedItem),
const selectedItemMeta = useMemo(
() => ({
icon: itemToIcon(selectedItem),
label: itemToLabelString(selectedItem),
}),
[selectedItem],
)

return (
<Flex>
<VisuallyHidden id={inputAria.id}>{inputAria.label}</VisuallyHidden>
<InputGroup
pos="relative"
display="grid"
gridTemplateColumns="1fr"
{...getComboboxProps({
disabled: isDisabled,
readOnly: isReadOnly,
Expand All @@ -44,16 +60,40 @@ export const SelectCombobox = forwardRef<HTMLInputElement>(
onFocus: () => setIsFocused(true),
})}
>
{selectedItemIcon ? <LabelIcon icon={selectedItemIcon} /> : null}
<Stack
visibility={inputValue ? 'hidden' : 'initial'}
direction="row"
spacing="1rem"
gridArea="1 / 1 / 2 / 3"
pointerEvents="none"
pl="calc(1rem + 1px)"
pr="calc(2.75rem + 1px)"
align="center"
zIndex={2}
>
{selectedItemMeta.icon ? (
<Icon
ml="-0.25rem"
sx={styles.icon}
as={selectedItemMeta.icon}
aria-disabled={isDisabled}
/>
) : null}
<Text textStyle="body-1" isTruncated>
{selectedItemMeta.label}
</Text>
</Stack>
<Input
isReadOnly={!isSearchable || isReadOnly}
isInvalid={isInvalid}
isDisabled={isDisabled}
placeholder={placeholder}
placeholder={selectedItem ? undefined : placeholder}
sx={styles.field}
{...getInputProps({
onClick: toggleMenu,
onBlur: () => !isOpen && resetInputValue(),
ref,
'aria-describedby': inputAria.id,
})}
/>
<ToggleChevron />
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/constants/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ export const REQUIRED_ERROR = 'This field is required'
export const INVALID_EMAIL_ERROR = 'Please enter a valid email'
export const INVALID_EMAIL_DOMAIN_ERROR =
'The entered email does not belong to an allowed email domain'

export const INVALID_DROPDOWN_OPTION_ERROR =
'Entered value is not a valid dropdown option'
Loading

0 comments on commit 36656d7

Please sign in to comment.