Skip to content

Commit 4d67845

Browse files
committed
fix(controls): Fix ContentDialog interaction, focus, and modal behavior issues
- Introduce ContentDialogHost to intercept host window input, implement focus preservation/restoration, and maintain backward compatibility with existing ContentPresenter - Add initial focus support to ContentDialog, aligning with WinUI and Windows App ContentDialog design guidelines - Implement ContentDialogAutomationPeer to allow UI automation/assistive tools to recognize ContentDialog as a modal window - Fix issue where opening a new ContentDialog before closing the previous one could cause ShowAsync to hang
1 parent 1de0cd3 commit 4d67845

18 files changed

+2196
-57
lines changed

src/Wpf.Ui.Gallery/Views/Windows/MainWindow.xaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,6 @@
7676
</ui:NavigationView.ContentOverlay>
7777
</ui:NavigationView>
7878

79-
<ContentPresenter x:Name="RootContentDialog" Grid.Row="0" />
80-
8179
<ui:TitleBar
8280
x:Name="TitleBar"
8381
Title="{Binding ViewModel.ApplicationTitle}"
@@ -98,5 +96,7 @@
9896
<ContextMenu DataContext="{Binding DataContext, Source={x:Reference NavigationView}}" ItemsSource="{Binding ViewModel.TrayMenuItems, Mode=OneWay}" />
9997
</tray:NotifyIcon.Menu>
10098
</tray:NotifyIcon>
99+
100+
<ui:ContentDialogHost x:Name="RootContentDialog" Grid.Row="0" />
101101
</Grid>
102102
</ui:FluentWindow>
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// This Source Code Form is subject to the terms of the MIT License.
2+
// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT.
3+
// Copyright (C) Leszek Pomianowski and WPF UI Contributors.
4+
// All Rights Reserved.
5+
6+
using System.Windows.Automation;
7+
using System.Windows.Automation.Peers;
8+
using System.Windows.Automation.Provider;
9+
using System.Windows.Threading;
10+
using Wpf.Ui.Controls;
11+
12+
namespace Wpf.Ui.AutomationPeers;
13+
14+
/// <summary>
15+
/// Automation peer that exposes a <see cref="ContentDialog"/> as a standard modal window
16+
/// for UI Automation clients.
17+
/// </summary>
18+
/// <remarks>
19+
/// This peer maps dialog-specific behavior to the <see cref="IWindowProvider"/> pattern so
20+
/// assistive technologies (screen readers, automation tools) perceive the <see cref="ContentDialog"/>
21+
/// as a modal, non-resizable dialog window.
22+
/// </remarks>
23+
internal sealed class ContentDialogAutomationPeer : UIElementAutomationPeer, IWindowProvider
24+
{
25+
/// <summary>
26+
/// Initializes a new instance of the <see cref="ContentDialogAutomationPeer"/> class.
27+
/// </summary>
28+
/// <param name="owner">The associated <see cref="ContentDialog"/>.</param>
29+
public ContentDialogAutomationPeer(ContentDialog owner)
30+
: base(owner)
31+
{
32+
}
33+
34+
/// <summary>
35+
/// Gets a value indicating whether the window is modal.
36+
/// Always <see langword="true"/> for <see cref="ContentDialog"/>.
37+
/// </summary>
38+
bool IWindowProvider.IsModal => true;
39+
40+
/// <summary>
41+
/// Gets a value indicating whether the window is topmost.
42+
/// <see cref="ContentDialog"/> are treated as topmost for automation.
43+
/// </summary>
44+
bool IWindowProvider.IsTopmost => true;
45+
46+
/// <summary>
47+
/// Gets the current interaction state of the dialog window for UI Automation.
48+
/// </summary>
49+
public WindowInteractionState InteractionState
50+
{
51+
get
52+
{
53+
if (Owner is ContentDialog dialog)
54+
{
55+
if (!dialog.IsLoaded || dialog.Dispatcher is { HasShutdownFinished: true } or { HasShutdownStarted: true })
56+
{
57+
return WindowInteractionState.Closing;
58+
}
59+
}
60+
61+
return WindowInteractionState.Running;
62+
}
63+
}
64+
65+
/// <summary>
66+
/// Gets a value indicating whether the window can be maximized.
67+
/// Always <see langword="false"/> for <see cref="ContentDialog"/>.
68+
/// </summary>
69+
public bool Maximizable => false;
70+
71+
/// <summary>
72+
/// Gets a value indicating whether the window can be minimized.
73+
/// Always <see langword="false"/> for <see cref="ContentDialog"/>.
74+
/// </summary>
75+
public bool Minimizable => false;
76+
77+
/// <summary>
78+
/// Gets the visual state of the window.
79+
/// <see cref="ContentDialog"/> report <see cref="WindowVisualState.Normal"/>.
80+
/// </summary>
81+
public WindowVisualState VisualState => WindowVisualState.Normal;
82+
83+
/// <inheritdoc/>
84+
protected override string GetClassNameCore()
85+
{
86+
// "Emulating WinUI3's ContentDialog ClassName"
87+
return "Popup";
88+
}
89+
90+
/// <inheritdoc/>
91+
protected override string? GetNameCore()
92+
{
93+
if (Owner is ContentDialog dialog)
94+
{
95+
return dialog.Title as string ?? dialog.Title?.ToString();
96+
}
97+
98+
return base.GetNameCore();
99+
}
100+
101+
/// <inheritdoc/>
102+
protected override AutomationControlType GetAutomationControlTypeCore()
103+
{
104+
return AutomationControlType.Window;
105+
}
106+
107+
#if NET48_OR_GREATER || NET5_0_OR_GREATER
108+
/// <inheritdoc/>
109+
protected override bool IsDialogCore()
110+
{
111+
return true;
112+
}
113+
#endif
114+
115+
/// <inheritdoc/>
116+
protected override bool IsControlElementCore()
117+
{
118+
return true;
119+
}
120+
121+
/// <inheritdoc/>
122+
protected override bool IsContentElementCore()
123+
{
124+
return true;
125+
}
126+
127+
/// <inheritdoc/>
128+
protected override bool IsKeyboardFocusableCore()
129+
{
130+
return false;
131+
}
132+
133+
/// <summary>
134+
/// Returns whether the dialog is currently offscreen. A dialog is considered offscreen when not loaded or not visible.
135+
/// </summary>
136+
protected override bool IsOffscreenCore()
137+
{
138+
return Owner is ContentDialog { IsLoaded: false } or { IsVisible: false };
139+
}
140+
141+
/// <summary>
142+
/// Returns automation pattern implementations supported by this peer. Provides <see cref="IWindowProvider"/>.
143+
/// </summary>
144+
/// <param name="pattern">The requested automation pattern.</param>
145+
/// <returns>An object implementing the requested pattern or <see langword="null"/> when not supported.</returns>
146+
public override object? GetPattern(PatternInterface pattern)
147+
{
148+
// Include PatternInterface.ScrollItem to align with WinUI3 behavior: WinUI3 exposes this pattern
149+
// for dialog-like popups, and exposing it here helps automation clients that rely on that behavior.
150+
if (pattern is PatternInterface.Window or PatternInterface.ScrollItem)
151+
{
152+
return this;
153+
}
154+
155+
return null;
156+
}
157+
158+
/// <summary>
159+
/// Closes the associated <see cref="ContentDialog"/>.
160+
/// This is invoked by UI Automation clients through the <see cref="IWindowProvider"/> pattern.
161+
/// </summary>
162+
void IWindowProvider.Close()
163+
{
164+
if (Owner is ContentDialog dialog)
165+
{
166+
Dispatcher? dispatcher = dialog.Dispatcher;
167+
if (dispatcher is { HasShutdownStarted: false, HasShutdownFinished: false })
168+
{
169+
dispatcher.BeginInvoke(
170+
() =>
171+
{
172+
dialog.Hide();
173+
},
174+
DispatcherPriority.Normal
175+
);
176+
}
177+
else
178+
{
179+
dialog.Hide();
180+
}
181+
}
182+
}
183+
184+
/// <summary>
185+
/// Sets the visual state of the window. Not supported for <see cref="ContentDialog"/>.
186+
/// </summary>
187+
void IWindowProvider.SetVisualState(WindowVisualState state)
188+
{
189+
// Not supported for this.
190+
}
191+
192+
/// <summary>
193+
/// Waits for the dialog to become idle.
194+
/// Always returns <see langword="true"/> for <see cref="ContentDialog"/>.
195+
/// </summary>
196+
/// <param name="milliseconds">Maximum time to wait in milliseconds (ignored).</param>
197+
/// <returns>
198+
/// <see langword="true"/> if the dialog is idle or the operation completed;
199+
/// otherwise <see langword="false"/>.
200+
/// </returns>
201+
public bool WaitForInputIdle(int milliseconds)
202+
{
203+
return true;
204+
}
205+
}

