Microsoft.UI.Reactor(Reactor)的无障碍接口分三层:映射到 WinUI 自动化属性的修饰符(.AutomationName、.HeadingLevel、.Landmark、.LiveRegion、.TabIndex、.AccessKey),增添运行时行为的 Hook(UseFocusTrap 用于模态焦点陷阱、UseAnnounce 用于实时区域播报、SemanticPanel 用于修饰符集合无法表达的自定义自动化对等体),以及分析器集合这第三层 —— REACTOR_A11Y_001..004 在编译期抓出最常见的遗漏(仅有图标的按钮缺少自动化名称、图片缺少替代文本、表单字段缺少标签、可点击容器无法通过键盘到达)。AccessibilityScanner 是它在运行时的表亲:它遍历协调之后的元素树,输出 8 个映射到 WCAG 的诊断类别,并附带具体的修复建议。目标是在输入时保持分析器输出干净,在合并前保持扫描器输出干净,最后用「讲述人」逐一 Tab 遍历做人工复核。请先读 第一梯队修饰符 —— 那五个修饰符覆盖了多数生产场景;本页其余内容面向那 20% 的非平凡情况。
无障碍¶
Reactor 为每个组件提供无障碍修饰符。它们直接映射到 WinUI 的自动化属性,因此屏幕阅读器、键盘导航与测试工具开箱即用。修饰符按使用频率分成两个梯队。
参考¶
| 接口 | 位置 | 作用 |
|---|---|---|
| 第一梯队修饰符 | .AutomationName、.HeadingLevel、.TabIndex、.AccessKey、.IsTabStop |
每次渲染内联应用 —— 标签、标题、键盘顺序。 |
| 第二梯队修饰符 | .HelpText、.FullDescription、.AccessibilityHidden、.AccessibilityView、.Landmark、.Required、.LiveRegion |
惰性分配;描述、地标、隐藏子树、区域。 |
| 关系引用 | .LabeledBy(ref)、.DescribedBy(refs...)、.FlowsTo(refs...)、.FlowsFrom(refs...) |
已实现元素之间的响应式 AutomationProperties 关系。 |
UseFocusTrap(isActive) |
Hook | 返回 FocusTrapHandle;在容器上用 .FocusTrap(handle) 应用。 |
UseAnnounce() |
Hook | 返回 AnnounceHandle,带 .Region(零尺寸元素)与 .Announce(message, assertive?)。 |
SemanticPanel |
包装器 | 为复合控件提供的自定义自动化对等体(角色 / 值 / 范围)。 |
AccessibilityScanner.Scan(root) |
静态 | 渲染后的运行时诊断 —— 8 项 WCAG 检查,附修复建议。 |
REACTOR_A11Y_001 |
分析器 | 仅有图标的 Button(icon, action) 缺少 .AutomationName()。 |
REACTOR_A11Y_002 |
分析器 | Image() 缺少 .AutomationName() 或 .AccessibilityHidden()。 |
REACTOR_A11Y_003 |
分析器 | 表单字段缺少 header: 或标签修饰符。 |
REACTOR_A11Y_004 |
分析器 | 可点击容器(Border/Grid/Canvas/Rectangle/Ellipse/VStack/HStack)带 .OnTapped 却缺少启用的 .IsTabStop(true)。 |
第一梯队修饰符¶
第一梯队修饰符是你一直在用的那些:标签、标题、Tab 顺序与键盘快捷键。它们在每次渲染时内联应用 —— 不做惰性分配。
class Tier1Demo : Component
{
public override Element Render()
{
return VStack(12,
TextBlock("Account Settings")
.FontSize(24).Bold()
.HeadingLevel(AutomationHeadingLevel.Level1),
TextBlock("Profile")
.FontSize(18).SemiBold()
.HeadingLevel(AutomationHeadingLevel.Level2),
TextBox("", _ => { }, placeholderText: "Display name")
.AutomationName("Display name")
.TabIndex(1)
.AccessKey("N"),
Button("Save", () => { })
.AutomationName("Save profile changes")
.TabIndex(2)
.AccessKey("S")
).Padding(24);
}
}

