'use client'; import { DropdownMenuProps } from '@radix-ui/react-dropdown-menu'; import React, { useEffect, useMemo } from 'react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, //Do I need this? DropdownMenuLabel, //Do I need this? DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, DropdownMenuCheckboxItemSingle, DropdownMenuCheckboxItemMultiple, DropdownMenuLink } from './DropdownBase'; import { CircleSpinnerLoader } from '../loaders/CircleSpinnerLoader'; import { useMemoizedFn } from '@/hooks'; import { cn } from '@/lib/classMerge'; import { Input } from '../inputs/Input'; import { useDebounceSearch } from '@/hooks'; import Link from 'next/link'; import { useHotkeys } from 'react-hotkeys-hook'; export interface DropdownItem { label: React.ReactNode | string; truncate?: boolean; searchLabel?: string; // Used for filtering secondaryLabel?: string; value: T; shortcut?: string; onClick?: () => void; icon?: React.ReactNode; disabled?: boolean; loading?: boolean; selected?: boolean; items?: DropdownItems; link?: string; linkIcon?: 'arrow-right' | 'arrow-external' | 'caret-right'; } export interface DropdownDivider { type: 'divider'; } export type DropdownItems = (DropdownItem | DropdownDivider | React.ReactNode)[]; export interface DropdownProps extends DropdownMenuProps { items: DropdownItems; selectType?: 'single' | 'multiple' | 'none'; menuHeader?: string | React.ReactNode; //if string it will render a search box closeOnSelect?: boolean; onSelect?: (value: T) => void; align?: 'start' | 'center' | 'end'; side?: 'top' | 'right' | 'bottom' | 'left'; emptyStateText?: string; className?: string; footerContent?: React.ReactNode; showIndex?: boolean; contentClassName?: string; footerClassName?: string; sideOffset?: number; disabled?: boolean; menuHeaderClassName?: string; } export interface DropdownContentProps extends Omit, 'align' | 'side'> {} const dropdownItemKey = (item: DropdownItems[number], index: number): string => { if ((item as DropdownDivider).type === 'divider') return `divider-${index}`; if ((item as DropdownItem).value) return String((item as DropdownItem).value); return `item-${index}`; }; export const DropdownBase = ({ items, selectType = 'none', menuHeader, contentClassName = '', closeOnSelect = true, onSelect, children, align = 'start', side = 'bottom', open, onOpenChange, emptyStateText, className, footerContent, dir, modal, menuHeaderClassName, sideOffset, footerClassName = '', showIndex = false, disabled = false }: DropdownProps) => { return ( {children} ); }; DropdownBase.displayName = 'Dropdown'; export const Dropdown = React.memo(DropdownBase) as unknown as typeof DropdownBase; export const DropdownContent = ({ items, selectType, menuHeader, closeOnSelect = true, showIndex = false, emptyStateText = 'No items found', footerContent, className, menuHeaderClassName, footerClassName, onSelect }: DropdownContentProps) => { const { filteredItems, searchText, handleSearchChange } = useDebounceSearch({ items, searchPredicate: (item, searchText) => { if ((item as DropdownItem).value) { const _item = item as DropdownItem; 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 DropdownItem).value); }, [filteredItems]); const { selectedItems, unselectedItems } = useMemo(() => { if (selectType === 'multiple') { const [selectedItems, unselectedItems] = filteredItems.reduce( (acc, item) => { if ((item as DropdownItem).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 DropdownItem).value); const item = correctIndex[index] as DropdownItem; if (item) { const disabled = (item as DropdownItem).disabled; if (!disabled && onSelect) { onSelect(item.value); // Close the dropdown if closeOnSelect is true if (closeOnSelect) { 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) => { // Only increment index for selectable items if ((item as DropdownItem).value && !(item as DropdownItem).items) { hotkeyIndex++; } return ( ); })} {selectedItems.length > 0 && } {dropdownItems.map((item) => { // Only increment index for selectable items if ((item as DropdownItem).value && !(item as DropdownItem).items) { hotkeyIndex++; } return ( ); })} ) : ( {emptyStateText} )}
{footerContent &&
{footerContent}
} ); }; const DropdownItemSelector = React.memo( ({ item, index, onSelect, onSelectItem, closeOnSelect, selectType, showIndex }: { item: DropdownItems[number]; index: number; 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; } return ( )} closeOnSelect={closeOnSelect} onSelect={onSelect} onSelectItem={onSelectItem} selectType={selectType} index={index} showIndex={showIndex} /> ); } ); DropdownItemSelector.displayName = 'DropdownItemSelector'; const DropdownItem = ({ label, value, showIndex, shortcut, onClick, icon, disabled = false, loading, selected, index, items, closeOnSelect, onSelect, onSelectItem, selectType, secondaryLabel, truncate, link, linkIcon }: DropdownItem & { onSelect?: (value: T) => void; onSelectItem: (index: number) => void; closeOnSelect: boolean; index: number; showIndex: boolean; selectType: DropdownProps['selectType']; }) => { const onClickItem = useMemoizedFn((e: React.MouseEvent) => { if (onClick) onClick(); if (onSelect) onSelect(value as T); }); const enabledHotKeys = showIndex && !disabled && !!onSelectItem; // Add hotkey support when showIndex is true useHotkeys(showIndex ? `${index}` : '', (e) => onSelectItem(index), { enabled: enabledHotKeys }); const isSubItem = items && items.length > 0; const isSelectable = !!selectType && selectType !== 'none'; // Helper function to render the content consistently with proper type safety const renderContent = () => { const content = ( <> {icon && !loading && {icon}}
{label} {secondaryLabel && {secondaryLabel}}
{loading && } {shortcut && {shortcut}} {link && ( )} ); // Wrap with Link if needed if (!isSelectable && link) { return ( {content} ); } return content; }; if (isSubItem) { return ( {renderContent()} ); } //I do not think this selected check is stable... look into refactoring if (selectType === 'single') { return ( {renderContent()} ); } if (selectType === 'multiple') { return ( {renderContent()} ); } return ( {renderContent()} ); }; interface DropdownSubMenuWrapperProps { items: DropdownItems | 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 DropdownItem).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) => ( ))} ); }; 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'; interface DropdownMenuHeaderSearchProps { text: string; onChange: (text: string) => void; onSelectItem: (index: number) => void; placeholder?: string; showIndex: boolean; } const DropdownMenuHeaderSearch = ({ text, onChange, onSelectItem, showIndex, placeholder }: DropdownMenuHeaderSearchProps) => { const onChangePreflight = useMemoizedFn((e: React.ChangeEvent) => { e.stopPropagation(); e.preventDefault(); onChange(e.target.value); }); const onKeyDownPreflight = useMemoizedFn((e: React.KeyboardEvent) => { const isFirstCharacter = (e.target as HTMLInputElement).value.length === 0; // Only prevent default for digit shortcuts when showIndex is true if (showIndex && isFirstCharacter && /^Digit[0-9]$/.test(e.code)) { e.preventDefault(); const index = parseInt(e.key); onSelectItem?.(index); } else { e.stopPropagation(); } }); return (
); }; DropdownMenuHeaderSearch.displayName = 'DropdownMenuHeaderSearch';