import React, { useRef, useEffect, useCallback, useMemo } from 'react' import * as RadixMenu from '@radix-ui/react-dropdown-menu' import styled from 'styled-components' import Block from '../Primitives/Block/Block' import { FOUNDATION_THEME } from '../../tokens' import { MultiSelectMenuAlignment, type MultiSelectMenuGroupType, type MultiSelectMenuItemType, type MultiSelectMenuProps, MultiSelectMenuSide, MultiSelectMenuSize, MultiSelectVariant, } from './types' import PrimitiveText from '../Primitives/PrimitiveText/PrimitiveText' import Text from '../Text/Text' import MultiSelectMenuItem from './MultiSelectMenuItem' import { type MultiSelectTokensType } from './multiSelect.tokens' import { SearchInput } from '../Inputs' import { filterMenuGroups, getAllAvailableValues } 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 { useSelectSearchFocusRecovery } from '../Select/useSelectSearchFocusRecovery' import SelectAllItem from './SelectAllItem' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import { usePreventParentScroll, useScrollLock, useShadowRoot, } from '../../hooks' import Button from '../Button/Button' import { ButtonType, ButtonSize } from '../Button/types' import VirtualList from '../VirtualList/VirtualList' import type { VirtualListItem } from '../VirtualList/types' import { dropdownContentAnimations } from './multiSelect.animations' import MultiSelectSkeleton from './MultiSelectSkeleton' const Content = styled(RadixMenu.Content)` position: relative; background-color: ${FOUNDATION_THEME.colors.gray[0]}; border-radius: ${FOUNDATION_THEME.border.radius[8]}; box-shadow: ${FOUNDATION_THEME.shadows.sm}; z-index: 101; border: ${FOUNDATION_THEME.border.width[1]} solid ${FOUNDATION_THEME.colors.gray[200]}; display: flex; flex-direction: column; overflow: hidden; &[data-state='closed'] { pointer-events: none; } ${dropdownContentAnimations} ` const StickyHeader = styled(Block)<{ $surface?: string }>(({ $surface }) => ({ position: 'sticky', top: 0, left: 0, right: 0, zIndex: 50, backgroundColor: $surface ?? FOUNDATION_THEME.colors.gray[0], })) const ScrollableContent = styled(Block)(() => ({ overflowY: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none', flex: '1 1 auto', minHeight: 0, '&::-webkit-scrollbar': { display: 'none', }, })) const FixedActionButtons = styled(Block)(() => ({ borderTop: `${FOUNDATION_THEME.border.width[1]} solid ${FOUNDATION_THEME.colors.gray[200]}`, padding: FOUNDATION_THEME.unit[16], display: 'flex', gap: FOUNDATION_THEME.unit[8], justifyContent: 'flex-end', margin: '0', flexShrink: 0, })) const MenuFooter = styled(Block)(() => ({ flexShrink: 0, })) type FlattenedMultiSelectItem = VirtualListItem & { type: 'item' | 'label' | 'separator' item?: MultiSelectMenuItemType label?: string groupId?: number } const flattenMultiSelectGroups = ( groups: MultiSelectMenuGroupType[] ): FlattenedMultiSelectItem[] => { const flattened: FlattenedMultiSelectItem[] = [] let idCounter = 0 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 MultiSelectMenu = ({ items, selected, onSelect, trigger, minMenuWidth, maxMenuWidth, maxMenuHeight, disabled = false, enableSearch = true, searchPlaceholder = 'Search options...', searchText: controlledSearchText, onSearchChange, isSearchLoading, emptyStateText, enableSelectAll = false, selectAllText = 'Select All', maxSelections, onSelectAll, alignment = MultiSelectMenuAlignment.START, side = MultiSelectMenuSide.BOTTOM, sideOffset = 8, alignOffset = 0, collisionBoundary, open, onOpenChange, showActionButtons = true, primaryAction, secondaryAction, enableVirtualization = false, virtualListItemHeight = 48, virtualListOverscan = 5, onEndReached, endReachedThreshold, hasMore, skeleton = { count: 3, show: false, variant: 'pulse', }, size = MultiSelectMenuSize.MEDIUM, variant = MultiSelectVariant.CONTAINER, allowCustomValue = false, customValueLabel = 'Specify', menuId, menuFooter, }: MultiSelectMenuProps) => { const multiSelectTokens = useResponsiveTokens('MULTI_SELECT') const { target: portalContainer } = useShadowRoot() const { value: searchText, isControlled: isSearchControlled, isSearchEnabled, shouldFilterInternally, valueForSearchBehavior, dispatchUserValue, resetUncontrolled, } = useSelectSearchController({ controlledValue: controlledSearchText, onValueChange: onSearchChange, explicitShow: enableSearch, existingSurfaceDefault: true, }) const shouldRenderSearch = isSearchEnabled && (isSearchControlled || items.length > 0) const isActiveSearchLoading = isSearchEnabled && Boolean(isSearchLoading) const searchInputRef = useRef(null) const contentRef = useRef(null) const handleSearchFocusRecovery = useSelectSearchFocusRecovery({ enabled: isSearchControlled && isSearchEnabled, open, items, searchInputRef, }) const justOpenedRef = useRef(false) const timeoutRef = useRef(null) const hasMatch = React.useMemo( () => checkExactMatch(valueForSearchBehavior, items), [valueForSearchBehavior, items] ) const selectors = [ '[data-dropdown="dropdown"]', '[role="listbox"]', '[role="menu"]', '[data-radix-popper-content-wrapper]', '[data-radix-dropdown-menu-content]', ] usePreventParentScroll(open, contentRef, selectors) useScrollLock(open) 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 availableValues = React.useMemo( () => getAllAvailableValues(selectAllItems), [selectAllItems] ) const flattenedItems = useMemo( () => flattenMultiSelectGroups(filteredItems), [filteredItems] ) useEffect(() => { if (open && isSearchEnabled && searchInputRef.current) { const timer = setTimeout(() => { searchInputRef.current?.focus() }, 50) return () => clearTimeout(timer) } }, [open, isSearchEnabled]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if ( isSearchEnabled && searchInputRef.current && e.target !== searchInputRef.current ) { if ( e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey ) { e.preventDefault() searchInputRef.current.focus() const newValue = searchText + e.key dispatchUserValue(newValue) setTimeout(() => { if (searchInputRef.current) { searchInputRef.current.setSelectionRange( newValue.length, newValue.length ) } }, 0) return } if (e.key === 'Backspace' && searchText.length > 0) { e.preventDefault() searchInputRef.current.focus() const newValue = searchText.slice(0, -1) dispatchUserValue(newValue) setTimeout(() => { if (searchInputRef.current) { searchInputRef.current.setSelectionRange( newValue.length, newValue.length ) } }, 0) return } } }, [dispatchUserValue, isSearchEnabled, searchText] ) const handleOpenChange = useCallback( (newOpen: boolean) => { if (disabled) return if (newOpen) { if (isSearchEnabled) resetUncontrolled() justOpenedRef.current = true if (timeoutRef.current) clearTimeout(timeoutRef.current) timeoutRef.current = setTimeout(() => { justOpenedRef.current = false timeoutRef.current = null }, 150) } else { if (timeoutRef.current) { clearTimeout(timeoutRef.current) timeoutRef.current = null } justOpenedRef.current = false } onOpenChange(newOpen) }, [disabled, isSearchEnabled, onOpenChange, resetUncontrolled] ) const handleSearchChange = useCallback( (e: React.ChangeEvent) => { dispatchUserValue(e.target.value) }, [dispatchUserValue] ) const handleOutsideInteraction = useCallback((e: Event) => { if (justOpenedRef.current) { e.preventDefault() return } const target = e.target as HTMLElement const trigger = target?.closest('[data-radix-dropdown-menu-trigger]') if (trigger) { e.preventDefault() } }, []) // RCA: When customTrigger is a Tooltip wrapping a Button, nested asChild props conflict // Solution: Detect Tooltip wrapper and restructure to: Tooltip > MenuTrigger > Button // This ensures tooltip shows on hover AND dropdown opens on click const isTooltipWrapper = React.isValidElement(trigger) && trigger.props !== null && typeof trigger.props === 'object' && 'content' in trigger.props && 'children' in trigger.props const maxScrollHeight = maxMenuHeight ? maxMenuHeight - 80 : 320 const scrollAreaHeight = enableVirtualization && flattenedItems.length > 0 ? Math.min( flattenedItems.length * virtualListItemHeight, maxScrollHeight ) : null return ( {isTooltipWrapper ? ( React.cloneElement( trigger as React.ReactElement>, { children: ( { ( trigger as React.ReactElement< Record & { children: React.ReactNode } > ).props.children } ), } ) ) : ( {trigger} )} {skeleton.show ? ( ) : ( <> {shouldRenderSearch && ( { if ( e.key === 'ArrowDown' || e.key === 'ArrowUp' ) { e.preventDefault() e.stopPropagation() const menuContent = e.currentTarget.closest( '[data-dropdown="dropdown"]' ) if (menuContent) { const firstMenuItem = menuContent.querySelector( '[role="option"]:not([data-disabled])' ) if (firstMenuItem) { firstMenuItem.focus() } } } }} /> )} {enableSelectAll && onSelectAll && availableValues.length > 0 && ( onSelectAll( selectAll, selectAllItems ) } selectAllText={selectAllText} disabled={disabled} /> )} {isActiveSearchLoading && !hasRenderableItems ? null : showEmptyState ? ( {emptyStateText || (!hasSourceItems ? 'No items available' : 'No results found')} ) : enableVirtualization && flattenedItems.length > 0 ? ( 0 ? Math.max( (scrollAreaHeight ?? maxScrollHeight) - searchStatusHeight, 0 ) : Math.max( scrollAreaHeight ?? maxScrollHeight, virtualListItemHeight ) } itemHeight={virtualListItemHeight} overscan={virtualListOverscan} onEndReached={onEndReached} endReachedThreshold={ endReachedThreshold } hasMore={hasMore} renderItem={({ item: flatItem, }) => { const typed = flatItem as FlattenedMultiSelectItem const VirtualItemWrapper = ({ children, }: { children: React.ReactNode }) => ( {children} ) if (typed.type === 'label') { return ( { typed.label } ) } if ( typed.type === 'separator' ) { return ( ) } if ( typed.type === 'item' && typed.item ) { const itemIndex = (() => { let idx = 0 for (const item of flattenedItems) { if ( item.id === flatItem.id ) { break } if ( ( item as FlattenedMultiSelectItem ).type === 'item' ) { idx++ } } return idx })() return ( g.items )} index={ itemIndex } focusIdentityEnabled={ isSearchControlled } /> ) } return null }} /> ) : ( {filteredItems.map((group, groupId) => { const groupStartIndex = filteredItems .slice(0, groupId) .reduce( (acc, g) => acc + g.items.length, 0 ) return ( {group.groupLabel && ( { group.groupLabel } )} {group.items.map( (item, itemIndex) => ( g.items )} index={ groupStartIndex + itemIndex } focusIdentityEnabled={ isSearchControlled } /> ) )} {groupId !== filteredItems.length - 1 && group.showSeparator && ( )} ) })} )} {showActionButtons && (primaryAction || secondaryAction) && hasSourceItems && !( !hasRenderableItems && searchText.length > 0 ) && ( {secondaryAction && (