Skip to content

Commit e628615

Browse files
committed
Support <FocusOnNavigate> in SSR, new events
1 parent b4558f7 commit e628615

File tree

13 files changed

+183
-12
lines changed

13 files changed

+183
-12
lines changed

src/Components/Web.JS/dist/Release/blazor.webassembly.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webview.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { ConsoleLogger } from './Platform/Logging/Loggers';
2525
import { LogLevel } from './Platform/Logging/Logger';
2626
import { resolveOptions } from './Platform/Circuits/CircuitStartOptions';
2727
import { JSInitializer } from './JSInitializers/JSInitializers';
28+
import { enableFocusOnNavigate } from './Rendering/FocusOnNavigate';
2829

2930
let started = false;
3031
let rootComponentManager: WebRootComponentManager;
@@ -50,12 +51,16 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
5051
const jsEventRegistry = JSEventRegistry.create(Blazor);
5152

5253
const navigationEnhancementCallbacks: NavigationEnhancementCallbacks = {
54+
enhancedNavigationStarted: (method) => {
55+
jsEventRegistry.dispatchEvent('enhancednavigationstart', { method });
56+
},
5357
documentUpdated: () => {
5458
rootComponentManager.onDocumentUpdated();
5559
jsEventRegistry.dispatchEvent('enhancedload', {});
5660
},
5761
enhancedNavigationCompleted() {
5862
rootComponentManager.onEnhancedNavigationCompleted();
63+
jsEventRegistry.dispatchEvent('enhancednavigationend', {});
5964
},
6065
};
6166

@@ -66,6 +71,8 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
6671
attachProgressivelyEnhancedNavigationListener(navigationEnhancementCallbacks);
6772
}
6873

74+
enableFocusOnNavigate(jsEventRegistry);
75+
6976
// Wait until the initial page response completes before activating interactive components.
7077
// If stream rendering is used, this helps to ensure that only the final set of interactive
7178
// components produced by the stream render actually get activated for interactivity.
@@ -79,7 +86,6 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
7986
}
8087

