- 后端
- 前端
- CRM
- 人工智能
- AI Agent
【免费下载链接】crm
Comp AI CRM is an open source, CRM designed for AI agents. Agentic-first CRM.
导读
本文以 Comp AI CRM 仓库中的 .agents/skills/better-accessibility/focus-and-keyboard.md 技能文档为主体,系统讲解 Agentic CRM 前端(apps/app与packages/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.tsx、dialog.tsx、sheet.tsx、tabs.tsx)可作为落地参照。
Focus Rings:只美化:focus-visible,永不裸写:focus
核心原则
样式化:focus-visible,而不是裸的:focus。浏览器只在键盘与辅助技术触发焦点时呈现:focus-visible,而鼠标点击虽然同样触发:focus,却通常不触发:focus-visible——因为鼠标用户的焦点位置是"显然"的。反过来,永远不要写outline: none或 Tailwind 的focus:outline-none而不提供可见替代:那等于删除了有视力的键盘用户的导航能力。
优先级顺序(从优到次)
- 保留浏览器原生焦点环,只加
outline-offset让它在背景上"透气"。原生指示会自动适配平台与forced-colors设置,作者无需预测每一种背景色。 - 当设计确实需要自定义焦点环时,使用项目已验证的焦点 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 是一种承诺"。
| Widget | Keys |
|---|---|
| Dialog | Tab/Shift+Tab 在内部循环(两端回绕);Escape 关闭 |
| Tabs | 箭头键在标签间移动(循环);Tab 移出到面板;Home/End 跳到第一个/最后一个 |
| Menu button | Enter/Space/ArrowDown 打开并聚焦第一项;ArrowUp 打开并聚焦最后一项;箭头导航;Escape 关闭并把焦点还给按钮 |
| Disclosure / accordion | 头部是<button aria-expanded>;Enter 和 Space 切换 |
| Combobox | ArrowDown 打开/移入列表;Enter 接受;Escape 关闭并回到输入框;输入即过滤 |
| Listbox / radio group | 箭头键移动选择;整个组只有一个 Tab 停靠点 |
通用规则
- Escape 逐级关闭最后打开的东西:先 tooltip,再 menu,最后 dialog;
- 箭头键负责复合组件内部的移动,Tab 键负责组件之间的移动;
- Tabs 选择激活模式:面板即时渲染用自动模式(箭头聚焦即切换面板);切换代价高时用手动模式(Enter/Space 才激活);
- Enter 提交当前聚焦输入框所属表单;在
<textarea>中 Enter 换行,⌘/Ctrl+Enter 提交。
SPA 路由切换:焦点与标题的迁移
客户端导航不会自动重置焦点,也不会自动播报任何内容。每次路由变化时:
- 更新
document.title以匹配新上下文; - 把焦点移到新视图的
<h1>(需要tabindex="-1")或<main>; - 返回/前进时恢复滚动位置,前进导航时滚动到顶部。
Comp AI CRM 前端是 Next.js 应用(apps/app),存在大量[slug]动态路由(如apps/app/app/(app)/[slug]/companies/page.tsx),因此"标题 + 焦点迁移"必须作为路由层通用逻辑处理,而非散落在各页面里。
自查清单:提交前的五步验证
把本文档压缩为可执行的自查清单,供实现与 review 时逐项勾选:
- 焦点可见性:所有交互元素在键盘聚焦时有可见指示;是否存在裸
outline-none/focus:outline-none且无替代环? - 跳过导航:重复导航前是否有 Skip Link,主内容是否为
<main id="main">? - Tab 顺序:是否存在正数
tabindex?复合组件是否实现了 roving tabindex(一个 Tab 停靠点 + 箭头键内移)? - 模态焦点:对话框/抽屉打开时背景是否被
inert或等价机制移除出 Tab 顺序?关闭后焦点是否回到触发器?是否提供可访问名称? - 键盘模型:自定义 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.
相关推荐
ShareDrop无障碍焦点管理:键盘导航与焦点陷阱实现
ShareDrop无障碍焦点管理:键盘导航与焦点陷阱实现 在现代Web应用开发中,无障碍设计(A11y)已成为提升用户体验的关键要素。ShareDrop作为基于
后端前端即时通讯Logoly无障碍模态框:焦点陷阱与键盘关闭实现
Logoly无障碍模态框:焦点陷阱与键盘关闭实现 在现代Web应用中,模态框(Modal)作为用户交互的重要组件,其无障碍设计直接影响着使用体验。本文将以Log
前端CANN驱动URMA设备计数API
dcmiv2\_get\_urma\_device\_cnt<a name="ZH CN_TOPIC_0000002519017001" </a 函数原型<a
驱动开发人工智能CANN
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考