diff --git a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Windows.cs index bc80b3b7cad9..5c29234788a8 100644 --- a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Windows.cs @@ -151,5 +151,170 @@ await CreateHandlerAndAddToWindow(window, Assert.True(windowRootView.NavigationViewControl.ButtonHolderGrid.Visibility == UI.Xaml.Visibility.Visible); })); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ModalPageDisablesHitTestOnUnderlyingPage(bool useColor) + { + SetupBuilder(); + + var navPage = new NavigationPage(new ContentPage() { Content = new Label() { Text = "Root Page" } }); + + await CreateHandlerAndAddToWindow(new Window(navPage), + async (handler) => + { + ContentPage modalPage = new ContentPage() { Content = new Label() { Text = "Modal Page" } }; + + if (useColor) + modalPage.BackgroundColor = Colors.Purple.WithAlpha(0.5f); + else + modalPage.Background = new SolidColorBrush(Colors.Purple.WithAlpha(0.5f)); + + var rootPageRootView = navPage.FindMauiContext().GetNavigationRootManager().RootView; + + await navPage.CurrentPage.Navigation.PushModalAsync(modalPage); + await OnLoadedAsync(modalPage); + + var modalRootView = modalPage.FindMauiContext().GetNavigationRootManager().RootView; + + // The underlying page should have IsHitTestVisible disabled + Assert.False(rootPageRootView.IsHitTestVisible, + "Underlying page should have IsHitTestVisible=false when a modal is displayed"); + + // The modal page should have IsHitTestVisible enabled + Assert.True(modalRootView.IsHitTestVisible, + "Modal page should have IsHitTestVisible=true"); + + await navPage.CurrentPage.Navigation.PopModalAsync(); + await OnUnloadedAsync(modalPage); + + // After popping the modal, the underlying page should be interactive again + Assert.True(rootPageRootView.IsHitTestVisible, + "Underlying page should have IsHitTestVisible=true after modal is dismissed"); + }); + } + + [Fact] + public async Task ModalPageFocusTrapsAndRestoresCorrectly() + { + SetupBuilder(); + + var button = new Button() { Text = "Test Button" }; + var rootPage = new ContentPage() { Content = button }; + var navPage = new NavigationPage(rootPage); + + await CreateHandlerAndAddToWindow(new Window(navPage), + async (handler) => + { + var modalButton = new Button() { Text = "Modal Button" }; + var modalPage = new ContentPage() + { + Content = modalButton, + BackgroundColor = Colors.Purple.WithAlpha(0.5f) + }; + + var container = (WindowRootViewContainer)handler.PlatformView.Content; + + // Push modal + await navPage.CurrentPage.Navigation.PushModalAsync(modalPage); + await OnLoadedAsync(modalPage); + + var rootPageRootView = navPage.FindMauiContext().GetNavigationRootManager().RootView; + var modalRootView = modalPage.FindMauiContext().GetNavigationRootManager().RootView; + + // Underlying page should be non-interactive + Assert.False(rootPageRootView.IsHitTestVisible); + + // Pop modal + await navPage.CurrentPage.Navigation.PopModalAsync(); + await OnUnloadedAsync(modalPage); + + // After pop, the root page should be fully interactive + Assert.True(rootPageRootView.IsHitTestVisible, + "Root page should be hit-test visible after modal pop"); + + // The root page should still be in the visual tree + Assert.Contains(rootPageRootView, container.CachedChildren); + + // The modal should be removed + Assert.DoesNotContain(modalRootView, container.CachedChildren); + }); + } + + [Fact] + public async Task NestedModalPagesMaintainHitTestVisibilityAndFocusTrap() + { + SetupBuilder(); + + var button = new Button() { Text = "Root Button" }; + var rootPage = new ContentPage() { Content = button }; + var navPage = new NavigationPage(rootPage); + + await CreateHandlerAndAddToWindow(new Window(navPage), + async (handler) => + { + var modalButtonA = new Button() { Text = "Modal A Button" }; + var modalPageA = new ContentPage() + { + Content = modalButtonA, + BackgroundColor = Colors.Green.WithAlpha(0.5f) + }; + + var modalButtonB = new Button() { Text = "Modal B Button" }; + var modalPageB = new ContentPage() + { + Content = modalButtonB, + BackgroundColor = Colors.Red.WithAlpha(0.5f) + }; + + var container = (WindowRootViewContainer)handler.PlatformView.Content; + + // Push first modal (A) + await navPage.CurrentPage.Navigation.PushModalAsync(modalPageA); + await OnLoadedAsync(modalPageA); + + var rootPageRootView = navPage.FindMauiContext().GetNavigationRootManager().RootView; + var modalARootView = modalPageA.FindMauiContext().GetNavigationRootManager().RootView; + + // Underlying root page should be non-interactive while modal A is showing + Assert.False(rootPageRootView.IsHitTestVisible); + Assert.Contains(modalARootView, container.CachedChildren); + + // Push second modal (B) on top of A + await navPage.CurrentPage.Navigation.PushModalAsync(modalPageB); + await OnLoadedAsync(modalPageB); + + var modalBRootView = modalPageB.FindMauiContext().GetNavigationRootManager().RootView; + + // Root should still be non-interactive with topmost modal B showing + Assert.False(rootPageRootView.IsHitTestVisible); + Assert.Contains(modalBRootView, container.CachedChildren); + + // Pop topmost modal (B) + await navPage.CurrentPage.Navigation.PopModalAsync(); + await OnUnloadedAsync(modalPageB); + + // After popping B, modal A is still visible, so the root page + // should remain non-interactive (focus trap still active) + Assert.False(rootPageRootView.IsHitTestVisible); + Assert.Contains(modalARootView, container.CachedChildren); + Assert.DoesNotContain(modalBRootView, container.CachedChildren); + + // Now pop modal A + await navPage.CurrentPage.Navigation.PopModalAsync(); + await OnUnloadedAsync(modalPageA); + + // After popping the last modal, the root page should be interactive again + Assert.True(rootPageRootView.IsHitTestVisible, + "Root page should be hit-test visible after all modals are popped"); + + // The root page should still be in the visual tree + Assert.Contains(rootPageRootView, container.CachedChildren); + + // Modal A should now be removed from the visual tree + Assert.DoesNotContain(modalARootView, container.CachedChildren); + }); + } } } diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue22938.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue22938.cs new file mode 100644 index 000000000000..e332732e6e37 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue22938.cs @@ -0,0 +1,92 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 22938, "Keyboard focus does not shift to a newly opened modal page", PlatformAffected.All)] +public class Issue22938 : ContentPage +{ + public Issue22938() + { + var clickCountLabel = new Label + { + Text = "0", + AutomationId = "ClickCountLabel", + FontSize = 24 + }; + + var mainPageButton = new Button + { + Text = "Click Me", + AutomationId = "MainPageButton", + Command = new Command(() => + { + int count = int.Parse(clickCountLabel.Text); + clickCountLabel.Text = (count + 1).ToString(); + }) + }; + + var openModalButton = new Button + { + Text = "Open Modal", + AutomationId = "OpenModalButton", + Command = new Command(async () => + { + // Use semi-transparent background to match the reproduction scenario. + // This causes the underlying page to remain in the visual tree + // (ModalNavigationManager does not call RemovePage for non-default backgrounds). + var modalPage = new ContentPage + { + BackgroundColor = Color.FromArgb("#40808080"), + Content = new VerticalStackLayout + { + Spacing = 20, + Padding = new Thickness(30), + VerticalOptions = LayoutOptions.Center, + Children = + { + new Label + { + Text = "Modal Page", + AutomationId = "ModalPageLabel", + FontSize = 24, + HorizontalOptions = LayoutOptions.Center + }, + new Entry + { + Placeholder = "Focus target on modal", + AutomationId = "ModalEntry" + }, + new Button + { + Text = "Close Modal", + AutomationId = "CloseModalButton", + Command = new Command(async () => + { + await Navigation.PopModalAsync(); + }) + } + } + } + }; + + await Navigation.PushModalAsync(modalPage); + }) + }; + + Content = new VerticalStackLayout + { + Spacing = 20, + Padding = new Thickness(30), + VerticalOptions = LayoutOptions.Center, + Children = + { + new Label + { + Text = "Main Page - Issue 22938", + FontSize = 24 + }, + mainPageButton, + clickCountLabel, + openModalButton + } + }; + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue22938.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue22938.cs new file mode 100644 index 000000000000..b108febd1ed4 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue22938.cs @@ -0,0 +1,78 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue22938 : _IssuesUITest +{ + public Issue22938(TestDevice device) : base(device) + { + } + + public override string Issue => "Keyboard focus does not shift to a newly opened modal page"; + + [Test] + [Category(UITestCategories.Focus)] + public void ModalPageShouldReceiveKeyboardFocus() + { + App.WaitForElement("OpenModalButton"); + + // Open the modal page + App.Tap("OpenModalButton"); + + // Wait for modal to appear — the Entry is the first focusable element + App.WaitForElement("ModalEntry"); + + // Press Enter — with the fix, focus is on ModalEntry (an Entry control), + // so Enter should NOT activate MainPageButton on the page beneath + App.PressEnter(); + + // Close the modal by tapping the close button explicitly + App.Tap("CloseModalButton"); + + // Wait for main page to reappear + App.WaitForElement("ClickCountLabel"); + + // Verify the main page button was NOT clicked by the Enter key + var clickCount = App.WaitForElement("ClickCountLabel").GetText(); + Assert.That(clickCount, Is.EqualTo("0"), + "Enter key should not activate buttons on the page beneath a modal"); + } + + [Test] + [Category(UITestCategories.Focus)] + public void TabShouldNotCycleToBehindModal() + { + App.WaitForElement("OpenModalButton"); + + // Open the modal page (uses semi-transparent background so underlying page stays in tree) + App.Tap("OpenModalButton"); + + // Wait for modal to appear + App.WaitForElement("ModalEntry"); + + // Tab through many times to attempt cycling past the modal into the underlying page. + // The modal has 2 focusable elements (Entry + CloseModalButton), so 10 tabs should + // cycle through them multiple times. If focus escapes to the underlying page, + // one of the tabs could land on MainPageButton. + for (int i = 0; i < 10; i++) + { + App.SendTabKey(); + } + + // Now press Enter. If focus leaked to MainPageButton, this would click it. + App.PressEnter(); + + // Close the modal + App.Tap("CloseModalButton"); + + // Wait for main page to reappear + App.WaitForElement("ClickCountLabel"); + + // Verify the main page button was NOT clicked during Tab cycling + var clickCount = App.WaitForElement("ClickCountLabel").GetText(); + Assert.That(clickCount, Is.EqualTo("0"), + "Tab key should not cycle focus to buttons on the page beneath a modal"); + } +} diff --git a/src/Core/src/Platform/Windows/WindowRootViewContainer.cs b/src/Core/src/Platform/Windows/WindowRootViewContainer.cs index dae4bcf97c50..6abef173dc98 100644 --- a/src/Core/src/Platform/Windows/WindowRootViewContainer.cs +++ b/src/Core/src/Platform/Windows/WindowRootViewContainer.cs @@ -1,6 +1,9 @@ +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; using Windows.Foundation; namespace Microsoft.Maui.Platform @@ -9,6 +12,10 @@ internal partial class WindowRootViewContainer : Panel { FrameworkElement? _topPage; UIElementCollection? _cachedChildren; + bool _modalFocusTrapActive; + TypedEventHandler? _gettingFocusHandler; + readonly Dictionary _originalTabNavigation = new(); + readonly Dictionary _originalIsHitTestVisible = new(); [SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "Panel.Children property is banned to enforce use of this CachedChildren property.")] internal UIElementCollection CachedChildren @@ -56,27 +63,187 @@ internal void AddPage(FrameworkElement pageView) { if (!CachedChildren.Contains(pageView)) { + if (_topPage is not null) + { + // Block pointer/touch input on the page being covered + _originalIsHitTestVisible[_topPage] = _topPage.IsHitTestVisible; + _topPage.IsHitTestVisible = false; + } + int indexOFTopPage = 0; - if (_topPage != null) + if (_topPage is not null) indexOFTopPage = CachedChildren.IndexOf(_topPage) + 1; CachedChildren.Insert(indexOFTopPage, pageView); _topPage = pageView; + + // When covering another page, activate keyboard focus trapping + // so Tab cannot escape the modal — mirroring how WinUI ContentDialog works. + if (indexOFTopPage > 0) + { + // Cycle Tab within the modal so it never escapes to underlying pages + if (pageView is Control modalControl) + { + _originalTabNavigation[pageView] = modalControl.TabFocusNavigation; + modalControl.TabFocusNavigation = KeyboardNavigationMode.Cycle; + } + + EnableModalFocusTrap(); + } + + TryMoveFocusToPage(_topPage); } } internal void RemovePage(FrameworkElement pageView) { - int indexOFTopPage = -1; - if (_topPage != null) - indexOFTopPage = CachedChildren.IndexOf(_topPage) - 1; + // Clean up any pending Loaded handler to prevent memory leaks + pageView.Loaded -= OnPageLoadedForFocus; + + // Restore the original TabFocusNavigation value that was overridden in AddPage + if (_originalTabNavigation.Remove(pageView, out var originalMode) && pageView is Control control) + { + control.TabFocusNavigation = originalMode; + } + + // Find the new top page by scanning backwards through children. + // CachedChildren may contain non-page elements (e.g., W2DGraphicsView for visual diagnostics), + // so we cannot rely on simple index arithmetic. We look for the topmost page that isn't + // the one being removed. + FrameworkElement? newTopPage = null; + int pageCount = 0; + for (int i = CachedChildren.Count - 1; i >= 0; i--) + { + var child = CachedChildren[i] as FrameworkElement; + if (child is null || child == pageView) + continue; + + // Only count actual page views (WindowRootView), not overlays + if (child is not WindowRootView) + continue; + + pageCount++; + newTopPage ??= child; + } + + // Update _topPage BEFORE removing from the collection. + // WinUI3 fires GettingFocus synchronously when a focused element is removed from the tree. + _topPage = newTopPage; + + // Disable the focus trap if we're back to a single page (no more modals) + if (pageCount <= 1) + { + DisableModalFocusTrap(); + } CachedChildren.Remove(pageView); - if (indexOFTopPage >= 0) - _topPage = (FrameworkElement)CachedChildren[indexOFTopPage]; + if (_topPage is not null) + { + // Re-enable pointer/touch on the revealed page, restoring its original value + _topPage.IsHitTestVisible = _originalIsHitTestVisible.Remove(_topPage, out var originalHitTest) + ? originalHitTest + : true; + TryMoveFocusToPage(_topPage); + } + } + + void EnableModalFocusTrap() + { + if (!_modalFocusTrapActive) + { + _gettingFocusHandler ??= new TypedEventHandler(OnContainerGettingFocus); + AddHandler(GettingFocusEvent, _gettingFocusHandler, true); + _modalFocusTrapActive = true; + } + } + + void DisableModalFocusTrap() + { + if (_modalFocusTrapActive && _gettingFocusHandler is not null) + { + RemoveHandler(GettingFocusEvent, _gettingFocusHandler); + _modalFocusTrapActive = false; + } + } + + void OnContainerGettingFocus(UIElement sender, GettingFocusEventArgs args) + { + // Guard: only act when trap is explicitly active + if (!_modalFocusTrapActive) + return; + + if (_topPage is null || args.NewFocusedElement is not DependencyObject newElement) + return; + + // Allow focus changes within the current top (modal) page + if (newElement == _topPage || IsDescendantOf(newElement, _topPage)) + return; + + // Focus is trying to leave the modal — redirect it back + if (FocusManager.FindFirstFocusableElement(_topPage) is DependencyObject firstFocusable) + { + args.TrySetNewFocusedElement(firstFocusable); + } + else + { + args.TryCancel(); + } + } + + static bool IsDescendantOf(DependencyObject element, DependencyObject ancestor) + { + var current = VisualTreeHelper.GetParent(element); + while (current is not null) + { + if (current == ancestor) + return true; + current = VisualTreeHelper.GetParent(current); + } + return false; + } + + static void TryMoveFocusToPage(FrameworkElement page) + { + if (page.IsLoaded) + { + SetFocusToFirstElement(page); + } else - _topPage = null; + { + page.Loaded -= OnPageLoadedForFocus; + page.Loaded += OnPageLoadedForFocus; + } + } + + static void OnPageLoadedForFocus(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement page) + { + page.Loaded -= OnPageLoadedForFocus; + SetFocusToFirstElement(page); + } + } + + static void SetFocusToFirstElement(FrameworkElement page) + { + if (FocusManager.FindFirstFocusableElement(page) is UIElement focusableElement) + { + if (focusableElement.Focus(FocusState.Programmatic)) + return; + } + + if (page.Focus(FocusState.Programmatic)) + return; + + // If immediate focus failed (visual tree not ready yet), defer until after layout + page.DispatcherQueue?.TryEnqueue(() => + { + if (FocusManager.FindFirstFocusableElement(page) is UIElement el) + el.Focus(FocusState.Programmatic); + else + page.Focus(FocusState.Programmatic); + }); } internal void AddOverlay(FrameworkElement overlayView) @@ -90,4 +257,4 @@ internal void RemoveOverlay(FrameworkElement overlayView) CachedChildren.Remove(overlayView); } } -} \ No newline at end of file +}