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¶
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 是模态框。传入标题、内容元素和主按钮标签。工厂方法返回一个 ContentDialogElement 记录;附加的 init-only 属性(IsOpen、OnClosed、次按钮/关闭按钮文本)用 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、被自己那个按钮递增的计数器 —— 会立即重新渲染。因为内容子树是被打补丁而非重新挂载,那些瞬时控件状态(焦点、光标、滚动位置、文本选区)能在更新中存活。 - 属性重新同步。
Title、IsPrimaryButtonEnabled、DefaultButton和按钮标签在每次渲染时都跟随元素,因此它们可以依赖住在对话框内部的状态。SecondaryButtonText和CloseButtonText都可为空,而 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 值告诉你用户走了哪条路。Primary 和 Secondary 对应那两个带标签的按钮;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 更新状态,组件重新渲染,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¶
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 把一个弹出菜单挂到目标元素上。点击目标 —— 菜单在它那里打开。点击菜单外部 —— 它关闭。工厂方法把目标作为第一个参数,把各项作为可变尾部;结果会把目标包起来,因此它出现在树中目标所在的位置。
| 项工厂方法 | 形状 |
|---|---|
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 是选择上下文工具栏 —— 浮在一段文本选区之上的剪切 / 复制 / 粘贴那一行,或某些列表显示的"针对这一行的动作"表面。主命令内联渲染为图标按钮;次命令折叠进溢出菜单。填满 CommandBar 的同一批 AppBarItemBase 记录也能填满这个表面 —— 整个项数组是可互换的。
一个 Button 或 SplitButton 目标点击即可打开浮出,无需额外接线。要改成由你自己的状态来打开 —— 比如当一次选区出现时 —— 就设置 IsOpen:
IsOpen 是一次性触发器,就像 FlyoutElement.IsOpen:它在 false → true 的跳变沿打开浮出,之后由用户自己关闭它。
| 项工厂方法 | 形状 |
|---|---|
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¶
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 是非结构化浮层 —— 当 ContentDialog、MenuFlyout 和 CommandBarFlyout 都是错误形状时,你就够它。取色器、原地重命名编辑器、自定义提示气泡、不是菜单的下拉内容 —— 这些都是 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 播报。这些都不需要配置 —— 传入标题和内容,无障碍表面就是正确的。
MenuFlyout 和 CommandBarFlyout 在 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。 |
对于 ContentDialog,OnClosed 回调是唯一的关闭通知 —— 读 ContentDialogResult 来得知用户走了哪条路,但要接受 None 就是用户的权利。
参考¶
| 元素 | 工厂方法 | 开/关 | 生命周期 |
|---|---|---|---|
ContentDialogElement |
ContentDialog(title, content, primaryButtonText) |
IsOpen(init)、OnClosed(result) |
OnOpened、OnClosed |
MenuFlyoutElement |
MenuFlyout(target, items...) |
点击目标自动打开 | 不适用 |
CommandBarFlyoutElement |
CommandBarFlyout(target, primary?, secondary?) |
点击目标自动打开,或 IsOpen(init) |
不适用 |
PopupElement |
Popup(child, isOpen?, onClosed?) |
isOpen 参数或 .IsOpen(init) |
.Opened、.Closed |
模式¶
对话框驱动的异步命令¶
异步确认 —— "确定吗?" → "正在做…" → "完成。" —— 适合塞进一个带 ExecuteAsync 的 Command。对话框主按钮触发该命令;UseCommand 把 ExecuteAsync 包成 Execute 并跟踪 IsExecuting,于是 Command.IsEnabled(CanExecute && !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。别试图区分它们 —— 用户没选 Primary 或 Secondary,就是没选。
三个结构化表面都失败之前,别去够 Popup。 ContentDialog、MenuFlyout、CommandBarFlyout 自带焦点与 ARIA 语义。Popup 没有,你得自己重新实现焦点陷阱。
每次决定打开一次对话框,而不是每次渲染打开一次。 如果 open 在你的状态里,只有按钮处理器该把它翻成 true。一个在某个条件上执行 setOpen(true) 的 UseEffect 会在每次关闭后重新打开对话框,直到那个条件改变 —— 用一个"用户已确认"标志把该副作用把住。
把长耗时动作的进度显示在对话框内部,而不是之后。 动作中途关掉对话框很唐突。把对话框主按钮接到一个异步 Command 上,让 IsExecuting 把主按钮标签换成转圈,完成后再关闭。
下一步¶
- 状态与信息 —— 上一篇:非交互式反馈(InfoBar、ProgressRing、TeachingTip)。
- 数据系统 —— 下一篇:DataGrid 与数据源管线。
- 命令 —— 驱动按钮、菜单项、对话框与快捷键的 Command 记录。
- 无障碍 —— 焦点陷阱、ARIA 角色与对话框地标行为。
- 实践范例:模态对话框 —— 端到端构建一个确认模态的范例。