各修饰符的作用:
.HeadingLevel()把元素标记为标题(Level1 到 Level9)。屏幕阅读器用户按标题导航,就像 HTML 里的h1--h6。.AutomationName()设置可访问标签。当可见文本无法完整描述控件用途时使用它。.TabIndex()设置 Tab 顺序。较小的值先获得焦点。.AccessKey()分配一个 Alt+键 快捷键。用户按下 Alt 时 WinUI 会显示按键提示。.IsTabStop()控制元素是否参与 Tab 导航。
第二梯队修饰符¶
第二梯队修饰符覆盖补充信息、地标与可见性控制。它们是惰性分配的 —— Reactor 只在你使用时才创建背后的存储,从而让常见情形保持轻量。
class Tier2Demo : Component
{
public override Element Render()
{
return VStack(12,
TextBox("", _ => { }, placeholderText: "Search...")
.AutomationName("Search products")
.HelpText("Type a product name or SKU to filter results")
.Width(300),
VStack(8,
TextBlock("Revenue by Region").Bold(),
TextBlock("Bar chart placeholder").Opacity(0.5)
).FullDescription(
"Bar chart showing Q1 revenue: East $4.2M, " +
"West $3.8M, Central $2.1M")
.Padding(16).Background(Theme.CardBackground).CornerRadius(8),
TextBlock("Decorative divider")
.Opacity(0.2)
.AccessibilityHidden()
).Padding(24);
}
}

| 修饰符 | 用途 |
|---|---|
.HelpText() |
在名称之后朗读的额外提示 |
.FullDescription() |
为复杂可视化提供的扩展描述 |
.AccessibilityHidden() |
把装饰性元素从树中隐藏 |
.AccessibilityView() |
Content、Control 或 Raw 可见性 |
.Landmark() |
Main、Navigation、Search、Form、Custom |
.Required() |
为表单字段播报「必填」 |
.LiveRegion() |
播报动态内容变化 |
分层设计意味着,对于只需要标签与标题层级的元素,第二梯队的开销为零。
关系引用属性¶
有些无障碍属性指向另一个已实现的元素,而不是一个字符串。这些关系请用 ElementRef<FrameworkElement>。UseElementRef<T> 是 Component / RenderContext 上的扩展方法,因此在组件内部要写成 this.UseElementRef<T>()(它位于 Microsoft.UI.Reactor.Hooks 命名空间):
var label = this.UseElementRef<FrameworkElement>();
var help = this.UseElementRef<FrameworkElement>();
return VStack(4,
TextBlock("Email").Ref(label),
Caption("Use your work address.").Ref(help),
TextBox(email, setEmail)
.LabeledBy(label)
.DescribedBy(help));
.LabeledBy(ref) 写入 AutomationProperties.LabeledBy。.DescribedBy(refs...)、.FlowsTo(refs...) 与 .FlowsFrom(refs...) 按声明顺序重建对应的 IList<DependencyObject>,并省略无法解析的目标。由于这些是响应式引用边,该关系能在挂载顺序差异、来源卸载与引用方重建中幸存:任一侧被重建时,这条边会重新订阅并在提交后重新应用。当屏幕阅读器关系跨越容器时,这是首选形态;字符串形式的 .LabeledBy(id) 只用于静态的 AutomationId 关系。
无障碍表单¶
一个真实的表单会组合第一与第二梯队修饰符。标签、必填标记、帮助文本、地标与 Tab 顺序协同工作:
class AccessibleFormDemo : Component
{
public override Element Render()
{
var (name, setName) = UseState("");
var (email, setEmail) = UseState("");
var (agree, setAgree) = UseState(false);
var valid = !string.IsNullOrWhiteSpace(name)
&& email.Contains('@') && agree;
return VStack(12,
TextBlock("Create Account").FontSize(24).Bold()
.HeadingLevel(AutomationHeadingLevel.Level1),
TextBox(name, setName, header: "Full Name")
.AutomationName("Full name").Required().TabIndex(1),
TextBox(email, setEmail, header: "Email")
.AutomationName("Email address").Required().TabIndex(2)
.HelpText("We'll send a verification link"),
CheckBox(agree, setAgree, label: "I accept the terms")
.TabIndex(3),
Button("Register", () => { })
.IsEnabled(valid).TabIndex(4).AccessKey("R")
).Landmark(AutomationLandmarkType.Form).Padding(24);
}
}

