首页
/ Gitea 的 Fomantic UI 无障碍(ARIA)补丁机制:Checkbox、Dropdown 与 Modal 的读屏适配实践

Gitea 的 Fomantic UI 无障碍(ARIA)补丁机制:Checkbox、Dropdown 与 Modal 的读屏适配实践

2026-09-06 11:03:29作者:卓炯娓

本文以 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.tsinitGiteaFomantic() 中,其中有两行注释点明了意图:

// 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 文本读出来。文档说明的解决方案是调用 initAriaLabelsinputlabel 关联起来,并且由 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 属性装配

attachStaticElementsdropdown.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"
  • 焦点主元素设置 rolearia-haspopuparia-controls(指向菜单 id)、aria-expanded="false"
  • 若 dropdown 带 data-tooltip-content 且没有 aria-label,则把 tooltip 文案用作 aria-label

多选场景下部分 dropdown 在 HTML 中没有静态 .menu 节点,attachInitElements 会预先创建一个空的 .menu 并追加到 dropdown 末尾,以便后续获得正确的 aria 属性。

键盘与展开状态管理

attachDomEventsdropdown.ts)实现了补丁的交互层:

  1. 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 卡顿。
  2. 方向键导航:监听 keyupkey.startsWith('Arrow') 时触发刷新,使 aria-activedescendant 跟随 Fomantic 原生的方向键高亮移动。
  3. Enter 语义keydown 中若选中项匹配 a.js-aria-clickable 且未隐藏/过滤,则 preventDefault 后手动 click();否则交给 Fomantic 原生的 Enter 处理(收起菜单)。之所以要自行判断,是为了避免与 Fomantic 的 Enter 处理叠加造成“双击”。
  4. 移动端点击序列兼容:桌面端事件序列是 mousedown → focus → mouseup → click,移动端是 focus → mousedown → mouseup → click。补丁用 ignoreClickPreEvents / ignoreClickPreVisible 两个计数器(以 capture 模式监听 mousedown/focus/blur/mouseup/click)识别“弹层已由 focus 打开”的场景,从而在 click 上 stopPropagation,防止 Android TalkBack 下弹层被立即关闭。

动态(AJAX)条目的二次处理

AJAX 加载的条目不经过静态初始化。delegateDropdownModuledropdown.ts)通过替换官方 settings 里的 templates.menuonLabelCreate 回调实现拦截:

  • 包裹 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 分隔线

hideScopedEmptyDividersdropdown.ts)处理带 data-scope 分组的下拉菜单:当某 scope 内的条目被搜索过滤后,自动隐藏对应的空 scope 分隔线、首尾多余分隔线以及连续重复分隔线。该函数有完整的单元测试覆盖,见 dropdown.test.tshideScopedEmptyDividers-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 方案可以概括为三层:

  1. 表单基线initAriaLabelsbase.ts)修复 label/input 绑定,并由 observer.ts 的 MutationObserver 覆盖动态节点;
  2. Dropdown 交互层initAriaDropdownPatchdropdown.ts)通过“包装 $.fn.dropdown + 委托官方回调”实现 combobox/menu 双模式、aria-activedescendant 导航与移动端事件序列兼容;
  3. Modal 键盘层initAriaModalPatchmodal.ts)修正 Enter 键语义。

适用前提需要留意:这些补丁全部运行在前端 JS 层,依赖浏览器执行 JS;多选下拉的 a11y 支持文档自述“not well-supported yet”;鼠标点击打开 dropdown 时方向键在 VoiceOver 下失效属于读屏器与 DOM 事件模型的交互限制,可用 Option + 方向键 绕过。对需要深入阅读的实现入口,可依次查看 web_src/js/modules/fomantic.tsweb_src/js/modules/fomantic/dropdown.ts 与配套测试 web_src/js/modules/fomantic/dropdown.test.ts

登录后查看全文
热门项目推荐
相关项目推荐