拖拽排序

drag · sortable-list · 灵感来源:Linear / Notion 的拖拽排序(指针)+ APG listbox 的方向键模型(键盘)

空格或回车拾起,方向键移动,Esc 取消;也可以按住把手拖动

  • 处理收件箱8 条未读
  • 设计评审14:00
  • 发布 0.4周五
  • 文案校对2 页
  • 写周报草稿

拖把手排序 · 键盘:空格拾起、↑↓ 移动、Esc 取消

order = inbox / review / release / copy / weekly

复用卡

Sortable List (Pointer + Keyboard) · 拖拽排序列表 · 拖拽 / Interaction

规格 · 术语提示词(给 AI 用)

做一个 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 的方向键模型(键盘)

参数(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

必缺状态

焦点 / 按压

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

拖拽排序列表

拆解

  • 双通道:指针从把手开始拖;键盘在把手上空格 / 回车拾起,↑↓ 移动,Esc 取消
  • 拖拽中只画一条落点线,松手才提交新顺序——不在拖动过程中改 DOM 顺序
  • 被拖的行跟手(translateY),行本身不重排;落点按每行中线判断
  • 卡片跟手时少走 8px:下缘让开,落点线不会跟卡片描边糊在一起
  • 提交 = 纯函数 moveItem(items, from, boundary),指针与键盘共用同一份
  • 每次拾起 / 移动 / 放下 / 取消都写入 aria-live,读屏能听到第几项

实现取舍

  • Pointer Events + setPointerCapture:指针移出把手甚至列表,事件仍回到把手
  • 触屏只在把手 touch-action: none,行本身仍可正常滚动
  • 落点用 offsetTop / offsetHeight 量,不用 getBoundingClientRect——被拖行带着 translate,rect 会跟鼠标跑
  • 受控组件:顺序由 items 决定,onReorder 回传下一份;组件不私存顺序
  • 拖动中不播放位移动画,prefers-reduced-motion 不影响直接操作

限制

  • v1 不做跨列表拖拽与自动滚动;列表比视口高时需要手动滚动
  • 落点线只表达"插到这一行之前",不预览行高变化

实现

drag/sortable-list
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>
  );
}

元信息

标识
drag/sortable-list
状态
done
最后提交
2026-11-01
风格轴
sortable-list
依赖 token
--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
在工作台打开 源码与 notes 都在 src/components/drag/sortable-list/