drag · sortable-list · 灵感来源:Linear / Notion 的拖拽排序(指针)+ APG listbox 的方向键模型(键盘)
空格或回车拾起,方向键移动,Esc 取消;也可以按住把手拖动
拖把手排序 · 键盘:空格拾起、↑↓ 移动、Esc 取消
order = inbox / review / release / copy / weekly
Sortable List (Pointer + Keyboard) · 拖拽排序列表 · 拖拽 / Interaction
做一个 Sortable List (Pointer + Keyboard)(拖拽排序列表):
- Pointer Events + setPointerCapture,只在把手按下时开始拖拽
- 拖拽中只画落点线,松手才提交新顺序
- 键盘等价:空格/回车拾起,↑↓ 移动,Esc 取消
- aria-live 播报拾起与落位;把手 touch-action: none
用这些 CSS 变量:--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --dur-fast --glass-border --glass-recess --shadow-md --shadow-sm --text-2xs
灵感参考:Linear / Notion 的拖拽排序(指针)+ APG listbox 的方向键模型(键盘)--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --dur-fast --glass-border --glass-recess --shadow-md --shadow-sm --text-2xs
焦点 / 按压
(尚未接进整页场景)
/r/drag--sortable-list.json
免外壳的 markdown 版:/llms/components/drag--sortable-list.mdmoveItem(items, from, boundary),指针与键盘共用同一份aria-live,读屏能听到第几项setPointerCapture:指针移出把手甚至列表,事件仍回到把手touch-action: none,行本身仍可正常滚动offsetTop / offsetHeight 量,不用 getBoundingClientRect——被拖行带着 translate,rect 会跟鼠标跑items 决定,onReorder 回传下一份;组件不私存顺序prefers-reduced-motion 不影响直接操作import { useId, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
import { GripVertical } from 'lucide-react';
import { Icon } from '~/components/Icon';
export interface SortableItem {
id: string;
label: string;
meta?: string;
}
interface Props {
items: SortableItem[];
onReorder?: (items: SortableItem[]) => void;
className?: string;
}
const moveItem = (list: SortableItem[], from: number, boundary: number) => {
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(boundary > from ? boundary - 1 : boundary, 0, item);
return next;
};
/** 卡片比指针少走 8px:下缘让开,落点线才不会跟卡片描边糊在一起 */
const LIFT = 8;
/**
* 拖拽排序列表:指针通道只从把手开始,拖拽中只画落点线、松手才提交;
* 键盘通道走同一份 reorder(空格拾起 / ↑↓ 移动 / Esc 取消)。
*/
export function SortableList({ items, onReorder, className = '' }: Props) {
const helpId = useId();
const [draggingId, setDraggingId] = useState<string | null>(null);
const [pickedId, setPickedId] = useState<string | null>(null);
const [drop, setDrop] = useState<{ index: number; y: number } | null>(null);
const [offsetY, setOffsetY] = useState(0);
const [message, setMessage] = useState('');
const listRef = useRef<HTMLUListElement>(null);
const rowRefs = useRef(new Map<string, HTMLLIElement>());
const drag = useRef<{ id: string; from: number; startY: number } | null>(null);
const dropRef = useRef<number | null>(null);
const pickedOriginal = useRef<SortableItem[] | null>(null);
const announce = (text: string) => setMessage(text);
/** boundary = 插入位置(0..n);落在原位置或原位置之后一格时不动 */
const commit = (from: number, boundary: number) => {
if (boundary === from || boundary === from + 1) return;
const item = items[from];
const next = moveItem(items, from, boundary);
const finalIndex = next.findIndex((x) => x.id === item.id);
onReorder?.(next);
announce(`「${item.label}」已移到第 ${finalIndex + 1} 项,共 ${next.length} 项`);
};
/** 用 offsetTop / offsetHeight 量布局位,不用 getBoundingClientRect:
拖动中的行带着 translate,rect 会跟着鼠标跑,落点判断就会抖。 */
const rowBox = (index: number) => {
const list = listRef.current;
const row = rowRefs.current.get(items[index].id);
if (!list || !row) return null;
return { top: list.getBoundingClientRect().top + row.offsetTop, height: row.offsetHeight };
};
const boundaryAt = (clientY: number) => {
for (let i = 0; i < items.length; i++) {
const box = rowBox(i);
if (box && clientY < box.top + box.height / 2) return i;
}
return items.length;
};
const lineY = (boundary: number) => {
const list = listRef.current;
if (!list) return 0;
const listTop = list.getBoundingClientRect().top;
if (boundary < items.length) {
const box = rowBox(boundary);
return box ? box.top - listTop : 0;
}
const last = items.length > 0 ? rowBox(items.length - 1) : null;
return last ? last.top + last.height - listTop : 0;
};
const onPointerDown = (e: PointerEvent<HTMLButtonElement>, index: number) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
const item = items[index];
drag.current = { id: item.id, from: index, startY: e.clientY };
setDraggingId(item.id);
setOffsetY(0);
dropRef.current = null;
setDrop(null);
};
const onPointerMove = (e: PointerEvent<HTMLButtonElement>) => {
const d = drag.current;
if (!d) return;
setOffsetY(e.clientY - d.startY - LIFT);
const boundary = boundaryAt(e.clientY);
dropRef.current = boundary;
// 落在原位或原位之后一格:没有可看的落点,收掉指示线
if (boundary === d.from || boundary === d.from + 1) setDrop(null);
else setDrop({ index: boundary, y: lineY(boundary) });
};
const endDrag = (commitOrder: boolean) => {
const d = drag.current;
const target = dropRef.current;
drag.current = null;
dropRef.current = null;
setDraggingId(null);
setOffsetY(0);
setDrop(null);
if (!commitOrder || !d || target == null) return;
commit(d.from, target);
};
const onPointerUp = () => endDrag(true);
const onPointerCancel = () => endDrag(false);
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
const item = items[index];
const picked = pickedId === item.id;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (picked) {
setPickedId(null);
pickedOriginal.current = null;
announce(`已放下「${item.label}」,第 ${index + 1} 项`);
} else {
pickedOriginal.current = items;
setPickedId(item.id);
announce(`已拾起「${item.label}」,第 ${index + 1} 项,共 ${items.length} 项;方向键移动,空格放下`);
}
return;
}
if (!picked) return;
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const boundary = e.key === 'ArrowUp'
? Math.max(0, index - 1)
: Math.min(items.length, index + 2);
commit(index, boundary);
} else if (e.key === 'Escape') {
e.preventDefault();
if (pickedOriginal.current) onReorder?.(pickedOriginal.current);
pickedOriginal.current = null;
setPickedId(null);
announce('已取消排序');
}
};
return (
<div className={className}>
<p id={helpId} className="sr-only">
空格或回车拾起,方向键移动,Esc 取消;也可以按住把手拖动
</p>
<ul ref={listRef} className="relative m-0 p-0 list-none flex flex-col gap-2">
{items.map((item, index) => {
const isDragging = draggingId === item.id;
const isPicked = pickedId === item.id;
return (
<li
key={item.id}
ref={(el) => {
if (el) rowRefs.current.set(item.id, el);
else rowRefs.current.delete(item.id);
}}
style={isDragging ? { transform: `translateY(${offsetY}px)`, zIndex: 10 } : undefined}
className={`
flex items-center gap-3 px-3 py-2.5
rounded-xl bg-[var(--color-surface)] border
transition-[box-shadow,border-color] duration-[var(--dur-fast)]
${isDragging || isPicked
? 'border-[var(--color-accent)] shadow-[var(--shadow-md)]'
: 'border-[var(--color-divider)] shadow-[var(--shadow-sm)]'}
${isDragging ? 'relative cursor-grabbing' : ''}
`}
>
<button
type="button"
aria-label={`拖动「${item.label}」`}
aria-describedby={helpId}
aria-pressed={isPicked}
onPointerDown={(e) => onPointerDown(e, index)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onKeyDown={(e) => onKeyDown(e, index)}
className="
press-feedback shrink-0 size-8 rounded-md
flex items-center justify-center
text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--glass-recess)]
transition-colors touch-none cursor-grab active:cursor-grabbing active:text-[var(--color-text)]
"
>
<Icon icon={GripVertical} size={14} />
</button>
<span className="flex-1 min-w-0 text-sm truncate">{item.label}</span>
{item.meta && (
<span className="shrink-0 text-xs text-[var(--color-text-subtle)] whitespace-nowrap">
{item.meta}
</span>
)}
</li>
);
})}
{drop && (
<div
aria-hidden="true"
className="pointer-events-none absolute left-2 right-2 h-0.5 rounded-full bg-[var(--color-accent)]"
style={{ top: drop.y, transform: 'translateY(-1px)', zIndex: 20 }}
/>
)}
</ul>
<p className="sr-only" role="status" aria-live="polite">{message}</p>
</div>
);
}
src/components/drag/sortable-list/