Skip to content

WinUI 参考: 完整的属性表面与设计建议,参见 Dialogs And Flyouts

对话框与浮出表面会打断用户。这使它们成为应用注意力预算里最昂贵的 UI 基元 —— 每个模态对话框都抢焦点,每个浮出层都在点击外部时关闭,每个 popup 都没有返回的路由。Microsoft.UI.Reactor(Reactor)把对话框和浮出当作受控组件:你持有开/关布尔值,组件树里始终包含那个对话框元素,而可见性只是一个由用户驱动的事件处理器负责翻回的状态标志。这与表单里的 TextBox 是同一个形状,它让对话框变得可测试 —— 对话框开着,当且仅当驱动它的状态这么说。当你为了保存/丢弃的选择而要够 ContentDialog、为了右键菜单够 MenuFlyout、为了选择态工具栏够 CommandBarFlyout、或为了上面三者都覆盖不了的定制浮层而够 Popup 时,就来读这一页。把每个对话框都与 commanding.md 里的对应条目配对 —— 同一个 Command 通常应该同时支撑触发器(按钮或菜单)和对话框的主按钮动作。

对话框与浮出

本页有四个基元。按形状挑,而不是按观感:

控件 形状 关闭方式 何时使用
ContentDialog 模态、屏幕居中、最多三个按钮 按钮点击 用户必须先做一个选择才能继续。
MenuFlyout 目标旁的弹出菜单 点击外部 / Esc 右键菜单或 V 形按钮引出的离散动作菜单。
CommandBarFlyout 目标旁的迷你工具栏 点击外部 / Esc 选择上下文 —— "对这个东西做什么"。
Popup 自由形态浮层 轻关闭 / 显式关闭 上面三者都渲染不出来的东西 —— 取色器、原地编辑器、自定义提示气泡。

ContentDialog

ContentDialog(string title, Element content, string primaryButtonText = "OK")
class BasicDialogDemo : Component
{
    public override Element Render()
    {
        var (open, setOpen) = UseState(false);

        return VStack(8,
            SubHeading("Basic ContentDialog"),
            Button("Show dialog", () => setOpen(true)),
            // 对话框始终存在于树中。IsOpen 控制可见性;
            // 用户关闭时由 OnClosed 把它翻回来。
            ContentDialog(
                "Welcome",
                TextBlock("Thank you for trying Reactor."),
                primaryButtonText: "OK") with
            {
                IsOpen = open,
                OnClosed = _ => setOpen(false),
            }
        ).Padding(24);
    }
}

带一个按钮的基础 ContentDialog

ContentDialog 是模态框。传入标题、内容元素和主按钮标签。工厂方法返回一个 ContentDialogElement 记录;附加的 init-only 属性(IsOpenOnClosed、次按钮/关闭按钮文本)用 with { ... } 表达式展开。对话框元素始终存在于树中 —— 它是一个记录,不是一次方法调用。IsOpen = false 让它保持隐藏;从按钮处理器把 IsOpen 翻成 true 就打开它;用户关闭时 OnClosed 回调把它翻回来。

