Skip to content

Commit 0952c34

Browse files
fix(controls): Fix ContentDialog interaction, focus, and modal behavior issues (#1601)
* 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 * chore: Format code --------- Co-authored-by: pomian <13592821+pomianowski@users.noreply.github.com>
1 parent bfc6080 commit 0952c34

22 files changed

+2253
-68
lines changed

src/Wpf.Ui.FontMapper/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ await httpClient.GetFromJsonAsync<IEnumerable<GitTag>>(
3939
)
4040
?.Last()
4141
?.Ref.Replace("refs/tags/", string.Empty)
42-
.Trim() ?? throw new Exception("Unable to parse the version string");
42+
.Trim()
43+
?? throw new Exception("Unable to parse the version string");
4344
}
4445

4546
string FormatIconName(string rawIconName)

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>

src/Wpf.Ui/Appearance/SystemThemeWatcher.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,11 @@ public static void UnWatch(Window? window)
151151
/// </summary>
152152
private static IntPtr WndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
153153
{
154-
if (msg == (int)PInvoke.WM_DWMCOLORIZATIONCOLORCHANGED ||
155-
msg == (int)PInvoke.WM_THEMECHANGED ||
156-
msg == (int)PInvoke.WM_SYSCOLORCHANGE)
154+
if (
155+
msg == (int)PInvoke.WM_DWMCOLORIZATIONCOLORCHANGED
156+
|| msg == (int)PInvoke.WM_THEMECHANGED
157+
|| msg == (int)PInvoke.WM_SYSCOLORCHANGE
158+
)
157159
{
158160
UpdateObservedWindow(hWnd);
159161
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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+
/// <summary>
33+
/// Gets a value indicating whether the window is modal.
34+
/// Always <see langword="true"/> for <see cref="ContentDialog"/>.
35+
/// </summary>
36+
bool IWindowProvider.IsModal => true;
37+
38+
/// <summary>
39+
/// Gets a value indicating whether the window is topmost.
40+
/// <see cref="ContentDialog"/> are treated as topmost for automation.
41+
/// </summary>
42+
bool IWindowProvider.IsTopmost => true;
43+
44+
/// <summary>
45+
/// Gets the current interaction state of the dialog window for UI Automation.
46+
/// </summary>
47+
public WindowInteractionState InteractionState
48+
{
49+
get
50+
{
51+
if (Owner is ContentDialog dialog)
52+
{
53+
if (
54+
!dialog.IsLoaded
55+
|| dialog.Dispatcher is { HasShutdownFinished: true } or { HasShutdownStarted: true }
56+
)
57+
{
58+
return WindowInteractionState.Closing;
59+
}
60+
}
61+
62+
return WindowInteractionState.Running;
63+
}
64+
}
65+
66+
/// <summary>
67+
/// Gets a value indicating whether the window can be maximized.
68+
/// Always <see langword="false"/> for <see cref="ContentDialog"/>.
69+
/// </summary>
70+
public bool Maximizable => false;
71+
72+
/// <summary>
73+
/// Gets a value indicating whether the window can be minimized.
74+
/// Always <see langword="false"/> for <see cref="ContentDialog"/>.
75+
/// </summary>
76+
public bool Minimizable => false;
77+
78+
/// <summary>
79+
/// Gets the visual state of the window.
80+
/// <see cref="ContentDialog"/> report <see cref="WindowVisualState.Normal"/>.
81+
/// </summary>
82+
public WindowVisualState VisualState => WindowVisualState.Normal;
83+
84+
/// <inheritdoc/>
85+
protected override string GetClassNameCore()
86+
{
87+
// "Emulating WinUI3's ContentDialog ClassName"
88+
return "Popup";
89+
}
90+
91+
/// <inheritdoc/>
92+
protected override string? GetNameCore()
93+
{
94+
if (Owner is ContentDialog dialog)
95+
{
96+
return dialog.Title as string ?? dialog.Title?.ToString();
97+
}
98+
99+
return base.GetNameCore();
100+
}
101+
102+
/// <inheritdoc/>
103+
protected override AutomationControlType GetAutomationControlTypeCore()
104+
{
105+
return AutomationControlType.Window;
106+
}
107+
108+
#if NET48_OR_GREATER || NET5_0_OR_GREATER
109+
/// <inheritdoc/>
110+
protected override bool IsDialogCore()
111+
{
112+
return true;
113+
}
114+
#endif
115+
116+
/// <inheritdoc/>
117+
protected override bool IsControlElementCore()
118+
{
119+
return true;
120+
}
121+
122+
/// <inheritdoc/>
123+
protected override bool IsContentElementCore()
124+
{
125+
return true;
126+
}
127+
128+
/// <inheritdoc/>
129+
protected override bool IsKeyboardFocusableCore()
130+
{
131+
return false;
132+
}
133+
134+
/// <summary>
135+
/// Returns whether the dialog is currently offscreen. A dialog is considered offscreen when not loaded or not visible.
136+
/// </summary>
137+
protected override bool IsOffscreenCore()
138+
{
139+
return Owner is ContentDialog { IsLoaded: false } or { IsVisible: false };
140+
}
141+
142+
/// <summary>
143+
/// Returns automation pattern implementations supported by this peer. Provides <see cref="IWindowProvider"/>.
144+
/// </summary>
145+
/// <param name="pattern">The requested automation pattern.</param>
146+
/// <returns>An object implementing the requested pattern or <see langword="null"/> when not supported.</returns>
147+
public override object? GetPattern(PatternInterface pattern)
148+
{
149+
// Include PatternInterface.ScrollItem to align with WinUI3 behavior: WinUI3 exposes this pattern
150+
// for dialog-like popups, and exposing it here helps automation clients that rely on that behavior.
151+
if (pattern is PatternInterface.Window or PatternInterface.ScrollItem)
152+
{
153+
return this;
154+
}
155+
156+
return null;
157+
}
158+
159+
/// <summary>
160+
/// Closes the associated <see cref="ContentDialog"/>.
161+
/// This is invoked by UI Automation clients through the <see cref="IWindowProvider"/> pattern.
162+
/// </summary>
163+
void IWindowProvider.Close()
164+
{
165+
if (Owner is ContentDialog dialog)
166+
{
167+
Dispatcher? dispatcher = dialog.Dispatcher;
168+
if (dispatcher is { HasShutdownStarted: false, HasShutdownFinished: false })
169+
{
170+
dispatcher.BeginInvoke(
171+
() =>
172+
{
173+
dialog.Hide();
174+
},
175+
DispatcherPriority.Normal
176+
);
177+
}
178+
else
179+
{
180+
dialog.Hide();
181+
}
182+
}
183+
}
184+
185+
/// <summary>
186+
/// Sets the visual state of the window. Not supported for <see cref="ContentDialog"/>.
187+
/// </summary>
188+
void IWindowProvider.SetVisualState(WindowVisualState state)
189+
{
190+
// Not supported for this.
191+
}
192+
193+
/// <summary>
194+
/// Waits for the dialog to become idle.
195+
/// Always returns <see langword="true"/> for <see cref="ContentDialog"/>.
196+
/// </summary>
197+
/// <param name="milliseconds">Maximum time to wait in milliseconds (ignored).</param>
198+
/// <returns>
199+
/// <see langword="true"/> if the dialog is idle or the operation completed;
200+
/// otherwise <see langword="false"/>.
201+
/// </returns>
202+
public bool WaitForInputIdle(int milliseconds)
203+
{
204+
return true;
205+
}
206+
}

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)