From 8822070aaf89dcd89745c34e3349aa98245f713c Mon Sep 17 00:00:00 2001 From: Corvin Date: Sat, 13 Sep 2025 12:59:41 +0200 Subject: [PATCH 1/4] restore focus to the last focused element inside of the dialog --- src/MaterialDesignThemes.Wpf/DialogHost.cs | 50 +++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/MaterialDesignThemes.Wpf/DialogHost.cs b/src/MaterialDesignThemes.Wpf/DialogHost.cs index 1fb5688e3a..a237299f0a 100644 --- a/src/MaterialDesignThemes.Wpf/DialogHost.cs +++ b/src/MaterialDesignThemes.Wpf/DialogHost.cs @@ -64,6 +64,8 @@ public class DialogHost : ContentControl private DialogClosingEventHandler? _attachedDialogClosingEventHandler; private DialogClosedEventHandler? _attachedDialogClosedEventHandler; private IInputElement? _restoreFocusDialogClose; + private IInputElement? _lastFocusedDialogElement; + private WindowState _previousWindowState; private Action? _currentSnackbarMessageQueueUnPauseAction; static DialogHost() @@ -370,6 +372,7 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj dialogHost.CurrentSession = new DialogSession(dialogHost); var window = Window.GetWindow(dialogHost); + dialogHost.ListenForWindowStateChanged(window); if (!dialogHost.IsRestoreFocusDisabled) { dialogHost._restoreFocusDialogClose = window != null ? FocusManager.GetFocusedElement(window) : null; @@ -395,7 +398,8 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj //https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/187 //totally not happy about this, but on immediate validation we can get some weird looking stuff...give WPF a kick to refresh... - Task.Delay(300).ContinueWith(t => dialogHost.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { + Task.Delay(300).ContinueWith(t => dialogHost.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { CommandManager.InvalidateRequerySuggested(); //Delay focusing the popup until after the animation has some time, Issue #2912 UIElement? child = dialogHost.FocusPopup(); @@ -405,6 +409,50 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj }))); } + + private void ListenForWindowStateChanged(Window? window) + { + window ??= Window.GetWindow(this); + + if (window is not null) + { + window.StateChanged += Window_StateChanged; + } + } + + private void Window_StateChanged(object? sender, EventArgs e) + { + if (sender is not Window window) + { + return; + } + + var windowState = window.WindowState; + if (windowState == WindowState.Minimized) + { + _lastFocusedDialogElement = FocusManager.GetFocusedElement(window); + _previousWindowState = windowState; + return; + } + + // We only need to focus anything manually if the window changes state from Minimized --> (Normal or Maximized) + // Going from Normal --> Maximized (and vice versa) is fine since the focus is already kept correctly + if (IsWindowRestoredFromMinimized() && IsLastFocusedDialogElementFocusable()) + { + // Kinda hacky, but without a delay the focus doesn't always get set correctly because the Focus() method fires too early + Task.Delay(50).ContinueWith(_ => this.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + _lastFocusedDialogElement!.Focus(); + }))); + } + _previousWindowState = windowState; + + bool IsWindowRestoredFromMinimized() => (windowState == WindowState.Normal || windowState == WindowState.Maximized) && + _previousWindowState == WindowState.Minimized; + + bool IsLastFocusedDialogElementFocusable() => _lastFocusedDialogElement is UIElement { Focusable: true, IsVisible: true }; + } + /// /// Returns a DialogSession for the currently open dialog for managing it programmatically. If no dialog is open, CurrentSession will return null /// From 2dc7672a868d07da6d4e72db9b26893997424565 Mon Sep 17 00:00:00 2001 From: Corvin Date: Sat, 13 Sep 2025 13:00:07 +0200 Subject: [PATCH 2/4] add tests --- .../DialogHost/WithMultipleTextBoxes.xaml | 28 +++++++++++++ .../DialogHost/WithMultipleTextBoxes.xaml.cs | 16 ++++++++ .../WPF/DialogHosts/DialogHostTests.cs | 40 ++++++++++++++++++- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml create mode 100644 tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml.cs diff --git a/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml b/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml new file mode 100644 index 0000000000..53ec2a6f53 --- /dev/null +++ b/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml.cs b/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml.cs new file mode 100644 index 0000000000..0911fa7f73 --- /dev/null +++ b/tests/MaterialDesignThemes.UITests/Samples/DialogHost/WithMultipleTextBoxes.xaml.cs @@ -0,0 +1,16 @@ +namespace MaterialDesignThemes.UITests.Samples.DialogHost; + +/// +/// Interaction logic for WithMultipleTextBoxes.xaml +/// +public partial class WithMultipleTextBoxes : UserControl +{ + public WithMultipleTextBoxes() + { + InitializeComponent(); + } + private void DialogHost_Loaded(object sender, RoutedEventArgs e) + { + SampleDialogHost.IsOpen = true; + } +} diff --git a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs index 088c82ad1a..719e221654 100644 --- a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs +++ b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs @@ -314,7 +314,7 @@ public async Task CornerRadius_AppliedToContentCoverBorder_WhenSetOnEmbeddedDial await Wait.For(async () => { var contentCoverBorder = await dialogHost.GetElement("ContentCoverBorder"); - + await Assert.That((await contentCoverBorder.GetCornerRadius()).TopLeft).IsEqualTo(1); await Assert.That((await contentCoverBorder.GetCornerRadius()).TopRight).IsEqualTo(2); await Assert.That((await contentCoverBorder.GetCornerRadius()).BottomRight).IsEqualTo(3); @@ -500,7 +500,7 @@ public async Task DialogHost_WithComboBox_CanSelectItem() var comboBox = await dialogHost.GetElement("TargetedPlatformComboBox"); await Task.Delay(500, TestContext.Current!.CancellationToken); await comboBox.LeftClick(); - + var item = await Wait.For(() => comboBox.GetElement("TargetItem")); await Task.Delay(TimeSpan.FromSeconds(1)); await item.LeftClick(); @@ -514,4 +514,40 @@ await Wait.For(async () => recorder.Success(); } + + [Test] + [Description("Issue 3434")] + [Arguments(WindowState.Minimized, WindowState.Maximized)] + [Arguments(WindowState.Minimized, WindowState.Normal)] + [Arguments(WindowState.Maximized, WindowState.Normal)] + public async Task DialogHost_WhenWindowStateChanges_FocusedElementStaysFocused(WindowState firstWindowState, WindowState secondWindowState) + { + await using var recorder = new TestRecorder(App); + + var dialogHost = (await LoadUserControl()).As(); + await Task.Delay(400, TestContext.Current!.CancellationToken); + + // Select the second TextBox + var tbTwo = await dialogHost.GetElement("TextBoxTwo"); + await tbTwo.MoveKeyboardFocus(); + await Assert.That(await tbTwo.GetIsFocused()).IsTrue(); + + // First state + await dialogHost.RemoteExecute(SetStateOfParentWindow, firstWindowState); + await Task.Delay(400, TestContext.Current!.CancellationToken); + // Second state + await dialogHost.RemoteExecute(SetStateOfParentWindow, secondWindowState); + await Task.Delay(400, TestContext.Current!.CancellationToken); + + // After changing state of the window the previously focused element should be focused again + await Assert.That(await tbTwo.GetIsFocused()).IsTrue(); + recorder.Success(); + + static object SetStateOfParentWindow(DialogHost dialogHost, WindowState state) + { + var window = Window.GetWindow(dialogHost); + window.WindowState = state; + return null!; + } + } } From 31fec8b4d2fa1c8feba107bd3304c555538633d6 Mon Sep 17 00:00:00 2001 From: Corvin Date: Sat, 13 Sep 2025 13:12:42 +0200 Subject: [PATCH 3/4] undo whitespace changes --- .../WPF/DialogHosts/DialogHostTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs index 719e221654..d2f739071e 100644 --- a/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs +++ b/tests/MaterialDesignThemes.UITests/WPF/DialogHosts/DialogHostTests.cs @@ -314,7 +314,6 @@ public async Task CornerRadius_AppliedToContentCoverBorder_WhenSetOnEmbeddedDial await Wait.For(async () => { var contentCoverBorder = await dialogHost.GetElement("ContentCoverBorder"); - await Assert.That((await contentCoverBorder.GetCornerRadius()).TopLeft).IsEqualTo(1); await Assert.That((await contentCoverBorder.GetCornerRadius()).TopRight).IsEqualTo(2); await Assert.That((await contentCoverBorder.GetCornerRadius()).BottomRight).IsEqualTo(3); @@ -500,7 +499,6 @@ public async Task DialogHost_WithComboBox_CanSelectItem() var comboBox = await dialogHost.GetElement("TargetedPlatformComboBox"); await Task.Delay(500, TestContext.Current!.CancellationToken); await comboBox.LeftClick(); - var item = await Wait.For(() => comboBox.GetElement("TargetItem")); await Task.Delay(TimeSpan.FromSeconds(1)); await item.LeftClick(); From a0c440cbddc4ac95c0745d30cb7dcd306e6fcf13 Mon Sep 17 00:00:00 2001 From: Corvin <43533385+corvinsz@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:07:27 +0200 Subject: [PATCH 4/4] Apply suggestions from code review LGTM, thank you for the review! Co-authored-by: Kevin B --- src/MaterialDesignThemes.Wpf/DialogHost.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/MaterialDesignThemes.Wpf/DialogHost.cs b/src/MaterialDesignThemes.Wpf/DialogHost.cs index a237299f0a..1486607cda 100644 --- a/src/MaterialDesignThemes.Wpf/DialogHost.cs +++ b/src/MaterialDesignThemes.Wpf/DialogHost.cs @@ -412,10 +412,10 @@ private static void IsOpenPropertyChangedCallback(DependencyObject dependencyObj private void ListenForWindowStateChanged(Window? window) { - window ??= Window.GetWindow(this); if (window is not null) { + window.StateChanged -= Window_StateChanged; window.StateChanged += Window_StateChanged; } } @@ -437,12 +437,15 @@ private void Window_StateChanged(object? sender, EventArgs e) // We only need to focus anything manually if the window changes state from Minimized --> (Normal or Maximized) // Going from Normal --> Maximized (and vice versa) is fine since the focus is already kept correctly - if (IsWindowRestoredFromMinimized() && IsLastFocusedDialogElementFocusable()) + if (IsWindowRestoredFromMinimized()) { // Kinda hacky, but without a delay the focus doesn't always get set correctly because the Focus() method fires too early Task.Delay(50).ContinueWith(_ => this.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => { - _lastFocusedDialogElement!.Focus(); + if (IsLastFocusedDialogElementFocusable()) + { + _lastFocusedDialogElement!.Focus(); + } }))); } _previousWindowState = windowState;