ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Front-End-Checklist 无障碍拖拽(Draggable Accessibility)规则实战:键盘替代方案、ARIA 状态与实时播报

Front-End-Checklist 无障碍拖拽(Draggable Accessibility)规则实战:键盘替代方案、ARIA 状态与实时播报 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类别categoryhtml子类别为 interaction 交互优先级prioritymedium难度difficultyadvanced预估耗时 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 的拖拽draggabletruedragstart/dragover/drop事件没有内建的键盘操作路径键盘用户无法抓取一个可拖拽元素屏幕阅读器用户听不到任何拖拽状态变化鼠标拖拽的视觉线索位置移动、占位符对其完全不可见即使给元素加了draggable属性Tab焦点到达后依然没有任何可执行的操作方式。因此规则的要求非常明确拖拽功能必须与键盘替代方案、ARIA 状态属性、live region 状态播报三者同时存在否则等同于把一部分用户锁在功能之外。无障碍需求总览规则用一张表格汇总了五项核心要求见 references/rule.mdRequirementImplementationKeyboard alternativeArrow keys, Enter to pick up/dropARIA attributesaria-grabbed,aria-dropeffectAnnouncementsLive regions for state changesVisual feedbackFocus indicators for drop zonesInstructionsClear usage guidance逐项展开键盘替代方案必须允许仅凭键盘完成抓起—移动—放下—取消的完整操作循环ARIA 属性aria-grabbed当前元素是否被抓起与aria-dropeffect元素可作为放置目标时的效果用于向辅助技术表达拖拽状态。规则同时注明or newer attributes——即 WAI-ARIA 后续版本中的替代状态机制也可以接受关键是有明确的状态语义而非完全缺失状态播报所有状态变化抓起、移动、放下、取消都必须通过 live region 播报给屏幕阅读器视觉反馈被抓取状态、放置目标需要有明确的焦点指示与高亮使用说明用户需要知道按什么键做什么——通过可见或sr-only的指令文本呈现。标准键盘交互模式规则定义了统一的键盘操作协议KeyActionTabNavigate between draggable itemsSpace/EnterPick up / drop itemArrow keysMove item to adjacent positionEscapeCancel drag operation要点说明Tab在可拖拽项目之间移动焦点每个项目需tabindex0Space或Enter是抓起/放下的开关焦点在未被抓取的元素上按下即抓起焦点在目标位置按下即放下方向键在抓起状态下把项目移动到相邻位置未抓起时则用于在项目间移动焦点实现中focusItem负责此行为Escape随时取消当前拖拽把项目放回原位并播报reorder cancelled。HTML 标记基础示例规则给出了最基础的可访问排序列表标记references/rule.mddiv rolelist aria-labelSortable tasks classsortable-list div rolelistitem tabindex0 aria-grabbedfalse draggabletrue classsortable-item span classsortable-item__handle aria-hiddentrue⋮⋮/span span classsortable-item__contentTask 1/span span classsr-onlyPress Space to pick up, use arrows to reorder/span /div div rolelistitem tabindex0 aria-grabbedfalse draggabletrue classsortable-item span classsortable-item__handle aria-hiddentrue⋮⋮/span span classsortable-item__contentTask 2/span span classsr-onlyPress Space to pick up, use arrows to reorder/span /div /div !-- Live region for announcements -- div iddrag-announcements aria-liveassertive classsr-only/div这份标记的语义要点列表结构容器用rolelistaria-label每个项目用rolelistitem让辅助技术把排序列表识别为可遍历的列表结构可聚焦tabindex0保证每个项目都能进入 Tab 焦点序列拖拽状态aria-grabbedfalse明确声明当前未被抓取项目也保留了原生draggabletrue以支持鼠标操作操作指引每个项目内嵌入sr-only的简短指令Press Space to pick up, use arrows to reorder播报容器页面末尾放置div iddrag-announcements aria-liveassertive classsr-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] useStatenumber | null(null) const [announcement, setAnnouncement] useState() const listRef useRefHTMLDivElement(null)grabbedIndex当前被抓起项目的下标null表示无抓取状态announcement要播报的文本注入到 live regionlistRef列表容器引用用于在项目间移动焦点。播报函数用 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([rolelistitem]) ;(items?.[index] as HTMLElement)?.focus() }moveItem以不可变方式拷贝数组 splice完成重排并把新数组通过onReorder上抛给父组件符合 React 单向数据流focusItem通过querySelectorAll([rolelistitem])定位项目并调用.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} rolelist aria-labelSortable items classNamesortable-list {items.map((item, index) ( div key{item.id} rolelistitem tabIndex{0} aria-grabbed{grabbedIndex index} aria-describedbydrag-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 classNamesortable-item__handle aria-hiddentrue ⋮⋮ /span span classNamesortable-item__content{item.content}/span /div ))} /div div iddrag-instructions classNamesr-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-liveassertive aria-atomictrue classNamesr-only {announcement} /div / )渲染层要点aria-grabbed{grabbedIndex index}动态反映抓取状态aria-describedbydrag-instructions把全局指令文本关联到每个项目拖拽把手图标⋮⋮用aria-hiddentrue隐藏避免被读屏软件当作内容朗读拖拽把手作为纯装饰真正的操作指引来自sr-only文本——这符合规则中 Instructions: Clear usage guidance 的要求播报容器使用aria-liveassertivearia-atomictrue排序属于重要状态变化采用 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] useStatestring | 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 classNamekanban-board {columns.map(column ( div key{column} classNamekanban-column aria-label{columnLabels[column]} h2{columnLabels[column]}/h2 div rolelist {tasks .filter(t t.column column) .map(task ( div key{task.id} rolelistitem tabIndex{0} aria-grabbed{selectedTask task.id} aria-describedbykanban-instructions onKeyDown{(e) handleTaskKeyDown(e, task)} className{kanban-task ${ selectedTask task.id ? kanban-task--selected : }} {task.title} /div ))} /div /div ))} div idkanban-instructions classNamesr-only Press Space to select. Use Left and Right arrows to move between columns. /div div aria-liveassertive classNamesr-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防止越界移动。样式与视觉反馈规则配套的 CSSreferences/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.mdNavigate to draggable items with TabPress Space to pick up itemUse Arrow keys to repositionPress Space to dropPress Escape to cancelVerify screen reader announces all state changesTest mouse drag still worksCheck visual feedback for grabbed state每条对应一个具体需求点第 1 步验证tabindex0与焦点序列第 25 步覆盖完整键盘操作循环包括取消路径第 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外还定义了四类操作化 promptcheck验证拖拽界面是否具备键盘替代方案、正确的 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_rulepackages/mcp/src/tools/check-rule.ts传入slug与可选code。无代码时返回checkPrompt作为验证指引有代码时进行启发式分析并返回问题清单发现问题才附带fixPromptfix_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 实现展示了一个可复用的设计鼠标与键盘共享同一个状态机grabbedIndexARIA 属性由该状态派生所有状态变化统一走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),仅供参考
返回列表