属性(通过 with { ... } 效果
IsOpen 受控的可见性标志。
PrimaryButtonText 默认动作按钮标签。通过工厂方法参数设定。
SecondaryButtonText 可选的第二个按钮(在主按钮左侧)。
CloseButtonText 可选的关闭按钮。也就是那个"X" / Esc 关闭。
DefaultButton 哪个按钮拿到强调色和回车绑定(Primary / Secondary / Close)。
OnClosed Action<ContentDialogResult> —— Primary / Secondary / None
OnOpened 对话框完成打开后触发(用于自动聚焦)。
IsPrimaryButtonEnabled 禁用主动作而不移除它。
IsSecondaryButtonEnabled 次按钮同理。

对话框开着的时候是活的

一个打开的对话框不是快照。宿主组件的每次渲染都会像协调树里其余部分那样协调这个对话框:

  • 内容原地重新渲染。 从对话框内部更新的状态 —— 用户正在键入的 TextBox、被自己那个按钮递增的计数器 —— 会立即重新渲染。因为内容子树是被打补丁而非重新挂载,那些瞬时控件状态(焦点、光标、滚动位置、文本选区)能在更新中存活。
  • 属性重新同步。 TitleIsPrimaryButtonEnabledDefaultButton 和按钮标签在每次渲染时都跟随元素,因此它们可以依赖住在对话框内部的状态。SecondaryButtonTextCloseButtonText 都可为空,而 null 意味着"没有这个按钮" —— 在后续渲染里把其中一个掉回 null 就会移除该按钮,等同于从未设置过。
  • IsOpen 完全受控。 设置 IsOpen = false 会关闭对话框,正如 true 打开它一样。关闭走的是正常关闭路径,因此 OnClosed 仍会以 ContentDialogResult.None 触发。
  • 关闭时内容被卸载。 对话框内部的副作用清理和 UseEffect 拆除会在它关闭时运行 —— 也包括当拥有该对话框元素的组件在对话框仍显示时被卸载的情况。这条拆除路径是静默的:OnClosed 报告的是用户关闭,而不是宿主消失,因此一个回写进已卸载状态的处理器永远不会运行。

三按钮对话框

三按钮形态(Primary + Secondary + Close)是破坏性确认的默认。来自 Fluent UI 和 macOS HIG 的约定一致:把破坏性动作放在 Primary,把非破坏性的取消放在 Secondary,并把 Esc 路由到 DefaultButton 所设的那个:

class ConfirmDialogDemo : Component
{
    public override Element Render()
    {
        var (open, setOpen) = UseState(false);
        var (result, setResult) = UseState("(none)");

        return VStack(8,
            SubHeading("Confirmation with three buttons"),
            Button("Delete item…", () => setOpen(true)),
            TextBlock($"Last result: {result}").Opacity(0.6),
            ContentDialog(
                "Delete this item?",
                TextBlock("This action cannot be undone."),
                primaryButtonText: "Delete") with
            {
                IsOpen = open,
                SecondaryButtonText = "Cancel",
                CloseButtonText = "Close",
                DefaultButton = ContentDialogButton.Close,
                OnClosed = r =>
                {
                    setResult(r.ToString());
                    setOpen(false);
                },
            }
        ).Padding(24);
    }
}

三按钮确认对话框

OnClosed 里的 ContentDialogResult 值告诉你用户走了哪条路。PrimarySecondary 对应那两个带标签的按钮;None 覆盖 Esc、关闭按钮和点击遮罩这三种关闭 —— 把它们统统当作取消。

主按钮把关

当对话框内容本身就是一份表单时(重命名文件、设置密码),主按钮应该在表单合法之前保持禁用。用 IsPrimaryButtonEnabled,而不是在内容里放一个自定义按钮再 .IsEnabled(false) —— 对话框主按钮保留着强调色、回车键盘绑定,以及 WinUI 对话框提供的焦点陷阱:

class DialogGatedPrimaryDemo : Component
{
    public override Element Render()
    {
        var (open, setOpen) = UseState(false);
        var (name, setName) = UseState("");

        return VStack(8,
            SubHeading("Primary disabled until input is valid"),
            Button("Rename file…", () => setOpen(true)),
            ContentDialog(
                "Rename file",
                VStack(8,
                    TextBox(name, setName, placeholderText: "untitled.txt", header: "New filename")
                        .Width(280)),
                primaryButtonText: "Rename") with
            {
                IsOpen = open,
                SecondaryButtonText = "Cancel",
                // .IsPrimaryButtonEnabled 驱动内联主按钮的禁用态,
                // 而不会把它移出 Tab 序。
                IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(name),
                OnClosed = _ => setOpen(false),
            }
        ).Padding(24);
    }
}

带 TextBox 的对话框;输入非空前主按钮禁用

这道闸门之所以成立,是因为对话框的属性在每次渲染时都会重新同步:内容里的 TextBox 更新状态,组件重新渲染,IsPrimaryButtonEnabled 随即在已经打开的对话框上跟上,而它不会闪一下再关掉。

注意: ContentDialog.ShowAsync 在每个 XamlRoot 上是单实例的 —— 如果两个对话框同时试图打开,WinUI 控件会抛出 Show another ContentDialog before closing the previous。Reactor 的受控 IsOpen 模式走的是同一个约束:从对话框 A 的 Primary 处理器打开对话框 B,会在 A 的关闭完成前就执行 B 的 IsOpen = true 赋值,于是第二次 show 抛异常。始终先关掉对话框 A(通过 setOpenA(false) 并等 dispatcher 提交),再在下一次渲染里打开 B —— 例如通过一个以 A 的 IsOpen 变 false 为键的 UseEffect 把 B 的打开排入队列。

WinUI 设计页:Dialogs

MenuFlyout(Element target, params MenuFlyoutItemBase[] items)
class MenuFlyoutDemo : Component
{
    public override Element Render()
    {
        var (action, setAction) = UseState("(none)");

        return VStack(8,
            SubHeading("MenuFlyout — right-click or button-click"),
            MenuFlyout(
                Button("Edit ▾"),
                MenuItem("Cut",   () => setAction("Cut"),   icon: "Cut"),
                MenuItem("Copy",  () => setAction("Copy"),  icon: "Copy"),
                MenuItem("Paste", () => setAction("Paste"), icon: "Paste"),
                MenuSeparator(),
                MenuSubItem("Format",
                    ToggleMenuItem("Bold", isChecked: false),
                    ToggleMenuItem("Italic", isChecked: true)),
                MenuSeparator(),
                MenuItem("Delete…", () => setAction("Delete"))
            ),
            TextBlock($"Last action: {action}").Opacity(0.6)
        ).Padding(24);
    }
}

带子菜单与分隔符的 MenuFlyout

MenuFlyout 把一个弹出菜单挂到目标元素上。点击目标 —— 菜单在它那里打开。点击菜单外部 —— 它关闭。工厂方法把目标作为第一个参数,把各项作为可变尾部;结果会把目标包起来,因此它出现在树中目标所在的位置。

项工厂方法 形状
MenuItem(text, onClick?, icon?) 带点击处理器的标准菜单项。
MenuItem(Command) 绑定到一个命令。标签、图标、启用态、键盘加速器都来自该命令。
MenuItem<T>(Command<T>, T parameter) 绑定到带参数的命令,参数取该行的数据。
ToggleMenuItem(text, isChecked?, onIsCheckedChanged?, icon?) 两态勾选项。
RadioMenuItem(text, groupName, isChecked?, onClick?, icon?) 单选组中的一项。
MenuSeparator() 水平分隔线。
MenuSubItem(text, items...) 嵌套子菜单。

驱动一个 Button 的同一个 Command 记录,可以直接插进 MenuItem(Command) —— 一次声明,三个表面(按钮、菜单、键盘快捷键)。参见下面的命令集成模式

WinUI 设计页:Menus and context menus

CommandBarFlyout

CommandBarFlyout(
    Element target,
    AppBarItemBase[]? primaryCommands = null,
    AppBarItemBase[]? secondaryCommands = null)
class CommandBarFlyoutDemo : Component
{
    public override Element Render()
    {
        var (action, setAction) = UseState("(none)");

        return VStack(8,
            SubHeading("CommandBarFlyout"),
            CommandBarFlyout(
                Button("Selection ▾"),
                primaryCommands: new AppBarItemBase[]
                {
                    AppBarButton("Cut",   () => setAction("Cut"),   icon: "Cut"),
                    AppBarButton("Copy",  () => setAction("Copy"),  icon: "Copy"),
                    AppBarButton("Paste", () => setAction("Paste"), icon: "Paste"),
                },
                secondaryCommands: new AppBarItemBase[]
                {
                    AppBarButton("Select All", () => setAction("Select All")),
                    AppBarButton("Find",       () => setAction("Find")),
                }),
            TextBlock($"Last action: {action}").Opacity(0.6)
        ).Padding(24);
    }
}

带主命令与次命令的 CommandBarFlyout

CommandBarFlyout 是选择上下文工具栏 —— 浮在一段文本选区之上的剪切 / 复制 / 粘贴那一行,或某些列表显示的"针对这一行的动作"表面。主命令内联渲染为图标按钮;次命令折叠进溢出菜单。填满 CommandBar 的同一批 AppBarItemBase 记录也能填满这个表面 —— 整个项数组是可互换的。

一个 ButtonSplitButton 目标点击即可打开浮出,无需额外接线。要改成由你自己的状态来打开 —— 比如当一次选区出现时 —— 就设置 IsOpen

CommandBarFlyout(Border(selection), primaryCommands: commands)
    with { IsOpen = hasSelection }

IsOpen 是一次性触发器,就像 FlyoutElement.IsOpen:它在 falsetrue 的跳变沿打开浮出,之后由用户自己关闭它。

项工厂方法 形状
AppBarButton(label, onClick?, icon?) 标准工具条按钮。
AppBarButton(Command) 命令驱动变体。
AppBarToggleButton(label, isChecked?, onIsCheckedChanged?, icon?) 两态。
AppBarSeparator() 垂直分隔线。

WinUI 设计页:Command bar flyout

TeachingTip 的目标引用

TeachingTip 可以锚定到住在另一个容器里的元素。创建一个 ElementRef<FrameworkElement>,用 .Ref(target) 把它挂到目标上,再通过工厂方法的 target: 参数或 .Target(target) 流畅方法把同一个单元格交给提示气泡:

class TeachingTipTargetDemo : Component
{
    public override Element Render()
    {
        var (show, setShow) = UseState(false);

        // ElementRef<T> 会隐式转换为 TeachingTip 的 target: 参数所接受的
        // 非泛型 ElementRef。UseElementRef 是 Component 上的扩展方法,
        // 所以要通过 `this.` 调用。
        var target = this.UseElementRef<FrameworkElement>();

        return HStack(16,
            Border(
                Button("Show anchored tip", () => setShow(true))
                    .Ref(target)),
            Border(
                TeachingTip(
                    "Cross-container target",
                    "This TeachingTip is declared in a different subtree.",
                    target: target) with
                {
                    IsOpen = show,
                    OnClosed = () => setShow(false),
                })
        ).Padding(24);
    }
}

目标是一个响应式引用属性,而不是一次性的 target.Current 读取。提示气泡可以在按钮之前或之后挂载,而如果按钮被重建,这条边会在下一次打开前重新解析。图库的 TeachingTipPage 在它的"TeachingTip targeting another subtree"示例里用的就是这一模式。

Popup(Element child, bool isOpen = false, Action? onClosed = null)
class PopupDemo : Component
{
    public override Element Render()
    {
        var (open, setOpen) = UseState(false);

        // Popup 是一个自由定位的表面。把它用于既非对话框也非浮出的覆盖层 ——
        // 取色器、原地编辑器、自定义提示气泡。
        var popupContent = Border(
            VStack(8,
                TextBlock("This is a Popup.").Bold(),
                TextBlock("Click outside to dismiss.")
            ).Padding(12)
        ).Background(Theme.SolidBackground).WithBorder(Theme.ControlStroke).CornerRadius(6);

        return VStack(8,
            SubHeading("Popup"),
            Button(open ? "Hide popup" : "Show popup",
                () => setOpen(!open))
                .AutomationName(open ? "Hide popup" : "Show popup"),
            Popup(popupContent, isOpen: open,
                onClosed: () => setOpen(false))
                .IsLightDismissEnabled()
                .Offset(120, 0)
        ).Padding(24);
    }
}

锚定在按钮上、启用轻关闭的 Popup

Popup 是非结构化浮层 —— 当 ContentDialogMenuFlyoutCommandBarFlyout 都是错误形状时,你就够它。取色器、原地重命名编辑器、自定义提示气泡、不是菜单的下拉内容 —— 这些都是 popup。

流畅方法 效果
.IsLightDismissEnabled(bool enabled = true) 点击外部时关闭。默认关闭(与 WinUI 一致);调用 .IsLightDismissEnabled() 启用。
.Offset(horizontal, vertical) 相对 popup 锚点的位置偏移。
.Opened(Action) popup 上屏之后触发。
.Closed(Action) popup 离屏之后触发。
.Set(p => p.PlacementTarget = ...) 锚定到特定元素。

popup 本身不提供焦点陷阱或 ARIA dialog 角色。想要有模态感的 popup,请自己用(accessibility.md 里的)UseFocusTrap 包住内容做焦点管理,并用 .Semantics(role: "dialog") 在根节点上施加对话框语义。对于非模态 popup(提示气泡、悬停卡片),两者都不需要。

WinUI 基元参考:Popup class

命令集成

要让对话框逻辑不渗进每一个打开它的表面,最干净的做法是把那个动作放到一个 Command 背后。同一个记录能点亮一个按钮、一个菜单项,以及(配合 .Accelerator)一个键盘快捷键 —— 把对话框主按钮写成 Command.Execute 就是自然的延伸:

class CommandingIntegrationDemo : Component
{
    public override Element Render()
    {
        // 一个 Command 驱动按钮、菜单项,以及(通过 .Accelerator)
        // Ctrl+S。同一个 Command 也能点亮 CommandBarFlyout 里的
        // AppBarButton —— 一次声明,三个表面。
        var (saved, setSaved) = UseState(false);

        var save = new Command
        {
            Label = "Save",
            Execute = () => setSaved(true),
            CanExecute = !saved,
            Icon = SymbolIcon("Save"),
        };

        return VStack(8,
            SubHeading("One Command, two surfaces"),
            Button(save),                          // 主 CTA
            MenuFlyout(
                Button("File ▾"),
                MenuItem(save),                    // 菜单里的副本
                MenuSeparator(),
                MenuItem("Reset", () => setSaved(false))),
            TextBlock(saved ? "Saved." : "Unsaved changes.")
                .Opacity(0.6)
        ).Padding(24);
    }
}

Command.CanExecute = false 会让绑定到该命令的每一个表面变灰 —— 菜单项、按钮、键盘绑定在一处全部禁用。不用命令的话,你得在每个表面上复制一遍 isEnabled 的推导。完整模式(异步命令、带参数的 Command<T>、重入保护)见 commanding.md

焦点与 ARIA

WinUI 的 ContentDialog 开箱即带焦点陷阱和 Dialog ARIA 角色。对话框打开时,焦点移到默认按钮;Tab 在对话框内环绕;Esc 通过关闭按钮关闭。屏幕阅读器把对话框标题作为 AutomationProperties.Name 播报。这些都不需要配置 —— 传入标题和内容,无障碍表面就是正确的。

MenuFlyoutCommandBarFlyout 在 WinUI 层面遵循同样的焦点规则:打开时把焦点移到第一个可用项,方向键导航,Esc 关闭,关闭时焦点回到发起元素。

例外是 Popup。它上面那些一概没有;它只是一个定位基元。要让一个 popup 表现得像模态,用 UseFocusTrap 包住它的内容并显式施加对话框语义。UseFocusTrap(bool isActive) 接受激活标志,句柄通过元素修饰符 .FocusTrap(handle) 附加上去,而 .Semantics(role: "dialog") 提供屏幕阅读器角色 —— 在 Component 内部,因为它是扩展方法,要以 this.UseFocusTrap(...) 的形式调用这个 Hook:

class ModalPopupDemo : Component
{
    public override Element Render()
    {
        var (open, setOpen) = UseState(false);

        // UseFocusTrap 是 Component 上的扩展方法(所以要 `this.`),
        // 并接受激活标志;用 .FocusTrap(...) 附加句柄。
        var trap = this.UseFocusTrap(open);

        return VStack(8,
            SubHeading("Popup that behaves modally"),
            Button("Open modal popup", () => setOpen(true)),
            Popup(
                Border(
                    VStack(8,
                        TextBlock("Focus stays inside this popup."),
                        Button("Close", () => setOpen(false))
                    ).Padding(16)
                ).FocusTrap(trap)
                 .Semantics(role: "dialog"),
                isOpen: open,
                onClosed: () => setOpen(false))
        ).Padding(24);
    }
}

陷阱的形状与 ARIA 映射记录在 accessibility.md 里。

关闭原因

每个对话框和浮出都有五种不同的关闭方式。在你的处理器里把它们一律等同视之 —— 不存在"真取消"与"隐式取消"之分:

表面 主要关闭路径
ContentDialog 主按钮、次按钮、关闭按钮、Esc、点击遮罩(返回 None)。
MenuFlyout 点击某项(执行动作)、点击外部、Esc。
CommandBarFlyout 点击命令、点击外部、Esc。
Popup 点击外部(当 LightDismiss = true)、显式 IsOpen = false

对于 ContentDialogOnClosed 回调是唯一的关闭通知 —— 读 ContentDialogResult 来得知用户走了哪条路,但要接受 None 就是用户的权利。

参考

元素 工厂方法 开/关 生命周期
ContentDialogElement ContentDialog(title, content, primaryButtonText) IsOpen(init)、OnClosed(result) OnOpenedOnClosed
MenuFlyoutElement MenuFlyout(target, items...) 点击目标自动打开 不适用
CommandBarFlyoutElement CommandBarFlyout(target, primary?, secondary?) 点击目标自动打开,或 IsOpen(init) 不适用
PopupElement Popup(child, isOpen?, onClosed?) isOpen 参数或 .IsOpen(init) .Opened.Closed

模式

对话框驱动的异步命令

异步确认 —— "确定吗?" → "正在做…" → "完成。" —— 适合塞进一个带 ExecuteAsyncCommand。对话框主按钮触发该命令;UseCommandExecuteAsync 包成 Execute 并跟踪 IsExecuting,于是 Command.IsEnabledCanExecute && !IsExecuting && !IsDebouncing)在这段时间内为 false,动作运行期间对话框的主按钮会自己禁用。用户没法连点两下那个破坏性按钮:

