Skip to content

Commit 427f2a4

Browse files
committed
Clean up, add tests
1 parent e628615 commit 427f2a4

17 files changed

+365
-36
lines changed

src/Components/Web.JS/src/Boot.Web.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
6060
},
6161
enhancedNavigationCompleted() {
6262
rootComponentManager.onEnhancedNavigationCompleted();
63-
jsEventRegistry.dispatchEvent('enhancednavigationend', {});
6463
},
6564
};
6665

src/Components/Web.JS/src/Rendering/FocusOnNavigate.ts

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,72 +7,75 @@ import { EnhancedNavigationStartEvent, JSEventRegistry } from '../Services/JSEve
77
const customElementName = 'blazor-focus-on-navigate';
88
const focusOnNavigateRegistrations: FocusOnNavigateRegistration[] = [];
99

10+
let allowFocusOnEnhancedLoad = false;
11+
1012
export function enableFocusOnNavigate(jsEventRegistry: JSEventRegistry) {
1113
customElements.define(customElementName, FocusOnNavigate);
1214
jsEventRegistry.addEventListener('enhancednavigationstart', onEnhancedNavigationStart);
13-
jsEventRegistry.addEventListener('enhancednavigationend', onEnhancedNavigationEnd);
1415
jsEventRegistry.addEventListener('enhancedload', onEnhancedLoad);
1516
document.addEventListener('focusin', onFocusIn);
1617
document.addEventListener('focusout', onFocusOut);
1718

1819
// Focus the element on the initial page load
1920
if (document.readyState === 'loading') {
20-
document.addEventListener('DOMContentLoaded', tryApplyFocus);
21+
allowFocusOnEnhancedLoad = true;
22+
document.addEventListener('DOMContentLoaded', afterInitialPageLoad);
2123
} else {
22-
tryApplyFocus();
24+
afterInitialPageLoad();
2325
}
2426
}
2527

26-
let shouldFocusOnEnhancedLoad = false;
27-
let isNavigating = false;
28+
function afterInitialPageLoad() {
29+
tryApplyFocus(/* forceMoveFocus */ false);
30+
}
2831

2932
function onEnhancedNavigationStart(ev: EnhancedNavigationStartEvent) {
3033
// Only focus on enhanced load if the enhanced navigation is not a form post.
31-
shouldFocusOnEnhancedLoad = ev.method !== 'post';
32-
isNavigating = true;
33-
}
34-
35-
function onEnhancedNavigationEnd() {
36-
isNavigating = false;
34+
allowFocusOnEnhancedLoad = ev.method !== 'post';
3735
}
3836

