Skip to content

Commit

Permalink
feat: Allow multiple selections in Select component (#394)
Browse files Browse the repository at this point in the history
  • Loading branch information
dogmar authored Jan 27, 2023
1 parent 9b172c2 commit e0d9a15
Show file tree
Hide file tree
Showing 4 changed files with 361 additions and 23 deletions.
43 changes: 36 additions & 7 deletions src/components/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
useState,
} from 'react'
import { HiddenSelect, useSelect } from '@react-aria/select'
import { SelectState, useSelectState } from '@react-stately/select'
import { AriaSelectProps } from '@react-types/select'
import { useButton } from '@react-aria/button'
import styled, { useTheme } from 'styled-components'

import { AriaSelectProps } from '@react-types/select'

import { BimodalSelectProps, BimodalSelectState, useBimodalSelectState } from '../utils/useBimodalSelectState'

import { ListBoxItemBaseProps } from './ListBoxItem'
import DropdownArrowIcon from './icons/DropdownArrowIcon'
import { PopoverListBox } from './PopoverListBox'
Expand Down Expand Up @@ -44,8 +46,9 @@ export type SelectProps = Exclude<SelectButtonProps, 'children'> & {
placement?: Placement
width?: string | number
maxHeight?: string | number
onSelectionChange?: (arg: any) => any
} & Omit<
AriaSelectProps<object>,
BimodalSelectProps<object>,
'autoFocus' | 'onLoadMore' | 'isLoading' | 'validationState' | 'placeholder'
>

Expand Down Expand Up @@ -147,6 +150,22 @@ const SelectInner = styled.div<{
},
}))

