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 && (