# 滑动删除（Swipe Actions）

- 类型 / 材质：`drag` / `swipe-to-dismiss`（滑动关闭）
- 状态：done ｜ 最后提交：2026-10-31
- 灵感来源：iOS 邮件 / 通知列表的左滑删除（UITableView swipe actions）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/drag/swipe-to-dismiss` ｜ 条目：`/r/drag--swipe-to-dismiss.json`
- 必缺状态：焦点 / 按压
- 整页场景：
  - 使用概览（`/r/scene--dashboard.json`）

## 规格

- 四段链：1:1 跟手 → 越界阻尼 30% → 阈值 96px / 速度 0.5px/ms 双判 → 吸附或飞出
- 轴锁：横向起手才接管，纵向让位给滚动（touch-action: pan-y）
- 测速只取最后 120ms 的位移，不用全程平均
- 键盘等价：行可聚焦，Delete / Backspace 删除，结果进 aria-live
- reduced-motion 下不画飞出过程，直接提交

## 参数（CSS 变量）

`--color-accent` `--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--color-warning-bg` `--color-warning-fg` `--dur-fast` `--ease-standard` `--glass-border` `--text-2xs`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `children` | `ReactNode` | 必填 | — |
| `onDismiss` | `() => void` | 可选 | — |
| `threshold` | `number` | 可选 | 触发关闭的距离阈值（px） |
| `dismissLabel` | `string` | 可选 | 露出的背景上写什么 |
| `className` | `string` | 可选 | — |

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

```
复现「滑动删除」— Swipe Actions（拖拽 / 手势）
灵感来源：iOS 邮件 / 通知列表的左滑删除（UITableView swipe actions）
规格：
- 四段链：1:1 跟手 → 越界阻尼 30% → 阈值 96px / 速度 0.5px/ms 双判 → 吸附或飞出
- 轴锁：横向起手才接管，纵向让位给滚动（touch-action: pan-y）
- 测速只取最后 120ms 的位移，不用全程平均
- 键盘等价：行可聚焦，Delete / Backspace 删除，结果进 aria-live
- reduced-motion 下不画飞出过程，直接提交
参数（CSS 变量）：--color-accent --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-bg --color-warning-fg --dur-fast --ease-standard --glass-border --text-2xs
必缺状态：焦点 / 按压
整页场景：使用概览
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/drag/swipe-to-dismiss · 标准 interaction /design#interaction · 调研笔记 /showcase/component/drag/swipe-to-dismiss#notes
```

## 调研笔记

import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';

interface Props {
  children: ReactNode;
  onDismiss?: () => void;
  /** 触发关闭的距离阈值（px） */
  threshold?: number;
  /** 露出的背景上写什么 */
  dismissLabel?: string;
  className?: string;
}

/** 越界之后的跟手比例：越过阈值仍在动，但不再 1:1 */
const DAMP = 0.3;
/** 甩出去就算删除的速度线（px/ms） */
const FLING = 0.5;
/** 测速窗口：只取最后这一段位移，更早的样本不参与 */
const WINDOW_MS = 120;

interface Sample {
  x: number;
  t: number;
}

const reducedMotion = () =>
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** 读出场的 token 时长，保证 JS 收尾与 CSS 过渡是同一个值 */
const exitDuration = () => {
  const raw = getComputedStyle(document.documentElement).getPropertyValue('--dur-fast').trim();
  const n = parseFloat(raw) || 0;
  return raw.endsWith('ms') ? n : n * 1000;
};

export function SwipeToDismiss({
  children,
  onDismiss,
  threshold = 96,
  dismissLabel = '松手删除',
  className = '',
}: Props) {
  const [dx, setDx] = useState(0);
  const [settling, setSettling] = useState<'idle' | 'snap' | 'out'>('idle');
  const rowRef = useRef<HTMLDivElement>(null);
  const drag = useRef<{ id: number; startX: number; startY: number; axis: 'x' | 'y' | null; samples: Sample[] } | null>(
    null,
  );

  /** 越界后阻尼：超过阈值的那部分只跟 30%，手感"拉住"但不硬停 */
  const damp = (raw: number) => {
    const over = Math.abs(raw) - threshold;
    if (over <= 0) return raw;
    return Math.sign(raw) * (threshold + over * DAMP);
  };

  const velocity = () => {
    const s = drag.current?.samples ?? [];
    if (s.length < 2) return 0;
    const last = s[s.length - 1];
    let first = s[0];
    for (const sample of s) {
      if (last.t - sample.t <= WINDOW_MS) {
        first = sample;
        break;
      }
    }
    const dt = last.t - first.t;
    return dt === 0 ? 0 : (last.x - first.x) / dt;
  };

  const flyOut = () => {
    const el = rowRef.current;
    if (!el) return;
    if (reducedMotion()) {
      onDismiss?.();
      return;
    }
    setSettling('out');
    setDx(-el.offsetWidth);
    // 用 token 时长收尾，不依赖 transitionend：页面隐藏或过渡被外部样式掐掉时它不会来
    window.setTimeout(() => onDismiss?.(), exitDuration());
  };

  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
    if (settling === 'out') return;
    // 行内如果放了按钮 / 链接，让它们自己处理，不从它们身上起拖
    if ((e.target as HTMLElement).closest('a, button, input, select, textarea')) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { id: e.pointerId, startX: e.clientX, startY: e.clientY, axis: null, samples: [] };
    setSettling('idle');
  };

  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;

    const moveX = e.clientX - d.startX;
    const moveY = e.clientY - d.startY;

    // 轴锁：先动哪边算哪边，纵向就让位给滚动
    if (!d.axis) {
      if (Math.abs(moveX) < 8 && Math.abs(moveY) < 8) return;
      if (Math.abs(moveY) >= Math.abs(moveX)) {
        drag.current = null;
        e.currentTarget.releasePointerCapture(e.pointerId);
        return;
      }
      d.axis = 'x';
    }

    d.samples.push({ x: e.clientX, t: e.timeStamp });
    if (d.samples.length > 20) d.samples.shift();

    // 只允许向左滑：右边才是删除区
    setDx(Math.min(0, damp(moveX)));
  };

  const settle = (e: PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    drag.current = null;

    const distance = Math.abs(dx);
    const speed = Math.abs(velocity());

    // 速度与距离双条件：甩得够快不用滑到底，慢慢拖必须过阈值
    if (distance >= threshold || speed >= FLING) {
      flyOut();
    } else {
      setSettling('snap');
      setDx(0);
    }
  };

  /** 键盘等价：行可聚焦，Delete / Backspace 直接删除 */
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key !== 'Delete' && e.key !== 'Backspace') return;
    e.preventDefault();
    flyOut();
  };

  return (
    <div className={`relative overflow-hidden ${className}`}>
      <div
        aria-hidden="true"
        className="absolute inset-0 flex items-center justify-end pr-4 bg-[var(--color-warning-bg)] text-xs text-[var(--color-warning-fg)]"
      >
        {dismissLabel}
      </div>

      <div
        ref={rowRef}
        role="group"
        aria-roledescription="可滑动删除"
        tabIndex={0}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={settle}
        onPointerCancel={settle}
        onKeyDown={onKeyDown}
        className="relative touch-pan-y bg-[var(--color-surface)] motion-reduce:transition-none"
        style={{
          transform: `translateX(${dx}px)`,
          transition: settling === 'idle' ? 'none' : 'transform var(--dur-fast) var(--ease-standard)',
        }}
      >
        {children}
      </div>
    </div>
  );
}