8188
function onInitialDomContentLoaded(options: Partial<WebStartOptions>) {
82-
8389
// Retrieve and start invoking the initializers.
8490
// Blazor server options get defaults that are configured before we invoke the initializers
8591
// so we do the same here.

src/Components/Web.JS/src/DomWrapper.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import '@microsoft/dotnet-js-interop';
55

66
export const domFunctions = {
77
focus,
8-
focusBySelector
8+
focusBySelector,
99
};
1010

1111
function focus(element: HTMLOrSVGElement, preventScroll: boolean): void {
@@ -22,7 +22,7 @@ function focus(element: HTMLOrSVGElement, preventScroll: boolean): void {
2222
}
2323
}
2424

25-
function focusBySelector(selector: string, preventScroll: boolean): void {
25+
function focusBySelector(selector: string): Element | null {
2626
const element = document.querySelector(selector) as HTMLElement;
2727
if (element) {
2828
// If no explicit tabindex is defined, mark it as programmatically-focusable.
@@ -33,5 +33,8 @@ function focusBySelector(selector: string, preventScroll: boolean): void {
3333
}
3434

3535
element.focus({ preventScroll: true });
36+
return element;
3637
}
37-
}
38+
39+
return null;
40+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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+
import { domFunctions } from '../DomWrapper';
5+
import { EnhancedNavigationStartEvent, JSEventRegistry } from '../Services/JSEventRegistry';
6+
7+
const customElementName = 'blazor-focus-on-navigate';
8+
const focusOnNavigateRegistrations: FocusOnNavigateRegistration[] = [];
9+
10+
export function enableFocusOnNavigate(jsEventRegistry: JSEventRegistry) {
11+
customElements.define(customElementName, FocusOnNavigate);
12+
jsEventRegistry.addEventListener('enhancednavigationstart', onEnhancedNavigationStart);
13+
jsEventRegistry.addEventListener('enhancednavigationend', onEnhancedNavigationEnd);
14+
jsEventRegistry.addEventListener('enhancedload', onEnhancedLoad);
15+
document.addEventListener('focusin', onFocusIn);
16+
document.addEventListener('focusout', onFocusOut);
17+
18+
// Focus the element on the initial page load
19+
if (document.readyState === 'loading') {
20+
document.addEventListener('DOMContentLoaded', tryApplyFocus);
21+
} else {
22+
tryApplyFocus();
23+
}
24+
}
25+
26+
let shouldFocusOnEnhancedLoad = false;
27+
let isNavigating = false;
28+
29+
function onEnhancedNavigationStart(ev: EnhancedNavigationStartEvent) {
30+
// 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;
37+
}
38+
39+
function onEnhancedLoad() {
40+
if (shouldFocusOnEnhancedLoad) {
41+
tryApplyFocus();
42+
}
43+
}
44+
45+
function onFocusIn() {
46+
// As soon as an element get successfully focused, don't attempt to focus again on future
47+
// enhanced page updates.
48+
shouldFocusOnEnhancedLoad = false;
49+
}
50+
51+
function onFocusOut(ev: FocusEvent) {
52+
// It's possible that the element lost focus because it was removed from the page,
53+
// 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.
60+
const target = ev.target;
61+
if (target instanceof Element && (isNavigating || target === lastFocusedElement)) {
62+
// We want to apply focus after all synchronous changes to the DOM have completed,
63+
// including the potential removal of this element.
64+
setTimeout(() => {
65+
const documentHasNoFocusedElement = document.activeElement === null || document.activeElement === document.body;
66+
if (documentHasNoFocusedElement && !document.contains(target)) {
67+
tryApplyFocus();
68+
}
69+
}, 0);
70+
}
71+
}
72+
73+
let lastFocusedElement: Element | null = null;
74+
75+
function tryApplyFocus() {
76+
lastFocusedElement = null;
77+
78+
const selector = findActiveSelector();
79+
if (selector) {
80+
lastFocusedElement = domFunctions.focusBySelector(selector);
81+
}
82+
}
83+
84+
function findActiveSelector(): string | null {
85+
// It's unlikely that there will be more than one <blazor-focus-on-navigate> registered
86+
// at a time. But if there is, we'll prefer the one most recently added to the DOM,
87+
// keeping a stack of all previous registrations to fall back on if the current one
88+
// gets removed.
89+
let registration: FocusOnNavigateRegistration | undefined;
90+
while ((registration = focusOnNavigateRegistrations.at(-1)) !== undefined) {
91+
if (registration.isConnected) {
92+
return registration.selector;
93+
}
94+
95+
focusOnNavigateRegistrations.pop();
96+
}
97+
98+
return null;
99+
}
100+
101+
type FocusOnNavigateRegistration = {
102+
isConnected: boolean;
103+
selector: string | null;
104+
}
105+
106+
class FocusOnNavigate extends HTMLElement {
107+
static observedAttributes = ['selector'];
108+
109+
private readonly _registration: FocusOnNavigateRegistration = {
110+
isConnected: true,
111+
selector: null,
112+
};
113+
114+
connectedCallback() {
115+
focusOnNavigateRegistrations.push(this._registration);
116+
}
117+
118+
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
119+
if (name === 'selector') {
120+
this._registration.selector = newValue;
121+
}
122+
}
123+
124+
disconnectedCallback() {
125+
this._registration.isConnected = false;
126+
}
127+
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@ interface BlazorEvent {
99
type: keyof BlazorEventMap;
1010
}
1111

12+
export interface EnhancedNavigationStartEvent extends BlazorEvent {
13+
method: string;
14+
}
15+
1216
// Maps Blazor event names to the argument type passed to registered listeners.
1317
export interface BlazorEventMap {
14-
'enhancedload': BlazorEvent;
18+
'enhancedload': BlazorEvent,
19+
'enhancednavigationstart': EnhancedNavigationStartEvent,
20+
'enhancednavigationend': BlazorEvent,
1521
}
1622

1723
export class JSEventRegistry {
@@ -44,7 +50,7 @@ export class JSEventRegistry {
4450
return;
4551
}
4652

47-
const event: BlazorEventMap[K] = {
53+
const event = {
4854
...ev,
4955
type,
5056
};

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ let performingEnhancedPageLoad: boolean;
4242
let currentContentUrl = location.href;
4343

4444
export interface NavigationEnhancementCallbacks {
45+
enhancedNavigationStarted: (method: string) => void;
4546
documentUpdated: () => void;
4647
enhancedNavigationCompleted: () => void;
4748
}
@@ -185,6 +186,10 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
185186
// Notify any interactive runtimes that an enhanced navigation is starting
186187
notifyEnhancedNavigationListners(internalDestinationHref, interceptedLink);
187188

189+
// Invoke other enhanced navigation handlers
190+
const requestMethod = fetchOptions?.method ?? 'get';
191+
navigationEnhancementCallbacks.enhancedNavigationStarted(requestMethod);
192+
188193
// Now request the new page via fetch, and a special header that tells the server we want it to inject
189194
// framing boundaries to distinguish the initial document and each subsequent streaming SSR update.
190195
currentEnhancedNavigationAbortController = new AbortController();
@@ -201,7 +206,7 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
201206
let isNonRedirectedPostToADifferentUrlMessage: string | null = null;
202207
await getResponsePartsWithFraming(responsePromise, abortSignal,
203208
(response, initialContent) => {
204-
const isGetRequest = !fetchOptions?.method || fetchOptions.method === 'get';
209+
const isGetRequest = requestMethod === 'get';
205210
const isSuccessResponse = response.status >= 200 && response.status < 300;
206211

207212
// For true 301/302/etc redirections to external URLs, we'll receive an opaque response
@@ -267,7 +272,7 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
267272
// we can navigate back to (as we don't know if the location supports GET) and we are not able to replicate the Resubmit form?
268273
// browser behavior.
269274
// The only case where this is acceptable is when the last content URL, is the same as the URL for the form we posted to.
270-
isNonRedirectedPostToADifferentUrlMessage = `Cannot perform enhanced form submission that changes the URL (except via a redirection), because then back/forward would not work. Either remove this form\'s \'action\' attribute, or change its method to \'get\', or do not mark it as enhanced.\nOld URL: ${location.href}\nNew URL: ${response.url}`;
275+
isNonRedirectedPostToADifferentUrlMessage = `Cannot perform enhanced form submission that changes the URL (except via a redirection), because then back/forward would not work. Either remove this form's 'action' attribute, or change its method to 'get', or do not mark it as enhanced.\nOld URL: ${location.href}\nNew URL: ${response.url}`;
271276
} else {
272277
if (location.href !== currentContentUrl) {
273278
// The url on the browser might be out of data, so push an entry to the stack to update the url in place.
@@ -301,7 +306,7 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
301306
retryEnhancedNavAsFullPageLoad(internalDestinationHref);
302307
} else {
303308
// For non-get requests, we can't safely re-request, so just treat it as an error
304-
replaceDocumentWithPlainText(`Error: ${fetchOptions.method} request to ${internalDestinationHref} returned non-HTML content of type ${responseContentType || 'unspecified'}.`);
309+
replaceDocumentWithPlainText(`Error: ${requestMethod} request to ${internalDestinationHref} returned non-HTML content of type ${responseContentType || 'unspecified'}.`);
305310
}
306311
}
307312
},

src/Components/Web/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.Invo
44
Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.get -> bool
55
Microsoft.AspNetCore.Components.Web.KeyboardEventArgs.IsComposing.set -> void
66
override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.RendererInfo.get -> Microsoft.AspNetCore.Components.RendererInfo!
7+
override Microsoft.AspNetCore.Components.Routing.FocusOnNavigate.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void

src/Components/Web/src/Routing/FocusOnNavigate.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Microsoft.AspNetCore.Components.Rendering;
45
using Microsoft.JSInterop;
56

67
namespace Microsoft.AspNetCore.Components.Routing;
@@ -12,6 +13,8 @@ namespace Microsoft.AspNetCore.Components.Routing;
1213
/// </summary>
1314
public class FocusOnNavigate : ComponentBase
1415
{
16+
private const string CustomElementName = "blazor-focus-on-navigate";
17+
1518
private Type? _lastNavigatedPageType = typeof(NonMatchingType);
1619
private bool _focusAfterRender;
1720

@@ -50,6 +53,19 @@ protected override void OnParametersSet()
5053
}
5154
}
5255

56+
/// <inheritdoc/>
57+
protected override void BuildRenderTree(RenderTreeBuilder builder)
58+
{
59+
if (AssignedRenderMode is not null)
60+
{
61+
return;
62+
}
63+
64+
builder.OpenElement(0, CustomElementName);
65+
builder.AddAttribute(1, "selector", Selector);
66+
builder.CloseElement();
67+
}
68+
5369
/// <inheritdoc />
5470
protected override async Task OnAfterRenderAsync(bool firstRender)
5571
{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<Router AppAssembly="@typeof(App).Assembly">
1212
<Found Context="routeData">
1313
<RouteView RouteData="@routeData" />
14-
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
14+
<FocusOnNavigate RouteData="@routeData" Selector="#test-element" />
1515
</Found>
1616
<NotFound>There's nothing here</NotFound>
1717
</Router>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<input type="text" id="test-element" placeholder="I want focus!" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@page "/nav/focus-on-navigate"
2+
3+
<h1>Focus on navigate</h1>
4+
5+
<ComponentWithElementToFocus @rendermode="RenderMode.InteractiveServer" />

src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/EnhancedNavLayout.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<NavLink href="nav/location-changed/server">LocationChanged/LocationChanging event (server)</NavLink>
2424
<NavLink href="nav/location-changed/wasm">LocationChanged/LocationChanging event (wasm)</NavLink>
2525
<NavLink href="nav/location-changed/server-and-wasm">LocationChanged/LocationChanging event (server-and-wasm)</NavLink>
26+
<NavLink href="nav/focus-on-navigate">Focus on navigate</NavLink>
2627
<NavLink href="nav/null-parameter/server">Null component parameter (server)</NavLink>
2728
<NavLink href="nav/null-parameter/wasm">Null component parameter (wasm)</NavLink>
2829
<br />

0 commit comments

Comments
 (0)