Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Windows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IWindowHandler>(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<IWindowHandler>(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<IWindowHandler>(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);
});
}
}
}
92 changes: 92 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue22938.cs
Original file line number Diff line number Diff line change
@@ -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
}
};
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading
Loading