import React, { useState, useMemo, useId } from 'react'
import {
Drawer,
DrawerTrigger,
DrawerPortal,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerBody,
} from '../Drawer'
import Block from '../Primitives/Block/Block'
import Text from '../Text/Text'
import MultiSelectTrigger from './MultiSelectTrigger'
import type { SkeletonVariant } from '../Skeleton/types'
import { FOUNDATION_THEME } from '../../tokens'
import InputLabels from '../Inputs/utils/InputLabels/InputLabels'
import InputFooter from '../Inputs/utils/InputFooter/InputFooter'
import { useBreakpoints } from '../../hooks/useBreakPoints'
import { BREAKPOINTS } from '../../breakpoints/breakPoints'
import { map, filterMenuGroups } from './utils'
import {
hasExactMatch as checkExactMatch,
getFilteredItemsWithCustomValue,
hasRenderableSelectItems,
} from '../Select/selectUtils'
import { useSelectSearchController } from '../Select/useSelectSearchController'
import SelectSearchStatus, {
SELECT_SEARCH_STATUS_HEIGHT,
} from '../Select/SelectSearchStatus'
import { useResponsiveTokens } from '../../hooks/useResponsiveTokens'
import type { MultiSelectTokensType } from './multiSelect.tokens'
import type {
MultiSelectProps,
MultiSelectMenuGroupType,
MultiSelectMenuItemType,
} from './types'
import {
MultiSelectMenuSize,
MultiSelectVariant,
MultiSelectSelectionTagType,
} from './types'
import Button from '../Button/Button'
import { ButtonType, ButtonSize } from '../Button/types'
import { Checkbox } from '../Checkbox'
import { CheckboxSize } from '../Checkbox/types'
import { TextInput } from '../Inputs/TextInput'
import { TextInputSize } from '../Inputs/TextInput/types'
import VirtualList from '../VirtualList/VirtualList'
import type { VirtualListItem } from '../VirtualList/types'
import Skeleton from '../Skeleton/Skeleton'
import { setupAccessibility } from '../SingleSelect/utils'
import {
clampScopeToMaxSelections,
emitLegacyScopeChanges,
getNextSelectionAfterToggle,
getNextSelectionForScope,
} from '../shared/multiSelectSelection'
type MobileMultiSelectProps = MultiSelectProps
type FlattenedMobileMultiSelectItem = VirtualListItem & {
type: 'item' | 'label' | 'separator' | 'selectAll'
item?: MultiSelectMenuItemType
label?: string
groupId?: number
}
const flattenMobileMultiSelectGroups = (
groups: MultiSelectMenuGroupType[],
enableSelectAll: boolean
): FlattenedMobileMultiSelectItem[] => {
const flattened: FlattenedMobileMultiSelectItem[] = []
let idCounter = 0
if (enableSelectAll) {
flattened.push({
id: 'select-all',
type: 'selectAll',
})
}
groups.forEach((group, groupId) => {
if (group.groupLabel) {
flattened.push({
id: `label-${groupId}`,
type: 'label',
label: group.groupLabel,
groupId,
})
}
group.items.forEach((item) => {
flattened.push({
id: `item-${idCounter++}`,
type: 'item',
item,
groupId,
})
})
if (groupId !== groups.length - 1 && group.showSeparator) {
flattened.push({
id: `separator-${groupId}`,
type: 'separator',
groupId,
})
}
})
return flattened
}
const SelectAllItem = ({
items,
selectedValues,
onChange,
onSelectionChange,
selectAllText,
maxSelections,
}: {
items: MultiSelectMenuGroupType[]
selectedValues: string[]
onChange?: (value: string) => void
onSelectionChange?: (selectedValues: string[]) => void
selectAllText: string
maxSelections?: number
}) => {
const allValues = items.flatMap((group) =>
group.items
.filter((item) => !item.disabled && !item.alwaysSelected)
.map((item) => item.value)
)
const allSelected =
allValues.length > 0 &&
allValues.every((value) => selectedValues.includes(value))
const someSelected = selectedValues.some((value) =>
allValues.includes(value)
)
const handleSelectAll = (checked: boolean | 'indeterminate') => {
const selectAll = checked === true || checked === 'indeterminate'
const scopedValues = selectAll
? clampScopeToMaxSelections(
selectedValues,
allValues,
maxSelections
)
: allValues
const nextSelection = getNextSelectionForScope(
selectedValues,
scopedValues,
selectAll
)
emitLegacyScopeChanges(
selectAll,
scopedValues,
selectedValues,
onChange
)
onSelectionChange?.(nextSelection)
}
const getCheckboxState = (): boolean | 'indeterminate' => {
if (allSelected) return true
if (someSelected) return 'indeterminate'
return false
}
return (
handleSelectAll(!allSelected)}
>
{selectAllText}
{
e.stopPropagation()
}}
>
)
}
const MultiSelectItem = ({
item,
isSelected,
onChange,
maxSelections,
selectedCount,
}: {
item: MultiSelectMenuItemType
isSelected: boolean
onChange: (value: string) => void
maxSelections?: number
selectedCount: number
}) => {
const isMaxReached =
maxSelections !== undefined &&
selectedCount >= maxSelections &&
!isSelected
const isItemDisabled = item.disabled || isMaxReached || item.alwaysSelected
return (
{
if (!isItemDisabled) {
onChange(item.value)
}
}}
>
{item.slot1 && (
{item.slot1}
)}
{item.label}
{
e.stopPropagation()
}}
>
{
if (!isItemDisabled) {
onChange(item.value)
}
}}
/>
{item.subLabel && (
{item.subLabel}
)}
)
}
const MobileMultiSelect: React.FC = ({
selectedValues,
onChange,
onSelectionChange,
items = [],
label,
sublabel,
disabled,
helpIconHintText,
name,
required,
variant = MultiSelectVariant.CONTAINER,
selectionTagType = MultiSelectSelectionTagType.COUNT,
slot,
hintText,
placeholder,
size = MultiSelectMenuSize.MEDIUM,
enableSearch = true,
searchPlaceholder = 'Search options...',
searchText: controlledSearchText,
onSearchChange,
isSearchLoading,
emptyStateText,
enableSelectAll = false,
selectAllText = 'Select All',
maxSelections,
customTrigger,
error,
errorMessage,
showActionButtons = true,
primaryAction = {
text: 'Apply',
onClick: () => {},
disabled: false,
loading: false,
},
secondaryAction,
showItemDividers = false,
showHeaderBorder = false,
enableVirtualization = false,
virtualListItemHeight = 56,
virtualListOverscan = 5,
onEndReached,
endReachedThreshold,
hasMore,
skeleton = {
count: 3,
show: false,
variant: 'pulse',
},
maxTriggerWidth,
minTriggerWidth,
allowCustomValue = false,
customValueLabel = 'Specify',
onOpenChange,
...rest
}) => {
const { onFocus, onBlur, ...triggerRest } = rest
const { breakPointLabel } = useBreakpoints(BREAKPOINTS)
const isSmallScreen = breakPointLabel === 'sm'
const multiSelectTokens =
useResponsiveTokens('MULTI_SELECT')
const [drawerOpen, setDrawerOpen] = useState(false)
const {
value: searchText,
isControlled: isSearchControlled,
isSearchEnabled,
shouldFilterInternally,
valueForSearchBehavior,
dispatchUserValue,
resetUncontrolled,
} = useSelectSearchController({
controlledValue: controlledSearchText,
onValueChange: onSearchChange,
explicitShow: enableSearch,
existingSurfaceDefault: true,
})
const isActiveSearchLoading = isSearchEnabled && Boolean(isSearchLoading)
const valueLabelMap = map(items)
const generatedId = useId()
const { uniqueName, labelId, hintTextId, errorMessageId } =
setupAccessibility({
name,
generatedId,
label,
hintText,
error,
errorMessage,
prefix: 'multiselect',
needsMenuId: false,
})
const hasMatch = React.useMemo(
() => checkExactMatch(valueForSearchBehavior, items),
[valueForSearchBehavior, items]
)
const filteredItems = React.useMemo(() => {
const baseFilteredItems = shouldFilterInternally
? filterMenuGroups(items, searchText)
: items
return getFilteredItemsWithCustomValue(
baseFilteredItems,
searchText,
hasMatch,
allowCustomValue,
isSearchEnabled && !isSearchLoading,
customValueLabel
)
}, [
items,
searchText,
allowCustomValue,
hasMatch,
isSearchEnabled,
isSearchLoading,
shouldFilterInternally,
customValueLabel,
])
const hasSourceItems = isSearchControlled
? hasRenderableSelectItems(items)
: items.length > 0
const hasRenderableItems = isSearchControlled
? hasRenderableSelectItems(filteredItems)
: filteredItems.length > 0
const showEmptyState = isSearchControlled
? !hasRenderableItems
: !hasSourceItems || (!hasRenderableItems && searchText.length > 0)
const searchStatusHeight =
isActiveSearchLoading && hasRenderableItems
? SELECT_SEARCH_STATUS_HEIGHT
: 0
const selectAllItems = isSearchControlled ? items : filteredItems
const flattenedItems = useMemo(
() => flattenMobileMultiSelectGroups(filteredItems, enableSelectAll),
[filteredItems, enableSelectAll]
)
const handleItemSelect = (value: string) => {
const nextSelection = getNextSelectionAfterToggle(selectedValues, value)
onChange?.(value)
onSelectionChange?.(nextSelection)
}
const handleTriggerChange = (value: string) => {
if (value === '') {
onChange?.('')
onSelectionChange?.([])
return
}
handleItemSelect(value)
}
return (
{variant === MultiSelectVariant.CONTAINER &&
(!isSmallScreen || size !== MultiSelectMenuSize.LARGE) && (
)}
{
setDrawerOpen(isOpen)
if (isOpen) {
if (typeof onFocus === 'function') {
onFocus(
undefined as unknown as React.FocusEvent
)
}
} else {
if (typeof onBlur === 'function') {
onBlur(
undefined as unknown as React.FocusEvent
)
}
if (isSearchEnabled) {
resetUncontrolled()
}
}
onOpenChange?.(isOpen)
}}
>
{customTrigger || (
setDrawerOpen(true)}
multiSelectTokens={multiSelectTokens}
error={error}
disabled={disabled}
{...triggerRest}
/>
)}
{label || 'Select Options'}
{skeleton.show ? (
{Array.from({
length: skeleton.count || 3,
}).map((_, index) => (
))}
) : (
{isSearchEnabled && (
dispatchUserValue(
e.target.value
)
}
aria-label={
searchPlaceholder ||
'Search options'
}
/>
)}
{isActiveSearchLoading &&
!hasRenderableItems ? null : showEmptyState ? (
{emptyStateText ||
(!hasSourceItems
? 'No items available'
: 'No results found')}
) : enableVirtualization &&
flattenedItems.length > 0 ? (
{
const typed =
flatItem as FlattenedMobileMultiSelectItem
if (
typed.type ===
'selectAll'
) {
return (
)
}
if (
typed.type === 'label'
) {
return (
{
typed.label
}
)
}
if (
typed.type ===
'separator'
) {
return (
)
}
if (
typed.type === 'item' &&
typed.item
) {
const isSelected =
selectedValues.includes(
typed.item.value
)
return (
)
}
return null
}}
/>
) : (
{enableSelectAll && (
)}
{filteredItems.map(
(group, groupId) => {
const isLastGroup =
groupId ===
filteredItems.length -
1
return (
{group.groupLabel && (
{
group.groupLabel
}
)}
{group.items.map(
(
item,
itemIndex
) => {
const isSelected =
selectedValues.includes(
item.value
)
const isLastItemInGroup =
itemIndex ===
group
.items
.length -
1
const isLastItemOverall =
isLastGroup &&
isLastItemInGroup
const shouldShowDivider =
showItemDividers &&
!isLastItemOverall
return (
{shouldShowDivider && (
)}
)
}
)}
{!isLastGroup &&
group.showSeparator && (
)}
)
}
)}
)}
{showActionButtons &&
(primaryAction || secondaryAction) && (
{secondaryAction && (
)}
)}
{variant === MultiSelectVariant.CONTAINER && (
)}
)
}
export default MobileMultiSelect