表单容器使用 .Landmark(AutomationLandmarkType.Form),让屏幕阅读器用户可以直接跳转过去。每个字段用 .AutomationName() 提供标签,用 .Required() 标记必填,用 .TabIndex() 保证可预期的键盘顺序。邮箱字段额外加了 .HelpText(),说明提交之后会发生什么。
导航地标¶
地标让屏幕阅读器用户在页面的主要区域之间跳转。把它们用在你的导航栏、主内容区与搜索框上:
class LandmarksDemo : Component
{
public override Element Render()
{
return VStack(16,
HStack(8,
Button("Home", () => { }),
Button("Products", () => { }),
Button("About", () => { })
).Landmark(AutomationLandmarkType.Navigation)
.AutomationName("Main navigation"),
VStack(12,
TextBlock("Dashboard")
.FontSize(20).Bold()
.HeadingLevel(AutomationHeadingLevel.Level1),
TextBlock("Welcome back. Here is your overview.")
).Landmark(AutomationLandmarkType.Main)
.AutomationName("Main content"),
TextBox("", _ => { }, placeholderText: "Search...")
.AutomationName("Site search")
.Landmark(AutomationLandmarkType.Search)
).Padding(24);
}
}

WinUI 支持五种地标类型:Navigation、Main、Search、Form 与 Custom。请给每个地标搭配 .AutomationName(),这样屏幕阅读器会播报「Main navigation」而不只是「navigation」。
标题层级¶
清晰的标题结构让屏幕阅读器用户能快速浏览你的页面。这与你的布局层级天然契合。页面标题用 Level1,章节用 Level2,子章节用 Level3:
class HeadingHierarchyDemo : Component
{
public override Element Render()
{
return VStack(12,
TextBlock("Application Settings")
.FontSize(24).Bold()
.HeadingLevel(AutomationHeadingLevel.Level1),
TextBlock("Appearance")
.FontSize(18).SemiBold()
.HeadingLevel(AutomationHeadingLevel.Level2),
TextBlock("Choose your preferred theme and font size."),
TextBlock("Notifications")
.FontSize(18).SemiBold()
.HeadingLevel(AutomationHeadingLevel.Level2),
TextBlock("Email Alerts")
.FontSize(15).SemiBold()
.HeadingLevel(AutomationHeadingLevel.Level3),
TextBlock("Configure which emails you receive.")
).Padding(24);
}
}

保持标题层级连续 —— 不要从 Level1 直接跳到 Level3。屏幕阅读器用这套层级构建页面大纲,断层会让用户困惑。
焦点陷阱¶
UseFocusTrap 把键盘焦点锁在一个容器内 —— 对模态对话框与浮出来说必不可少。激活时,Tab 与 Shift+Tab 会在被陷阱的子树内循环,无法逃出:
class FocusTrapDemo : Component
{
public override Element Render()
{
var (showModal, setShowModal) = UseState(false);
var trap = this.UseFocusTrap(showModal);
return VStack(12,
SubHeading("Focus Trapping"),
Button("Open Modal", () => setShowModal(true)),
Border(
VStack(12,
TextBlock("Modal Dialog").FontSize(18).Bold(),
TextBlock("Tab/Shift+Tab stays inside this panel."),
TextBox("", _ => { }, placeholderText: "Name")
.AutomationName("Name")
.TabIndex(0),
Button("Close", () => setShowModal(false))
.TabIndex(1)
).Padding(24)
).WithBorder(Theme.CardStroke, 1)
.CornerRadius(8)
.Background(Theme.SolidBackground)
.FocusTrap(trap)
.IsVisible(showModal)
).Padding(24);
}
}

