SnowLuma v2.4.2 source snapshot
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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+457
View File
@@ -0,0 +1,457 @@
// 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>
);
}