src/Wpf.Ui/ContentDialogService.cs

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Wpf.Ui;
1313
/// </summary>
1414
/// <example>
1515
/// <code lang="xml">
16-
/// &lt;ContentPresenter x:Name="RootContentDialogPresenter" Grid.Row="0" /&gt;
16+
/// &lt;ContentDialogHost x:Name="RootContentDialogPresenter" Grid.Row="0" /&gt;
1717
/// </code>
1818
/// <code lang="csharp">
1919
/// IContentDialogService contentDialogService = new ContentDialogService();
@@ -33,6 +33,7 @@ namespace Wpf.Ui;
3333
public class ContentDialogService : IContentDialogService
3434
{
3535
private ContentPresenter? _dialogHost;
36+
private ContentDialogHost? _dialogHostEx;
3637

3738
[Obsolete("Use SetDialogHost instead.")]
3839
public void SetContentPresenter(ContentPresenter contentPresenter)
@@ -47,34 +48,110 @@ public void SetContentPresenter(ContentPresenter contentPresenter)
4748
}
4849

4950
/// <inheritdoc/>
51+
[Obsolete("Use SetDialogHost(ContentDialogHost) instead.")]
5052
public void SetDialogHost(ContentPresenter contentPresenter)
5153
{
54+
if (contentPresenter == null)
55+
{
56+
throw new ArgumentNullException(nameof(contentPresenter));
57+
}
58+
59+
if (_dialogHostEx != null)
60+
{
61+
throw new InvalidOperationException(
62+
"Cannot set ContentPresenter: a ContentDialogHost host has already been set. " +
63+
"Only one host type is allowed per instance for compatibility."
64+
);
65+
}
66+
5267
_dialogHost = contentPresenter;
5368
}
5469