用 UseFocusTrap(isActive) 创建 FocusTrapHandle,再用 .FocusTrap(handle) 应用到某个容器上。isActive 为 true 时,焦点在子树内环绕;为 false 时恢复正常 Tab 行为。
任何应阻止与背景内容交互的覆盖层都应使用焦点陷阱:模态对话框、确认面板与下拉菜单。一个把 UseFocusTrap 与 ContentDialog 结合起来的完整对话框模式,见模态对话框实践范例。
注意:
UseFocusTrap会针对三种「没有合理容器」的状态保护其LosingFocus处理程序 ——IsLoaded == false、Visibility != Visible以及!IsHitTestVisible。在这些保护之外,只要句柄还声称IsActive,陷阱就会持续吞掉焦点变化。经典失败案例:一个When(isOpen, () => ...)模态,它的setOpen(false)先执行(卸载了被陷阱的容器),然后在下一次渲染时才把isActive翻成 false —— 在这两者之间,从已被移除的容器外部按Tab仍会命中那个陈旧句柄,args.Cancel = true会把焦点卡在邻近的页面元素上。请始终在卸载容器的同一个状态批次里把isActive翻成 false,或者把陷阱应用到在开/关过渡期间始终留在树中的元素上(一个用.IsVisible(open)而非When(open, ...)的常驻覆盖层)。
屏幕阅读器播报¶
UseAnnounce 通过实时区域(WCAG 4.1.3)向屏幕阅读器发送程序化播报。用它告知用户那些在焦点路径中不可见的动态状态变化:
class AnnouncementsDemo : Component
{
public override Element Render()
{
var (count, setCount) = UseState(0);
var announce = this.UseAnnounce();
return VStack(12,
SubHeading("Screen Reader Announcements"),
Button("Save", () =>
{
setCount(count + 1);
announce.Announce($"Document saved ({count + 1} times)");
}),
Button("Error (Assertive)", () =>
announce.Announce("Connection lost!", assertive: true)),
TextBlock($"Saves: {count}").Opacity(0.6),
announce.Region // invisible live region — must be in tree
).Padding(24);
}
}

