import type { FocusEvent, KeyboardEvent } from 'react' import * as RadixMenu from '@radix-ui/react-dropdown-menu' import styled, { type CSSObject } from 'styled-components' import React, { useRef, useMemo } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { Search } from 'lucide-react' import Block from '../Primitives/Block/Block' import PrimitiveText from '../Primitives/PrimitiveText/PrimitiveText' import SearchInput from '../Inputs/SearchInput/SearchInput' import MenuV2Item from './MenuV2Item' import MenuV2SubMenu from './MenuV2SubMenu' import type { MenuV2GroupType, MenuV2ItemType } from './menuV2.types' import type { MenuV2VirtualScrollingConfig } from './menuV2.types' import type { MenuV2TokensType } from './menuV2.tokens' import { menuV2ContentAnimations } from './menuV2.animations' import { flattenMenuV2Groups, type MenuV2FlatRow } from './menuV2.utils' import { getBaseVirtualViewportHeight } from '../common/virtualViewport' import { addPxToValue } from '../../global-utils/GlobalUtils' type ContentStyledProps = { $zIndex?: number | string } const Content = styled(RadixMenu.Content)` position: relative; z-index: ${(p) => p.$zIndex ?? 101}; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; scrollbar-color: transparent transparent; &[data-state='closed'] { pointer-events: none; } ${menuV2ContentAnimations} ` const DEFAULT_VIRTUAL_ITEM_HEIGHT = 40 const DEFAULT_VIRTUAL_OVERSCAN = 2 const DEFAULT_VIRTUAL_THRESHOLD = 30 function renderFlatRow( row: MenuV2FlatRow, tokens: MenuV2TokensType, maxHeight?: CSSObject['maxHeight'] ) { if (row.type === 'label') { return ( {row.label} ) } if (row.type === 'separator') { return ( ) } const { item, itemIndex, selectionStyle, selectionMode } = row return item.subMenu && item.subMenu.length > 0 ? ( ) : ( ) } export type MenuV2ContentProps = { filteredItems: MenuV2GroupType[] menuTokens: MenuV2TokensType enableSearch: boolean searchPlaceholder: string searchText: string onSearchTextChange: (value: string) => void onEnter?: () => void maxHeight?: CSSObject['maxHeight'] minHeight?: CSSObject['minHeight'] minWidth?: CSSObject['minWidth'] maxWidth?: CSSObject['maxWidth'] enableVirtualScrolling?: boolean virtualScrolling?: MenuV2VirtualScrollingConfig alignment?: 'start' | 'center' | 'end' side?: 'top' | 'right' | 'bottom' | 'left' sideOffset?: number alignOffset?: number collisionBoundaryRef?: HTMLElement | null | (HTMLElement | null)[] onInteractOutside?: (e: unknown) => void onPointerDownOutside?: (e: unknown) => void onFocusCapture?: (e: FocusEvent) => void onKeyDown?: (e: KeyboardEvent) => void } const MenuV2Content = React.forwardRef( ( { filteredItems, menuTokens, enableSearch, searchPlaceholder, searchText, onSearchTextChange, onEnter, maxHeight, minHeight, minWidth: minWidthProp, maxWidth: maxWidthProp, enableVirtualScrolling = false, virtualScrolling, alignment = 'center', side = 'bottom', sideOffset = 8, alignOffset = 0, collisionBoundaryRef, onInteractOutside, onPointerDownOutside, onKeyDown, }, ref ) => { const searchInputRef = useRef(null) const virtualScrollRef = useRef(null) const content = menuTokens const minWidth = minWidthProp ?? (typeof content.minWidth === 'number' ? content.minWidth : 200) const maxWidth = maxWidthProp ?? (typeof content.maxWidth === 'number' ? content.maxWidth : 280) const flatRows = useMemo( () => flattenMenuV2Groups(filteredItems), [filteredItems] ) const itemHeight = virtualScrolling?.itemHeight ?? DEFAULT_VIRTUAL_ITEM_HEIGHT const overscan = virtualScrolling?.overscan ?? DEFAULT_VIRTUAL_OVERSCAN const threshold = virtualScrolling?.threshold ?? DEFAULT_VIRTUAL_THRESHOLD const useVirtual = enableVirtualScrolling && flatRows.length >= threshold const viewportHeight = getBaseVirtualViewportHeight(maxHeight as number) const virtualizer = useVirtualizer({ count: flatRows.length, getScrollElement: () => virtualScrollRef.current, getItemKey: (index) => flatRows[index]?.id ?? index, estimateSize: (index) => { const row = flatRows[index] if (row?.type === 'label') return 28 if (row?.type === 'separator') return 8 return itemHeight }, overscan, enabled: useVirtual, initialRect: { width: 0, height: viewportHeight }, }) const virtualItems = virtualizer.getVirtualItems() const virtualGroups = new Map< number, Array<{ virtualRow: (typeof virtualItems)[number] row: MenuV2FlatRow }> >() virtualItems.forEach((virtualRow) => { const row = flatRows[virtualRow.index] if (!row) return const groupId = row.groupId ?? virtualRow.index const groupRows = virtualGroups.get(groupId) ?? [] groupRows.push({ virtualRow, row }) virtualGroups.set(groupId, groupRows) }) return ( ) => { if (enableSearch && searchInputRef.current) { if (e.target === e.currentTarget) { searchInputRef.current.focus() } } }} onCloseAutoFocus={(event: Event) => event.preventDefault()} $zIndex={content.zIndex} style={{ maxHeight: maxHeight ? `${maxHeight}px` : 'var(--radix-popper-available-height)', minHeight: minHeight ? `${minHeight}px` : undefined, minWidth: `${minWidth}px`, maxWidth: `${maxWidth}px`, paddingTop: enableSearch ? 0 : content.paddingTop, paddingRight: content.paddingRight, paddingBottom: content.paddingBottom, paddingLeft: content.paddingLeft, backgroundColor: content.backgroundColor, border: content.border, borderRadius: content.borderRadius, boxShadow: content.boxShadow, }} > {enableSearch && ( } placeholder={searchPlaceholder} value={searchText} onChange={(e) => onSearchTextChange(e.target.value)} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter') { e.preventDefault() onEnter?.() } }} aria-label={ searchPlaceholder ? `Search menu items: ${searchPlaceholder}` : 'Search menu items' } /> )} {useVirtual ? ( {Array.from(virtualGroups).map( ([groupId, rows]) => ( {rows.map(({ virtualRow, row }) => ( {renderFlatRow( row, content, maxHeight )} ))} ) )} ) : ( {filteredItems.map((group, groupId) => ( {group.label && ( {group.label} )} {group.items.map( ( item: MenuV2ItemType, itemIndex: number ) => item.subMenu && item.subMenu.length > 0 ? ( ) : ( ) )} {groupId !== filteredItems.length - 1 && group.showSeparator && ( )} ))} )} ) } ) MenuV2Content.displayName = 'MenuV2Content' export default MenuV2Content