Gitea 的 Fomantic UI 无障碍(ARIA)补丁机制:Checkbox、Dropdown 与 Modal 的读屏适配实践
本文以 Gitea 仓库中 aria.md 这份前端无障碍参考文档为主体,系统梳理 Gitea 是如何在不侵入官方 Fomantic UI 库的前提下,通过一组独立的 ARIA 补丁让 Checkbox、Dropdown、Modal 等组件对屏幕阅读器(VoiceOver、TalkBack)可用;读完你可以掌握 Gitea 前端 a11y 补丁的初始化链路、aria-activedescendant/aria-expanded 等关键属性的落地方式,以及用读屏软件验证 UI 可访问性的标准步骤。
一、背景与设计原则:不动官方库的“旁路补丁”
Fomantic UI 作为 Gitea 的前端组件库,存在不少无障碍(a11y)问题。Gitea 的应对策略是:所有 aria 相关代码都放在 web_src/js/modules/fomantic/ 目录下,以补丁(patch)形式运行在官方组件之上。aria.md 明确了这条设计原则:
- aria 相关代码刻意避免触碰官方 Fomantic UI 库源码;
- 补丁追求尽可能独立(as independent as possible),以便未来可以轻松修改或整体移除。
补丁的注册入口在 web_src/js/modules/fomantic.ts 的 initGiteaFomantic() 中,其中有两行注释点明了意图:
// Use the patches to improve accessibility, these patches are designed
// to be as independent as possible, make it easy to modify or remove in the future.
initAriaDropdownPatch();
initAriaModalPatch();
补丁的“独立性”体现在实现手法上:以 dropdown.ts 为例,Gitea 先把官方函数保存起来,再整体替换 jQuery 插件入口:
const ariaPatchKey = '_giteaAriaPatchDropdown';
const fomanticDropdownFn = $.fn.dropdown;
export function initAriaDropdownPatch() {
if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once');
$.fn.dropdown = ariaDropdownFn; // 替换入口
$.fn.fomanticExt.onDropdownAfterFiltered = onDropdownAfterFiltered;
(ariaDropdownFn as FomanticInitFunction).settings = fomanticDropdownFn.settings;
}
替换后的 ariaDropdownFn 是一个包装器:先原样把参数透传给官方 $.fn.dropdown,然后在返回后为每个 .ui.dropdown 元素补充一次性的 aria 初始化。由于官方库完全未被修改,未来若 Fomantic 修复了这些问题,整套补丁可以原样删除而不影响其余功能。
二、如何用读屏软件验证 ARIA 补丁
aria.md 中给出了一套开发者自测步骤,这部分内容是验证任何 a11y 改动的基本盘,完整继承如下。
macOS:VoiceOver
- 按
Command + F5开启 VoiceOver; - 尝试仅用键盘操作 UI;
- 用
Tab/Shift+Tab在元素之间切换焦点; - 用方向键(
Option + Up/Down)在菜单/combobox 条目之间导航——注意此时只是aria-active,并没有真正获得焦点; - 按
Enter触发当前 aria-active 的条目。
Android:TalkBack
- 进入 Settings → Accessibility → TalkBack 并开启;
- 长按或“按压+滑动”切换 aria-active 元素(并非真正聚焦);
- 双击等价于对 aria-active 元素的旧式单击;
- 双指滑动等价于旧式单指滑动。
文档中还有一个未完成的 TODO:Windows、Linux、iOS 平台的验证步骤待补充——这说明 Gitea 的 a11y 测试目前主要覆盖 macOS 与 Android 两条线。
已知问题:鼠标打开的 Dropdown 方向键失灵
文档记录了一个经过 VoiceOver 实测的关键问题:如果 dropdown 是用鼠标点击打开的,方向键不生效;但如果先用键盘 Tab 聚焦再打开,方向键就正常,之后即使改用鼠标点击也基本正常。原因是当 dropdown 仅由鼠标点击打开时,VoiceOver 不会把方向键的 keydown 事件派发到 DOM(VoiceOver 期望用方向键在元素间导航但做不到)。临时解法:鼠标打开菜单/combobox 时,使用 Option + 方向键 在条目(或已选中的 label)之间导航。
三、Checkbox:把 Fomantic 风格的 HTML 变得可访问
理想结构与现状
理想的 checkbox 应当是 label 包裹 input:
<label><input type="checkbox"> ... </label>
但 Gitea 的模板仍保留 Fomantic 风格的布局:
<div class="ui checkbox">
<input type="checkbox">
<label>...</label>
</div>
这种结构下 label 点击无法触发 input,屏幕阅读器也无法把 label 文本读出来。文档说明的解决方案是调用 initAriaLabels 把 input 与 label 关联起来,并且由 JS 自动为所有 Fomantic UI checkbox 补上 ID;如果 label 部分为空,则该 checkbox 必须手工添加 aria-label 属性。
源码实现:linkLabelAndInput 与幂等标记
实现位于 web_src/js/modules/fomantic/base.ts。核心函数 linkLabelAndInput 处理三种情况:
function linkLabelAndInput(label: Element, input: Element) {
const labelFor = label.getAttribute('for');
const inputId = input.getAttribute('id');
if (inputId && !labelFor) { // 缺 "for"
label.setAttribute('for', inputId);
} else if (!inputId && !labelFor) { // "id" 和 "for" 都缺
const id = generateElemId('_aria_label_input_');
input.setAttribute('id', id);
label.setAttribute('for', id);
}
}
即:input 已有 id 就补 for;两者都缺就生成形如 _aria_label_input_ 前缀的唯一 id 并双向绑定。外层的 patchLabels 则按“容器 → label → input”的层级在 .ui.checkbox 与 .ui.form .field 中查找配对元素,并写入 data-checkbox-patched / data-field-patched 标记属性防止重复处理:
export function initAriaLabels(container: ParentNode) {
patchLabels(container, '.ui.checkbox', 'label', 'input', 'data-checkbox-patched');
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select, :scope > textarea', 'data-field-patched');
}
注意第二个选择器用的是 :scope >,只对表单 field 的直接子级做绑定,避免误伤嵌套结构。
动态内容的覆盖:MutationObserver
页面上的 checkbox 并非一次性渲染完毕。web_src/js/modules/observer.ts 中的全局 MutationObserver 会在两类时机调用 initAriaLabels:页面初始加载时对 document 全量执行一次;之后任何新增节点(addedNode)都会即时补做 label 关联。这保证了 AJAX 加载出来的表单同样可访问。
四、Fomantic Dropdown:a11y 补丁的核心战场
Dropdown 的多种用途与焦点约束
aria.md 指出 Fomantic Dropdown 被复用于很多场景:
- 菜单(导航栏的用户菜单、页脚的语言菜单);
- 弹出层(分支/标签面板、代码评审输入框);
- 表单里大量的简单
<select>; - 静态条目的可搜索选项列表;
- 动态条目的可搜索选项列表(AJAX);
- 动态条目的可搜索多选列表(如仓库 topic 设置);
- 更复杂的用法,如 Issue 标签选择器。
补丁面临的核心约束是:Fomantic Dropdown 要求焦点必须停留在其主元素上,一旦焦点变化就会隐藏菜单或行为异常。因此 aria 补丁的整体思路是“焦点不动,用 aria-activedescendant 指向内部条目”。
两种 ARIA 模式的自动选择
文档给出了两条候选路线(对应 W3C ARIA APG 的 Combobox 与 Menubar 模式):
- combobox + listbox + option:带输入框的弹层选择控件,弹层呈现可选值或建议值;
- menu + menuitem:提供一组动作/功能的菜单控件。
Gitea 的折中方案是运行时自动判别:检测 dropdown 内是否存在 input,有则按 combobox 处理,无则按 menu 处理。这一决策在 dropdown.ts 中落地:
// There are 2 possible solutions about the role: combobox or menu.
// The idea is that if there is an input, then it's a combobox, otherwise it's a menu.
const isComboBox = dropdown.querySelectorAll('input').length > 0;
(dropdown as any)[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu';
(dropdown as any)[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : '';
(dropdown as any)[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem';
文档同时声明:当前代码只部分解决了“带条目”的 dropdown 的 a11y 问题,多选下拉(multiple selection)支持尚不完整,属于明确的待办。
两种 HTML 形态对应的焦点模型
文档给出了两类典型 DOM 结构:只读下拉(焦点在 .ui.dropdown 本身)和搜索型下拉(焦点在 input.search)。前者用 aria-activedescendant 指向菜单条目时并不完美,后者则由输入框指向兄弟元素,体验更佳。对应 dropdown.ts 中的注释:
// Dropdown has 2 different focusing behaviors
// * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
// * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown
const textSearch = dropdown.querySelector<HTMLElement>('input.search');
const focusable = textSearch || dropdown; // the primary element for focus
补丁还会顺带给搜索框补上 autocomplete="off"(combobox 的输入不应触发浏览器自动完成)。
静态元素的 aria 属性装配
attachStaticElements(dropdown.ts)为服务端模板生成的静态 dropdown 补齐属性:
- 菜单容器补 id(
_aria_dropdown_menu_前缀),并设置role(combobox 场景为listbox)。代码注释特别说明:role 只能在菜单内容就绪后设置,否则某些浏览器+读屏器组合(如 Chrome + AppleVoice)会崩溃; - 每个
.item经由updateMenuItem获得role=option/menuitem、唯一 id,并强制tabindex="-1"——菜单内元素一律不可聚焦,同时递归把 item 内部的a, input, button也设为tabindex="-1"; - 焦点主元素设置
role、aria-haspopup、aria-controls(指向菜单 id)、aria-expanded="false"; - 若 dropdown 带
data-tooltip-content且没有aria-label,则把 tooltip 文案用作aria-label。
多选场景下部分 dropdown 在 HTML 中没有静态 .menu 节点,attachInitElements 会预先创建一个空的 .menu 并追加到 dropdown 末尾,以便后续获得正确的 aria 属性。
键盘与展开状态管理
attachDomEvents(dropdown.ts)实现了补丁的交互层:
aria-expanded/aria-activedescendant的刷新:refreshAriaActiveItem根据菜单是否可见(class 为visible且非out,或处于in动画中)更新aria-expanded;再取.item.active或.item.selected作为目标,弹层可见时把它的 id 写入aria-activedescendant。menu 模式下弹层隐藏后清除aria-activedescendant并移除条目的 active/selected 类。刷新统一用setTimeout延后到下一 tick,确保 Fomantic 自身代码先执行完毕;隐藏时还刻意加 100ms 延迟,避免与 Fomantic 动画争抢导致 UI 卡顿。- 方向键导航:监听
keyup且key.startsWith('Arrow')时触发刷新,使aria-activedescendant跟随 Fomantic 原生的方向键高亮移动。 - Enter 语义:
keydown中若选中项匹配a或.js-aria-clickable且未隐藏/过滤,则preventDefault后手动click();否则交给 Fomantic 原生的 Enter 处理(收起菜单)。之所以要自行判断,是为了避免与 Fomantic 的 Enter 处理叠加造成“双击”。 - 移动端点击序列兼容:桌面端事件序列是
mousedown → focus → mouseup → click,移动端是focus → mousedown → mouseup → click。补丁用ignoreClickPreEvents/ignoreClickPreVisible两个计数器(以 capture 模式监听 mousedown/focus/blur/mouseup/click)识别“弹层已由 focus 打开”的场景,从而在 click 上stopPropagation,防止 Android TalkBack 下弹层被立即关闭。
动态(AJAX)条目的二次处理
AJAX 加载的条目不经过静态初始化。delegateDropdownModule(dropdown.ts)通过替换官方 settings 里的 templates.menu 与 onLabelCreate 回调实现拦截:
- 包裹
templates.menu:官方模板生成 HTML 后,补丁在中间插入一步,把每个> .item交给updateMenuItem处理,再调用deferredRefreshAriaActiveItem刷新aria-activedescendant; - 包裹
onLabelCreate:多选场景动态创建的选中 label(形如<a|div class="ui label" data-value="1">名称 <i class="delete icon"/></a>)由updateSelectionLabel补 id、tabindex="-1",并给删除图标设置role="button"、aria-hidden="false"以及由 i18n 模板remove_label_str生成的aria-label,让读屏器能朗读“删除标签 X”。
文档还列出了 dropdown 回归测试的重点页面:首页(“新建仓库”/“Profile”/“Language”菜单)、新建仓库页(大量 combobox)、协作者页的“权限”下拉。
顺带修复的视觉细节:scoped 分隔线
hideScopedEmptyDividers(dropdown.ts)处理带 data-scope 分组的下拉菜单:当某 scope 内的条目被搜索过滤后,自动隐藏对应的空 scope 分隔线、首尾多余分隔线以及连续重复分隔线。该函数有完整的单元测试覆盖,见 dropdown.test.ts:hideScopedEmptyDividers-simple、-items-all-filtered、-hide-last、-scoped-items 四个用例分别验证了首尾隐藏、全部过滤、scope 切换与末项隐藏的 DOM 断言。
五、Modal:让 Enter 键正确提交表单
虽然 aria.md 未专章讨论 Modal,但同目录的 modal.ts 是同一“独立补丁”设计下的产物,且解决的正是键盘可访问性问题:Fomantic 中按钮默认都是 type="submit",当 modal 内“取消”按钮位于“确定”之前时,按 Enter 会先触发“取消”从而误关对话框。补丁在 modal('show') 时把所有 form button.cancel:not([type]) 显式标记为 type="button":
// In such case, the "Enter" key will trigger the "cancel" button instead of "ok" button...
// So, all "cancel" buttons without "[type]" must be marked as "type=button".
for (const button of el.querySelectorAll('form button.cancel:not([type])')) {
button.setAttribute('type', 'button');
}
此外 onModalApproveDefault 默认禁止表单提交期间自动关闭 modal(对 .form-fetch-action 保留重试能力),保证键盘用户提交失败时仍能看到错误并重试。
六、小结与适用前提
把 aria.md 的设计脉络与源码对照起来,Gitea 的 a11y 方案可以概括为三层:
- 表单基线:
initAriaLabels(base.ts)修复 label/input 绑定,并由 observer.ts 的 MutationObserver 覆盖动态节点; - Dropdown 交互层:
initAriaDropdownPatch(dropdown.ts)通过“包装$.fn.dropdown+ 委托官方回调”实现 combobox/menu 双模式、aria-activedescendant导航与移动端事件序列兼容; - Modal 键盘层:
initAriaModalPatch(modal.ts)修正 Enter 键语义。
适用前提需要留意:这些补丁全部运行在前端 JS 层,依赖浏览器执行 JS;多选下拉的 a11y 支持文档自述“not well-supported yet”;鼠标点击打开 dropdown 时方向键在 VoiceOver 下失效属于读屏器与 DOM 事件模型的交互限制,可用 Option + 方向键 绕过。对需要深入阅读的实现入口,可依次查看 web_src/js/modules/fomantic.ts、web_src/js/modules/fomantic/dropdown.ts 与配套测试 web_src/js/modules/fomantic/dropdown.test.ts。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00