183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
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<T> {
|
|
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<T>({
|
|
label,
|
|
required,
|
|
hint,
|
|
className,
|
|
displayLabel,
|
|
placeholder = 'Select…',
|
|
items,
|
|
renderItem,
|
|
itemKey,
|
|
onPick,
|
|
onClear,
|
|
searchable = true,
|
|
search,
|
|
onSearch,
|
|
loading,
|
|
emptyText = 'No matches.',
|
|
onOpenChange,
|
|
disabled,
|
|
}: PickerShellProps<T>) {
|
|
const [open, setOpen] = useState(false);
|
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
const panelRef = useRef<HTMLDivElement>(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 (
|
|
<label className={cn('flex flex-col gap-1.5 font-sans', className)}>
|
|
{label && (
|
|
<span className="text-sm font-medium text-muted">
|
|
{label}
|
|
{required && <span className="text-ruby-600"> *</span>}
|
|
</span>
|
|
)}
|
|
<div
|
|
ref={triggerRef}
|
|
className={cn(
|
|
'flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border border-border-default focus-ring',
|
|
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
|
)}
|
|
onClick={() => toggle(!open)}
|
|
>
|
|
<span className={cn('flex-1 truncate text-base', displayLabel ? 'text-strong' : 'text-faint')}>
|
|
{displayLabel || placeholder}
|
|
</span>
|
|
{onClear && displayLabel ? (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClear();
|
|
}}
|
|
className="text-faint hover:text-strong"
|
|
>
|
|
<X size={15} />
|
|
</button>
|
|
) : (
|
|
<ChevronDown size={15} className="text-faint" />
|
|
)}
|
|
</div>
|
|
|
|
{hint && <span className="text-xs text-faint">{hint}</span>}
|
|
|
|
{open &&
|
|
pos &&
|
|
createPortal(
|
|
<div
|
|
ref={panelRef}
|
|
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 10050 }}
|
|
className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
|
|
>
|
|
{searchable && (
|
|
<div className="p-2 border-b border-border-subtle">
|
|
<input
|
|
autoFocus
|
|
value={search}
|
|
onChange={(e) => onSearch(e.target.value)}
|
|
placeholder="Search…"
|
|
className="w-full h-9 px-2.5 border border-border-default rounded-md outline-none text-sm focus:border-sunrise-500"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="max-h-56 overflow-auto">
|
|
{loading ? (
|
|
<div className="px-3 py-3 text-sm text-faint">Searching…</div>
|
|
) : items.length ? (
|
|
items.map((item, i) => (
|
|
<button
|
|
type="button"
|
|
key={itemKey(item, i)}
|
|
onClick={() => {
|
|
onPick(item);
|
|
toggle(false);
|
|
}}
|
|
className="w-full text-left px-3 py-2.5 text-sm text-body hover:bg-slate-50 border-none bg-transparent cursor-pointer"
|
|
>
|
|
{renderItem(item)}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="px-3 py-3 text-sm text-faint">{emptyText}</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</label>
|
|
);
|
|
}
|