3937
function onEnhancedLoad() {
40-
if (shouldFocusOnEnhancedLoad) {
41-
tryApplyFocus();
38+
if (allowFocusOnEnhancedLoad) {
39+
tryApplyFocus(/* forceMoveFocus */ true);
4240
}
4341
}
4442

4543
function onFocusIn() {
4644
// As soon as an element get successfully focused, don't attempt to focus again on future
4745
// enhanced page updates.
48-
shouldFocusOnEnhancedLoad = false;
46+
allowFocusOnEnhancedLoad = false;
4947
}
5048

49+
let lastFocusedElement: Element | null = null;
50+
5151
function onFocusOut(ev: FocusEvent) {
5252
// It's possible that the element lost focus because it was removed from the page,
5353
// and now the document doesn't have an active element.
54-
// There are two variations of this case that we care about:
55-
// [1] This happens during enhanced navigation. In this case, we'll attempt to re-apply focus to
56-
// the element specified by the active <blazor-focus-on-navigate>, because we're still navigating.
57-
// [2] This happens after an enhanced navigation. One common cause of this is when the focused element
58-
// gets replaced in the transition to interactivity. In this case, we'll only attempt to re-apply
59-
// focus if the removed element is the same one we manually applied focus to.
54+
// This could have happened either because an enhanced page update removed the element or
55+
// because the focused element was replaced during the transition to interactivity
56+
// (see https://github.com/dotnet/aspnetcore/issues/42561).
57+
// In either case, we'll attempt to reapply focus only if:
58+
// [1] that element was the one we last focused programmatically, and
59+
// [2] it's about to get removed from the DOM.
6060
const target = ev.target;
61-
if (target instanceof Element && (isNavigating || target === lastFocusedElement)) {
61+
if (target instanceof Element && target === lastFocusedElement) {
6262
// We want to apply focus after all synchronous changes to the DOM have completed,
6363
// including the potential removal of this element.
6464
setTimeout(() => {
65-
const documentHasNoFocusedElement = document.activeElement === null || document.activeElement === document.body;
66-
if (documentHasNoFocusedElement && !document.contains(target)) {
67-
tryApplyFocus();
65+
if (!document.contains(target)) {
66+
tryApplyFocus(/* forceMoveFocus */ false);
6867
}
6968
}, 0);
7069
}
7170
}
7271

73-
let lastFocusedElement: Element | null = null;
72+
function tryApplyFocus(forceMoveFocus: boolean) {
73+
// Don't apply focus if there's already a focused element and 'forceMoveFocus' is false.
74+
// See also: https://developer.mozilla.org/docs/Web/API/Document/activeElement#value
75+
if (!forceMoveFocus && document.activeElement !== null && document.activeElement !== document.body) {
76+
return;
77+
}
7478

75-
function tryApplyFocus() {
7679
lastFocusedElement = null;
7780

7881
const selector = findActiveSelector();

src/Components/Web.JS/src/Services/JSEventRegistry.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export interface EnhancedNavigationStartEvent extends BlazorEvent {
1717
export interface BlazorEventMap {
1818
'enhancedload': BlazorEvent,
1919
'enhancednavigationstart': EnhancedNavigationStartEvent,
20-
'enhancednavigationend': BlazorEvent,
2120
}
2221

2322
export class JSEventRegistry {
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Components.TestServer.RazorComponents;
5+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
6+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
7+
using Microsoft.AspNetCore.E2ETesting;
8+
using OpenQA.Selenium;
9+
using OpenQA.Selenium.Support.Extensions;
10+
using TestServer;
11+
using Xunit.Abstractions;
12+
using StreamRenderedComponent = Components.TestServer.RazorComponents.Pages.FocusOnNavigate.StreamRendered;
13+
14+
namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;
15+
16+
public class FocusOnNavigateTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>
17+
{
18+
public FocusOnNavigateTest(
19+
BrowserFixture browserFixture,
20+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
21+
ITestOutputHelper output)
22+
: base(browserFixture, serverFixture, output)
23+
{
24+
}
25+
26+
public override Task InitializeAsync()
27+
=> InitializeAsync(BrowserFixture.StreamingContext);
28+
29+
[Fact]
30+
public void FocusIsMoved_AfterInitialPageLoad_WhenNoElementHasFocus()
31+
{
32+
Navigate($"{ServerPathBase}/focus-on-navigate/static");
33+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("data-focus-on-navigate") is not null);
34+
}
35+
36+
[Fact]
37+
public void FocusIsPreserved_AfterInitialPageLoad_WhenAnyElementHasFocus()
38+
{
39+
Navigate($"{ServerPathBase}/focus-on-navigate/static-with-other-focused-element");
40+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("data-focus-on-load") is not null);
41+
}
42+
43+
[Fact]
44+
public void FocusIsPreserved_OnEnhancedNavigation_WhenNoElementMatchesSelector()
45+
{
46+
Navigate($"{ServerPathBase}/focus-on-navigate/static");
47+
WaitUntilDocumentReady();
48+
Browser.Click(By.LinkText("Home"));
49+
Browser.True(() => Browser.SwitchTo().ActiveElement().Text == "Home");
50+
}
51+
52+
[Fact]
53+
public void FocusIsMoved_OnEnhancedNavigation_WhenAnyElementMatchesSelector()
54+
{
55+
Navigate($"{ServerPathBase}/focus-on-navigate");
56+
WaitUntilDocumentReady();
57+
Browser.Click(By.LinkText("Statically rendered"));
58+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("data-focus-on-navigate") is not null);
59+
}
60+
61+
[Fact]
62+
public void FocusIsPreserved_OnEnhancedFormPost_WhenAnyElementMatchesSelector()
63+
{
64+
Navigate($"{ServerPathBase}/focus-on-navigate/form-submission");
65+
WaitUntilDocumentReady();
66+
Browser.Click(By.LinkText("Form submission"));
67+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "value-to-submit");
68+
Browser.FindElement(By.Id("value-to-submit")).ReplaceText("Some value");
69+
Browser.Click(By.Id("submit-button"));
70+
Browser.Equal("Some value", () => Browser.FindElement(By.Id("submitted-value")).Text);
71+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "submit-button");
72+
}
73+
74+
[Fact]
75+
public void FocusIsMoved_OnStreamingUpdate_WhenElementMatchingSelectorGetsAddedToDocument()
76+
{
77+
Navigate($"{ServerPathBase}/focus-on-navigate/stream");
78+
Browser.Equal("Streaming...", () => Browser.FindElement(By.Id("streaming-status")).Text);
79+
80+
// Add an element that does NOT match the focus selector.
81+
StreamRenderedComponent.AddElement(new(WantsFocus: false));
82+
Browser.True(() => Browser.SwitchTo().ActiveElement().TagName == "body");
83+
Browser.Exists(By.Id("input-element-0"));
84+
85+
// Add an element that does match the focus selector. It should receive focus.
86+
StreamRenderedComponent.AddElement(new(WantsFocus: true));
87+
Browser.Exists(By.Id("input-element-1"));
88+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-1");
89+
90+
StreamRenderedComponent.EndResponse();
91+
Browser.Equal("Complete", () => Browser.FindElement(By.Id("streaming-status")).Text);
92+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-1");
93+
}
94+
95+
[Fact]
96+
public void FocusIsPreserved_OnStreamingUpdate_WhenUserFocusesElementNotMatchingSelector()
97+
{
98+
Navigate($"{ServerPathBase}/focus-on-navigate/stream");
99+
Browser.Equal("Streaming...", () => Browser.FindElement(By.Id("streaming-status")).Text);
100+
101+
// Add an element that does not get autofocused, but then manually focus it
102+
StreamRenderedComponent.AddElement(new(WantsFocus: false));
103+
Browser.Click(By.Id("input-element-0"));
104+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-0");
105+
106+
// Add an element that wants to get autofocused. However, it should not receive focus
107+
// because the user has already focused the previous element
108+
StreamRenderedComponent.AddElement(new(WantsFocus: true));
109+
Browser.Exists(By.Id("input-element-1"));
110+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-0");
111+
112+
StreamRenderedComponent.EndResponse();
113+
Browser.Equal("Complete", () => Browser.FindElement(By.Id("streaming-status")).Text);
114+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-0");
115+
}
116+
117+
[Fact]
118+
public void FocusIsMoved_OnStreamingUpdate_WhenElementMatchingSelectorGetsRemovedFromDocument_ThenAddedBack()
119+
{
120+
Navigate($"{ServerPathBase}/focus-on-navigate/stream");
121+
Browser.Equal("Streaming...", () => Browser.FindElement(By.Id("streaming-status")).Text);
122+
123+
// Add an element that does NOT match the focus selector.
124+
StreamRenderedComponent.AddElement(new(WantsFocus: false));
125+
Browser.True(() => Browser.SwitchTo().ActiveElement().TagName == "body");
126+
Browser.Exists(By.Id("input-element-0"));
127+
128+
// Add an element that does match the focus selector. It should receive focus.
129+
StreamRenderedComponent.AddElement(new(WantsFocus: true));
130+
Browser.Exists(By.Id("input-element-1"));
131+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-1");
132+
133+
// Remove the element that received focus.
134+
StreamRenderedComponent.RemoveElement(index: 1);
135+
Browser.DoesNotExist(By.Id("input-element-1"));
136+
Browser.True(() => Browser.SwitchTo().ActiveElement().TagName == "body");
137+
138+
// Add an element back that matches the focus selector. It should receive focus once again.
139+
StreamRenderedComponent.AddElement(new(WantsFocus: true));
140+
Browser.Exists(By.Id("input-element-1"));
141+
142+
StreamRenderedComponent.EndResponse();
143+
Browser.Equal("Complete", () => Browser.FindElement(By.Id("streaming-status")).Text);
144+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-1");
145+
}
146+
147+
[Fact]
148+
public void FocusIsPreserved_OnStreamingUpdate_WhenElementMatchingSelectorGetsRemovedFromDocument_ThenAddedBack_AfterUserFocusesDifferentElement()
149+
{
150+
Navigate($"{ServerPathBase}/focus-on-navigate/stream");
151+
Browser.Equal("Streaming...", () => Browser.FindElement(By.Id("streaming-status")).Text);
152+
153+
// Add an element that does NOT match the focus selector.
154+
StreamRenderedComponent.AddElement(new(WantsFocus: false));
155+
Browser.True(() => Browser.SwitchTo().ActiveElement().TagName == "body");
156+
Browser.Exists(By.Id("input-element-0"));
157+
158+
// Add an element that does match the focus selector. It should receive focus.
159+
StreamRenderedComponent.AddElement(new(WantsFocus: true));
160+
Browser.Exists(By.Id("input-element-1"));
161+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-1");
162+
163+
// Remove the element that received focus.
164+
StreamRenderedComponent.RemoveElement(index: 1);
165+
Browser.DoesNotExist(By.Id("input-element-1"));
166+
Browser.True(() => Browser.SwitchTo().ActiveElement().TagName == "body");
167+
168+
// Focus a different element by clicking it.
169+
Browser.Click(By.Id("input-element-0"));
170+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-0");
171+
172+
// Add an element back that matches the focus selector. It should not receive focus because
173+
// the user has explicitly focused the previous element.
174+
StreamRenderedComponent.AddElement(new(WantsFocus: true));
175+
Browser.Exists(By.Id("input-element-1"));
176+
177+
StreamRenderedComponent.EndResponse();
178+
Browser.Equal("Complete", () => Browser.FindElement(By.Id("streaming-status")).Text);
179+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("id") == "input-element-0");
180+
}
181+
182+
[Fact]
183+
public void FocusIsRestored_AfterInteractivityStarts_WhenElementMatchingSelectorWasRemoved()
184+
{
185+
Navigate($"{ServerPathBase}/focus-on-navigate");
186+
WaitUntilDocumentReady();
187+
Browser.Click(By.LinkText("Interactively rendered"));
188+
Browser.Equal("interactive", () => Browser.FindElement(By.Id("focus-on-nav-status")).Text);
189+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("data-focus-on-navigate") is not null);
190+
}
191+
192+
[Fact]
193+
public void FocusIsNotRestored_AfterInteractivityStarts_WhenFocusedElementWasSelectedByUser()
194+
{
195+
Navigate($"{ServerPathBase}/focus-on-navigate/interactive-with-other-focused-element");
196+
Browser.Equal("interactive", () => Browser.FindElement(By.Id("focus-on-nav-status")).Text);
197+
Browser.True(() => Browser.SwitchTo().ActiveElement().GetAttribute("data-focus-on-load") is not null);
198+
}
199+
200+
private void WaitUntilDocumentReady()
201+
{
202+
Browser.True(() => Browser.ExecuteJavaScript<bool>("return document.readyState !== 'loading';"));
203+
}
204+
}

