news 2026/9/24 16:08:47

Comp AI CRM 无障碍焦点与键盘实现指南:Focus Ring、Skip Link、Tabindex 与 APG 键盘模式

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Comp AI CRM 无障碍焦点与键盘实现指南:Focus Ring、Skip Link、Tabindex 与 APG 键盘模式
  • 后端
  • 前端
  • CRM
  • 人工智能
  • AI Agent

【免费下载链接】crm

Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.

项目地址:https://gitcode.com/gh_mirrors/crm48/crm
点击查看免费下载

导读

本文以 Comp AI CRM 仓库中的 .agents/skills/better-accessibility/focus-and-keyboard.md 技能文档为主体,系统讲解 Agentic CRM 前端(apps/apppackages/ui)在焦点指示、跳过导航、tabindex 治理、模态框焦点圈定与 ARIA APG 键盘交互模式上的工程规范。读完本文,你将掌握一套可直接落地的键盘可访问性实现方案:如何用:focus-visible兼顾美观与可见性、如何为复杂应用布置 Skip Link、如何用inert与现代<dialog>替代繁琐的手写焦点陷阱,以及如何让自定义组件完整兑现 ARIA 角色承诺的键盘模型。

为什么键盘可访问性对 Agent 型 CRM 尤其重要

Comp AI CRM 是一个"Agentic-first"的开源 CRM,界面包含大量会话面板、Agent 构建器(apps/app/components/agent-builder/)、数据表格、抽屉与对话框等复合组件。键盘用户(含依赖辅助技术的用户)完全无法使用鼠标点击,因此任何一次交互都必须有:

  • 清晰可见的焦点位置(否则视线键盘用户"迷路");
  • 可跳过的重复导航(否则每次进入主内容都要 Tab 过整个侧栏);
  • 符合预期的 Tab 顺序与箭头键模型(否则自定义小组件变得不可操作);
  • 模态上下文下的焦点圈定与还原(否则焦点会"逃逸"到背景页面)。

以下规范正是围绕这四件事展开,仓库中的真实组件(如agent-builder-sidebar.tsxdialog.tsxsheet.tsxtabs.tsx)可作为落地参照。

Focus Rings:只美化:focus-visible,永不裸写:focus

核心原则

样式化:focus-visible,而不是裸的:focus。浏览器只在键盘与辅助技术触发焦点时呈现:focus-visible,而鼠标点击虽然同样触发:focus,却通常不触发:focus-visible——因为鼠标用户的焦点位置是"显然"的。反过来,永远不要写outline: none或 Tailwind 的focus:outline-none而不提供可见替代:那等于删除了有视力的键盘用户的导航能力。

优先级顺序(从优到次)

  1. 保留浏览器原生焦点环,只加outline-offset让它在背景上"透气"。原生指示会自动适配平台与forced-colors设置,作者无需预测每一种背景色。
  2. 当设计确实需要自定义焦点环时,使用项目已验证的焦点 token,并人工检查渲染结果。
/* Best: keep the browser ring, just give it breathing room */ :focus-visible { outline-offset: 2px; } /* Custom ring when the design requires one: use the project's verified token */ :focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
// Tailwind: use the project's focus token or established focus-ring utility <button className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--focus-ring)]"> Save </button>

一个常见的错误是outline: 2px solid不写颜色——它会被渲染成currentColor,但这并不自动无障碍:焦点环可能跨越与文本自身背景颜色不同的区域(组件填充、页面表面、图片、渐变、hover/selected 状态),对比度会失效。因此,token、品牌色或currentColor只有当针对其穿过的每一条边缘做过渲染检查后才可接受。

forced-colors 与 group focus

forced-colors: active(Windows 高对比度)下,保留默认颜色调整,或显式使用系统颜色如Highlight不要forced-color-adjust: none冻结作者颜色,除非该控件在冻结后仍然可感知。