class DialogAsyncCommandDemo : Component
{
    public override Element Render()
    {
        var (open, setOpen) = UseState(false);
        var (deleted, setDeleted) = UseState(false);

        // UseCommand 把 ExecuteAsync 包成 Execute 并跟踪 IsExecuting,
        // 于是 IsEnabled 在异步动作期间变为 false。
        var delete = UseCommand(new Command
        {
            Label = "Delete",
            ExecuteAsync = async () =>
            {
                await Task.Delay(400);
                setDeleted(true);
                setOpen(false);
            },
            CanExecute = !deleted,
        });

        return VStack(8,
            SubHeading("Dialog-driven async command"),
            Button("Delete item…", () => setOpen(true)),
            TextBlock(deleted ? "Deleted." : "Not deleted.").Opacity(0.6),
            ContentDialog(
                "Delete this item?",
                TextBlock("This action cannot be undone."),
                primaryButtonText: "Delete") with
            {
                IsOpen = open,
                SecondaryButtonText = "Cancel",
                IsPrimaryButtonEnabled = delete.IsEnabled,
                OnClosed = r =>
                {
                    if (r == ContentDialogResult.Primary && delete.IsEnabled)
                        delete.Execute?.Invoke();
                    else
                        setOpen(false);
                },
            }
        ).Padding(24);
    }
}

