mcpField
A
accessibility-as-code

accessibility-as-code

github.com/zakariaf设计与 UI63165 次安装5 周前更新
代码仓库

关于 accessibility-as-code

面向 Flutter 开发者的无障碍界面编写规范,涵盖语义、文字缩放、对比度、点击目标和遍历顺序,并建议通过组件测试验证。

主要功能

  1. 01为交互控件添加语义信息
  2. 02为图像添加标签或排除装饰图像
  3. 03遵循系统文字缩放和粗体设置
  4. 04使用非颜色信号表达状态
  5. 05设定对比度与点击目标要求
  6. 06定义辅助技术的遍历顺序

适用场景

  1. 01构建适配屏幕阅读器的控件
  2. 02支持放大文字且避免截断
  3. 03让状态指示不依赖颜色也易于理解

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill accessibility-as-code

安装后在 Claude Code 中输入/accessibility-as-code确认已加载。

详细介绍

无障碍是编码规范,而非检查清单

编写组件时,就要将无障碍纳入其中——它和「按钮确实能用」处于同一优先级。没有针对这些内容的 lint:flutter_lints 和 very_good_analysis 都不包含任何 a11y 规则。落实这些要求靠组件本身、审查自己的 diff 和组件测试。适用于每个交互式 View、每个 Icon/Image、每种状态和每个颜色选择。

测试方法(指南匹配器、黄金测试通道、RTL、如实说明符合性限制)位于 widget-golden-and-a11y-testing;本技能讲的是编写时的规范。

不可妥协的规则

  1. 每个交互节点都要添加 Semantics(button: true, label: ...)——没有语义信息的原始 GestureDetector 会悄无声息地将所有屏幕阅读器和开关控制用户拒之门外。正确的语义信息能让 TalkBack、VoiceOver、Switch Access 和 Switch Control 开箱即用;这是完成定义的一部分,绝不是可以留到以后处理的待办事项。
  2. 每个 Icon/Image 都要设置 semanticLabel,或用 ExcludeSemantics 包裹,因为它是装饰元素。没有第三种选择——未加标签的 Icon 对屏幕阅读器来说不可见。
  3. 在构建时从 MediaQuery 读取 a11y 状态,绝不要从应用状态读取。 MediaQuery 是一个按设计即可正确触发失效的 InheritedWidget;通过 provider 传递它,会把编译器保证的重建换成一帧的陈旧状态,而在这个领域,出错就意味着彻底失败。
  4. 绝不使用 MediaQuery.withClampedTextScaling 或 textScaleFactor。 这两者都是限制缩放的变通做法,会悄悄破坏文本缩放矩阵测试,而对比度和点击目标指南仍然会显示绿色通过。使用源码 grep 检查是否存在这些用法(参见反模式)。
  5. 绝不使用 FittedBox、计算得出的 fontSize 或 TextOverflow.ellipsis 来让标签适应空间。 它们会把「在 200% 缩放时放不下」从明显的测试失败变成设备上的截断或缩小文本。应构建高度灵活的布局,让文本自动换行。
  6. 绝不只通过颜色表达状态。 每种状态信号都至少要搭配一种非颜色通道——图标 + 文本标签 + 形状/图案 + 位置。仅靠颜色表达的状态,在 invertColors、灰度色彩校正下,以及对所有屏幕阅读器用户来说,都是不可见的。
  7. 在两种主题下,都要根据合成后的背景测量对比度——正文/图标文本 ≥ 4.5:1(AA 1.4.3),大号/主视觉文本 ≥ 3:1(AA);大号文本不应比正文文本面临更高的门槛。当填充、渐变或半透明效果介于文本与表面之间时,绝不要用名义上的令牌颜色进行测试。
  8. 每个交互目标都要达到 ≥ 44×44 逻辑像素,并且支持单击。 不得只提供长按或需要精准手势的交互方式;交接操作和行动不便用户都需要大而宽容的点击目标。
  9. 使用 sortKey 明确编写遍历顺序,绝不要依赖布局继承的顺序——为拇指优化的视觉位置,会严重恶化屏幕阅读器/开关控制用户的线性扫描体验。
  10. 遵循 boldText 设置;减少动画时仍保留触觉反馈。 硬编码 fontWeight 会直接忽略该设置。在这里从 MediaQuery 读取 disableAnimations,但应通过 design-system-structure 的减少动画令牌和 resolveMotion 辅助方法来决定如何简化动画(该技能负责实现机制)——如果动画在没有减少动画方案时仍传递含义,那就是一个缺陷。