用 UseAnnounce() 创建 AnnounceHandle。把 announce.Region 放在元素树中的某处 —— 它会渲染一个不可见的实时区域。然后调用 announce.Announce(message) 做礼貌播报(排在当前朗读之后),或 announce.Announce(message, assertive: true) 打断当前朗读。
在 Region 挂载之前调用 Announce 是静默空操作 —— 该句柄内部持有一个 TextBlock?,由修饰符管线在 OnMount 时填充。如果某个依赖为 [] 的 UseEffect 在挂载时立即触发 Announce,实时区域可能还没接线完成;请把首次播报推迟一个调度器 tick,或从 onNavigatedTo 触发它。
常见用例:表单提交确认、异步操作完成、错误消息,以及基于计时器的状态更新。
语义面板¶
SemanticPanel 包裹一个子元素,以提供 Reactor 组件无法直接暴露的自定义自动化元数据(因为它们是 C# record,而不是拥有可重写自动化对等体的 WinUI 控件):
class SemanticPanelDemo : Component
{
public override Element Render()
{
var (rating, setRating) = UseState(3);
// .Semantics() wraps the element in a SemanticPanel so
// screen readers announce it as a slider, not raw buttons
return VStack(12,
SubHeading("Star Rating (Semantic Panel)"),
HStack(4, Enumerable.Range(1, 5).Select(i =>
Button(i <= rating ? "\u2605" : "\u2606",
() => setRating(i))
.AutomationName($"{i} star{(i == 1 ? "" : "s")}")
.AccessibilityHidden()
.WithKey($"star-{i}")
).ToArray())
.Semantics(
role: "slider",
value: $"{rating} of 5 stars",
rangeMin: 1, rangeMax: 5, rangeValue: rating),
TextBlock($"Current: {rating}/5").Opacity(0.6)
).Padding(24);
}
}

| 属性 | 用途 |
|---|---|
SemanticRole |
自动化角色(例如 "slider") |
SemanticValue |
报告给辅助技术的当前值 |
RangeMinimum / RangeMaximum |
范围值模式的数值区间 |
RangeValue |
区间中的当前数值位置 |
IsReadOnly |
值是否可改 |
对于星级评分、进度指示器,或任何需要超出 WinUI 从可视化树推断出的特定自动化角色的复合控件,请使用 SemanticPanel。
无障碍扫描器¶
AccessibilityScanner 是一个协调之后的诊断工具,遍历元素树并标出常见的无障碍问题。在开发期间或 CI 中运行它,尽早抓住违规:
// Run AccessibilityScanner during development or CI:
//
// var diagnostics = AccessibilityScanner.Scan(rootElement);
// foreach (var d in diagnostics)
// {
// Console.WriteLine($"[{d.Severity}] {d.Message}");
// Console.WriteLine($" WCAG: {d.WcagCriterion}");
// Console.WriteLine($" Fix: {d.Fix?.Modifier}({d.Fix?.SuggestedValue})");
// }
//
// Export structured JSON for CI integration:
// AccessibilityScanner.ExportJson(diagnostics, "a11y-report.json");
扫描器检查 8 类常见问题:
| 检查 | WCAG 准则 |
|---|---|
| 图标按钮缺少 AutomationName | 4.1.2 Name, Role, Value |
| 图片缺少替代文本 | 1.1.1 Non-text Content |
| 表单字段缺少标签 | 1.3.1 Info and Relationships |
| 标题样式的文本缺少 HeadingLevel | 1.3.1 Info and Relationships |
| 交互控件上使用实色画笔 | 1.4.11 Non-text Contrast |
| 缺少 Main 地标 | 1.3.1 Info and Relationships |
| TabIndex 断层 | 2.4.3 Focus Order |
| 无法解析的 LabeledBy 引用 | 1.3.1 Info and Relationships |
每个 A11yDiagnostic 都包含一条 Fix 建议,给出应添加的确切修饰符与值。用 AccessibilityScanner.ExportJson() 导出为 JSON。
Roslyn 分析器¶
Reactor 附带四个编译期无障碍分析器,在你的 IDE 中边输入边标出违规:
| 诊断 ID | 规则 |
|---|---|
REACTOR_A11Y_001 |
仅有图标的 Button(icon, action) 调用缺少 .AutomationName() |
REACTOR_A11Y_002 |
Image() 缺少 .AutomationName() 或 .AccessibilityHidden() |
REACTOR_A11Y_003 |
TextBox/NumberBox/PasswordBox 缺少 header: 参数或标签修饰符 |
REACTOR_A11Y_004 |
可点击的 Border/Grid/Canvas/Rectangle/Ellipse/VStack/HStack(.OnTapped)缺少启用的 .IsTabStop(true) |
引用 Reactor.Analyzers 包时,这些分析器会自动运行。它们通过在编译期抓出最常见的违规,与运行时的 AccessibilityScanner 形成互补。分析器架构本身记录在「底层原理」轨道的分析器架构中。
模式¶
带焦点陷阱与播报的模态对话框¶
标准的无障碍模态组合三个原语:一个受控的 isOpen 布尔值、一个以它为键的 UseFocusTrap,以及一个在首次渲染播报「对话框已打开」、在关闭时播报「对话框已关闭」的 UseAnnounce。陷阱容器应当在开/关过渡期间始终挂载(用 .IsVisible(open) 而不是 When(open, ...)),这样当 isActive 翻成 false 时焦点能被干净地释放 —— 见上文注意事项。模态对话框实践范例 把这三者端到端地接到了 ContentDialog 上。
吐司 / 状态更新¶
应用级的实时播报(保存成功、连接已恢复、错误吐司)应归到一个提升到应用根、并通过上下文共享的单一 AnnounceHandle。每个想要播报的界面用 UseContext 读取它,并调用 announce.Announce(message)。单一实时区域既满足 WCAG 4.1.3,又不必在树中到处撒区域;而由于底层的 RaiseNotificationEvent 在自动化对等体处排队,消息顺序是串行的。
为列表与网格行命名¶
ListView / GridView 的每一行是它自己的自动化节点,与你渲染进去的项视图相互独立。当项视图只是一段文本时,WinUI 会用该文本组装行的名称,你什么都不用做。当它是一个复合体 —— 一个由图标、标题与副标题组成的 HStack,或一个卡片 Border —— WinUI 没有单一字符串可用,该行就没有名字,于是「讲述人」会退化成逐个朗读其后代。
要给该行一个单一的口播名称,请把 .AutomationName(...) 放在项视图的根上;Reactor 会把它转发给生成的 ListViewItem / GridViewItem 容器,并在行被添加、改名、重排与回收时保持同步:
ListView(contacts, c => c.Id, (contact, i) =>
HStack(
Image(contact.AvatarUrl).AccessibilityHidden(),
VStack(TextBlock(contact.Name).Bold(), TextBlock(contact.Role))
).AutomationName($"{contact.Name}, {contact.Role}"));
当复合体逐后代朗读已经足够好时,就不要设置它 —— 无名行是正确且标准的结果,不是 bug。Reactor 从不从自己的内部簿记推导行名。
自定义控件的语义¶
复合控件(由 Button 子项构成的星级评分、自定义开关组、可拖拽滑块)需要单一的自动化节点,声明「这是一个滑块,值是 5 中的 3」。把复合体包在 SemanticPanel { SemanticRole = "slider", RangeMinimum = 1, RangeMaximum = 5, RangeValue = rating } 里。给子按钮标记 .AccessibilityHidden(),让屏幕阅读器看到一个节点而不是五个 —— 该隐藏修饰符把后代从自动化树中移除,但仍保留它们的键盘与指针交互性。
常见错误¶
未加 .IsDisabledFocusable() 的禁用提交按钮¶
被禁用的按钮会从 Tab 顺序中移除。用户修好最后一个字段,按 Tab 想去点提交,焦点却跳过了仍处于禁用状态的按钮,于是他们被困住,没有键盘路径可走。请在按钮上使用 .IsDisabledFocusable(!form.IsValid) —— 它让按钮保持可键盘聚焦,同时呈现为禁用状态。表单页 覆盖了完整形态(它与失焦提交输入上的 .Immediate() 组合使用)。
在可聚焦元素上使用 AccessibilityHidden¶
.AccessibilityHidden() 只影响自动化树 —— 键盘焦点与指针命中测试依旧工作。结果是一个用户能 Tab 到、能点击,但屏幕阅读器用户无法发现的按钮。如果这个控件不应对辅助技术暴露,那它也不应对键盘用户存在;请用 .IsEnabled(false) 或通过 When(...) 把它移出树。只在装饰性、不接受焦点的元素(图标、装饰性边框、冗余标签)上使用 .AccessibilityHidden()。
标题层级跳级或乱序¶
// Don't:
TextBlock("Page Title").HeadingLevel(AutomationHeadingLevel.Level1),
TextBlock("Subtle Detail").HeadingLevel(AutomationHeadingLevel.Level4)
屏幕阅读器通过按顺序遍历标题层级来构建页面大纲;断层会搅乱导航(NVDA 的「跳到下一个 2 级标题」会静默失败)。把第二个标题提升为 Level2,或把第一个降到与结构匹配。像对待 HTML 一样对待标题层级 —— 每页一个 h1,章节用 h2,子章节用 h3。
提示¶
给每个交互控件加标签。 如果可见文本已足够,WinUI 会自动推断名称。不够时就用 .AutomationName() —— 图标按钮、图片按钮,以及只有占位符文本的控件都需要它。
用 .Required() 而不是星号。 屏幕阅读器会自动播报「必填」。视觉星号对辅助技术是不可见的,除非你也设置了自动化属性。
用「讲述人」测试。 按 Win+Ctrl+Enter 启动「讲述人」,然后用 Tab 遍历你的应用。每个控件都应播报其名称、角色与状态。
保持地标数量最少。 每页一个 Main、一个 Navigation,可选一个 Search。地标太多就失去了意义 —— 当每个区块都是地标时,用户就无法快速跳转。
给高频操作设置 .AccessKey()。 保存用 Alt+S,新建用 Alt+N。用户按下 Alt 时 WinUI 会自动渲染按键提示。