当一个包装容器需要在内部输入框获得焦点时"亮起"(典型场景:带图标的搜索框,图标嵌在边框内),用:focus-within把焦点样式作用到整个容器。仓库中的真实案例:

  • apps/app/components/agent-composer-frame.ts:focus-within:border-muted-foreground/60 focus-within:ring-1 focus-within:ring-ring/40,输入框聚焦时整条 composer 边框点亮;
  • apps/app/components/agent-builder/share-chat-dialog.tsx:focus-within:ring-2 focus-within:ring-ring/60
  • apps/app/components/landing/ask-card.tsx:着陆页提问卡片同样用focus-within:border-ring

Comp AI CRM 中的既有实践(可直接仿写)

在 Agent 构建器相关组件中,项目已经系统性地采用focus-visible:ring-2 focus-visible:ring-ring/60这一组合,例如:

  • apps/app/components/agent-builder/agent-builder-sidebar.tsx:outline-none ... focus-visible:ring-2 focus-visible:ring-ring/60
  • apps/app/components/agent-builder/agent-history.tsx:按钮同时使用outline-none focus-visible:ring-2 focus-visible:ring-ring/60
  • apps/app/components/agent-builder/chat-chips.tsx:图标按钮focus-visible:ring-2 focus-visible:ring-ring/60

注意这些写法都遵循了本文档的"铁律":outline-none(清除默认环)必定伴随focus-visible:ring-*(可见替代环),两者成对出现,从不单独使用。

Skip Link:让键盘用户直接跳到主内容

当重复导航或其他重复的"chrome"(侧栏、页头)位于主内容之前时,第一个可获得焦点的元素应是"Skip to content"链接,目标指向<main id="main">。它平时视觉隐藏,聚焦时再显示:

.skip-link { position: absolute; inset-inline-start: -999px; } .skip-link:focus { inset-inline-start: 16px; top: 16px; }
<body> <a class="skip-link" href="#main">Skip to content</a> <header>…</header> <main id="main">…</main> </body>

两个配套细节:

  • 页内锚点目标需要scroll-margin-top(例如 sticky 页头下用scroll-margin-top: 80px),否则跳转后目标会被吸顶元素遮住。
  • Skip Link 的隐藏方式用inset-inline-start: -999px而非display: none,保证它仍存在于可访问性树且可被聚焦。

Tabindex 三原则与 Roving Tabindex

三条铁律

含义与适用场景
tabindex="0"把元素加入自然 Tab 顺序。用于非原生可聚焦的自定义交互元素
tabindex="-1"只能被 JS 聚焦(el.focus())。用于要移焦到的标题、模态容器、roving-tabindex 成员
正数tabindex永远不要用。它会劫持整页 Tab 顺序;正确做法是修正 DOM 顺序

Roving Tabindex:复合组件只占一个 Tab 停靠点

复合组件(tabs、菜单、工具栏、单选组)整体只占一个Tab 停靠点:激活项tabindex="0",其余全部tabindex="-1",箭头键同时移动焦点与那个0