a11y 状态的来源

从 context 读取平台无障碍状态,从 notifier 读取应用状态:

@override
Widget build(BuildContext context) {
  final boldText = MediaQuery.boldTextOf(context);
  final highContrast = MediaQuery.highContrastOf(context);
  final reduceMotion = MediaQuery.disableAnimationsOf(context);
  // textScaler is deliberately NOT read and NOT clamped. Text scales itself.
  ...
}

MediaQuery.highContrastOf 仅适用于 iOS,在 Android 上始终为 false(AccessibilityFeatures.highContrast 的文档说明为「仅支持 iOS」)。可以酌情读取此标志,但绝不要据此启用或禁用任何功能;应提供应用内对比度/主题控制,作为适用于所有平台的机制。invertColors 是系统合成滤镜——绝不要重新实现它;只要不单独依靠颜色表达含义,屏幕反色就不会改变 UI 所表达的内容。

点击目标:错误做法与正确做法

// WRONG — invisible to a screen reader, a clamping hack, a hidden overflow bug
class ItemTile extends StatelessWidget {
  const ItemTile({super.key, required this.item, required this.onTap});
  final Item item;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return MediaQuery.withClampedTextScaling(   // (1) BANNED clamp
      maxScaleFactor: 1.3,
      child: GestureDetector(
        onTap: onTap,
        child: Column(
          children: [
            const Icon(Icons.star),             // (2) no semanticLabel
            Text(
              item.title,                        // (3) no Semantics button role
              style: const TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.normal,   // (4) ignores boldText
              ),
              overflow: TextOverflow.ellipsis,   // (5) HIDES the overflow bug
            ),
          ],
        ),
      ),
    );
  }
}
// RIGHT — semantics authored, text free to scale, target ≥ 44px, non-color state
class ItemTile extends StatelessWidget {
  const ItemTile({super.key, required this.item, required this.onToggle});
  final Item item;
  final void Function(ItemId id) onToggle; // resolve at tap from the stable id

