Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
458 lines
21 KiB
TypeScript
458 lines
21 KiB
TypeScript
// 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<PickerOption>;
|
|
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<HTMLDivElement>(null);
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const searchRef = useRef<HTMLInputElement>(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 (
|
|
<div ref={rootRef} className={cn('relative', className)} onKeyDown={onKeyDown}>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
aria-label={ariaLabel}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
onClick={() => {
|
|
if (suppressTriggerClick.current) {
|
|
suppressTriggerClick.current = false;
|
|
return;
|
|
}
|
|
|
|
if (!disabled) {
|
|
setOpen((v) => !v);
|
|
}
|
|
}}
|
|
className={cn(
|
|
'flex h-9 w-full items-center gap-2 rounded-md border border-border bg-transparent px-3 text-sm outline-none transition-colors',
|
|
'focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring disabled:opacity-50',
|
|
!selected && !value && 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{selected?.avatar && (
|
|
<img
|
|
src={selected.avatar}
|
|
alt=""
|
|
className="media-outline h-5 w-5 shrink-0 rounded-full"
|
|
/>
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-left">
|
|
{selected ? selected.label : value || placeholder}
|
|
{selected?.sub && (
|
|
<span className="ml-1.5 text-xs text-muted-foreground tabular-nums">
|
|
{selected.sub}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<motion.svg
|
|
aria-hidden
|
|
viewBox="0 0 12 12"
|
|
className="size-3 shrink-0 text-muted-foreground"
|
|
initial={false}
|
|
animate={{ rotate: open ? 180 : 0 }}
|
|
transition={reduced ? NONE : NUDGE}
|
|
>
|
|
<path
|
|
d="M3 4.75 6 7.75 9 4.75"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.4"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</motion.svg>
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -8 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{
|
|
opacity: 0,
|
|
scale: 0.97,
|
|
y: -6,
|
|
transition: reduced ? NONE : { duration: 0.12, ease: EXIT },
|
|
}}
|
|
transition={
|
|
reduced ? NONE : { ...OPEN, opacity: { duration: 0.12, ease: EASE } }
|
|
}
|
|
style={{ transformOrigin: 'top left' }}
|
|
className="absolute z-[200] mt-1.5 w-full overflow-hidden rounded-[11px] border border-border bg-popover shadow-[0_1px_2px_rgb(0_0_0/0.06),0_16px_36px_-18px_rgb(0_0_0/0.5)]"
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border/60 px-2.5 py-2">
|
|
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<input
|
|
ref={searchRef}
|
|
value={query}
|
|
onChange={(e) => {
|
|
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 && (
|
|
<button
|
|
type="button"
|
|
title="刷新"
|
|
onClick={onRefresh}
|
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<RefreshCw
|
|
className={cn('h-3.5 w-3.5', loading && 'animate-spin')}
|
|
/>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<SkeletonSwap
|
|
ready={!waitingForOptions}
|
|
lines={4}
|
|
lineHeight={28}
|
|
reserve={112}
|
|
label={ariaLabel}
|
|
className={waitingForOptions ? 'mx-3 my-2' : 'skeleton-swap-fluid'}
|
|
>
|
|
{!waitingForOptions ? (
|
|
error ? (
|
|
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
|
<p className="text-destructive">{error}</p>
|
|
<p className="mt-1">可直接在上方输入号码后回车使用</p>
|
|
</div>
|
|
) : totalRows === 0 ? (
|
|
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
|
无匹配项{query && ';输入完整号码可直接使用'}
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={listRef}
|
|
role="listbox"
|
|
onScroll={(e) =>
|
|
setScrollTop((e.target as HTMLDivElement).scrollTop)
|
|
}
|
|
className="overflow-auto overscroll-contain"
|
|
style={{ height: viewH, touchAction: 'pan-y' }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: totalRows * ROW_H,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<motion.span
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-x-0 top-0 rounded-[7px] bg-accent"
|
|
style={{ height: ROW_H }}
|
|
initial={false}
|
|
animate={{
|
|
y: active * ROW_H,
|
|
opacity: totalRows > 0 ? 1 : 0,
|
|
}}
|
|
transition={
|
|
reduced
|
|
? NONE
|
|
: {
|
|
...SLIDE,
|
|
opacity: {
|
|
duration: 0.1,
|
|
ease: EASE,
|
|
},
|
|
}
|
|
}
|
|
/>
|
|
{rows.map(({ idx, opt }) => (
|
|
<div
|
|
key={opt ? opt.value : `__raw-${idx}`}
|
|
role="option"
|
|
aria-selected={idx === active}
|
|
onMouseEnter={() => 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 ? (
|
|
<img
|
|
src={opt.avatar}
|
|
alt=""
|
|
className="media-outline h-7 w-7 shrink-0 rounded-full"
|
|
/>
|
|
) : (
|
|
<span className="media-outline h-7 w-7 shrink-0 rounded-full bg-muted" />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-sm">
|
|
{opt.label}
|
|
</span>
|
|
{opt.sub && (
|
|
<span className="shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
|
|
{opt.sub}
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="text-sm">
|
|
使用{' '}
|
|
<span className="font-mono text-primary">
|
|
{rawRow}
|
|
</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
) : null}
|
|
</SkeletonSwap>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|