纯前端实现可访问拖拽排序:指针捕获、自动滚动与键盘等价操作
拖拽排序看起来是鼠标用户才关心的交互,但可访问性规范要求它必须有一个键盘等价物。真正动手实现时会发现,拖动本身只是很小的一部分,剩下的精力都花在滚动容器里的自动滚动、放置位置的计算精度、中途取消的回滚,以及让读屏用户知道自己把条目移动到了第几位。
一、先确定拖拽的语义
可拖动列表要回答的第一个问题是:拖动改变的是什么。是排序、是分组归属,还是两者兼有。排序场景下容器是 list,条目是 listitem;跨组拖动则更接近两个 listbox 之间的数据流动。无论哪种,都应当先确定一个稳定的标识符体系,拖动过程中只传递标识符,不传递 DOM 节点,避免指针事件里夹带真实元素带来的内存与状态问题。
const items = new Map(); // id -> { id, groupId, index }
let dragState = null; // { id, fromGroup, startY, pointerId }
二、指针事件与指针捕获
早期实现常混用 mousedown 和 touchstart,结果在触屏和手写笔上要写两套补偿逻辑。指针事件把这些输入统一成 pointerdown、pointermove、pointerup,配合 setPointerCapture,可以在指针移出元素甚至移出窗口后继续收到移动事件,不必再往 document 上挂全局监听。
捕获之后要处理 pointercancel。系统手势、来电、页面被切到后台都会触发取消,如果不处理,条目会永远停在“被拖起”的状态。取消时应按取消处理,而不是当作放置——用户的意图并没有完成。
function onPointerDown(event, id) {
dragState = { id, pointerId: event.pointerId, startY: event.clientY, moved: false };
event.currentTarget.setPointerCapture(event.pointerId);
}
function onPointerCancel() {
cancelDrag(); // 回滚到起始位置,不清除选择
}
还有一个容易被忽视的细节:拖动阈值。手指按下时的轻微抖动不应该立刻进入拖拽态,否则点击会被误判成拖动。一般设 4 到 8 像素的阈值,超过之后才切换成拖拽模式并阻止默认滚动行为。
三、放置位置的计算
放置指示必须与最终结果一致,否则用户松手后条目会跳到另一个位置。最稳妥的方式是先按当前顺序取回所有可见条目的矩形,再根据指针位置算出目标索引,然后把这个索引对应的插入间隙画出来,而不是给某个条目画上边框。
计算时要注意两个坑:一是被拖动的条目自身仍占着原来的位置,算索引时需要把它排除,否则会出现偏一位;二是列表可能正在做过滤,可见条目与数据顺序不一致,插入位置要换算回原始数组的索引,再写回数据。
function computeTargetIndex(rects, pointerY, draggingId) {
const others = rects.filter((rect) => rect.id !== draggingId);
let index = others.findIndex((rect) => pointerY < rect.top + rect.height / 2);
if (index === -1) index = others.length;
return index;
}
如果条目高度不等,用中点判断通常比用边界更符合直觉。列表很长时不要每次 pointermove 都重新测量全部矩形,缓存一次测量结果,只在滚动或列表变化时失效。
四、滚动容器里的自动滚动
列表比视口长时,用户拖到边缘会希望列表自动滚动。实现方式是在容器附近划分出上下两个感应带,指针进入感应带后启动一个动画帧循环,按指针到边缘的距离换算滚动速度,距离越近滚动越快。这个循环要在指针离开感应带、松手或取消时立刻停止,否则列表会一直滚。
function tick() {
if (!inEdgeZone) return;
container.scrollTop += speed;
relocateIndicator();
rafId = requestAnimationFrame(tick);
}
速度建议按距离做一次缓动,而不是线性,否则在感应带边缘几乎不动、贴近边缘时又突然很快。滚动的每一帧都要重新计算放置位置,这样指示线才会跟着内容一起移动。
五、键盘等价操作
键盘用户需要一个显式的“拿起 / 放下”模型。常见做法是空格或回车把当前条目切换成可移动状态,方向键上下来回移动、左右键跨组移动,再按空格放下,Esc 取消并回到原位。移动过程中要通过 aria-live 区域播报“已移动到第 3 项,共 12 项”,让读屏用户不至于失去空间感。
function moveBy(delta) {
const next = clamp(currentIndex + delta, 0, order.length - 1);
if (next === currentIndex) return;
const [moved] = order.splice(currentIndex, 1);
order.splice(next, 0, moved);
currentIndex = next;
render();
announce(`已移动到第 ${next + 1} 项,共 ${order.length} 项`);
}
拿起状态下应当禁用方向键的默认滚动,并把 aria-grabbed 或等效状态设置好。放下之后焦点必须停在条目本身,而不是掉到容器上,否则用户要继续下一次移动就得重新 Tab 进来。
六、取消、回滚与持久化
拖拽的取消路径比成功路径更容易出问题。指针取消、按 Esc、目标容器拒绝接收,都属于取消。取消时不仅要恢复顺序,还要恢复滚动位置和焦点,并把播报内容改成“已取消移动”。
持久化建议只提交最终顺序,而不是每次移动都发请求。拖动过程中先在本地改数据并立刻渲染,松手后再批量保存;保存失败则回滚到拖动前的快照,并用同一次播报告知失败。这样既保证手感,也能在弱网下自洽。
七、测试重点
覆盖超长列表的自动滚动、条目高度不等时的插入位置、拖到自己身上、拖到列表首尾之外、拖动中触屏取消、跨组移动、键盘拿起与放下、Esc 取消后的焦点与顺序、过滤状态下拖动、以及保存失败的回滚。拖动过程中反复改变窗口大小,也常常能暴露出缓存矩形未失效的问题。
八、小结
拖拽排序的可访问实现,本质上是把“位置”这件事抽象成一维索引,让指针和键盘两条输入路径都只是修改同一个索引,再由索引统一驱动 DOM、放置指示和播报。指针捕获解决输入连续性,自动滚动解决可达范围,键盘等价操作解决可达人群;三者都建立在同一份数据模型上,交互才不会在边界条件下互相打架。
- 点赞
- 收藏
- 关注作者
评论(0)