  @override
  Widget build(BuildContext context) {
    final bold = MediaQuery.boldTextOf(context);
    final theme = Theme.of(context);
    final l10n = AppLocalizations.of(context); // non-null getter (i18n-rtl-l10n owns this contract)

    return Semantics(
      container: true,
      button: true,
      label: item.title,                       // display label, not a sentence
      // Non-color state: read out AND flip an icon (below), never color alone.
      value: item.isSelected ? l10n.selected : l10n.notSelected, // localized, not literal
      sortKey: OrdinalSortKey(item.priority.toDouble()), // priority, not layout
      child: GestureDetector(
        behavior: HitTestBehavior.opaque,       // whole cell is the target
        onTap: () => onToggle(item.id),         // stable id, no stale-closure capture
        child: ConstrainedBox(
          constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
          child: ExcludeSemantics(              // face already announced above
            child: Row(
              children: [
                Icon(item.isSelected ? Icons.check_circle : Icons.circle_outlined),
                Text(
                  item.title,                   // no ellipsis, no FittedBox
                  style: theme.textTheme.bodyLarge!.copyWith(
                    fontWeight: bold ? FontWeight.bold : FontWeight.normal,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

label 是显示标签,绝不是更长的语音播报内容——类型系统无法区分这两个 String,因此除非将它们分开,否则使用屏幕阅读器的用户每次操作都会听到整句话。应在点击时根据不可变 ID 解析操作;如果在闭包中捕获可变字段,快速再次点击时就会触发过期值。

文本缩放:直觉恰恰是问题所在

当固定布局在 200% 缩放下溢出时,人们的本能反应是禁用文本缩放以保持稳定。这种本能就是缺陷。自动缩小——FittedBox、计算得出的 fontSize、maxLines + ellipsis——只是同一缺陷的伪装:布局看起来依然整洁,但用户的设置悄无声息地失效,而所有准则检查仍然全绿。

  • 遵从 MediaQuery.textScalerOf(context),也就是不要动它。Flutter 文本组件已经会进行缩放。
  • textScaleFactor 已弃用,改用 TextScaler,正是为了支持 Android 的非线性缩放;使用它几乎总是意味着设置了缩放上限。
  • 构建高度由内容决定或可灵活调整的布局;让文本按需换成任意多行。标题和展示字号可以限制自身的增长,但应换到第二行,而不是截断。
  • 在无障碍文本字号下,让水平行(标签栏、工具栏)重新排布,而不是裁切内容。
  • 在数字会变化或需要对齐的地方使用 FontFeature.tabularFigures()(或 .monospacedDigit() 风格的等宽数字),避免布局重排或抖动。
  • 让溢出在测试中明显暴露。Widget 测试中的红黄溢出条纹就是反馈机制;而设备上被截断的单词则是无人报告的失败。

绝不只靠颜色表达状态

无需依靠颜色,就应能识别真实状态——已选中、活动中、待处理、已完成、错误、已禁用。应通过相互独立的渠道重复编码:

  • 图标 / 字形,其形状随状态变化(灰度下仍清晰可辨)。
  • 文本标签——表示状态的词语,通过本地化处理。
  • 形状 / 图案 / 轮廓——单向条纹、基准线或缺口。
  • 位置——在标题下方,将需要关注的项目排在最上方。

通过 Semantics 向辅助技术公开状态——使用 value/hint,或针对临时播报使用 liveRegion——绝不能只依靠亮度变化:

Semantics(
  liveRegion: true, // announced when it changes, e.g. "Sync complete"
  label: statusLabel(status), // the word, not the color
  child: StatusBadge(status: status), // icon + label + shape, color is decoration
)

将视觉反馈与 HapticFeedback.selectionClick() 结合,让确认反馈在减少动态效果或低视力情况下依然有效。对于任何锁定的“活动中”视觉状态,都应设置超时以强制清除——卡住的状态既会造成视觉误导,也会留下过期的语义值。

与合成背景的对比度

在浅色和深色模式下,根据像素实际合成出的背景验证对比度,而不是根据标称令牌:

  • 正文和有意义的图标 / 轮廓 ≥ 4.5:1(AA 1.4.3);正文在可行时应达到 7:1(AAA 1.4.6)。大号 / 标题文本 ≥ 3:1(AA)或 4.5:1(AAA)——对比度要求从不高于正文。
  • 绝不要在渐变、半透明玻璃效果或标签填充上呈现关键数字或标签;应将其放在不透明表面上。
  • 任何承载文本的强调色填充,都必须预先搭配在该填充上对比度达到 4.5:1 的前景色,并在深色模式下重新验证(深色值需手动调整,绝不能自动翻转)。
  • 避免红绿混淆:中性值使用中性色文字;“成功”应为绿色 + ✓ 字形 + 文字;危险红色只用于破坏性操作。
  • 在灰度模式下,以及在红绿色盲 / 红色盲 / 蓝黄色盲模拟下验证整个界面。

遍历顺序是一项设计决策

针对拇指触达范围优化的布局(将最高优先级控件放在靠下且居中的位置),再加上 Flutter 默认的行优先遍历,会让最重要的控件成为线性扫描时最后到达的对象。使用一个参数,让遍历顺序与位置脱钩:

Semantics(
  sortKey: OrdinalSortKey(item.priority.toDouble()), // authored from priority
  ... )

在测试中断言 sortKey 顺序与优先级顺序一致(而非布局顺序)。了解其局限:Flutter 没有发布任何 Switch Access 支持声明,也没有可模拟扫描或组选择的 API,因此这只是对设计意图的回归保护——只有在真实设备上通过 TalkBack、VoiceOver 和 Switch Access 测试,才能作为符合性证据。触控、开关和屏幕阅读器是三个不同的渠道:要么兼顾三者进行设计,要么明确说明舍弃了哪一个。

反模式

  • MediaQuery.withClampedTextScaling / textScaleFactor——未来的贡献者看到溢出测试失败时,可能会想到这个一行“修复”;它会悄无声息地破坏整个文本缩放测试矩阵。在 CI 中通过 grep -rn "withClampedTextScaling\|textScaleFactor" lib/ 禁止使用。
  • 在实际标签上使用 FittedBox / 计算得出的 fontSize / ellipsis——同一个错误换了伪装;设置不再生效,而布局却依然整齐。
  • 未标注的 Icon/Image——屏幕阅读器无法识别;必须添加标签或使用 ExcludeSemantics。
  • 从 provider 读取 boldText/highContrast/disableAnimations——会滞后一帧,而这些设置出错会导致彻底失效。
  • 基于 highContrastOf 设置门控条件——在 Android 上始终为 false;这会悄无声息地在占多数的平台上禁用你的功能。
  • 仅用颜色表达状态——在 invertColors、灰度模式和屏幕阅读器下都会失效。
  • 在 onTap 闭包中捕获可变字段——快速重复点击时会触发旧值;应在点击时根据稳定 ID 解析值。
  • 仅支持精确手势或长按的操作方式——会排除行动不便的用户和需要交接使用的场景;应提供 ≥44px 的单击操作。
  • 在测试中使用 containsSemantics(...)——已弃用;请改用 isSemantics(...)。

完成标准

对于任何带有点击目标或状态的 widget:

  1. 提供包含 button: true、仅供显示的 label 和手动指定的 sortKey 的 Semantics 节点。
  2. 为每个 Icon/Image 添加标签,或明确使用 ExcludeSemantics。
  3. 不截断、不使用 FittedBox,也不在实际标签上使用 ellipsis;在 TextScaler.linear(2.0) 下渲染时不溢出。
  4. 遵循 boldText 设置;面向用户的文本不使用硬编码的 fontWeight。
  5. 每种状态都带有非颜色提示;瞬时状态使用 liveRegion 或 value;反馈包含触觉提示。
  6. 在两种主题下,基于合成后的背景验证对比度(正文 ≥4.5:1 / 大号文本 ≥3:1),并通过灰度 + CVD 检查。
  7. 交互目标至少为 44×44,且支持单击操作。
  8. 添加测试,使用 isSemantics(...) 断言语义,并验证遍历顺序与优先级顺序一致。

相关技能

  • widget-golden-and-a11y-testing — 用于验证此处编写内容的 guideline 匹配器、golden 测试流程、RTL golden 图,以及对无障碍符合性局限的如实说明。
  • i18n-rtl-l10n — 通过 gen-l10n/ARB 路由状态标签和播报,并使用 Directional 几何属性,从设计上确保 RTL 正确。
  • design-system-structure — token→theme 分层、手动编写的 ColorScheme、确保对比度配对可靠的禁止原始值检查,以及由该技能负责的减弱动态效果 token + resolveMotion 辅助函数(此技能读取标志,该技能解析动态效果)。
  • state-management-riverpod — 两项技能共同负责的分工:应用/领域状态通过 Riverpod notifier 流转,而平台/无障碍标志从 MediaQuery/BuildContext 读取,绝不经由 provider 路由。
  • widget-composition — 作为这项语义工作基础的小型 const View、无状态 widget,以及 dispose 规范。
  • design-review-workflow — 构建结束时的截图检查(浅色/深色 × RTL × 最大字号 × 减弱动态效果),其中无障碍底线始终是阻断条件。

References

评论

支持 Markdown