drag · swipe-to-dismiss · 灵感来源:iOS 邮件 / 通知列表的左滑删除(UITableView swipe actions)
构建完成
main 分支已部署到预览环境
收到 3 条新评论
组件矩阵页 · 交互建议
依赖更新
astro 5.18 → 5.19 可用
周报已生成
本周合并 12 个提交
向左滑删除 · 聚焦后按 Delete
Swipe Actions · 滑动删除 · 拖拽 / 手势
做一个 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
灵感参考:iOS 邮件 / 通知列表的左滑删除(UITableView swipe actions)--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
焦点 / 按压
/r/drag--swipe-to-dismiss.json
免外壳的 markdown 版:/llms/components/drag--swipe-to-dismiss.mdsetPointerCapture,不用 HTML5 drag-and-drop;touch-action: pan-y 让纵向滚动照常--dur-fast 的时长收尾,不依赖 transitionend:页面隐藏、或过渡被外部样式掐掉时它不会来,行会卡在屏外却还在 DOM 里。JS 读的是同一个 token,不写第二份prefers-reduced-motion 下不画飞出过程,直接调用 onDismissaria-live,读屏能听到"还剩几条"变量:--color-warning-bg / --color-warning-fg / --dur-fast / --ease-standard。
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>
);
}
src/components/drag/swipe-to-dismiss/