# 拖拽排序（Sortable List (Pointer + Keyboard)）

- 类型 / 材质：`drag` / `sortable-list`（sortable-list）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Linear / Notion 的拖拽排序（指针）+ APG listbox 的方向键模型（键盘）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/drag/sortable-list` ｜ 条目：`/r/drag--sortable-list.json`
- 必缺状态：焦点 / 按压
- 整页场景：尚未接入

## 规格

- 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`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `items` | `SortableItem[]` | 必填 | — |
| `onReorder` | `(items: SortableItem[]) => void` | 可选 | — |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「拖拽排序」— Sortable List (Pointer + Keyboard)（拖拽 / Interaction）
灵感来源：Linear / Notion 的拖拽排序（指针）+ APG listbox 的方向键模型（键盘）
规格：
- 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
必缺状态：焦点 / 按压
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/drag/sortable-list · 标准 interaction /design#interaction · 调研笔记 /showcase/component/drag/sortable-list#notes
```

## 调研笔记

import { useId, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
import { GripVertical } from 'lucide-react';
import { Icon } from '@/components/ui-echo/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>
  );
}
