纯前端实现可访问拖拽排序:指针捕获、自动滚动与键盘等价操作

举报
yd_213843956 发表于 2026/11/08 07:54:01 2026/11/08
【摘要】 拖拽排序的难点不在拖动本身,而在滚动容器里的自动滚动、放置位置的计算精度、取消与回滚,以及为键盘用户提供一套完全等价的移动操作。

拖拽排序看起来是鼠标用户才关心的交互,但可访问性规范要求它必须有一个键盘等价物。真正动手实现时会发现,拖动本身只是很小的一部分,剩下的精力都花在滚动容器里的自动滚动、放置位置的计算精度、中途取消的回滚,以及让读屏用户知道自己把条目移动到了第几位。

一、先确定拖拽的语义

可拖动列表要回答的第一个问题是:拖动改变的是什么。是排序、是分组归属,还是两者兼有。排序场景下容器是 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、放置指示和播报。指针捕获解决输入连续性,自动滚动解决可达范围,键盘等价操作解决可达人群;三者都建立在同一份数据模型上,交互才不会在边界条件下互相打架。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。