import { useState, useMemo, useRef, useEffect } from 'react' import * as RadixMenu from '@radix-ui/react-dropdown-menu' import styled from 'styled-components' import { useVirtualizer } from '@tanstack/react-virtual' import { SingleSelectV2Alignment, SingleSelectV2Side, SingleSelectV2Size, SingleSelectV2Variant, type SingleSelectV2MenuProps, } from './singleSelectV2.types' import Text from '../Text/Text' import Block from '../Primitives/Block/Block' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import { usePreventParentScroll, useScrollLock, useShadowRoot, } from '../../hooks' import { SingleSelectV2TokensType } from './singleSelectV2.tokens' import SingleSelectV2Skeleton from './SingleSelectV2Skeleton' import { hasExactMatch as checkExactMatch, getFilteredItemsWithCustomValue, hasRenderableSelectItems, } from '../Select/selectUtils' import { flattenGroups, filterSingleSelectV2MenuGroups, getVirtualRowEstimate, defaultSingleSelectV2Skeleton, MENU_SCROLL_SELECTORS, DEFAULT_END_REACHED_THRESHOLD, } from './utils' import { menuContentAnimations } from './singleSelectV2.animations' import SingleSelectV2Search from './SingleSelectV2Search' import SingleSelectV2List from './SingleSelectV2List' import SingleSelectV2VirtualList from './SingleSelectV2VirtualList' import { SELECT_V2_MENU_Z_INDEX } from '../SelectV2/selectV2.constants' import { useSelectV2MenuBehavior } from '../SelectV2/useSelectV2MenuBehavior' import { getBaseVirtualViewportHeight, getAdjustedVirtualViewportHeight, VIRTUAL_DEFAULT_VIEWPORT, } from '../common/virtualViewport' import { useSelectSearchController } from '../Select/useSelectSearchController' import SelectSearchStatus, { SELECT_SEARCH_STATUS_HEIGHT, } from '../Select/SelectSearchStatus' import { useSelectSearchFocusRecovery } from '../Select/useSelectSearchFocusRecovery' const Content = styled(RadixMenu.Content)` position: relative; z-index: ${SELECT_V2_MENU_Z_INDEX}; display: flex; flex-direction: column; overflow: hidden; &[data-state='closed'] { pointer-events: none; } ${menuContentAnimations} ` 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 SingleSelectV2Menu = ({ items, selected, onSelect, trigger, menuDimensions, search, disabled, menuPosition, collisionBoundary, open, onOpenChange, size = SingleSelectV2Size.MD, variant = SingleSelectV2Variant.CONTAINER, enableVirtualization = false, virtualListItemHeight = 48, virtualListOverscan = 2, onEndReached, endReachedThreshold = DEFAULT_END_REACHED_THRESHOLD, hasMore, loadingComponent, skeleton = defaultSingleSelectV2Skeleton, allowCustomValue = false, customValueLabel = 'Specify', menuId, menuFooter, }: SingleSelectV2MenuProps) => { const singleSelectTokens = useResponsiveTokens('SINGLE_SELECT_V2') const { target: portalContainer } = useShadowRoot() const searchPlaceholder = search?.placeholder ?? 'Search options...' const maxMenuHeight = menuDimensions?.maxHeight as number | undefined const alignment = menuPosition?.alignment ?? SingleSelectV2Alignment.START const side = menuPosition?.side ?? SingleSelectV2Side.BOTTOM const sideOffset = menuPosition?.sideOffset ?? 8 const alignOffset = menuPosition?.alignOffset ?? 0 const { value: searchText, isControlled: isSearchControlled, isSearchEnabled, shouldFilterInternally, valueForSearchBehavior, dispatchUserValue, resetUncontrolled, } = useSelectSearchController({ controlledValue: search?.searchText, onValueChange: search?.onSearchChange, explicitShow: search?.show, existingSurfaceDefault: false, }) const searchInputRef = useRef(null) const searchContainerRef = useRef(null) const contentRef = useRef(null) const virtualScrollRef = useRef(null) const [virtualReady, setVirtualReady] = useState(false) const handleSearchFocusRecovery = useSelectSearchFocusRecovery({ enabled: isSearchControlled && isSearchEnabled, open, items, searchInputRef, }) usePreventParentScroll(open, contentRef, [...MENU_SCROLL_SELECTORS]) useScrollLock(open) const hasMatch = useMemo( () => checkExactMatch(valueForSearchBehavior, items), [valueForSearchBehavior, items] ) const filteredItems = useMemo(() => { const base = shouldFilterInternally && searchText ? filterSingleSelectV2MenuGroups(items, searchText) : items return getFilteredItemsWithCustomValue( base, searchText, hasMatch, allowCustomValue ?? false, isSearchEnabled && !search?.isSearchLoading, customValueLabel ) }, [ items, searchText, allowCustomValue, hasMatch, isSearchEnabled, search?.isSearchLoading, shouldFilterInternally, customValueLabel, ]) const hasSourceItems = isSearchControlled ? hasRenderableSelectItems(items) : items.length > 0 const hasRenderableItems = isSearchControlled ? hasRenderableSelectItems(filteredItems) : filteredItems.length > 0 const flattenedItems = useMemo( () => flattenGroups(filteredItems), [filteredItems] ) const virtualViewportHeight = getBaseVirtualViewportHeight(maxMenuHeight) useEffect(() => { if (enableVirtualization && open && virtualScrollRef.current) { setVirtualReady(true) return } if (!open) { setVirtualReady(false) } }, [enableVirtualization, open]) const virtualizer = useVirtualizer({ count: flattenedItems.length, getScrollElement: () => virtualScrollRef.current, getItemKey: (index: number) => flattenedItems[index]?.id ?? index, estimateSize: (index: number) => getVirtualRowEstimate(flattenedItems, index) ?? virtualListItemHeight, overscan: virtualListOverscan, initialRect: { width: 0, height: virtualViewportHeight, }, enabled: enableVirtualization && flattenedItems.length > 0 && virtualReady, }) const { searchHeight } = useSelectV2MenuBehavior({ open, enableSearch: isSearchEnabled, searchText, searchInputRef, searchContainerRef, shouldMeasureSearchHeight: true, refocusSearchOnTextChange: true, enableVirtualization, onVirtualizedSearchChange: () => virtualizer.scrollToIndex(0), virtualScrollRef, onEndReached, hasMore, endReachedThreshold, }) const searchStatusHeight = isSearchEnabled && search?.isSearchLoading && hasRenderableItems ? SELECT_SEARCH_STATUS_HEIGHT : 0 const reservedVirtualViewportHeight = (isSearchEnabled ? searchHeight : 0) + searchStatusHeight const adjustedVirtualViewportHeight = searchStatusHeight > 0 ? Math.max( (maxMenuHeight ?? VIRTUAL_DEFAULT_VIEWPORT) - reservedVirtualViewportHeight, 0 ) : getAdjustedVirtualViewportHeight( maxMenuHeight, reservedVirtualViewportHeight ) const handleOpenChange = (newOpen: boolean) => { if (disabled && newOpen) return if (newOpen && isSearchEnabled) resetUncontrolled() onOpenChange(newOpen) } const menuContent = singleSelectTokens.menu.content const menuItem = singleSelectTokens.menu.item const showEmptyState = isSearchControlled ? !hasRenderableItems : !hasSourceItems || (!hasRenderableItems && searchText.length > 0) const isActiveSearchLoading = isSearchEnabled && Boolean(search?.isSearchLoading) const emptyMessage = !hasSourceItems ? 'No items available' : 'No results found' const showVirtualList = enableVirtualization && flattenedItems.length > 0 && !showEmptyState && virtualReady const virtualItems = virtualizer.getVirtualItems() return ( {trigger} { if (!isSearchEnabled || !searchInputRef.current) return const targetIsSearch = e.target === searchInputRef.current || searchInputRef.current.contains(e.target as Node) if (targetIsSearch) return const isTypingKey = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey if (e.key === 'Backspace') { e.preventDefault() dispatchUserValue((prev) => prev.length > 0 ? prev.slice(0, -1) : prev ) searchInputRef.current.focus() return } if (e.key === 'Delete') { e.preventDefault() searchInputRef.current.focus() return } if (isTypingKey) { searchInputRef.current.focus() } }} onFocusCapture={handleSearchFocusRecovery} > {skeleton.show ? ( ) : ( <> 0 } backgroundColor={ menuContent.backgroundColor as string } searchPlaceholder={searchPlaceholder} searchText={searchText} onSearchTextChange={dispatchUserValue} searchInputRef={searchInputRef} containerRef={searchContainerRef} /> {isActiveSearchLoading && !hasRenderableItems ? null : showEmptyState ? ( {search?.emptyStateText || emptyMessage} ) : showVirtualList ? ( ) : ( )} {menuFooter && ( {menuFooter} )} )} ) } SingleSelectV2Menu.displayName = 'SingleSelectV2Menu' export default SingleSelectV2Menu