src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@
1111
<Router AppAssembly="@typeof(App).Assembly">
1212
<Found Context="routeData">
1313
<RouteView RouteData="@routeData" />
14-
<FocusOnNavigate RouteData="@routeData" Selector="#test-element" />
14+
<FocusOnNavigate RouteData="@routeData" Selector="[data-focus-on-navigate]" />
1515
</Found>
1616
<NotFound>There's nothing here</NotFound>
1717
</Router>
18+
<script>
19+
const elementToFocus = document.querySelector('[data-focus-on-load]');
20+
if (elementToFocus) {
21+
elementToFocus.focus();
22+
}
23+
</script>
1824
<script src="_framework/blazor.web.js" autostart="false" suppress-error="BL9992"></script>
1925
<script src="_content/TestContentPackage/counterInterop.js"></script>
2026
<script>
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
<input type="text" id="test-element" placeholder="I want focus!" />
1+
<input type="text" placeholder="I want focus on navigation!" data-focus-on-navigate />
2+
3+
<p>
4+
Render mode: <span id="focus-on-nav-status">@(RendererInfo.IsInteractive ? "interactive" : "static")</span>
5+
</p>

src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/EnhancedNav/FocusOnNavigatePage.razor

Lines changed: 0 additions & 5 deletions
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
@page "/focus-on-navigate/form-submission"
2+
@using Microsoft.AspNetCore.Components.Forms
3+
4+
<h1>Form submission</h1>
5+
6+
<p>
7+
Submitted value: <span id="submitted-value">@SubmittedValue</span>
8+
</p>
9+
10+
<form @formname="some-form" method="post" data-enhance>
11+
<input type="text" id="value-to-submit" name="value-to-submit" placeholder="Value to submit" data-focus-on-navigate />
12+
<br/>
13+
<button id="submit-button" type="submit">Submit</button>
14+
<AntiforgeryToken />
15+
</form>
16+
17+
@code {
18+
[SupplyParameterFromForm(Name = "value-to-submit")]
19+
public string? SubmittedValue { get; set; }
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@page "/focus-on-navigate"
2+
3+
<h1>Focus on navigate</h1>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@page "/focus-on-navigate/interactive"
2+
3+
<h1>Interactively rendered with other focused element</h1>
4+
5+
<ComponentWithElementToFocus @rendermode="RenderMode.InteractiveServer" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@page "/focus-on-navigate/interactive-with-other-focused-element"
2+
3+
<h1>Interactively rendered element to focus</h1>
4+
5+
<ComponentWithElementToFocus @rendermode="RenderMode.InteractiveServer" />
6+
7+
<input type="text" placeholder="I want focus on load!" data-focus-on-load />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@page "/focus-on-navigate/static"
2+
3+
<h1>Statically rendered element to focus</h1>
4+
5+
<ComponentWithElementToFocus />

0 commit comments

Comments
 (0)