'use client'; import { cn } from '@/lib/utils'; import { ChevronsDown, Loader2, Search, X } from 'lucide-react'; import * as React from 'react'; export interface ToggleListOption { /** Arbitrary value, including objects. Used as the React key when primitive. */ value: V; /** Display content. Strings participate in local search filtering. */ label: React.ReactNode; /** Per-item click handler. Whether the list closes afterwards is up to the caller. */ onClick?: () => void; } export interface ToggleListLoadMore { /** Whether more items are available to load. When false, the button is hidden. */ hasMore: boolean; /** Triggered when the user clicks the "Load more" button at the bottom of the list. */ onLoadMore: () => void; /** Show a loading spinner inside the button and disable interaction. */ loading?: boolean; /** Custom label for the button. Defaults to "Load more". */ text?: React.ReactNode; } export interface ToggleListProps { /** Class applied to the outer container that wraps the button and the list. */ className?: string; /** Text (or any node) shown inside the trigger button. */ btnText?: React.ReactNode; /** List items rendered inside the scrollable area. */ options: ToggleListOption[]; /** * When true (default), clicking anywhere outside the component will close the list. * Set to false if the list should only be toggled by the button itself. */ closeOnOutsideClick?: boolean; /** Max height (in px) of the scrollable list area. Defaults to 500. */ maxHeight?: number; /** Class applied to the list container (the box around the search input + items). */ listClassName?: string; /** Class applied to each list item. */ itemClassName?: string; /** Class applied to the trigger button (merged with the default styles). */ buttonClassName?: string; /** Placeholder rendered when `options` is empty (and no query is active). */ emptyText?: React.ReactNode; /** Placeholder rendered when a search query yields no matches. */ noResultsText?: React.ReactNode; /** Show a search input above the list. */ searchable?: boolean; /** Placeholder for the search input. */ searchPlaceholder?: string; /** * If provided, switches to API search mode: every query change calls this * callback. The component does NOT filter `options` locally — the caller is * expected to update `options` (typically after a debounce + fetch). * If omitted, the component performs a case-insensitive substring filter * locally against the stringified label. */ onSearch?: (query: string) => void; /** Show a spinner inside the search input. Useful for API search loading state. */ searchLoading?: boolean; /** Class applied to the search input wrapper. */ searchClassName?: string; /** * Load-more pagination config. The caller owns the data; the component just * renders a button at the bottom of the list when `hasMore` is true or a * load is in flight. */ loadMore?: ToggleListLoadMore; /** * Optional callback fired whenever the list is opened (`true`) or * closed (`false`). The component still owns the open/close state; this * is just a notification hook so callers can trigger side effects * (e.g. lazy-fetching list data) on first open. */ onOpenChange?: (open: boolean) => void; } /** * ToggleList — a button that toggles a vertically stacked, scrollable list area * rendered directly below it (no portal/overlay). Click the button to expand, * click again to collapse. * * Features: * - The list occupies real DOM space and stretches to the parent's width * (the trigger button keeps its natural width). * - Optional search input. Local mode (no `onSearch`) filters options client-side; * API mode (with `onSearch`) only emits the query and lets the caller refetch. * - Optional load-more pagination. The caller owns the data; the component * renders the trigger button. * * Behavior notes: * - Clicking a list item calls that item's `onClick`. The component does not * auto-close after a click — let the caller's onClick decide via controlled * state if needed. * - External-click closing is opt-out via `closeOnOutsideClick={false}`. */ export function ToggleList({ className, btnText, options, closeOnOutsideClick = false, maxHeight = 500, listClassName, itemClassName, buttonClassName, emptyText = 'No options', noResultsText = 'No matching results', searchable = false, searchPlaceholder = 'Search…', onSearch, searchLoading = false, searchClassName, loadMore, onOpenChange, }: ToggleListProps) { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); const containerRef = React.useRef(null); const listId = React.useId(); // Close on outside click React.useEffect(() => { if (!open || !closeOnOutsideClick) return; const handlePointerDown = (event: MouseEvent) => { const node = containerRef.current; if (node && !node.contains(event.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', handlePointerDown); return () => { document.removeEventListener('mousedown', handlePointerDown); }; }, [open, closeOnOutsideClick]); const isApiSearch = Boolean(onSearch); // Local search: case-insensitive substring filter on the stringified label. // In API search mode the caller is responsible for filtering, so we pass through. const filteredOptions = React.useMemo(() => { if (isApiSearch || !query.trim()) return options; const q = query.trim().toLowerCase(); return options.filter((opt) => String(opt.label ?? '') .toLowerCase() .includes(q), ); }, [options, query, isApiSearch]); const handleQueryChange = React.useCallback( (next: string) => { setQuery(next); if (onSearch) onSearch(next); }, [onSearch], ); const showNoResults = searchable && query.trim().length > 0 && filteredOptions.length === 0; const showEmpty = !showNoResults && options.length === 0; return (
{open && (
{searchable && (
handleQueryChange(e.target.value)} placeholder={searchPlaceholder} className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-text-secondary/60" /> {searchLoading && ( )} {query && !searchLoading && ( )}
)}
{showEmpty && (
{emptyText}
)} {showNoResults && (
{noResultsText}
)} {!showEmpty && !showNoResults && filteredOptions.map((option, index) => { const raw = option.value; // Use index as the key for objects (no stable identity) and // for nullish values; stringify primitives for a stable key. const key = raw === null || raw === undefined || (typeof raw === 'object' && raw !== null) ? index : String(raw); return (
{ option.onClick?.(); }} className={cn( 'cursor-pointer px-3 py-2 text-sm hover:bg-border-button ', itemClassName, )} > {option.label}
); })} {loadMore && (loadMore.hasMore || loadMore.loading) && filteredOptions.length > 0 && ( )}
)}
); }