这与 recipes/modal-dialog 范例相呼应 —— 那一篇用内联模态而不是 WinUI 控件搭出了同样的形状。

在列表行上右键

MenuFlyout 附着在它的目标上。在 ListView 的行模板内部,用 MenuFlyout(rowContent, items...) 把行包起来,菜单就绑定到那一个行实例。把菜单项与该行的数据系在一起 —— MenuItem<T>(Command<T>, T) 让一个命令适用于每一行的上下文。带键的 ListView<T> 重载接受 Func<T, string> 键选择器,因此把非字符串 id 投影成字符串:

ListView(items, item => item.Id.ToString(), (item, _) =>
    MenuFlyout(
        RowContent(item),
        MenuItem(deleteCommand, item),
        MenuItem(renameCommand, item),
        MenuSeparator(),
        MenuItem(propertiesCommand, item)))

常见错误

在事件处理器里命令式地打开对话框

// 不要这样:
Button("Save", async () =>
{
    var dialog = new ContentDialog { Title = "Confirm" };
    await dialog.ShowAsync();
})
class BasicDialogDemo : Component
{
    public override Element Render()
    {
        var (open, setOpen) = UseState(false);

        return VStack(8,
            SubHeading("Basic ContentDialog"),
            Button("Show dialog", () => setOpen(true)),
            // 对话框始终存在于树中。IsOpen 控制可见性;
            // 用户关闭时由 OnClosed 把它翻回来。
            ContentDialog(
                "Welcome",
                TextBlock("Thank you for trying Reactor."),
                primaryButtonText: "OK") with
            {
                IsOpen = open,
                OnClosed = _ => setOpen(false),
            }
        ).Padding(24);
    }
}

