Skip to content

Commit

Permalink
refactor(dropdown): removed downshift logic from context logic
Browse files Browse the repository at this point in the history
  • Loading branch information
Powerplex committed Jan 3, 2024
1 parent fbd1d04 commit 15e5425
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 82 deletions.
96 changes: 14 additions & 82 deletions packages/components/dropdown/src/DropdownContext.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useId } from '@radix-ui/react-id'
import { useFormFieldControl } from '@spark-ui/form-field'
import { Popover } from '@spark-ui/popover'
import { useMultipleSelection, useSelect, UseSelectProps } from 'downshift'
import {
createContext,
Dispatch,
Expand All @@ -14,7 +13,9 @@ import {
} from 'react'

import { type DownshiftState, type DropdownItem, type ItemsMap } from './types'
import { useDropdown } from './useDropdown'
import { getElementByIndex, getItemsFromChildren } from './utils'

export interface DropdownContextState extends DownshiftState {
itemsMap: ItemsMap
highlightedItem: DropdownItem | undefined
Expand Down Expand Up @@ -93,14 +94,11 @@ interface DropdownPropsMultiple {
onValueChange?: (value: string[]) => void
}

type OnChangeValueType = string & string[]

export type DropdownContextProps = DropdownContextCommonProps &
(DropdownPropsSingle | DropdownPropsMultiple)

const DropdownContext = createContext<DropdownContextState | null>(null)

// eslint-disable-next-line complexity
export const DropdownProvider = ({
children,
defaultValue,
Expand All @@ -119,89 +117,24 @@ export const DropdownProvider = ({
const [lastInteractionType, setLastInteractionType] = useState<'mouse' | 'keyboard'>('mouse')

const field = useFormFieldControl()
const state = field.state || stateProp
const items = [...itemsMap.values()]

const state = field.state || stateProp
const id = useId(field.id)
const labelId = useId(field.labelId)

const disabled = field.disabled ?? disabledProp
const readOnly = field.readOnly ?? readOnlyProp

const downshiftMultipleSelection = useMultipleSelection<DropdownItem>({
selectedItems: value
? items.filter(item => (value as string[]).includes(item.value))
: undefined,
initialSelectedItems: defaultValue
? items.filter(item => (defaultValue as string[]).includes(item.value))
: undefined,

onSelectedItemsChange: ({ selectedItems }) => {
if (selectedItems != null && multiple) {
onValueChange?.(selectedItems.map(item => item.value) as OnChangeValueType)
}
},
})

/**
* Custom state reducer for multiple selection behaviour:
* - keeps the component opened when the user selects an item
* - preserves the higlighted index when the user select an item
* - selected items can be unselected, even the last selected item (as opposed to single selection behaviour)
*/
const stateReducer: UseSelectProps<DropdownItem>['stateReducer'] = (state, { changes, type }) => {
if (!multiple) return changes

const { selectedItems, removeSelectedItem, addSelectedItem } = downshiftMultipleSelection

switch (type) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick:
if (changes.selectedItem != null) {
const isAlreadySelected = selectedItems.some(
selectedItem => selectedItem.value === changes.selectedItem?.value
)

if (isAlreadySelected) {
removeSelectedItem(changes.selectedItem)
} else {
addSelectedItem(changes.selectedItem)
}
}

return {
...changes,
isOpen: true, // keep the menu open after selection.
highlightedIndex: state.highlightedIndex, // preserve highlighted index position
}
default:
return changes
}
}

const downshift = useSelect<DropdownItem>({
items,
isItemDisabled: item => item.disabled,
itemToString: item => (item ? item.text : ''),
// a11y attributes
const dropdownState = useDropdown({
itemsMap,
defaultValue,
value,
onValueChange,
open,
onOpenChange,
defaultOpen,
multiple,
id,
labelId,
// Controlled open state
isOpen: open,
onIsOpenChange: ({ isOpen }) => {
if (isOpen != null) onOpenChange?.(isOpen)
},
initialIsOpen: defaultOpen ?? false,
stateReducer,
// Controlled mode (single selection)
selectedItem: value ? itemsMap.get(value as string) : undefined,
initialSelectedItem: defaultValue ? itemsMap.get(defaultValue as string) : undefined,
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem?.value && !multiple) {
onValueChange?.(selectedItem?.value as OnChangeValueType)
}
},
})

/**
Expand Down Expand Up @@ -248,10 +181,9 @@ export const DropdownProvider = ({
multiple,
disabled,
readOnly,
...downshift,
...downshiftMultipleSelection,
...dropdownState,
itemsMap,
highlightedItem: getElementByIndex(itemsMap, downshift.highlightedIndex),
highlightedItem: getElementByIndex(itemsMap, dropdownState.highlightedIndex),
hasPopover,
setHasPopover,
state,
Expand Down
114 changes: 114 additions & 0 deletions packages/components/dropdown/src/useDropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useMultipleSelection, useSelect, UseSelectProps } from 'downshift'

import { type DropdownItem, type ItemsMap } from './types'

type OnChangeValueType = string & string[]

export interface DropdownContextProps {
itemsMap: ItemsMap
value: string | string[] | undefined
defaultValue: string | string[] | undefined
onValueChange: ((value: string) => void) | ((value: string[]) => void) | undefined
open: boolean | undefined
onOpenChange: ((isOpen: boolean) => void) | undefined
defaultOpen: boolean | undefined
multiple: boolean | undefined
id: string
labelId: string
}

/**
* This hooks abstract the complexity of using downshift with both single and multiple selection.
*/
export const useDropdown = ({
itemsMap,
defaultValue,
value,
onValueChange,
open,
onOpenChange,
defaultOpen,
multiple = false,
id,
labelId,
}: DropdownContextProps) => {
const items = [...itemsMap.values()]

const downshiftMultipleSelection = useMultipleSelection<DropdownItem>({
selectedItems: value
? items.filter(item => (value as string[]).includes(item.value))
: undefined,
initialSelectedItems: defaultValue
? items.filter(item => (defaultValue as string[]).includes(item.value))
: undefined,

onSelectedItemsChange: ({ selectedItems }) => {
if (selectedItems != null && multiple) {
onValueChange?.(selectedItems.map(item => item.value) as OnChangeValueType)
}
},
})

/**
* Custom state reducer for multiple selection behaviour:
* - keeps the component opened when the user selects an item
* - preserves the higlighted index when the user select an item
* - selected items can be unselected, even the last selected item (as opposed to single selection behaviour)
*/
const stateReducer: UseSelectProps<DropdownItem>['stateReducer'] = (state, { changes, type }) => {
if (!multiple) return changes

const { selectedItems, removeSelectedItem, addSelectedItem } = downshiftMultipleSelection

switch (type) {
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick:
if (changes.selectedItem != null) {
const isAlreadySelected = selectedItems.some(
selectedItem => selectedItem.value === changes.selectedItem?.value
)

if (isAlreadySelected) removeSelectedItem(changes.selectedItem)
else addSelectedItem(changes.selectedItem)
}

return {
...changes,
isOpen: true, // keep the menu open after selection.
highlightedIndex: state.highlightedIndex, // preserve highlighted index position
}
default:
return changes
}
}

const downshift = useSelect<DropdownItem>({
items,
isItemDisabled: item => item.disabled,
itemToString: item => (item ? item.text : ''),
// a11y attributes
id,
labelId,
// Controlled open state
isOpen: open,
onIsOpenChange: ({ isOpen }) => {
if (isOpen != null) onOpenChange?.(isOpen)
},
initialIsOpen: defaultOpen ?? false,
stateReducer,
// Controlled mode (single selection)
selectedItem: value ? itemsMap.get(value as string) : undefined,
initialSelectedItem: defaultValue ? itemsMap.get(defaultValue as string) : undefined,
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem?.value && !multiple) {
onValueChange?.(selectedItem?.value as OnChangeValueType)
}
},
})

return {
...downshift,
...downshiftMultipleSelection,
}
}

0 comments on commit 15e5425

Please sign in to comment.