import type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu'; import { Link, type RegisteredRouter } from '@tanstack/react-router'; import React, { useEffect, useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useDebounceSearch } from '@/hooks/useDebounceSearch'; import { useMemoizedFn } from '@/hooks/useMemoizedFn'; import { cn } from '@/lib/classMerge'; import { CircleSpinnerLoader } from '../loaders/CircleSpinnerLoader'; import { DropdownMenu, DropdownMenuCheckboxItemMultiple, DropdownMenuCheckboxItemSingle, DropdownMenuContent, DropdownMenuItem, DropdownMenuLink, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from './DropdownBase'; import { DropdownMenuHeaderSearch } from './DropdownMenuHeaderSearch'; import type { DropdownDivider, IDropdownItem, IDropdownItems } from './dropdown-items.types'; export interface DropdownProps< T = string, TRouter extends RegisteredRouter = RegisteredRouter, TOptions = unknown, TFrom extends string = string, > extends DropdownMenuProps { items: IDropdownItems; selectType?: 'single' | 'multiple' | 'none' | 'single-selectable-link'; menuHeader?: string | React.ReactNode; //if string it will render a search box onSelect?: (value: T) => void; align?: 'start' | 'center' | 'end'; side?: 'top' | 'right' | 'bottom' | 'left'; emptyStateText?: string | React.ReactNode; className?: string; footerContent?: React.ReactNode; showIndex?: boolean; contentClassName?: string; footerClassName?: string; sideOffset?: number; disabled?: boolean; menuHeaderClassName?: string; showEmptyState?: boolean; } export type DropdownContentProps = Omit, 'align' | 'side'>; const dropdownItemKey = (item: IDropdownItems[number], index: number): string => { if ((item as DropdownDivider).type === 'divider') return `divider-${index}`; if ((item as IDropdownItem).value) return String((item as IDropdownItem).value); return `item-${index}`; }; export const DropdownBase = ({ items, selectType = 'none', menuHeader, contentClassName = '', onSelect, children, align = 'start', side = 'bottom', open, onOpenChange, emptyStateText, className, footerContent, dir, modal, menuHeaderClassName, sideOffset, footerClassName = '', showIndex = false, disabled = false, showEmptyState = true, }: DropdownProps) => { return ( {children} ); }; DropdownBase.displayName = 'Dropdown'; export const Dropdown = React.memo(DropdownBase) as unknown as typeof DropdownBase; export const DropdownContent = ({ items, selectType, menuHeader, showIndex = false, emptyStateText = 'No items found', footerContent, className, menuHeaderClassName, footerClassName, showEmptyState, onSelect, }: DropdownContentProps) => { const { filteredItems, searchText, handleSearchChange } = useDebounceSearch({ items, searchPredicate: (item, searchText) => { if ((item as IDropdownItem).value) { const _item = item as IDropdownItem; const searchContent = _item.searchLabel || (typeof _item.label === 'string' ? _item.label : ''); return searchContent?.toLowerCase().includes(searchText.toLowerCase()); } return true; }, debounceTime: 50, }); const hasShownItem = useMemo(() => { return ( filteredItems.length > 0 && filteredItems.some((item) => (item as IDropdownItem).value) ); }, [filteredItems]); const { selectedItems, unselectedItems } = useMemo(() => { if (selectType === 'multiple') { const [selectedItems, unselectedItems] = filteredItems.reduce( (acc, item) => { if ((item as IDropdownItem).selected) { acc[0].push(item); } else { acc[1].push(item); } return acc; }, [[], []] as [typeof filteredItems, typeof filteredItems] ); return { selectedItems, unselectedItems }; } return { selectedItems: [], unselectedItems: [], }; }, [selectType, filteredItems]); // Keep track of selectable item index let hotkeyIndex = -1; const dropdownItems = selectType === 'multiple' ? unselectedItems : filteredItems; const onSelectItem = useMemoizedFn((index: number) => { const correctIndex = dropdownItems.filter((item) => (item as IDropdownItem).value); const item = correctIndex[index] as IDropdownItem; if (item) { const disabled = (item as IDropdownItem).disabled; if (!disabled && onSelect) { onSelect(item.value); // Close the dropdown if closeOnSelect is true if (item.closeOnSelect !== false) { const dropdownTrigger = document.querySelector('[data-state="open"][role="menu"]'); if (dropdownTrigger) { const closeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); dropdownTrigger.dispatchEvent(closeEvent); } } } } }); return ( <> {menuHeader && (
)}
{ //this is need to prevent bug when it is inside a dialog or modal e.stopPropagation(); }} > {hasShownItem ? ( <> {selectedItems.map((item, index) => { // Only increment index for selectable items if ((item as IDropdownItem).value && !(item as IDropdownItem).items) { hotkeyIndex++; } return ( ).selectType || selectType} onSelect={onSelect} onSelectItem={onSelectItem} closeOnSelect={(item as IDropdownItem).closeOnSelect !== false} showIndex={showIndex} /> ); })} {selectedItems.length > 0 && } {dropdownItems.map((item, index) => { // Only increment index for selectable items if ((item as IDropdownItem).value && !(item as IDropdownItem).items) { hotkeyIndex++; } return ( ).selectType || selectType} onSelect={onSelect} onSelectItem={onSelectItem} closeOnSelect={(item as IDropdownItem).closeOnSelect !== false} showIndex={showIndex} /> ); })} ) : ( showEmptyState && ( {emptyStateText} ) )}
{footerContent && (
{footerContent}
)} ); }; const DropdownItemSelector = < T, TRouter extends RegisteredRouter = RegisteredRouter, TOptions = unknown, TFrom extends string = string, >({ item, index, onSelect, onSelectItem, closeOnSelect, selectType, showIndex, }: { item: IDropdownItems[number]; index: number; // biome-ignore lint/suspicious/noExplicitAny: I had a devil of a time trying to type this... This is a hack to get the type to work onSelect?: (value: any) => void; // Using any here to resolve the type mismatch onSelectItem: (index: number) => void; closeOnSelect: boolean; showIndex: boolean; selectType: DropdownProps['selectType']; }) => { if ((item as DropdownDivider).type === 'divider') { return ; } if (typeof item === 'object' && React.isValidElement(item)) { return item; } // Type guard to ensure item is a DropdownItem if (!item || typeof item !== 'object' || !('value' in item)) { return null; } return ( closeOnSelect={closeOnSelect} onSelect={onSelect} onSelectItem={onSelectItem} selectType={selectType} index={index} showIndex={showIndex} {...item} /> ); }; DropdownItemSelector.displayName = 'DropdownItemSelector'; const DropdownItem = < T, TRouter extends RegisteredRouter = RegisteredRouter, TOptions = unknown, TFrom extends string = string, >({ label, value, showIndex, shortcut, onClick, icon, disabled = false, loading, selected, index, items, closeOnSelect, onSelect, onSelectItem, selectType, secondaryLabel, truncate, link, linkIcon, }: IDropdownItem & { onSelect?: (value: T) => void; onSelectItem: (index: number) => void; closeOnSelect: boolean; index: number; showIndex: boolean; }) => { const onClickItem = useMemoizedFn((e: React.MouseEvent | KeyboardEvent) => { if (disabled) return; if (onClick) onClick(e); if (onSelect) onSelect(value as T); }); const enabledHotKeys = showIndex && !disabled && !!onSelectItem; // Add hotkey support when showIndex is true useHotkeys(`${index}`, onClickItem, { enabled: enabledHotKeys, }); const isSubItem = items && items.length > 0; const isSelectable = !!selectType && selectType !== 'none' && selectType !== 'single-selectable-link'; // Helper function to render the content consistently with proper type safety const renderContent = () => { const content = ( <> {icon && {icon}}
{label} {secondaryLabel && {secondaryLabel}}
{loading && } {shortcut && {shortcut}} {link && ( className="ml-auto opacity-0 group-hover:opacity-50 hover:opacity-100" link={isSelectable ? link : null} linkIcon={linkIcon} /> )} ); // Wrap with Link if needed if (!isSelectable && link) { const className = 'flex w-full items-center gap-x-2'; if (typeof link === 'string') { const isExternal = link.startsWith('http'); return ( {content} ); } return ( {content} ); } return content; }; if (isSubItem) { return ( {renderContent()} ); } //I do not think this selected check is stable... look into refactoring if (selectType === 'single' || selectType === 'single-selectable-link') { return ( {renderContent()} ); } if (selectType === 'multiple') { return ( {renderContent()} ); } return ( {renderContent()} ); }; interface DropdownSubMenuWrapperProps { items: IDropdownItems | undefined; children: React.ReactNode; closeOnSelect: boolean; showIndex: boolean; onSelect?: (value: T) => void; onSelectItem: (index: number) => void; selectType: DropdownProps['selectType']; } const DropdownSubMenuWrapper = ({ items, children, closeOnSelect, onSelect, onSelectItem, selectType, showIndex, }: DropdownSubMenuWrapperProps) => { const subContentRef = React.useRef(null); const [isOpen, setIsOpen] = React.useState(false); const scrollToSelectedItem = React.useCallback(() => { if (!subContentRef.current) return; const selectedIndex = items?.findIndex((item) => (item as IDropdownItem).selected); if (selectedIndex === undefined || selectedIndex === -1) return; const menuItems = subContentRef.current.querySelectorAll('[role="menuitem"]'); const selectedElement = menuItems[selectedIndex - 1]; if (selectedElement) { selectedElement.scrollIntoView({ block: 'start', behavior: 'instant', inline: 'start' }); } }, [items]); useEffect(() => { if (isOpen) { const timeoutId = setTimeout(scrollToSelectedItem, 70); return () => clearTimeout(timeoutId); } }, [isOpen, scrollToSelectedItem]); return ( {children} {items?.map((item, index) => ( ).selectType || selectType} showIndex={showIndex} /> ))} ); }; const DropdownMenuHeaderSelector = ({ menuHeader, onChange, onSelectItem, text, showIndex, }: { menuHeader: NonNullable['menuHeader']>; onSelectItem: (index: number) => void; onChange: (text: string) => void; text: string; showIndex: boolean; }) => { if (typeof menuHeader === 'string') { return ( ); } return menuHeader; }; DropdownMenuHeaderSelector.displayName = 'DropdownMenuHeaderSelector';