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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user