// Picker — a searchable, windowed combobox for choosing from large lists // (group members can be thousands). Apple-HIG flavour built on the same idiom // as DropdownSelect: calm trigger matching Input height, a soft floating panel // with a search field, virtualised rows (avatar + name + sub), keyboard nav, // and an always-present escape hatch — if what you typed isn't in the list you // can still commit it as a raw value. Loading / error / empty states degrade to // the raw-value input so the picker never traps you. import { useEffect, useMemo, useRef, useState } from 'react'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { RefreshCw, Search } from 'lucide-react'; import { SkeletonSwap } from '@/components/interior/skeleton-swap'; import { cn } from '@/lib/utils'; export interface PickerOption { value: string; label: string; /** Secondary line (e.g. the uin under a nickname). */ sub?: string; /** Avatar URL. */ avatar?: string; } interface PickerProps { value: string; onChange: (v: string) => void; options: ReadonlyArray; loading?: boolean; error?: string | null; onRefresh?: () => void; placeholder?: string; /** Validates a typed raw value before it can be committed via the escape * hatch. Defaults to "non-empty". */ validateRaw?: (raw: string) => boolean; ariaLabel: string; className?: string; disabled?: boolean; } const ROW_H = 44; // px — fixed row height drives the windowing math const OVERSCAN = 6; const PANEL_MAX = 264; // px of scroll viewport const EASE = [0.23, 1, 0.32, 1] as const; const EXIT = [0.4, 0, 1, 1] as const; const NUDGE = { type: 'spring', stiffness: 700, damping: 46, mass: 0.5 } as const; const SLIDE = { type: 'spring', stiffness: 700, damping: 46, mass: 0.5 } as const; const OPEN = { type: 'spring', stiffness: 620, damping: 38, mass: 0.6 } as const; const NONE = { duration: 0 } as const; export function Picker({ value, onChange, options, loading, error, onRefresh, placeholder = '选择…', validateRaw, ariaLabel, className, disabled, }: PickerProps) { const reduced = useReducedMotion(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [scrollTop, setScrollTop] = useState(0); const [active, setActive] = useState(0); const rootRef = useRef(null); const listRef = useRef(null); const searchRef = useRef(null); const suppressTriggerClick = useRef(false); const dragRef = useRef<{ id: number; y: number; dragged: boolean } | null>(null); const waitingForOptions = Boolean(loading && options.length === 0); const selected = options.find((o) => o.value === value); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) { return options; } return options.filter( (o) => o.label.toLowerCase().includes(q) || o.value.includes(q) || (o.sub?.toLowerCase().includes(q) ?? false), ); }, [options, query]); // The escape-hatch row: show when the query is a committable raw value that // isn't already an exact option. const canRaw = query.trim() !== '' && (validateRaw ? validateRaw(query.trim()) : true) && !options.some((o) => o.value === query.trim()); const rawRow = canRaw ? query.trim() : null; useEffect(() => { if (!open) { return; } setScrollTop(0); setActive(0); const coarse = window.matchMedia('(pointer: coarse)').matches; const t = coarse ? undefined : setTimeout(() => searchRef.current?.focus(), 10); const onPointer = (e: PointerEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('pointerdown', onPointer); return () => { if (t !== undefined) { clearTimeout(t); } document.removeEventListener('pointerdown', onPointer); }; }, [open]); const totalRows = filtered.length + (rawRow ? 1 : 0); // Keep the keyboard cursor in range when the list shrinks (refresh / typing) // without a keystroke that would have reset it. useEffect(() => { setActive((a) => Math.min(a, Math.max(0, totalRows - 1))); }, [totalRows]); const viewH = Math.min(PANEL_MAX, totalRows * ROW_H); const start = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN); const end = Math.min(totalRows, Math.ceil((scrollTop + viewH) / ROW_H) + OVERSCAN); const commit = (v: string) => { onChange(v); setOpen(false); setQuery(''); }; const choose = (idx: number) => { if (rawRow && idx === filtered.length) { commit(rawRow); } else { const o = filtered[idx]; if (o) { commit(o.value); } } }; const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setOpen(false); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(totalRows - 1, a + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); } else if (e.key === 'Enter') { e.preventDefault(); choose(active); } }; // Keep the active row scrolled into view. useEffect(() => { if (!open || !listRef.current) { return; } const top = active * ROW_H; const el = listRef.current; if (top < el.scrollTop) { el.scrollTop = top; } else if (top + ROW_H > el.scrollTop + el.clientHeight) { el.scrollTop = top + ROW_H - el.clientHeight; } }, [active, open]); const rows: { idx: number; opt: PickerOption | null }[] = []; for (let i = start; i < end; i++) { if (rawRow && i === filtered.length) { rows.push({ idx: i, opt: null }); } else { rows.push({ idx: i, opt: filtered[i] ?? null }); } } return (
{open && (
{ setQuery(e.target.value); setActive(0); setScrollTop(0); if (listRef.current) { listRef.current.scrollTop = 0; } }} placeholder="搜索名称 / 号码…" className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground md:text-sm" /> {onRefresh && ( )}
{!waitingForOptions ? ( error ? (

{error}

可直接在上方输入号码后回车使用

) : totalRows === 0 ? (
无匹配项{query && ';输入完整号码可直接使用'}
) : (
setScrollTop((e.target as HTMLDivElement).scrollTop) } className="overflow-auto overscroll-contain" style={{ height: viewH, touchAction: 'pan-y' }} >
0 ? 1 : 0, }} transition={ reduced ? NONE : { ...SLIDE, opacity: { duration: 0.1, ease: EASE, }, } } /> {rows.map(({ idx, opt }) => (
setActive(idx)} onPointerDown={(e) => { if (e.pointerType === 'mouse') { suppressTriggerClick.current = true; choose(idx); return; } dragRef.current = { id: e.pointerId, y: e.clientY, dragged: false, }; }} onPointerMove={(e) => { const drag = dragRef.current; if (!drag || drag.id !== e.pointerId) { return; } if (Math.abs(e.clientY - drag.y) > 8) { drag.dragged = true; } }} onPointerUp={(e) => { const drag = dragRef.current; dragRef.current = null; if ( !drag || drag.id !== e.pointerId || drag.dragged ) { return; } suppressTriggerClick.current = true; choose(idx); }} onPointerCancel={() => { dragRef.current = null; }} className={cn( 'absolute left-0 right-0 z-[1] flex cursor-pointer items-center gap-2.5 px-3', idx === active ? 'text-accent-foreground' : 'hover:bg-accent/50', )} style={{ top: idx * ROW_H, height: ROW_H }} > {opt ? ( <> {opt.avatar ? ( ) : ( )} {opt.label} {opt.sub && ( {opt.sub} )} ) : ( 使用{' '} {rawRow} )}
))}
) ) : null}
)}
); }