Skip to content

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 支持五种地标类型:NavigationMainSearchFormCustom。请给每个地标搭配 .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 行为。

任何应阻止与背景内容交互的覆盖层都应使用焦点陷阱:模态对话框、确认面板与下拉菜单。一个把 UseFocusTrapContentDialog 结合起来的完整对话框模式,见模态对话框实践范例

注意: UseFocusTrap 会针对三种「没有合理容器」的状态保护其 LosingFocus 处理程序 —— IsLoaded == falseVisibility != 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() 的禁用提交按钮

// Don't:
Button("Submit", onSubmit).IsEnabled(form.IsValid)

被禁用的按钮会从 Tab 顺序中移除。用户修好最后一个字段,按 Tab 想去点提交,焦点却跳过了仍处于禁用状态的按钮,于是他们被困住,没有键盘路径可走。请在按钮上使用 .IsDisabledFocusable(!form.IsValid) —— 它让按钮保持可键盘聚焦,同时呈现为禁用状态。表单页 覆盖了完整形态(它与失焦提交输入上的 .Immediate() 组合使用)。

在可聚焦元素上使用 AccessibilityHidden

// Don't:
Button("Save", onSave)
    .AccessibilityHidden()  // hides the button from screen readers

.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 会自动渲染按键提示。

下一步

  • 上下文 —— 上一个主题:在树中共享状态而无需逐层传递 props
  • 本地化 —— 下一个主题:翻译字符串、格式化数字/日期,并支持 RTL 布局
  • 表单与输入 —— 用标签、校验与 Tab 顺序构建无障碍表单
  • 导航 —— 添加地标与可键盘导航的页面结构
  • 样式与主题 —— 确保高对比度主题与你无障碍控件配合正常
  • 对话框与浮出 —— 模态界面的焦点陷阱与 ARIA 语义
  • 分析器架构 —— REACTOR_A11Y_001..004 是如何编写的
  • WinForms 互操作 —— WinForms 与 Reactor 之间的无障碍桥接