import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, X } from 'lucide-react'; import { cn } from '../../lib/cn'; export interface PickerShellProps { label?: string; required?: boolean; hint?: string; /** Class for the outer label wrapper (width etc.). */ className?: string; /** Text shown in the trigger for the current selection ('' = nothing picked). */ displayLabel: string; placeholder?: string; /** Rows shown in the open panel (already filtered/fetched by the caller). */ items: T[]; renderItem: (item: T) => ReactNode; itemKey: (item: T, i: number) => string | number; onPick: (item: T) => void; /** When provided and something is selected, a clear (✕) button shows. */ onClear?: () => void; /** Show the search box (default true). */ searchable?: boolean; search: string; onSearch: (s: string) => void; loading?: boolean; emptyText?: string; /** Notifies the caller so it can fetch on open / reset on close. */ onOpenChange?: (open: boolean) => void; disabled?: boolean; } /** Shared searchable-picker chrome: trigger pill + portal dropdown with an * optional search box and a scrollable option list. Both Select (static * options) and LookupField (remote rows) render through this so the two are * visually identical — one picker UI everywhere. The panel lives in a portal * so a Card's `overflow-hidden` can't clip it. */ export function PickerShell({ label, required, hint, className, displayLabel, placeholder = 'Select…', items, renderItem, itemKey, onPick, onClear, searchable = true, search, onSearch, loading, emptyText = 'No matches.', onOpenChange, disabled, }: PickerShellProps) { const [open, setOpen] = useState(false); const triggerRef = useRef(null); const panelRef = useRef(null); const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null); const toggle = (next: boolean) => { if (disabled) return; setOpen(next); onOpenChange?.(next); }; // Anchor the portal panel under the trigger; track scroll/resize while open. useLayoutEffect(() => { if (!open) return; const place = () => { const el = triggerRef.current; if (!el) return; const r = el.getBoundingClientRect(); setPos({ top: r.bottom + 4, left: r.left, width: r.width }); }; place(); window.addEventListener('scroll', place, true); window.addEventListener('resize', place); return () => { window.removeEventListener('scroll', place, true); window.removeEventListener('resize', place); }; }, [open]); // Close on outside click — ignore clicks inside the trigger or the portal panel. useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { const t = e.target as Node; if (triggerRef.current?.contains(t) || panelRef.current?.contains(t)) return; toggle(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); return ( ); }