buster/apps/web/src/components/ui/dropdown/Dropdown.tsx

578 lines
16 KiB
TypeScript

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<T, TRouter, TOptions, TFrom>;
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<T = string> = Omit<DropdownProps<T>, 'align' | 'side'>;
const dropdownItemKey = <T,>(item: IDropdownItems<T>[number], index: number): string => {
if ((item as DropdownDivider).type === 'divider') return `divider-${index}`;
if ((item as IDropdownItem<T>).value) return String((item as IDropdownItem<T>).value);
return `item-${index}`;
};
export const DropdownBase = <T,>({
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<T>) => {
return (
<DropdownMenu
open={open}
defaultOpen={open}
onOpenChange={onOpenChange}
dir={dir}
modal={modal}
>
<DropdownMenuTrigger asChild disabled={disabled}>
<span className="dropdown-trigger">{children}</span>
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn('max-w-62 min-w-62', className)}
align={align}
side={side}
sideOffset={sideOffset}
>
<DropdownContent
items={items}
selectType={selectType}
menuHeader={menuHeader}
onSelect={onSelect}
showIndex={showIndex}
emptyStateText={emptyStateText}
footerContent={footerContent}
className={contentClassName}
footerClassName={footerClassName}
menuHeaderClassName={menuHeaderClassName}
showEmptyState={showEmptyState}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};
DropdownBase.displayName = 'Dropdown';
export const Dropdown = React.memo(DropdownBase) as unknown as typeof DropdownBase;
export const DropdownContent = <T,>({
items,
selectType,
menuHeader,
showIndex = false,
emptyStateText = 'No items found',
footerContent,
className,
menuHeaderClassName,
footerClassName,
showEmptyState,
onSelect,
}: DropdownContentProps<T>) => {
const { filteredItems, searchText, handleSearchChange } = useDebounceSearch({
items,
searchPredicate: (item, searchText) => {
if ((item as IDropdownItem<T>).value) {
const _item = item as IDropdownItem<T>;
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<T>).value)
);
}, [filteredItems]);
const { selectedItems, unselectedItems } = useMemo(() => {
if (selectType === 'multiple') {
const [selectedItems, unselectedItems] = filteredItems.reduce(
(acc, item) => {
if ((item as IDropdownItem<T>).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<T>).value);
const item = correctIndex[index] as IDropdownItem<T>;
if (item) {
const disabled = (item as IDropdownItem<T>).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 && (
<div className="flex flex-col">
<div className={cn('p-1', menuHeaderClassName)}>
<DropdownMenuHeaderSelector
menuHeader={menuHeader}
onChange={handleSearchChange}
text={searchText}
onSelectItem={onSelectItem}
showIndex={showIndex}
/>
</div>
<div className="bg-border h-[0.5px] w-full" />
</div>
)}
<div
className={cn('max-h-[375px] overflow-y-auto', className)}
onWheel={(e) => {
//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<T>).value && !(item as IDropdownItem<T>).items) {
hotkeyIndex++;
}
return (
<DropdownItemSelector
key={dropdownItemKey(item, index)}
item={item}
index={hotkeyIndex}
selectType={(item as IDropdownItem<T>).selectType || selectType}
onSelect={onSelect}
onSelectItem={onSelectItem}
closeOnSelect={(item as IDropdownItem<T>).closeOnSelect !== false}
showIndex={showIndex}
/>
);
})}
{selectedItems.length > 0 && <DropdownMenuSeparator />}
{dropdownItems.map((item, index) => {
// Only increment index for selectable items
if ((item as IDropdownItem<T>).value && !(item as IDropdownItem<T>).items) {
hotkeyIndex++;
}
return (
<DropdownItemSelector
key={dropdownItemKey(item, index)}
item={item}
index={hotkeyIndex}
selectType={(item as IDropdownItem<T>).selectType || selectType}
onSelect={onSelect}
onSelectItem={onSelectItem}
closeOnSelect={(item as IDropdownItem<T>).closeOnSelect !== false}
showIndex={showIndex}
/>
);
})}
</>
) : (
showEmptyState && (
<DropdownMenuItem disabled className="text-gray-light text-center">
{emptyStateText}
</DropdownMenuItem>
)
)}
</div>
{footerContent && (
<div className={cn(hasShownItem && 'border-t', 'p-1', footerClassName)}>
{footerContent}
</div>
)}
</>
);
};
const DropdownItemSelector = <
T,
TRouter extends RegisteredRouter = RegisteredRouter,
TOptions = unknown,
TFrom extends string = string,
>({
item,
index,
onSelect,
onSelectItem,
closeOnSelect,
selectType,
showIndex,
}: {
item: IDropdownItems<T, TRouter, TOptions, TFrom>[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<T>['selectType'];
}) => {
if ((item as DropdownDivider).type === 'divider') {
return <DropdownMenuSeparator />;
}
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 (
<DropdownItem<T, TRouter, TOptions, TFrom>
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<T, TRouter, TOptions, TFrom> & {
onSelect?: (value: T) => void;
onSelectItem: (index: number) => void;
closeOnSelect: boolean;
index: number;
showIndex: boolean;
}) => {
const onClickItem = useMemoizedFn((e: React.MouseEvent<HTMLDivElement> | 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 && <span className="text-icon-color text-lg">{icon}</span>}
<div className={cn('flex flex-col space-y-1', truncate && 'overflow-hidden')}>
<span className={cn(truncate && 'truncate')}>{label}</span>
{secondaryLabel && <span className="text-gray-light text-xs">{secondaryLabel}</span>}
</div>
{loading && <CircleSpinnerLoader size={9} />}
{shortcut && <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut>}
{link && (
<DropdownMenuLink<TRouter, TOptions, TFrom>
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 (
<a href={link} target={isExternal ? '_blank' : '_self'} className={className}>
{content}
</a>
);
}
return (
<Link className={className} {...link}>
{content}
</Link>
);
}
return content;
};
if (isSubItem) {
return (
<DropdownSubMenuWrapper
items={items}
closeOnSelect={closeOnSelect}
onSelect={onSelect}
onSelectItem={onSelectItem}
showIndex={showIndex}
selectType={selectType}
>
{renderContent()}
</DropdownSubMenuWrapper>
);
}
//I do not think this selected check is stable... look into refactoring
if (selectType === 'single' || selectType === 'single-selectable-link') {
return (
<DropdownMenuCheckboxItemSingle
checked={selected}
disabled={disabled}
onClick={onClickItem}
index={showIndex ? index : undefined}
closeOnSelect={closeOnSelect}
>
{renderContent()}
</DropdownMenuCheckboxItemSingle>
);
}
if (selectType === 'multiple') {
return (
<DropdownMenuCheckboxItemMultiple
checked={selected}
disabled={disabled}
onClick={onClickItem}
closeOnSelect={closeOnSelect}
dataTestId={`dropdown-checkbox-${value}`}
>
{renderContent()}
</DropdownMenuCheckboxItemMultiple>
);
}
return (
<DropdownMenuItem
truncate={truncate}
disabled={disabled}
onClick={onClickItem}
closeOnSelect={closeOnSelect}
>
{renderContent()}
</DropdownMenuItem>
);
};
interface DropdownSubMenuWrapperProps<T> {
items: IDropdownItems<T> | undefined;
children: React.ReactNode;
closeOnSelect: boolean;
showIndex: boolean;
onSelect?: (value: T) => void;
onSelectItem: (index: number) => void;
selectType: DropdownProps<T>['selectType'];
}
const DropdownSubMenuWrapper = <T,>({
items,
children,
closeOnSelect,
onSelect,
onSelectItem,
selectType,
showIndex,
}: DropdownSubMenuWrapperProps<T>) => {
const subContentRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
const scrollToSelectedItem = React.useCallback(() => {
if (!subContentRef.current) return;
const selectedIndex = items?.findIndex((item) => (item as IDropdownItem<T>).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 (
<DropdownMenuSub onOpenChange={setIsOpen}>
<DropdownMenuSubTrigger>{children}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
ref={subContentRef}
sideOffset={8}
className="max-h-[375px] overflow-y-auto"
>
{items?.map((item, index) => (
<DropdownItemSelector
key={dropdownItemKey(item, index)}
item={item}
index={index}
onSelect={onSelect}
onSelectItem={onSelectItem}
closeOnSelect={closeOnSelect}
selectType={(item as IDropdownItem<T>).selectType || selectType}
showIndex={showIndex}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
};
const DropdownMenuHeaderSelector = <T,>({
menuHeader,
onChange,
onSelectItem,
text,
showIndex,
}: {
menuHeader: NonNullable<DropdownProps<T>['menuHeader']>;
onSelectItem: (index: number) => void;
onChange: (text: string) => void;
text: string;
showIndex: boolean;
}) => {
if (typeof menuHeader === 'string') {
return (
<DropdownMenuHeaderSearch
showIndex={showIndex}
placeholder={menuHeader}
onChange={onChange}
onSelectItem={onSelectItem}
text={text}
/>
);
}
return menuHeader;
};
DropdownMenuHeaderSelector.displayName = 'DropdownMenuHeaderSelector';