Skip to content

Commit

Permalink
feat(autocomplete): Implement selectAll functionality (#3245)
Browse files Browse the repository at this point in the history
* feat(autocomplete): Implement selectAll functionality

* feat(autocomplete): Implement selectAll functionality

Fix lint errors

* feat(autocomplete): Implement selectAll functionality

Add missing aria-setsize to select-all option

* feat(autocomplete): Implement selectAll functionality

Add test for selectAll

* feat(autocomplete): Implement selectAll functionality

Throw an error if multiple is falsy and allowSelectAll is true

* feat(autocomplete): Implement selectAll functionality

Scroll item to center if multiple
  • Loading branch information
yusijs authored Feb 8, 2024
1 parent 4f22b41 commit e15ce0e
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ Example using [react-hook-form](https://react-hook-form.com/) library.
<Canvas of={ComponentStories.WithReactHookForm} />

### With Select All option
This is an example of how to make a "Select All" option for quicker selection.
Make sure to use `useMemo` if you are mutating the options in the render function to avoid object memory references bugs on re-renders.

Use prop `allowSelectAll`

<Canvas of={ComponentStories.SelectAll} />

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { useState, useEffect, useMemo, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import styled from 'styled-components'
import { Autocomplete, AutocompleteProps, AutocompleteChanges } from '.'
import { Checkbox } from '../Checkbox'
Expand Down Expand Up @@ -854,84 +854,41 @@ export const WithReactHookForm: StoryFn<

export const SelectAll: StoryFn<AutocompleteProps<MyOptionType>> = (args) => {
const { options } = args

const selectAllOption: MyOptionType = useMemo(
() => ({
label: 'Select All',
}),
[],
)

const optionsWithAll = useMemo(
() => [selectAllOption, ...options],
[options, selectAllOption],
)

const [selectedItems, setSelectedItems] = useState<MyOptionType[]>([])

const onChange = (changes: AutocompleteChanges<MyOptionType>) => {
const nextAll = changes.selectedItems.find(
(item) => item.label === selectAllOption.label,
)
const prevAll = selectedItems.find(
(item) => item.label === selectAllOption.label,
)

switch (true) {
case nextAll && selectedItems.length === 1:
case prevAll && !nextAll:
setSelectedItems([])
break
case !prevAll && changes.selectedItems.length === options.length:
case nextAll && !prevAll:
setSelectedItems(optionsWithAll)
break
case nextAll &&
changes.selectedItems.length === optionsWithAll.length - 1:
setSelectedItems(
changes.selectedItems.filter(
(option) => !(option.label === selectAllOption.label),
),
)
break
default:
setSelectedItems(changes.selectedItems)
break
}
}

const onDelete = (itemLabel: string) =>
setSelectedItems(selectedItems.filter((x) => !(x.label === itemLabel)))

const onChange = (e: AutocompleteChanges<MyOptionType>) => {
action('optionsChange')(e)
setSelectedItems(e.selectedItems)
}

return (
<>
<Autocomplete
label="Select stocks"
options={options}
selectedOptions={selectedItems}
allowSelectAll={true}
onOptionsChange={onChange}
placeholder={`${selectedItems.length}/ ${options.length} selected`}
multiple
optionLabel={optionLabel}
/>
<div
style={{
display: 'grid',
gap: '16px',
gridTemplateColumns: 'repeat(4, auto)',
}}
>
{selectedItems
.filter((option) => !(option.label === selectAllOption.label))
.map((x) => (
<Chip key={x.label} onDelete={() => onDelete(x.label)}>
{x.label}
</Chip>
))}
{selectedItems.map((x) => (
<Chip key={x.label} onDelete={() => onDelete(x.label)}>
{x.label}
</Chip>
))}
</div>
<Autocomplete
label="Select stocks"
options={optionsWithAll}
selectedOptions={selectedItems}
onOptionsChange={onChange}
placeholder={`${
selectedItems.filter((o) => !(o.label === selectAllOption.label))
.length
}/ ${options.length} selected`}
multiple
optionLabel={optionLabel}
/>
</>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,43 @@ describe('Autocomplete', () => {
expect(checked.length).toBe(2)
})

it('Can select all options', async () => {
const onChange = jest.fn()
render(
<StyledAutocomplete
label={labelText}
options={items}
data-testid="styled-autocomplete"
multiple={true}
disablePortal={true}
allowSelectAll={true}
onOptionsChange={onChange}
/>,
)

const labeledNodes = await screen.findAllByLabelText(labelText)
const optionsList = labeledNodes[1]

const buttonNode = await screen.findByLabelText('toggle options', {
selector: 'button',
})

fireEvent.click(buttonNode)

const options = await within(optionsList).findAllByRole('option')
fireEvent.click(options[0])

await waitFor(() => {
expect(onChange).toHaveBeenCalledWith({ selectedItems: items })
})

fireEvent.click(options[0])

await waitFor(() => {
expect(onChange).toHaveBeenCalledWith({ selectedItems: [] })
})
})

it('Can open the options on button click', async () => {
render(<Autocomplete disablePortal options={items} label={labelText} />)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const Container = styled.div`
position: relative;
`

const AllSymbol = Symbol('Select all')

const StyledList = styled(List)(
({ theme }) => css`
background-color: ${theme.background};
Expand Down Expand Up @@ -96,7 +98,10 @@ const StyledButton = styled(Button)(
`,
)

type IndexFinderType = <T>({
// Typescript can struggle with parsing generic arrow functions in a .tsx file (see https://github.com/microsoft/TypeScript/issues/15713)
// Workaround is to add a trailing , after T, which tricks the compiler, but also have to ignore prettier rule.
// prettier-ignore
type IndexFinderType = <T,>({
calc,
index,
optionDisabled,
Expand Down Expand Up @@ -244,6 +249,8 @@ export type AutocompleteProps<T> = {
onInputChange?: (text: string) => void
/** Enable multiselect */
multiple?: boolean
/** Add select-all option. Throws an error if true while multiple = false */
allowSelectAll?: boolean
/** Custom option label. NOTE: This is required when option is an object */
optionLabel?: (option: T) => string
/** Custom option template */
Expand Down Expand Up @@ -292,6 +299,7 @@ function AutocompleteInner<T>(
onInputChange,
selectedOptions,
multiple,
allowSelectAll,
initialSelectedOptions = [],
disablePortal,
optionDisabled = () => false,
Expand All @@ -312,9 +320,29 @@ function AutocompleteInner<T>(

const isControlled = Boolean(selectedOptions)
const [inputOptions, setInputOptions] = useState(options)
const [availableItems, setAvailableItems] = useState(inputOptions)
const [_availableItems, setAvailableItems] = useState(inputOptions)
const [typedInputValue, setTypedInputValue] = useState<string>('')

const showSelectAll = useMemo(() => {
if (!multiple && allowSelectAll) {
throw new Error(`allowSelectAll can only be used with multiple`)
}
return allowSelectAll && !typedInputValue
}, [allowSelectAll, multiple, typedInputValue])

const availableItems = useMemo(() => {
if (showSelectAll) return [AllSymbol as T, ..._availableItems]
return _availableItems
}, [_availableItems, showSelectAll])

const toggleAllSelected = () => {
if (selectedItems.length === inputOptions.length) {
setSelectedItems([])
} else {
setSelectedItems(inputOptions)
}
}

useEffect(() => {
const availableHash = JSON.stringify(inputOptions)
const optionsHash = JSON.stringify(options)
Expand Down Expand Up @@ -353,7 +381,9 @@ function AutocompleteInner<T>(
...multipleSelectionProps,
onSelectedItemsChange: (changes) => {
if (onOptionsChange) {
const { selectedItems } = changes
const selectedItems = changes.selectedItems.filter(
(item) => item !== AllSymbol,
)
onOptionsChange({ selectedItems })
}
},
Expand All @@ -376,6 +406,14 @@ function AutocompleteInner<T>(
setSelectedItems,
} = useMultipleSelection(multipleSelectionProps)

const allSelectedState = useMemo(() => {
if (!inputOptions || !selectedItems) return 'NONE'
if (inputOptions.length === selectedItems.length) return 'ALL'
if (inputOptions.length != selectedItems.length && selectedItems.length > 0)
return 'SOME'
return 'NONE'
}, [inputOptions, selectedItems])

const getLabel = useCallback(
(item: T) => {
//note: non strict check for null or undefined to allow 0
Expand Down Expand Up @@ -448,7 +486,9 @@ function AutocompleteInner<T>(
type !== useCombobox.stateChangeTypes.MenuMouseLeave &&
highlightedIndex >= 0
) {
rowVirtualizer.scrollToIndex(highlightedIndex)
rowVirtualizer.scrollToIndex(highlightedIndex, {
align: allowSelectAll ? 'center' : 'auto',
})
}
},
onIsOpenChange: ({ selectedItem }) => {
Expand All @@ -465,7 +505,9 @@ function AutocompleteInner<T>(
case useCombobox.stateChangeTypes.ItemClick:
//note: non strict check for null or undefined to allow 0
if (selectedItem != null && !optionDisabled(selectedItem)) {
if (multiple) {
if (selectedItem === AllSymbol) {
toggleAllSelected()
} else if (multiple) {
selectedItems.includes(selectedItem)
? removeSelectedItem(selectedItem)
: addSelectedItem(selectedItem)
Expand Down Expand Up @@ -606,6 +648,9 @@ function AutocompleteInner<T>(
}
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
if (clearSearchOnChange) {
setTypedInputValue('')
}
return {
...changes,
isOpen: true, // keep menu open after selection.
Expand Down Expand Up @@ -756,10 +801,44 @@ function AutocompleteInner<T>(
const label = getLabel(item)
const isDisabled = optionDisabled(item)
const isSelected = selectedItemsLabels.includes(label)
if (item === AllSymbol) {
return (
<AutocompleteOption
key={'select-all'}
data-index={0}
data-testid={'select-all'}
value={'Select all'}
aria-setsize={availableItems.length}
multiple={true}
isSelected={allSelectedState === 'ALL'}
indeterminate={allSelectedState === 'SOME'}
highlighted={
highlightedIndex === index && !isDisabled
? 'true'
: 'false'
}
isDisabled={false}
multiline={multiline}
onClick={toggleAllSelected}
style={{
position: 'sticky',
top: 0,
zIndex: 99,
}}
{...getItemProps({
...(multiline && {
ref: rowVirtualizer.measureElement,
}),
item,
index: index,
})}
/>
)
}
return (
<AutocompleteOption
key={virtualItem.key}
data-index={virtualItem.index}
data-index={index}
aria-setsize={availableItems.length}
aria-posinset={index + 1}
value={label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type AutocompleteOptionProps = {
isDisabled?: boolean
multiline: boolean
optionComponent?: ReactNode
indeterminate?: boolean
} & LiHTMLAttributes<HTMLLIElement>

function AutocompleteOptionInner(
Expand All @@ -73,6 +74,7 @@ function AutocompleteOptionInner(
optionComponent,
multiple,
isSelected,
indeterminate,
isDisabled,
multiline,
highlighted,
Expand All @@ -97,6 +99,7 @@ function AutocompleteOptionInner(
<Checkbox
disabled={isDisabled}
checked={isSelected}
indeterminate={indeterminate}
value={value}
onChange={() => {
return null
Expand Down

0 comments on commit e15ce0e

Please sign in to comment.