Front-End-Checklist 无障碍拖拽(Draggable Accessibility)规则实战:键盘替代方案、ARIA 状态与实时播报
【免费下载链接】Front-End-Checklist🗂 The essential checklist for modern web development, for humans and AI agents项目地址: https://gitcode.com/gh_mirrors/fr/Front-End-Checklist
拖拽(drag and drop)交互默认完全依赖鼠标,键盘用户与屏幕阅读器用户会被彻底排除在外。本文基于 Front-End-Checklist 仓库中的
draggable-accessibility规则,系统讲解如何为拖拽/排序/看板类组件补齐键盘替代操作、aria-grabbed/aria-dropeffect等 ARIA 状态,以及基于 live region 的状态播报,并提供可直接落地的 HTML、React 与 CSS 完整实现与验证步骤。
规则定位与适用场景
本规则在仓库中的 Skill 定义为draggable-accessibility,元数据声明如下(见 SKILL.md):
- 类别(category):
html,子类别为 interaction 交互; - 优先级(priority):medium;难度(difficulty):advanced;预估耗时 35 分钟;
- 来源:frontendchecklist.io 的 html/draggable-accessibility 规则。
规则的核心使用场景是:审查模板、服务端渲染的 HTML 或共享组件中与"拖拽排序"相关的输出标记。它特别强调"Validate the final browser-facing markup, not just the source framework abstraction"——即审查时必须验证浏览器最终面对的渲染标记,而不能只看源码框架层的抽象(例如 React/Vue 组件里的逻辑写对了,不代表最终输出的 DOM 与 ARIA 属性正确)。
该规则的完整内容在仓库中有两个权威载体,内容一致:
- Skill 实现参考:references/rule.md
- 内容包中的规则正文:packages/content/rules/en/html/draggable-accessibility.mdx(带结构化 frontmatter,包含 check/fix/explain/codeReview 四类 prompts 与关联规则)
为什么拖拽默认就不可访问
Drag and drop is mouse-dependent by default—keyboard and screen reader users are completely locked out without proper alternatives and ARIA announcements.
原生 HTML5 的拖拽(draggable="true"+dragstart/dragover/drop事件)没有内建的键盘操作路径:
- 键盘用户无法"抓取"一个可拖拽元素;
- 屏幕阅读器用户听不到任何拖拽状态变化,鼠标拖拽的视觉线索(位置移动、占位符)对其完全不可见;
- 即使给元素加了
draggable属性,Tab焦点到达后依然没有任何可执行的操作方式。
因此规则的要求非常明确:拖拽功能必须与键盘替代方案、ARIA 状态属性、live region 状态播报三者同时存在,否则等同于把一部分用户锁在功能之外。
无障碍需求总览
规则用一张表格汇总了五项核心要求(见 references/rule.md):
| Requirement | Implementation |
|---|---|
| Keyboard alternative | Arrow keys, Enter to pick up/drop |
| ARIA attributes | aria-grabbed,aria-dropeffect |
| Announcements | Live regions for state changes |
| Visual feedback | Focus indicators for drop zones |
| Instructions | Clear usage guidance |
逐项展开:
- 键盘替代方案:必须允许仅凭键盘完成"抓起—移动—放下—取消"的完整操作循环;
- ARIA 属性:
aria-grabbed(当前元素是否被抓起)与aria-dropeffect(元素可作为放置目标时的效果)用于向辅助技术表达拖拽状态。规则同时注明"or newer attributes"——即 WAI-ARIA 后续版本中的替代状态机制也可以接受,关键是有明确的状态语义而非完全缺失; - 状态播报:所有状态变化(抓起、移动、放下、取消)都必须通过 live region 播报给屏幕阅读器;
- 视觉反馈:被抓取状态、放置目标需要有明确的焦点指示与高亮;
- 使用说明:用户需要知道"按什么键做什么"——通过可见或
sr-only的指令文本呈现。
标准键盘交互模式
规则定义了统一的键盘操作协议:
| Key | Action |
|---|---|
Tab | Navigate between draggable items |
Space/Enter | Pick up / drop item |
Arrow keys | Move item to adjacent position |
Escape | Cancel drag operation |
要点说明:
Tab在可拖拽项目之间移动焦点(每个项目需tabindex="0");Space或Enter是"抓起/放下"的开关:焦点在未被抓取的元素上按下即抓起,焦点在目标位置按下即放下;- 方向键在抓起状态下把项目移动到相邻位置;未抓起时则用于在项目间移动焦点(实现中
focusItem负责此行为); Escape随时取消当前拖拽,把项目放回原位并播报"reorder cancelled"。
HTML 标记基础示例
规则给出了最基础的可访问排序列表标记(references/rule.md):
<div role="list" aria-label="Sortable tasks" class="sortable-list"> <div role="listitem" tabindex="0" aria-grabbed="false" draggable="true" class="sortable-item" > <span class="sortable-item__handle" aria-hidden="true">⋮⋮</span> <span class="sortable-item__content">Task 1</span> <span class="sr-only">Press Space to pick up, use arrows to reorder</span> </div> <div role="listitem" tabindex="0" aria-grabbed="false" draggable="true" class="sortable-item" > <span class="sortable-item__handle" aria-hidden="true">⋮⋮</span> <span class="sortable-item__content">Task 2</span> <span class="sr-only">Press Space to pick up, use arrows to reorder</span> </div> </div> <!-- Live region for announcements --> <div id="drag-announcements" aria-live="assertive" class="sr-only"></div>这份标记的语义要点:
- 列表结构:容器用
role="list"+aria-label,每个项目用role="listitem",让辅助技术把排序列表识别为可遍历的列表结构; - 可聚焦:
tabindex="0"保证每个项目都能进入 Tab 焦点序列; - 拖拽状态:
aria-grabbed="false"明确声明"当前未被抓取";项目也保留了原生draggable="true"以支持鼠标操作; - 操作指引:每个项目内嵌入
sr-only的简短指令("Press Space to pick up, use arrows to reorder"); - 播报容器:页面末尾放置
<div id="drag-announcements" aria-live="assertive" class="sr-only">,作为所有拖拽状态播报的目标 live region。
React 可排序列表完整实现
规则文档提供了一个完整的 React 组件(SortableList),同时支持键盘与鼠标两种操作路径。下面按模块解读(完整代码见 references/rule.md 的 "React Sortable List" 一节)。
状态与类型定义
import { useState, useRef, KeyboardEvent } from 'react' interface SortableItem { id: string content: string } interface SortableListProps { items: SortableItem[] onReorder: (items: SortableItem[]) => void } export function SortableList({ items, onReorder }: SortableListProps) { const [grabbedIndex, setGrabbedIndex] = useState<number | null>(null) const [announcement, setAnnouncement] = useState('') const listRef = useRef<HTMLDivElement>(null)grabbedIndex:当前被抓起项目的下标,null表示无抓取状态;announcement:要播报的文本,注入到 live region;listRef:列表容器引用,用于在项目间移动焦点。
播报函数:用 rAF 强制触发重读
const announce = (message: string) => { setAnnouncement('') // Force re-render for screen reader requestAnimationFrame(() => setAnnouncement(message)) }先清空再在requestAnimationFrame中写入新消息,是为了强制屏幕阅读器重新读取——如果 live region 中的文本与上一次完全相同,读屏软件不会再次播报,清空后再赋值可确保每次状态变化都被听到。
键盘事件处理
const handleKeyDown = (e: KeyboardEvent, index: number) => { switch (e.key) { case ' ': case 'Enter': e.preventDefault() if (grabbedIndex === null) { // Pick up setGrabbedIndex(index) announce(`${items[index].content} grabbed. Use arrow keys to move, Space to drop, Escape to cancel.`) } else { // Drop setGrabbedIndex(null) announce(`${items[grabbedIndex].content} dropped at position ${index + 1}.`) } break case 'ArrowUp': e.preventDefault() if (grabbedIndex !== null && grabbedIndex > 0) { moveItem(grabbedIndex, grabbedIndex - 1) setGrabbedIndex(grabbedIndex - 1) announce(`${items[grabbedIndex].content} moved to position ${grabbedIndex}.`) } else if (grabbedIndex === null && index > 0) { focusItem(index - 1) } break case 'ArrowDown': e.preventDefault() if (grabbedIndex !== null && grabbedIndex < items.length - 1) { moveItem(grabbedIndex, grabbedIndex + 1) setGrabbedIndex(grabbedIndex + 1) announce(`${items[grabbedIndex].content} moved to position ${grabbedIndex + 2}.`) } else if (grabbedIndex === null && index < items.length - 1) { focusItem(index + 1) } break case 'Escape': if (grabbedIndex !== null) { e.preventDefault() announce(`${items[grabbedIndex].content} reorder cancelled.`) setGrabbedIndex(null) } break } }分支逻辑解读:
Space/Enter:无抓取时执行"抓起"并播报指引;有抓取时在当前位置"放下"并播报目标位置(位置用index + 1表达为人类可读的 1 起始序号);ArrowUp/ArrowDown:抓起状态下执行moveItem并同步更新grabbedIndex与播报;未抓起时仅把焦点移动到相邻项目(focusItem),注意方向键在未抓起时也要preventDefault(),避免页面滚动打断焦点移动;Escape:仅在有抓取时生效,播报取消并复位状态。
移动与焦点辅助函数
const moveItem = (from: number, to: number) => { const newItems = [...items] const [moved] = newItems.splice(from, 1) newItems.splice(to, 0, moved) onReorder(newItems) } const focusItem = (index: number) => { const list = listRef.current const items = list?.querySelectorAll('[role="listitem"]') ;(items?.[index] as HTMLElement)?.focus() }moveItem以不可变方式(拷贝数组 +splice)完成重排,并把新数组通过onReorder上抛给父组件,符合 React 单向数据流;focusItem通过querySelectorAll('[role="listitem"]')定位项目并调用.focus(),实现方向键在项目间的焦点漫游。
鼠标拖拽处理:与键盘共享同一状态机
const handleDragStart = (e: React.DragEvent, index: number) => { e.dataTransfer.effectAllowed = 'move' setGrabbedIndex(index) } const handleDragOver = (e: React.DragEvent, index: number) => { e.preventDefault() if (grabbedIndex !== null && grabbedIndex !== index) { moveItem(grabbedIndex, index) setGrabbedIndex(index) } } const handleDragEnd = () => { setGrabbedIndex(null) }关键设计:鼠标拖拽复用了grabbedIndex状态,aria-grabbed因此对鼠标与键盘两种操作保持一致;dataTransfer.effectAllowed = 'move'声明拖拽效果为移动。
渲染输出
return ( <> <div ref={listRef} role="list" aria-label="Sortable items" className="sortable-list" > {items.map((item, index) => ( <div key={item.id} role="listitem" tabIndex={0} aria-grabbed={grabbedIndex === index} aria-describedby="drag-instructions" draggable onKeyDown={(e) => handleKeyDown(e, index)} onDragStart={(e) => handleDragStart(e, index)} onDragOver={(e) => handleDragOver(e, index)} onDragEnd={handleDragEnd} className={`sortable-item ${ grabbedIndex === index ? 'sortable-item--grabbed' : '' }`} > <span className="sortable-item__handle" aria-hidden="true"> ⋮⋮ </span> <span className="sortable-item__content">{item.content}</span> </div> ))} </div> <div id="drag-instructions" className="sr-only"> Press Space to pick up. Use Arrow keys to move. Press Space to drop. Press Escape to cancel. </div> {/* Live region for announcements */} <div aria-live="assertive" aria-atomic="true" className="sr-only"> {announcement} </div> </> )渲染层要点:
aria-grabbed={grabbedIndex === index}动态反映抓取状态;aria-describedby="drag-instructions"把全局指令文本关联到每个项目;- 拖拽把手图标
⋮⋮用aria-hidden="true"隐藏,避免被读屏软件当作内容朗读; - 拖拽把手作为纯装饰,真正的操作指引来自
sr-only文本——这符合规则中 "Instructions: Clear usage guidance" 的要求; - 播报容器使用
aria-live="assertive"+aria-atomic="true":排序属于重要状态变化,采用 assertive(打断当前朗读立即播报);aria-atomic保证整个内容整体播报而非增量。对比同类规则可参考 accessible-notifications 中polite/assertive的选择逻辑:普通提示用 polite,错误与关键状态用 assertive。
Kanban 看板示例:跨列移动
规则还给出了看板场景的完整实现(references/rule.md 的 "Kanban Board Example"),演示"跨列移动"与"同列排序"不同的键盘模型:
interface Task { id: string title: string column: 'todo' | 'in-progress' | 'done' } interface KanbanBoardProps { tasks: Task[] onMoveTask: (taskId: string, newColumn: Task['column']) => void } export function KanbanBoard({ tasks, onMoveTask }: KanbanBoardProps) { const [selectedTask, setSelectedTask] = useState<string | null>(null) const [announcement, setAnnouncement] = useState('') const columns: Task['column'][] = ['todo', 'in-progress', 'done'] const columnLabels = { 'todo': 'To Do', 'in-progress': 'In Progress', 'done': 'Done' } const handleTaskKeyDown = (e: KeyboardEvent, task: Task) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault() if (selectedTask === task.id) { setSelectedTask(null) setAnnouncement(`${task.title} deselected`) } else { setSelectedTask(task.id) setAnnouncement(`${task.title} selected. Use arrow keys to move between columns.`) } } if (selectedTask === task.id) { const currentColumnIndex = columns.indexOf(task.column) if (e.key === 'ArrowLeft' && currentColumnIndex > 0) { e.preventDefault() const newColumn = columns[currentColumnIndex - 1] onMoveTask(task.id, newColumn) setAnnouncement(`${task.title} moved to ${columnLabels[newColumn]}`) } if (e.key === 'ArrowRight' && currentColumnIndex < columns.length - 1) { e.preventDefault() const newColumn = columns[currentColumnIndex + 1] onMoveTask(task.id, newColumn) setAnnouncement(`${task.title} moved to ${columnLabels[newColumn]}`) } if (e.key === 'Escape') { setSelectedTask(null) setAnnouncement(`${task.title} deselected`) } } } return ( <div className="kanban-board"> {columns.map(column => ( <div key={column} className="kanban-column" aria-label={columnLabels[column]} > <h2>{columnLabels[column]}</h2> <div role="list"> {tasks .filter(t => t.column === column) .map(task => ( <div key={task.id} role="listitem" tabIndex={0} aria-grabbed={selectedTask === task.id} aria-describedby="kanban-instructions" onKeyDown={(e) => handleTaskKeyDown(e, task)} className={`kanban-task ${ selectedTask === task.id ? 'kanban-task--selected' : '' }`} > {task.title} </div> ))} </div> </div> ))} <div id="kanban-instructions" className="sr-only"> Press Space to select. Use Left and Right arrows to move between columns. </div> <div aria-live="assertive" className="sr-only"> {announcement} </div> </div> ) }看板模型与列表模型的关键差异:
- 选择语义:
Space/Enter是"选中/取消选中"(toggle),因为看板移动是"选择任务 → 移动到另一列",而非"抓起后逐格移动"; - 方向键语义:
ArrowLeft/ArrowRight跨列移动(先查columns.indexOf(task.column)判断边界),同列内不提供上下重排——符合看板"卡片在列间流转"的交互本质; - 列名播报:每列用
aria-label命名(To Do / In Progress / Done),移动成功后播报${task.title} moved to ${columnLabels[newColumn]},屏幕阅读器用户能明确感知卡片进入了哪一列; - 边界保护:
currentColumnIndex > 0/< columns.length - 1防止越界移动。
样式与视觉反馈
规则配套的 CSS(references/rule.md 的 "Styling" 一节)确保"视觉反馈"这一需求落地:
.sortable-list { display: flex; flex-direction: column; gap: 0.5rem; } .sortable-item { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; background: #fff; border: 1px solid #ddd; border-radius: 4px; cursor: grab; } .sortable-item:focus-visible { outline: 2px solid #0066cc; outline-offset: 2px; } .sortable-item--grabbed { background: #e3f2fd; border-color: #2196f3; cursor: grabbing; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .sortable-item__handle { color: #999; font-size: 1.25rem; line-height: 1; } /* Screen reader only */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; } /* Kanban board */ .kanban-board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } .kanban-task { padding: 1rem; background: #fff; border: 2px solid transparent; border-radius: 4px; cursor: pointer; } .kanban-task:focus-visible { outline: 2px solid #0066cc; outline-offset: 2px; } .kanban-task--selected { border-color: #2196f3; background: #e3f2fd; }视觉反馈的三个层次:
- 焦点可见性:
:focus-visible提供 2px 高对比外框,键盘用户移动焦点时能明确看到当前位置; - 抓取状态:
.sortable-item--grabbed用蓝色背景、蓝色边框、阴影与cursor: grabbing标识"当前被抓取",且该 class 由grabbedIndex === index驱动,鼠标与键盘操作共享; - 看板选中态:
.kanban-task--selected用蓝色边框 + 浅蓝背景标识当前选中的任务卡。
sr-only类采用标准裁剪手法(clip: rect(0, 0, 0, 0))把指令文本与播报容器从视觉上隐藏,同时保留给屏幕阅读器。
验证清单(Verification)
规则给出了 8 步手动验证流程(references/rule.md):
- Navigate to draggable items with Tab
- Press Space to pick up item
- Use Arrow keys to reposition
- Press Space to drop
- Press Escape to cancel
- Verify screen reader announces all state changes
- Test mouse drag still works
- Check visual feedback for grabbed state
每条对应一个具体需求点:
- 第 1 步验证
tabindex="0"与焦点序列; - 第 2~5 步覆盖完整键盘操作循环,包括取消路径;
- 第 6 步验证 live region 播报了抓起/移动/放下/取消的所有状态文本;
- 第 7 步回归鼠标拖拽,确认键盘替代方案没有破坏原生拖拽;
- 第 8 步确认抓取态的视觉高亮与焦点指示。
警告:仅鼠标实现不可接受。规则原文明确:"Native HTML5 drag and drop is not keyboard accessible. Always implement keyboard alternatives alongside mouse drag functionality."(原生 HTML5 拖拽不具备键盘可访问性,必须始终与键盘替代方案并存。)
仓库中的规则定义与 Agent/LLM 调用方式
本规则在仓库中不只是静态文档,它已结构化进内容管线,供 MCP 工具与 AI Agent 直接调用。
规则 frontmatter:四类 Prompt
在 packages/content/rules/en/html/draggable-accessibility.mdx 的 frontmatter 中,除元数据(priority: medium、difficulty: advanced、estimatedTime: 35)外,还定义了四类操作化 prompt:
check:验证拖拽界面是否具备键盘替代方案、正确的 ARIA 属性与 live region 播报;fix:实现键盘替代方案(方向键、Enter/Space)、aria-grabbed、aria-dropeffect与状态播报;explain:解释可访问拖拽实现如何为键盘与屏幕阅读器用户提供等价功能;codeReview:审查模板、服务端渲染 HTML 与共享组件中相关输出标记,指出具体违反规则的元素、属性与路由。
同时 frontmatter 中的aiContext与 SKILL.md 的description一致,明确 Agent 应在审查模板/渲染 HTML/共享组件时使用本规则,并"验证浏览器最终标记而非框架源码抽象"。
MCP 工具调用链
这些 prompts 被 MCP 服务器暴露为可编程工具(见 packages/mcp/src/tools 目录):
search_rules按关键词检索规则;get_rule获取规则全文;check_rule(packages/mcp/src/tools/check-rule.ts):传入slug与可选code。无代码时返回checkPrompt作为验证指引;有代码时进行启发式分析并返回问题清单,发现问题才附带fixPrompt;fix_rule/explain_rule分别用于获取修复指引与原理解释;review_code在代码审查中触发规则检查。
因此,draggable-accessibility的整套内容(标记示例、React 实现、验证清单、修复指引)都可以被 Agent 在审查与修复流程中程序化调用,这也是本仓库"for humans and AI agents"设计理念的体现。
关联规则
该规则与以下规则共享审查场景(见 mdx frontmatter 的relatedRules,可对照阅读):
- carousel-accessibility:两者都可能在同一个实现中失败,常一起审查;
- accessible-notifications:涉及 live region 的用法,与拖拽播报相互印证;
- custom-element-accessibility 与 pagination-accessibility:常见于同一审查场景。
其中 live region 的完整语义可进一步阅读 aria-live-regions 规则。
小结
可访问拖拽的实现可以概括为一条等式:原生鼠标拖拽 + 键盘操作替代方案 +aria-grabbed/aria-dropeffect状态 + live region 状态播报 + 明确焦点与视觉反馈 + 使用指引。本规则的 React 实现展示了一个可复用的设计:鼠标与键盘共享同一个状态机(grabbedIndex),ARIA 属性由该状态派生,所有状态变化统一走announce()注入 live region——这正是保证"等价功能"的关键。落地时请以 8 步验证清单收尾,并牢记:只支持鼠标的拖拽,在任何情况下都不是可访问的实现。
【免费下载链接】Front-End-Checklist🗂 The essential checklist for modern web development, for humans and AI agents项目地址: https://gitcode.com/gh_mirrors/fr/Front-End-Checklist
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考