import * as RadixMenu from '@radix-ui/react-dropdown-menu' import styled, { type CSSObject } from 'styled-components' import { FOUNDATION_THEME } from '../../tokens' import { type MenuProps, MenuAlignment, MenuSide, type MenuItemType, } from './types' import { MenuSelectionProvider, type MenuSelectionContextValue, type MenuSelectionMode, type MenuSelectionStyle, } from './selection' import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react' import { filterMenuGroups, defaultSearchSortFn } from './utils' import MenuItem from './MenuItem' import Block from '../Primitives/Block/Block' import SearchInput from '../Inputs/SearchInput/SearchInput' import { Search } from 'lucide-react' import PrimitiveText from '../Primitives/PrimitiveText/PrimitiveText' import { type MenuTokensType } from './menu.tokens' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import { VirtualList, type VirtualListItem } from '../VirtualList' import { menuContentAnimations } from './menu.animations' import { Skeleton, SkeletonVariant } from '../Skeleton' import { useDropdownInteractionLock, useShadowRoot } from '../../hooks' import useScrollLock from '../../hooks/useScrollLock' export const contentBaseStyle: CSSObject = { backgroundColor: 'white', boxShadow: FOUNDATION_THEME.shadows.sm, zIndex: 101, overflowY: 'auto', overflowX: 'hidden', scrollbarWidth: 'none', scrollbarColor: 'transparent transparent', paddingBottom: 6, borderRadius: 8, border: `1px solid ${FOUNDATION_THEME.colors.gray[200]}`, } const Content = styled(RadixMenu.Content)` box-shadow: ${FOUNDATION_THEME.shadows.sm}; z-index: 101; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; scrollbar-color: transparent transparent; padding-bottom: 6px; border-radius: 8px; ${menuContentAnimations} ` type SelectionContentProps = React.ComponentProps & { selectionContextValue: MenuSelectionContextValue } const SelectionContent = ({ selectionContextValue, children, ...props }: SelectionContentProps) => ( {children} ) const Menu = ({ trigger, items = [], asModal = false, alignment = MenuAlignment.CENTER, side = MenuSide.BOTTOM, sideOffset = 8, alignOffset = 0, collisonBoundaryRef, maxHeight, enableSearch = false, searchPlaceholder = 'Search', searchSortFn = defaultSearchSortFn, onEnter, minWidth, maxWidth, open, onOpenChange, selectionStyle, selectionMode, closeOnSelect = true, enableVirtualScrolling = false, virtualItemHeight = 40, virtualOverscan = 5, virtualScrollThreshold = 20, skeleton = { count: 3, show: false, variant: 'pulse', }, }: MenuProps) => { const [searchText, setSearchText] = useState('') const [isOpen, setIsOpen] = useState(false) const searchInputRef = useRef(null) const justOpenedRef = useRef(false) const timeoutRef = useRef(null) const filteredItems = filterMenuGroups(items, searchText, searchSortFn) const menuTokens = useResponsiveTokens('MENU') const { target: portalContainer } = useShadowRoot() const selectionContextValue = useMemo( () => ({ selectionStyle, selectionMode, closeOnSelect }), [selectionStyle, selectionMode, closeOnSelect] ) const menuIsOpen = open ?? isOpen useDropdownInteractionLock(asModal && menuIsOpen) const handleSearchChange = (e: React.ChangeEvent) => { setSearchText(e.target.value) } const handleOpenChange = (newOpen: boolean) => { setIsOpen(newOpen) if (timeoutRef.current) { clearTimeout(timeoutRef.current) timeoutRef.current = null } if (newOpen) { justOpenedRef.current = true timeoutRef.current = setTimeout(() => { justOpenedRef.current = false timeoutRef.current = null }, 100) } else { justOpenedRef.current = false if (enableSearch) { setSearchText('') } } onOpenChange?.(newOpen) } useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current) } } }, []) const handleOutsideInteraction = useCallback((e: Event) => { if (justOpenedRef.current) { e.preventDefault() return } const target = e.target as HTMLElement if (target?.closest('[data-radix-dropdown-menu-trigger]')) { e.preventDefault() } }, []) const virtualListItems = useMemo(() => { const virtualItems: VirtualListItem[] = [] filteredItems.forEach((group, groupId) => { if (group.label) { virtualItems.push({ id: `label-${groupId}`, data: { type: 'label', label: group.label }, }) } group.items.forEach((item, itemIndex) => { virtualItems.push({ id: `item-${groupId}-${itemIndex}`, data: { type: 'item', originalItem: item, groupId, itemIndex, selectionStyle: group.selectionStyle, selectionMode: group.selectionMode, }, }) }) if (groupId !== filteredItems.length - 1 && group.showSeparator) { virtualItems.push({ id: `separator-${groupId}`, data: { type: 'separator' }, }) } }) return virtualItems }, [filteredItems]) const totalItemCount = useMemo(() => { return filteredItems.reduce( (count, group) => count + group.items.length, 0 ) }, [filteredItems]) const shouldUseVirtualScrolling = enableVirtualScrolling && totalItemCount >= virtualScrollThreshold useScrollLock(asModal && menuIsOpen) const renderVirtualItem = useCallback( ({ item }: { item: VirtualListItem; index: number }) => { const data = item.data || {} const { type, label, originalItem, groupId, itemIndex, selectionStyle, selectionMode, } = data as { type?: string label?: string originalItem?: unknown groupId?: number itemIndex?: number selectionStyle?: MenuSelectionStyle selectionMode?: MenuSelectionMode } if (type === 'label') { return ( {label} ) } if (type === 'separator') { return ( ) } if (type === 'item' && originalItem) { return ( ) } return null }, [menuTokens, maxHeight] ) return ( {trigger} { if (enableSearch && searchInputRef.current) { if ( e.target !== searchInputRef.current && !searchInputRef.current.contains( e.target as Node ) ) { e.preventDefault() searchInputRef.current.focus() } } }} onKeyDown={(e) => { if (enableSearch && searchInputRef.current) { if ( e.target !== searchInputRef.current && !searchInputRef.current.contains( e.target as Node ) && e.key.length === 1 ) { searchInputRef.current.focus() } } }} > {skeleton.show ? ( {Array.from({ length: skeleton.count || 3, }).map((_, index) => ( ))} ) : ( <> {enableSearch && ( )} {shouldUseVirtualScrolling ? ( ) : ( {filteredItems && filteredItems.map((group, groupId) => ( {group.label && ( {group.label} )} {group.items.map( (item, itemIndex) => ( ) )} {groupId !== filteredItems.length - 1 && group.showSeparator && ( )} ))} )} )} ) } Menu.displayName = 'Menu' export default Menu