命令式路径掉出了 Reactor 的渲染模型 —— 对话框不在树里,因此它无法被父级的主题样式化、无法被 testing.md 的渲染器 fixture 测试、也无法与组件其余部分共享状态。受控的 IsOpen 标志才是规范模式。

用一个对话框复用于不相关的多个决定

// 不要这样:
var (open, setOpen) = UseState(false);
var (dialogType, setDialogType) = UseState("");

// 一个对话框,靠 dialogType 分支出标题/正文/按钮。

两个决定,两个对话框。分支版本让每个对话框状态都要读那个 dialog-type 字符串,这把对话框的关切泄漏进了你的组件数据模型,也让测试 fixture 变得脆弱。每个模态动作都该有自己的对话框元素和自己的状态对 —— 代价只是两次 useState 调用和一个额外的 ContentDialog 声明,两者都近乎免费。

提示

把每个对话框触发器都与一个 Command 配对。 触发器可能存在于一个或多个表面(工具栏、菜单、键盘快捷键);命令给了你一个统一的地方去接启用态、遥测和撤销。对话框主按钮再通过 command.Execute 路由。

ContentDialogResult.None 当作取消。 Esc、点击遮罩和右上角的 X 全部返回 None。别试图区分它们 —— 用户没选 PrimarySecondary,就是没选。

三个结构化表面都失败之前,别去够 Popup ContentDialogMenuFlyoutCommandBarFlyout 自带焦点与 ARIA 语义。Popup 没有,你得自己重新实现焦点陷阱。

每次决定打开一次对话框,而不是每次渲染打开一次。 如果 open 在你的状态里,只有按钮处理器该把它翻成 true。一个在某个条件上执行 setOpen(true)UseEffect 会在每次关闭后重新打开对话框,直到那个条件改变 —— 用一个"用户已确认"标志把该副作用把住。

把长耗时动作的进度显示在对话框内部,而不是之后。 动作中途关掉对话框很唐突。把对话框主按钮接到一个异步 Command 上,让 IsExecuting 把主按钮标签换成转圈,完成后再关闭。

下一步

  • 状态与信息 —— 上一篇:非交互式反馈(InfoBar、ProgressRing、TeachingTip)。
  • 数据系统 —— 下一篇:DataGrid 与数据源管线。
  • 命令 —— 驱动按钮、菜单项、对话框与快捷键的 Command 记录。
  • 无障碍 —— 焦点陷阱、ARIA 角色与对话框地标行为。
  • 实践范例:模态对话框 —— 端到端构建一个确认模态的范例。