<div role="tablist"> {tabs.map((tab, i) => ( <button role="tab" tabIndex={i === activeIndex ? 0 : -1} aria-selected={i === activeIndex} onKeyDown={handleArrowKeys} // ArrowLeft/ArrowRight move activeIndex, wrapping > {tab.label} </button> ))} </div>

这个模式的意义在于:Tab 键负责"在组件之间移动",箭头键负责"在组件内部移动",两者分工清晰,屏幕阅读器用户不会迷失在几十个 tab 里。仓库中 UI 基础库提供了 packages/ui/src/components/tabs.tsx,它封装了TabList/Tab原语,业务侧应优先复用而非手写。

焦点圈定与还原:inert优先,<dialog>最优

模态框必须圈定焦点。现代做法是用inert属性把对话框背后的所有内容一次性从 Tab 顺序与辅助技术树中移除,而不再需要逐个元素管理:

// On open document.getElementById("app-content").inert = true; const dialog = dialogRef.current; (dialog.querySelector("[autofocus]") ?? dialog.querySelector("button, [href], input, select, textarea"))?.focus(); // On close document.getElementById("app-content").inert = false; triggerRef.current?.focus(); // always return focus to the element that opened it

优先使用原生<dialog>+showModal():焦点圈定、背景inert、Escape 关闭全部免费获得。若自定义遮罩无法使用<dialog>,则必须手工加上role="dialog"aria-modal="true",并用指向标题的aria-labelledby提供可访问名称。

无论哪种方式,共同规则:

  • 打开时:聚焦第一个可聚焦元素;对于破坏性确认弹窗,应聚焦"最不具破坏性"的操作(如"取消")而非危险按钮;
  • 关闭时:焦点返回触发器;若触发器已不存在,则移到最近的逻辑容器;
  • 在对话框上加overscroll-behavior: contain,防止内部滚动带动背景页面。

仓库对照:packages/ui/src/components/dialog.tsx

Comp AI CRM 的 UI 库将 Radix UI 的Dialog封装为packages/ui/src/components/dialog.tsx

  • 根组件DialogPrimitive.Root保留 Radix 的焦点圈定(focus trap)语义;
  • DialogOverlay(dialog.tsx)使用fixed inset-0 isolate z-50覆盖层;
  • DialogContent(dialog.tsx)自带outline-none与关闭按钮,关闭按钮内层文字用sr-only隐藏、但保留给屏幕阅读器:
    <Button variant="ghost" className="absolute top-2 right-2" size="icon-sm"> <XIcon /> <span className="sr-only">Close</span> </Button>

    这正是"图标按钮必须配可访问名称"的范例——视觉上只有叉号图标,辅助技术上是一个可读的 "Close" 按钮。

同类封装还包括 packages/ui/src/components/sheet.tsx(抽屉)与 packages/ui/src/components/alert-dialog.tsx(确认对话框),业务侧(如apps/app/app/(app)/[slug]/companies/create-company-sheet.tsx)统一通过SheetTitle/SheetDescription补齐对话框标题与描述,避免"无名对话框"。

键盘模式(ARIA APG):一个角色就是一个承诺

原生元素自带这些行为;自定义组件必须自行实现。给元素加上role="tab",用户就会期待完整的 tab 键盘模型——"role 是一种承诺"。

WidgetKeys
DialogTab/Shift+Tab 在内部循环(两端回绕);Escape 关闭
Tabs箭头键在标签间移动(循环);Tab 移出到面板;Home/End 跳到第一个/最后一个
Menu buttonEnter/Space/ArrowDown 打开并聚焦第一项;ArrowUp 打开并聚焦最后一项;箭头导航;Escape 关闭并把焦点还给按钮
Disclosure / accordion头部是<button aria-expanded>;Enter 和 Space 切换
ComboboxArrowDown 打开/移入列表;Enter 接受;Escape 关闭并回到输入框;输入即过滤
Listbox / radio group箭头键移动选择;整个组只有一个 Tab 停靠点

通用规则

  • Escape 逐级关闭最后打开的东西:先 tooltip,再 menu,最后 dialog;
  • 箭头键负责复合组件内部的移动,Tab 键负责组件之间的移动;
  • Tabs 选择激活模式:面板即时渲染用自动模式(箭头聚焦即切换面板);切换代价高时用手动模式(Enter/Space 才激活);
  • Enter 提交当前聚焦输入框所属表单;在<textarea>中 Enter 换行,⌘/Ctrl+Enter 提交。

SPA 路由切换:焦点与标题的迁移

客户端导航不会自动重置焦点,也不会自动播报任何内容。每次路由变化时:

  1. 更新document.title以匹配新上下文;
  2. 把焦点移到新视图的<h1>(需要tabindex="-1")或<main>
  3. 返回/前进时恢复滚动位置,前进导航时滚动到顶部。

Comp AI CRM 前端是 Next.js 应用(apps/app),存在大量[slug]动态路由(如apps/app/app/(app)/[slug]/companies/page.tsx),因此"标题 + 焦点迁移"必须作为路由层通用逻辑处理,而非散落在各页面里。

自查清单:提交前的五步验证

把本文档压缩为可执行的自查清单,供实现与 review 时逐项勾选:

  1. 焦点可见性:所有交互元素在键盘聚焦时有可见指示;是否存在裸outline-none/focus:outline-none且无替代环?
  2. 跳过导航:重复导航前是否有 Skip Link,主内容是否为<main id="main">
  3. Tab 顺序:是否存在正数tabindex?复合组件是否实现了 roving tabindex(一个 Tab 停靠点 + 箭头键内移)?
  4. 模态焦点:对话框/抽屉打开时背景是否被inert或等价机制移除出 Tab 顺序?关闭后焦点是否回到触发器?是否提供可访问名称?
  5. 键盘模型:自定义 widget 是否兑现其 ARIA 角色的完整 APG 键盘行为(特别是 Escape 与箭头键)?

延伸阅读

  • 技能文档原文:.agents/skills/better-accessibility/focus-and-keyboard.md
  • 同一技能目录下的相关规范可继续查阅.agents/skills/better-accessibility/下的其他文档(如数据边界、证据与身份匹配等 Agent 技能文档位于 apps/agent/skills/)
  • UI 基础库组件实现:packages/ui/src/components/dialog.tsx、packages/ui/src/components/tabs.tsx、packages/ui/src/components/sheet.tsx
  • 业务侧可访问性写法实例:apps/app/components/agent-builder/agent-builder-sidebar.tsx、apps/app/components/agent-composer-frame.ts
  • 后端
  • 前端
  • CRM
  • 人工智能
  • AI Agent

【免费下载链接】crm

Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.

项目地址:https://gitcode.com/gh_mirrors/crm48/crm
点击查看免费下载
上一篇:BepInEx IL2CPP启动失败:3种解决方案从诊断到修复
下一篇:BepInEx IL2CPP启动失败深度解析:从架构诊断到系统级修复

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 16:04:29

Akka Streams Unzip 算子深度解析:将二元组流拆分到两个下游流

后端并发编程异步编程 【免费下载链接】akka-core A platform to build and run apps that are elastic, agile, and resilient. SDK, libraries, and hosted environments. 项目地址&#xff1a; https://gitcode.com/gh_mirrors/ak/akka-core 点击查看 免费下载 导读 Unzip…

作者头像 李华
网站建设 2026/9/24 16:03:48

YOLOv8与多模态大模型融合实战:从CLIP开放词汇检测到SAM实例分割再到三模态统一框架的完整落地指南

🎪 摸鱼匠:个人主页 🎒 个人专栏:《YOLOv8 入门到精通:全栈实战》 🥇 没有好的理念,只有脚踏实地! 文章目录 一、YOLOv8与多模态大模型融合基础 1.1 多模态大模型时代的计算机视觉新范式 1.2 YOLOv8与CLIP的协同工作原理 1.3 YOLOv8与SAM的协同工作原理 二、YOLO…

作者头像 李华
网站建设 2026/9/24 16:01:43

【Dify】自动化多主题研究与深度报告工作

深度研究与多主题分析需求日益增长,自动化工具成为提升效率与报告质量的关键。结构化研究流程和智能内容生成方案受到编程自学者关注。 本篇介绍Dify自动化多主题研究与深度报告的完整工作流,实现主题拆解、子问题分析、AI模型驱动内容生成,以及高质量研究成果的系统输出。…

作者头像 李华
网站建设 2026/9/24 16:00:06

DRF 3.x Format Suffixes 格式后缀使用示例和配置方法

在现代Web开发中,API已经成为了核心架构的一部分。Django Rest Framework(简称DRF)是Python中一个强大且广泛使用的库,帮助开发者快速构建高效且灵活的API。在API开发中,如何支持客户端使用不同的响应格式是一项重要的需求。为了满足这一需求,DRF引入了格式后缀机制,通过…

作者头像 李华