function Select(
props: Omit<
SelectProps,
'selectionMode' | 'selectedKeys' | 'onSelectionChange'
> & {
selectionMode?: 'single'
} & Pick<AriaSelectProps<object>, 'onSelectionChange'>
): ReactElement
function Select(
props: Omit<
SelectProps,
'selectionMode' | 'selectedKey' | 'onSelectionChange'
> & {
selectionMode: 'multiple'
} & { onSelectionChange: (keys: Set<Key>) => any }
): ReactElement
function Select({
children,
selectedKey,
Expand All @@ -169,13 +188,16 @@ function Select({
maxHeight,
...props
}: SelectProps) {
const stateRef = useRef<SelectState<object> | null>(null)
const stateRef = useRef<BimodalSelectState<object> | null>(null)
const [isOpenUncontrolled, setIsOpen] = useState(false)
const nextFocusedKeyRef = useRef<Key>(null)

if (typeof isOpen !== 'boolean') {
isOpen = isOpenUncontrolled
}
if (props.selectionMode === 'multiple' && selectedKey) {
throw new Error('When using selectionMode="multiple", you must use "selectedKeys" instead of "selectedKey"')
}

const selectStateBaseProps = useSelectComboStateProps<SelectProps>({
dropdownHeader,
Expand All @@ -190,7 +212,7 @@ function Select({
nextFocusedKeyRef,
})

const selectStateProps: AriaSelectProps<object> = {
const selectStateProps: BimodalSelectProps<object> = {
...selectStateBaseProps,
isOpen,
defaultOpen: false,
Expand All @@ -199,7 +221,7 @@ function Select({
...props,
}

const state = useSelectState(selectStateProps)
const state = useBimodalSelectState(selectStateProps)

setNextFocusedKey({ nextFocusedKeyRef, state, stateRef })

Expand All @@ -215,7 +237,14 @@ function Select({
rightContent={rightContent}
isOpen={state.isOpen}
>
{state.selectedItem?.props?.children?.props?.label || label}
{(props.selectionMode === 'multiple'
&& state.selectedItems.length > 0
&& state.selectedItems
.map(item => item?.props?.children?.props?.label)
.filter(label => !!label)
.join(', '))
|| state.selectedItem?.props?.children?.props?.label
|| label}
</SelectButton>
)

Expand Down
48 changes: 35 additions & 13 deletions src/components/SelectComboShared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import {
MutableRefObject,
RefObject,
SetStateAction,
useCallback,
useRef,
} from 'react'
import { ListState } from '@react-stately/list'

import { Selection } from '@react-types/shared'

import { FOOTER_KEY, HEADER_KEY, useItemWrappedChildren } from './ListBox'
import { ComboBoxProps } from './ComboBox'
import { SelectProps } from './Select'
Expand All @@ -34,6 +37,10 @@ type UseSelectComboStatePropsReturn<T extends TType> = Pick<
'children' | 'onOpenChange' | 'onSelectionChange'
>

function setDifference<T>(a: Set<T>, b: Set<T>): Set<T> {
return new Set([...a].filter(x => !b.has(x)))
}

function useSelectComboStateProps<T extends TType>({
setIsOpen,
onOpenChange,
Expand All @@ -47,6 +54,8 @@ function useSelectComboStateProps<T extends TType>({
nextFocusedKeyRef,
}: UseSelectComboStatePropsArgs<T>): UseSelectComboStatePropsReturn<T> {
const temporarilyPreventClose = useRef(false)
const getCurrentKeys = useCallback(() => new Set<Key>(stateRef.current?.selectionManager.selectedKeys ?? []),
[stateRef])

return {
onOpenChange: (open: boolean, ...args: any[]) => {
Expand All @@ -60,24 +69,37 @@ function useSelectComboStateProps<T extends TType>({
onOpenChange.apply(this, [open, ...args])
}
},
onSelectionChange: (newKey, ...args) => {
if (newKey === HEADER_KEY && onHeaderClick) {
temporarilyPreventClose.current = true
onHeaderClick()
onSelectionChange: (newKeyOrKeys: Key | Selection, ...args: any) => {
let newKey: Key

if (
typeof newKeyOrKeys === 'string'
|| typeof newKeyOrKeys === 'number'
) {
newKey = newKeyOrKeys
}
else if (newKey === FOOTER_KEY && onFooterClick) {
else {
const currentKeys = getCurrentKeys()
const diff = setDifference(newKeyOrKeys, currentKeys)

newKey = diff.keys().next().value || ''
}
switch (newKey) {
case HEADER_KEY:
temporarilyPreventClose.current = true
onFooterClick()
onHeaderClick?.()
break
case FOOTER_KEY:
temporarilyPreventClose.current = true
onFooterClick?.()
if (stateRef.current) {
nextFocusedKeyRef.current
= stateRef?.current?.collection?.getKeyBefore(FOOTER_KEY)
= stateRef?.current?.collection?.getKeyBefore(FOOTER_KEY)
}
}
else if (onSelectionChange) {
onSelectionChange.apply(this, [
typeof newKey === 'string' ? newKey : '',
...args,
])
break
default:
onSelectionChange?.apply(this, [newKeyOrKeys, ...args])
break
}
},
children: useItemWrappedChildren(children, dropdownHeader, dropdownFooter),
Expand Down
155 changes: 152 additions & 3 deletions src/stories/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Div, Flex } from 'honorable'
import { Div, Flex, H4 } from 'honorable'
import {
ComponentProps,
Key,
Expand Down Expand Up @@ -37,7 +37,7 @@ const portrait = (
)
const smallIcon = <PersonIcon size={16} />

const chipProps:Partial<ComponentProps<typeof Chip>> = {
const chipProps: Partial<ComponentProps<typeof Chip>> = {
size: 'small',
hue: 'lighter',
}
Expand Down Expand Up @@ -177,18 +177,28 @@ function Template() {
const shownStep = 4
const [shownLimit, setShownLimit] = useState<number>(shownStep)

const [selectedKeys, setSelectedKeys] = useState(new Set<Key>(['pizza', 'sushi']))

const curItem = items.find(item => item.key === selectedKey)
const customLabel = curItem
? `You have selected ${curItem.label}`
: 'Select an item please'

const curItems = items.filter(item => selectedKeys.has(item.key))
const customLabelMultiple
= curItems.length > 0
? `Selections: ${curItems.map(item => item.label).join(', ')}`
: 'Select items'

return (
<Flex
flexDirection="column"
gap="large"
maxWidth={512}
>
{/* SINGLE SELECT */}
<Div>
<h4>Single select</h4>
<Select
defaultOpen={false}
label="Pick something"
Expand All @@ -207,7 +217,6 @@ function Template() {
))}
</Select>
</Div>

<Div>
<Select
label="Pick something"
Expand Down Expand Up @@ -315,6 +324,146 @@ function Template() {
))}
</Select>
</Flex>

{/* */}
{/* */}
{/* */}
{/* */}
{/* */}
{/* */}

{/* MULTIPLE SELECT */}
<H4
subtitle
margin="0"
marginBottom="small"
>
Multiple select
</H4>
<Div>
<Select
defaultOpen={false}
label="Pick something"
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={keys => {
setSelectedKeys(keys)
}}
>
{items.slice(0, 4).map(({ key, label }) => (
<ListBoxItem
key={key}
label={label}
textValue={label}
leftContent={smallIcon}
/>
))}
</Select>
</Div>

<Div>
<Select
label="Pick something"
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={keys => {
setSelectedKeys(keys)
}}
defaultOpen={false}
leftContent={<SearchIcon />}
rightContent={<ListBoxItemChipList chips={curItem?.chips} />}
dropdownFooterFixed={
<ListBoxFooterPlus>Create new</ListBoxFooterPlus>
}
>
{items.map(({
key, label, description, chips,
}) => (
<ListBoxItem
key={key}
label={label}
textValue={label}
description={description}
rightContent={<ListBoxItemChipList chips={chips} />}
leftContent={portrait}
/>
))}
</Select>
</Div>

<Div>
<Select
label="Pick something"
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={keys => {
setSelectedKeys(keys)
}}
defaultOpen={false}
dropdownFooterFixed={
<ListBoxFooterPlus>Create new</ListBoxFooterPlus>
}
triggerButton={(
<SelectButton leftContent={curItem ? <CheckIcon /> : <InfoIcon />}>
{customLabelMultiple}
</SelectButton>
)}
>
{items.map(({
key, label, description, chips,
}) => (
<ListBoxItem
key={key}
label={label}
textValue={label}
description={description}
rightContent={<ListBoxItemChipList chips={chips} />}
leftContent={portrait}
/>
))}
</Select>
</Div>

<Flex justifyContent="right">
<Select
label="Version"
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={keys => {
setSelectedKeys(keys)
}}
triggerButton={<CustomTriggerButton />}
width="max-content"
maxHeight={197}
placement="right"
onFooterClick={() => {
setShownLimit(shownLimit + shownStep)
}}
onOpenChange={open => {
if (!open) setShownLimit(shownStep)
}}
dropdownFooter={
shownLimit < items.length && (
<ListBoxFooterPlus>View more</ListBoxFooterPlus>
)
}
>
{items.slice(0, shownLimit).map(({ key, chips, version }) => (
<ListBoxItem
key={key}
label={version}
textValue={version}
rightContent={(
<ListBoxItemChipList
maxVisible={1}
showExtra
chips={chips}
/>
)}
/>
))}
</Select>
</Flex>
</Flex>
)
}
Expand Down
Loading

0 comments on commit e0d9a15

Please sign in to comment.