import { useRef, useState, useId } from 'react' import Block from '../Primitives/Block/Block' import InputLabels from '../Inputs/utils/InputLabels/InputLabels' import InputFooter from '../Inputs/utils/InputFooter/InputFooter' import MultiSelectMenu from './MultiSelectMenu' import MobileMultiSelect from './MobileMultiSelect' import PrimitiveButton from '../Primitives/PrimitiveButton/PrimitiveButton' import { FOUNDATION_THEME } from '../../tokens' import Text from '../Text/Text' import { ChevronDown, X } from 'lucide-react' import { MultiSelectMenuAlignment, MultiSelectMenuSize, type MultiSelectProps, MultiSelectSelectionTagType, MultiSelectVariant, } from './types' import { type MultiSelectTokensType } from './multiSelect.tokens' import { useBreakpoints } from '../../hooks/useBreakPoints' import { BREAKPOINTS } from '../../breakpoints/breakPoints' import { getAllValues, getMultiSelectBorderRadius, getMultiSelectCrossBorderRadius, map, } from './utils' import { clampScopeToMaxSelections, emitLegacyScopeChanges, getNextSelectionAfterToggle, getNextSelectionForScope, } from '../shared/multiSelectSelection' import { toPixels } from '../../global-utils/GlobalUtils' import FloatingLabels from '../Inputs/utils/FloatingLabels/FloatingLabels' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import { Tooltip } from '../Tooltip' import { useErrorShake } from '../common/useErrorShake' import { getErrorShakeStyle, errorShakeAnimation, } from '../common/error.animations' import styled from 'styled-components' import { setupAccessibility } from '../SingleSelect/utils' import { useDropdownInteractionLock } from '../../hooks' const Wrapper = styled(Block)` ${errorShakeAnimation} ` const MultiSelect = ({ 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, onSearchChange, isSearchLoading, emptyStateText, enableSelectAll = false, selectAllText = 'Select All', maxSelections, customTrigger, useDrawerOnMobile = true, minMenuWidth, maxMenuWidth, maxMenuHeight, alignment = MultiSelectMenuAlignment.START, side, sideOffset, alignOffset, inline = false, error, errorMessage, showActionButtons, primaryAction, secondaryAction, showItemDividers = false, showHeaderBorder = false, fullWidth = false, enableVirtualization = items.length > 20 ? true : false, virtualListItemHeight = 48, virtualListOverscan = 5, itemsToRender, onEndReached, endReachedThreshold, hasMore, loadingComponent, skeleton = { count: 3, show: false, variant: 'pulse', }, maxTriggerWidth, minTriggerWidth, allowCustomValue = false, customValueLabel = 'Specify', showClearButton, onClearAllClick, onOpenChange, multiSelectGroupPosition, menuFooter, ...rest }: MultiSelectProps) => { const { onFocus, onBlur, ...buttonRest } = rest const { breakPointLabel } = useBreakpoints(BREAKPOINTS) const isSmallScreen = breakPointLabel === 'sm' const slotRef = useRef(null) const slotWidth = slotRef.current?.offsetWidth const multiSelectTokens = useResponsiveTokens('MULTI_SELECT') const [open, setOpen] = useState(false) const { innerWidth } = useBreakpoints() const isMobile = innerWidth < 1024 const valueLabelMap = map(items) const shouldShowClearButton = showClearButton !== undefined ? showClearButton && selectedValues.length > 0 : variant === MultiSelectVariant.CONTAINER && selectedValues.length > 0 const shouldShowActionButtons = showActionButtons !== undefined ? showActionButtons : !!(primaryAction || secondaryAction) const [showTooltip, setShowTooltip] = useState(false) const textContainerRef = useRef(null) const checkTruncation = () => { if (textContainerRef.current) { const isOverflowing = textContainerRef.current.scrollWidth > textContainerRef.current.clientWidth setShowTooltip(isOverflowing) } } const isItemSelected = selectedValues.length > 0 const isSmallScreenWithLargeSize = isSmallScreen && size === MultiSelectMenuSize.LARGE const paddingX = toPixels( multiSelectTokens.trigger.padding[size][variant].x ) const paddingY = toPixels( multiSelectTokens.trigger.padding[size][variant].y ) const paddingInlineStart = slot && slotWidth ? paddingX + slotWidth + 8 : paddingX const shouldShake = useErrorShake(error || false) const callOnFocus = () => { if (typeof onFocus === 'function') { onFocus(undefined as unknown as React.FocusEvent) } } const callOnBlur = () => { if (typeof onBlur === 'function') { onBlur(undefined as unknown as React.FocusEvent) } } const generatedId = useId() const { uniqueName, labelId, hintTextId, errorMessageId, menuId, ariaAttributes, } = setupAccessibility({ name, generatedId, label, hintText, error, errorMessage, prefix: 'multiselect', needsMenuId: true, }) useDropdownInteractionLock(!isMobile && open) const handleItemSelect = (value: string) => { const nextSelection = getNextSelectionAfterToggle(selectedValues, value) onChange?.(value) onSelectionChange?.(nextSelection) } const handleBulkSelection = ( selectAll: boolean, filteredItems: typeof items ) => { const availableValues = getAllValues(filteredItems) const scopedValues = selectAll ? clampScopeToMaxSelections( selectedValues, availableValues, maxSelections ) : availableValues const nextSelection = getNextSelectionForScope( selectedValues, scopedValues, selectAll ) emitLegacyScopeChanges( selectAll, scopedValues, selectedValues, onChange ) onSelectionChange?.(nextSelection) } const handleClearSelection = () => { if (onClearAllClick) { onClearAllClick() return } onChange?.('') onSelectionChange?.([]) } if (isMobile && useDrawerOnMobile) { return ( primaryAction.onClick(selectedValues), } : undefined } secondaryAction={secondaryAction} showItemDividers={showItemDividers} showHeaderBorder={showHeaderBorder} enableVirtualization={enableVirtualization} virtualListItemHeight={virtualListItemHeight} virtualListOverscan={virtualListOverscan} itemsToRender={itemsToRender} onEndReached={onEndReached} endReachedThreshold={endReachedThreshold} hasMore={hasMore} loadingComponent={loadingComponent} skeleton={skeleton} allowCustomValue={allowCustomValue} customValueLabel={customValueLabel} onOpenChange={onOpenChange} /> ) } return ( {variant === MultiSelectVariant.CONTAINER && (!isSmallScreen || size !== MultiSelectMenuSize.LARGE) && ( )} valueLabelMap[v] || v) .join(', ')) || '' } fullWidth={fullWidth} > { setOpen(isOpen) if (isOpen) { callOnFocus() } else { callOnBlur() } onOpenChange?.(isOpen) }} showActionButtons={shouldShowActionButtons} primaryAction={ primaryAction ? { ...primaryAction, onClick: () => primaryAction.onClick( selectedValues ), } : undefined } secondaryAction={secondaryAction} enableVirtualization={enableVirtualization} virtualListItemHeight={virtualListItemHeight} virtualListOverscan={virtualListOverscan} itemsToRender={itemsToRender} onEndReached={onEndReached} endReachedThreshold={endReachedThreshold} hasMore={hasMore} loadingComponent={loadingComponent} menuId={menuId} menuFooter={menuFooter} trigger={ customTrigger || ( { callOnFocus() }} onBlur={() => { callOnBlur() }} {...buttonRest} > {slot && ( {slot} )} checkTruncation() } > {/* NO CONTAINER Label*/} {variant === MultiSelectVariant.NO_CONTAINER && ( {label} )} {isSmallScreenWithLargeSize && variant === MultiSelectVariant.CONTAINER && ( )} {/* Variant == Container - always show the placeholder*/} {variant === MultiSelectVariant.CONTAINER && (selectedValues.length > 0 || !isSmallScreen || size !== MultiSelectMenuSize.LARGE) && ( {placeholder} )} {selectedValues.length > 0 && ( valueLabelMap[ v ] ?? (searchText !== undefined ? v : undefined) ) .join(', ') } > {selectionTagType === MultiSelectSelectionTagType.COUNT ? selectedValues.length : selectedValues .map( (v) => valueLabelMap[ v ] ?? (searchText !== undefined ? v : undefined) ) .join(', ')} )} ) } allowCustomValue={allowCustomValue} customValueLabel={customValueLabel} /> {shouldShowClearButton && ( )} {variant === MultiSelectVariant.CONTAINER && ( )} ) } export default MultiSelect