import React, { useMemo, useRef } from 'react' import * as RadixMenu from '@radix-ui/react-dropdown-menu' import type { SelectMenuGroupType } from '../Select' import { FOUNDATION_THEME } from '../../tokens' import styled from 'styled-components' import Text from '../Text/Text' import Block from '../Primitives/Block/Block' import { ChevronRight } from 'lucide-react' import { SearchInput } from '../Inputs/SearchInput' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import { usePreventParentScroll, useScrollLock, useShadowRoot, } from '../../hooks' import { SingleSelectTokensType } from './singleSelect.tokens' import SelectItem, { SelectItemType } from '../Select/SelectItem' import { SelectMenuAlignment, SelectMenuItemType, SelectMenuSide, SelectMenuSize, SelectMenuVariant, SingleSelectSkeletonProps, } from './types' import VirtualList from '../VirtualList/VirtualList' import { dropdownContentAnimations, submenuContentAnimations, hoverTransition, } from './singleSelect.animations' import SingleSelectSkeleton from './SingleSelectSkeleton' 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' type SingleSelectMenuProps = { items: SelectMenuGroupType[] selected: string onSelect: (value: string) => void trigger: React.ReactNode minMenuWidth?: number maxMenuWidth?: number maxMenuHeight?: number enableSearch?: boolean searchPlaceholder?: string searchText?: string onSearchChange?: (text: string) => void isSearchLoading?: boolean emptyStateText?: string disabled?: boolean // alignment alignment?: SelectMenuAlignment side?: SelectMenuSide sideOffset?: number alignOffset?: number collisionBoundary?: Element | null | Array // open open: boolean onOpenChange: (open: boolean) => void // size size?: SelectMenuSize variant?: SelectMenuVariant // virtualization enableVirtualization?: boolean virtualListItemHeight?: number virtualListOverscan?: number // infinite scroll onEndReached?: () => void endReachedThreshold?: number hasMore?: boolean loadingComponent?: React.ReactNode skeleton?: SingleSelectSkeletonProps allowCustomValue?: boolean customValueLabel?: string menuId?: string menuFooter?: React.ReactNode } type FlattenedItem = { id: string type: 'item' | 'label' | 'separator' item?: SelectMenuItemType label?: string groupId?: number } const flattenGroups = (groups: SelectMenuGroupType[]): FlattenedItem[] => { const flattened: FlattenedItem[] = [] 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 Content = styled(RadixMenu.Content)` position: relative; border-radius: 8px; box-shadow: ${FOUNDATION_THEME.shadows.sm}; z-index: 101; display: flex; flex-direction: column; overflow: hidden; &[data-state='closed'] { pointer-events: none; } ${dropdownContentAnimations} ` const ScrollableContent = styled(Block)` overflow-y: auto; overflow-x: hidden; scrollbar-width: none; scrollbar-color: transparent transparent; flex-grow: 1; min-height: 0; &::-webkit-scrollbar { display: none; } ` const MenuFooter = styled(Block)` flex-shrink: 0; ` const StyledSubMenu = styled(RadixMenu.Sub)(() => ({ padding: '8px 6px', margin: '0px 8px', })) const SubTrigger = styled(RadixMenu.SubTrigger)` align-items: center; padding: 8px 6px; margin: 0px 8px; border-radius: 4px; ${hoverTransition} &:hover { background-color: ${FOUNDATION_THEME.colors.gray[50]}; } &[data-disabled] { opacity: 0.5; cursor: not-allowed; } &[data-highlighted] { border: none; outline: none; background-color: ${FOUNDATION_THEME.colors.gray[50]}; } ` const SubContent = styled(RadixMenu.SubContent)<{ singleSelectTokens?: SingleSelectTokensType }>` background-color: ${({ singleSelectTokens }) => singleSelectTokens?.menu.backgroundColor || FOUNDATION_THEME.colors.gray[0]}; border-radius: 8px; padding: 8px 0px; box-shadow: ${FOUNDATION_THEME.shadows.lg}; border: ${({ singleSelectTokens }) => `1px solid ${ singleSelectTokens?.menu.border ?? FOUNDATION_THEME.colors.gray[200] }`}; z-index: 1000; ${submenuContentAnimations} ` const SubMenu = ({ item, onSelect, selected, singleSelectTokens, focusIdentityEnabled, }: { item: SelectMenuItemType onSelect: (value: string) => void selected: string singleSelectTokens: SingleSelectTokensType focusIdentityEnabled?: boolean }) => { return ( {item.slot1 && ( {item.slot1} )} {item.label} {item.slot2 && ( {item.slot2} )} {item.slot3 && ( {item.slot3} )} {item.slot4 && ( {item.slot4} )} {item.subMenu?.map((subItem, subIdx) => ( ))} ) } const Item = ({ item, onSelect, selected, singleSelectTokens, index, focusIdentityEnabled, }: { item: SelectMenuItemType onSelect: (value: string) => void selected: string singleSelectTokens?: SingleSelectTokensType index?: number focusIdentityEnabled?: boolean }) => { if (item.subMenu) { return ( ) } return ( ) } const Label = styled(RadixMenu.Label)(() => ({ margin: '0', padding: '8px 6px', userSelect: 'none', textTransform: 'uppercase', overflow: 'clip', })) // Utility: Recursively filter menu items and groups by search text function filterMenuGroups( groups: SelectMenuGroupType[], searchText: string ): SelectMenuGroupType[] { if (!searchText) return groups const lower = searchText.toLowerCase() return groups .map((group: SelectMenuGroupType) => { const filteredItems = group.items .map((item: SelectMenuItemType) => filterMenuItem(item, lower)) .filter(Boolean) as SelectMenuItemType[] if (filteredItems.length === 0) return null return { ...group, items: filteredItems } }) .filter(Boolean) as SelectMenuGroupType[] } function filterMenuItem( item: SelectMenuItemType, lower: string ): SelectMenuItemType | null { const matches = (item.label && item.label.toLowerCase().includes(lower)) || (item.subLabel && item.subLabel.toLowerCase().includes(lower)) if (item.subMenu) { const filteredSub = item.subMenu .map((sub: SelectMenuItemType) => filterMenuItem(sub, lower)) .filter(Boolean) as SelectMenuItemType[] if (filteredSub.length > 0 || matches) { return { ...item, subMenu: filteredSub } } return null } return matches ? item : null } const SingleSelectMenu = ({ items, selected, onSelect, trigger, minMenuWidth, maxMenuWidth, maxMenuHeight = 400, enableSearch, searchPlaceholder = 'Search options...', searchText: controlledSearchText, onSearchChange, isSearchLoading, emptyStateText, disabled, alignment = SelectMenuAlignment.START, side = SelectMenuSide.BOTTOM, sideOffset = 8, alignOffset = 0, collisionBoundary, open, onOpenChange, size = SelectMenuSize.MEDIUM, variant = SelectMenuVariant.CONTAINER, enableVirtualization = false, virtualListItemHeight = 48, virtualListOverscan = 2, onEndReached, endReachedThreshold, hasMore, skeleton = { count: 3, show: false, variant: 'pulse', }, allowCustomValue = false, customValueLabel = 'Specify', menuFooter, }: SingleSelectMenuProps) => { const singleSelectTokens = useResponsiveTokens('SINGLE_SELECT') const { target: portalContainer } = useShadowRoot() const { value: searchText, isControlled: isSearchControlled, isSearchEnabled, shouldFilterInternally, valueForSearchBehavior, dispatchUserValue, resetUncontrolled, } = useSelectSearchController({ controlledValue: controlledSearchText, onValueChange: onSearchChange, explicitShow: enableSearch, existingSurfaceDefault: false, }) const shouldRenderSearch = isSearchEnabled && (isSearchControlled || items.length > 0) const isActiveSearchLoading = isSearchEnabled && Boolean(isSearchLoading) const searchInputRef = React.useRef(null) const contentRef = useRef(null) const handleSearchFocusRecovery = useSelectSearchFocusRecovery({ enabled: isSearchControlled && isSearchEnabled, open, items, searchInputRef, }) let itemCounter = 0 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 hasMatch = useMemo( () => checkExactMatch(valueForSearchBehavior, items), [valueForSearchBehavior, items] ) const filteredItems = useMemo(() => { const baseFilteredItems = shouldFilterInternally && searchText ? filterMenuGroups(items, searchText) : items return getFilteredItemsWithCustomValue( baseFilteredItems, searchText, hasMatch, allowCustomValue || false, 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 flattenedItems = useMemo( () => enableVirtualization || searchText ? flattenGroups(filteredItems) : [], [filteredItems, enableVirtualization, searchText] ) const handleOpenChange = (newOpen: boolean) => { if (disabled) return if (newOpen && isSearchEnabled) resetUncontrolled() onOpenChange(newOpen) } const renderVirtualItem = ({ item: flatItem, }: { item: FlattenedItem index: number }) => { const VirtualItemWrapper = ({ children, }: { children: React.ReactNode }) => ( {children} ) if (flatItem.type === 'label') { return ( ) } if (flatItem.type === 'separator') { return ( ) } if (flatItem.type === 'item' && flatItem.item) { const currentIndex = itemCounter itemCounter++ return ( ) } return null } // 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 return ( {isTooltipWrapper ? ( React.cloneElement( trigger as React.ReactElement>, { children: ( { ( trigger as React.ReactElement< Record & { children: React.ReactNode } > ).props.children } ), } ) ) : ( {trigger} )} { if (isSearchEnabled && searchInputRef.current) { if ( e.target !== searchInputRef.current && !searchInputRef.current.contains( e.target as Node ) && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey ) { searchInputRef.current.focus() } } }} onFocusCapture={handleSearchFocusRecovery} > {skeleton.show ? ( ) : ( <> {shouldRenderSearch && ( ) => { e.preventDefault() e.stopPropagation() dispatchUserValue(e.target.value) }} onKeyDown={(e) => { 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="menuitem"]:not([data-disabled])' ) if (firstMenuItem) { firstMenuItem.focus() } } } if (e.key === 'Tab') { return } }} autoFocus aria-label={ searchPlaceholder || 'Search options' } /> )} {isActiveSearchLoading && !hasRenderableItems ? null : showEmptyState ? ( {emptyStateText || (!hasSourceItems ? 'No items available' : 'No results found')} ) : enableVirtualization && hasRenderableItems ? ( 0 ? Math.max( maxMenuHeight - 60 - searchStatusHeight, 0 ) : Math.max( maxMenuHeight - 60, virtualListItemHeight ) } itemHeight={virtualListItemHeight} overscan={virtualListOverscan} onEndReached={onEndReached} endReachedThreshold={ endReachedThreshold } hasMore={hasMore} isLoading={false} /> ) : ( {filteredItems.map((group, groupId) => ( {group.groupLabel && ( )} {group.items.map( (item, itemIndex) => { let itemIdx = 0 for ( let i = 0; i < groupId; i++ ) { itemIdx += filteredItems[i] .items .length } itemIdx += itemIndex return ( ) } )} {groupId !== filteredItems.length - 1 && group.showSeparator && ( )} ))} )} {menuFooter && ( {menuFooter} )} )} ) } export default SingleSelectMenu