5570
/// <inheritdoc/>
71+
[Obsolete("Use GetDialogHostEx() instead.")]
5672
public ContentPresenter? GetDialogHost()
5773
{
5874
return _dialogHost;
5975
}
6076

77+
/// <inheritdoc/>
78+
/// <exception cref="ArgumentNullException">
79+
/// Thrown when <paramref name="dialogHost"/> is <see langword="null"/>.
80+
/// </exception>
81+
/// <exception cref="InvalidOperationException">
82+
/// Thrown when a legacy dialog host (ContentPresenter) has already been set via
83+
/// <see cref="SetDialogHost(ContentPresenter)"/>. Only one host type can be set per instance.
84+
/// </exception>
85+
/// <remarks>
86+
/// <para>
87+
/// This method sets the enhanced <see cref="ContentDialogHost"/> to contain and manage dialogs.
88+
/// For compatibility reasons, an instance can have either a legacy host (set via
89+
/// <see cref="SetDialogHost(ContentPresenter)"/>) or an enhanced host (set via this method),
90+
/// but not both.
91+
/// </para>
92+
/// </remarks>
93+
public void SetDialogHost(ContentDialogHost dialogHost)
94+
{
95+
if (dialogHost == null)
96+
{
97+
throw new ArgumentNullException(nameof(dialogHost));
98+
}
99+
100+
// Defense mechanism: prevent mixed host types for compatibility
101+
if (_dialogHost != null)
102+
{
103+
throw new InvalidOperationException(
104+
"Cannot set ContentDialogHost: a legacy ContentPresenter host has already been set. " +
105+
"Only one host type is allowed per instance for compatibility."
106+
);
107+
}
108+
109+
_dialogHostEx = dialogHost;
110+
}
111+
112+
/// <inheritdoc/>
113+
public ContentDialogHost? GetDialogHostEx()
114+
{
115+
return _dialogHostEx;
116+
}
117+
61118
/// <inheritdoc/>
62119
public Task<ContentDialogResult> ShowAsync(ContentDialog dialog, CancellationToken cancellationToken)
63120
{
64-
if (_dialogHost == null)
121+
#pragma warning disable CS0618 // (Warning: Obsolete) To maintain compatibility
122+
123+
if (dialog == null)
124+
{
125+
throw new ArgumentNullException(nameof(dialog));
126+
}
127+
128+
if (_dialogHostEx == null && _dialogHost == null)
65129
{
66130
throw new InvalidOperationException("The DialogHost was never set.");
67131
}
68132

69-
if (dialog.DialogHost != null && _dialogHost != dialog.DialogHost)
133+
object? svcHost = _dialogHostEx is not null ? _dialogHostEx : _dialogHost;
134+
135+
object? dlgHost = dialog.DialogHostEx is not null ? dialog.DialogHostEx : dialog.DialogHost;
136+
137+
if (dlgHost != null && !ReferenceEquals(dlgHost, svcHost))
70138
{
71139
throw new InvalidOperationException(
72140
"The DialogHost is not the same as the one that was previously set."
73141
);
74142
}
75143

76-
dialog.DialogHost = _dialogHost;
144+
if (_dialogHostEx != null)
145+
{
146+
dialog.DialogHostEx = _dialogHostEx;
147+
}
148+
else
149+
{
150+
dialog.DialogHost = _dialogHost;
151+
}
77152

78153
return dialog.ShowAsync(cancellationToken);
154+
155+
#pragma warning restore CS0618 // (Warning: Obsolete)
79156
}
80157
}

0 commit comments

Comments
 (0)