Skip to content

Commit 43d0754

Browse files
[Blazor] Allow <FocusOnNavigate> to work when rendered statically (#57131)
1 parent e048f30 commit 43d0754

23 files changed

+366
-32
lines changed

src/Components/Web.JS/dist/Release/blazor.server.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.web.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.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: () => {
55+
jsEventRegistry.dispatchEvent('enhancednavigationstart', {});
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: 3 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) {
2626
const element = document.querySelector(selector) as HTMLElement;
2727
if (element) {
2828
// If no explicit tabindex is defined, mark it as programmatically-focusable.
@@ -34,4 +34,4 @@ function focusBySelector(selector: string, preventScroll: boolean): void {
3434

3535
element.focus({ preventScroll: true });
3636
}
37-
}
37+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 { JSEventRegistry } from '../Services/JSEventRegistry';
6+
import { isForSamePath } from '../Services/NavigationUtils';
7+
8+
const customElementName = 'blazor-focus-on-navigate';
9+
let currentFocusOnNavigateElement: FocusOnNavigateElement | null = null;
10+
let locationOnLastNavigation = location.href;
11+
let allowApplyFocusAfterEnhancedNavigation = false;
12+
13+
export function enableFocusOnNavigate(jsEventRegistry: JSEventRegistry) {
14+
customElements.define(customElementName, FocusOnNavigateElement);
15+
jsEventRegistry.addEventListener('enhancednavigationstart', onEnhancedNavigationStart);
16+
jsEventRegistry.addEventListener('enhancednavigationend', onEnhancedNavigationEnd);
17+
document.addEventListener('focusin', onFocusIn);
18+
19+
if (document.readyState === 'loading') {
20+
document.addEventListener('DOMContentLoaded', onInitialPageLoad, { once: true });
21+
} else {
22+
onInitialPageLoad();
23+
}
24+
}
25+
26+
function onInitialPageLoad() {
27+
// On the initial page load, we only want to apply focus if there isn't already
28+
// a focused element.
29+
// See also: https://developer.mozilla.org/docs/Web/API/Document/activeElement#value
30+
if (document.activeElement !== null && document.activeElement !== document.body) {
31+
return;
32+
}
33+
34+
// If an element on the page is requesting autofocus, but hasn't yet been focused,
35+
// we'll respect that.
36+
if (document.querySelector('[autofocus]')) {
37+
return;
38+
}
39+
40+
tryApplyFocus();
41+
}
42+
43+
function onEnhancedNavigationStart() {
44+
// Only move focus when navigating to a new page.
45+
if (!isForSamePath(locationOnLastNavigation, location.href)) {
46+
allowApplyFocusAfterEnhancedNavigation = true;
47+
}
48+
49+
locationOnLastNavigation = location.href;
50+
}
51+
52+
function onEnhancedNavigationEnd() {
53+
if (allowApplyFocusAfterEnhancedNavigation) {
54+
tryApplyFocus();
55+
}
56+
}
57+
58+
function onFocusIn() {
59+
// If the user explicitly focuses a different element before a navigation completes,
60+
// don't move focus again.
61+
allowApplyFocusAfterEnhancedNavigation = false;
62+
}
63+
64+
function tryApplyFocus() {
65+
const selector = currentFocusOnNavigateElement?.getAttribute('selector');
66+
if (selector) {
67+
domFunctions.focusBySelector(selector);
68+
}
69+
}
70+
71+
class FocusOnNavigateElement extends HTMLElement {
72+
connectedCallback() {
73+
// eslint-disable-next-line @typescript-eslint/no-this-alias
74+
currentFocusOnNavigateElement = this;
75+
}
76+
77+
disconnectedCallback() {
78+
if (currentFocusOnNavigateElement === this) {
79+
currentFocusOnNavigateElement = null;
80+
}
81+
}
82+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ interface BlazorEvent {
1111

1212
// Maps Blazor event names to the argument type passed to registered listeners.
1313
export interface BlazorEventMap {
14-
'enhancedload': BlazorEvent;
14+
'enhancedload': BlazorEvent,
15+
'enhancednavigationstart': BlazorEvent,
16+
'enhancednavigationend': BlazorEvent,
1517
}
1618

1719
export class JSEventRegistry {
@@ -44,7 +46,7 @@ export class JSEventRegistry {
4446
return;
4547
}
4648

47-
const event: BlazorEventMap[K] = {
49+
const event = {
4850
...ev,
4951
type,
5052
};

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

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
import { synchronizeDomContent } from '../Rendering/DomMerging/DomSync';
5-
import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isSamePageWithHash, notifyEnhancedNavigationListners, performScrollToElementOnTheSamePage } from './NavigationUtils';
5+
import { attachProgrammaticEnhancedNavigationHandler, handleClickForNavigationInterception, hasInteractiveRouter, isForSamePath, isSamePageWithHash, notifyEnhancedNavigationListeners, performScrollToElementOnTheSamePage } from './NavigationUtils';
66

77
/*
88
In effect, we have two separate client-side navigation mechanisms:
@@ -42,6 +42,7 @@ let performingEnhancedPageLoad: boolean;
4242
let currentContentUrl = location.href;
4343

4444
export interface NavigationEnhancementCallbacks {
45+
enhancedNavigationStarted: () => void;
4546
documentUpdated: () => void;
4647
enhancedNavigationCompleted: () => void;
4748
}
@@ -167,7 +168,7 @@ function onDocumentSubmit(event: SubmitEvent) {
167168
fetchOptions.headers = {
168169
'content-type': enctype,
169170
// Setting Accept header here as well so it wouldn't be lost when coping headers
170-
'accept': acceptHeader
171+
'accept': acceptHeader,
171172
};
172173
}
173174
}
@@ -183,7 +184,10 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
183184
currentEnhancedNavigationAbortController?.abort();
184185

185186
// Notify any interactive runtimes that an enhanced navigation is starting
186-
notifyEnhancedNavigationListners(internalDestinationHref, interceptedLink);
187+
notifyEnhancedNavigationListeners(internalDestinationHref, interceptedLink);
188+
189+
// Notify handlers that enhanced navigation is starting
190+
navigationEnhancementCallbacks.enhancedNavigationStarted();
187191

188192
// Now request the new page via fetch, and a special header that tells the server we want it to inject
189193
// framing boundaries to distinguish the initial document and each subsequent streaming SSR update.
@@ -262,12 +266,12 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
262266

263267
if (!response.redirected && !isGetRequest && isSuccessResponse) {
264268
// If this is the result of a form post that didn't trigger a redirection.
265-
if (!isForSamePath(response)) {
269+
if (!isForSamePath(response.url, currentContentUrl)) {
266270
// In this case we don't want to push the currentContentUrl to the history stack because we don't know if this is a location
267271
// 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?
268272
// browser behavior.
269273
// 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}`;
274+
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}`;
271275
} else {
272276
if (location.href !== currentContentUrl) {
273277
// The url on the browser might be out of data, so push an entry to the stack to update the url in place.
@@ -335,20 +339,6 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
335339
throw new Error(isNonRedirectedPostToADifferentUrlMessage);
336340
}
337341
}
338-
339-
function isForSamePath(response: Response) {
340-
// We are trying to determine if the response URL is compatible with the last content URL that was successfully loaded on to
341-
// the page.
342-
// We are going to use the scheme, host, port and path to determine if they are compatible. We do not account for the query string
343-
// as we want to allow for the query string to change. (Blazor doesn't use the query string for routing purposes).
344-
345-
const responseUrl = new URL(response.url);
346-
const currentContentUrlParsed = new URL(currentContentUrl!);
347-
return responseUrl.protocol === currentContentUrlParsed.protocol
348-
&& responseUrl.host === currentContentUrlParsed.host
349-
&& responseUrl.port === currentContentUrlParsed.port
350-
&& responseUrl.pathname === currentContentUrlParsed.pathname;
351-
}
352342
}
353343

354344
async function getResponsePartsWithFraming(responsePromise: Promise<Response>, abortSignal: AbortSignal, onInitialDocument: (response: Response, initialDocumentText: string) => void, onStreamingElement: (streamingElementMarkup) => void) {

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { WebRendererId } from '../Rendering/WebRendererId';
55

66
let interactiveRouterRendererId: WebRendererId | undefined = undefined;
77
let programmaticEnhancedNavigationHandler: typeof performProgrammaticEnhancedNavigation | undefined;
8-
let enhancedNavigationListener: typeof notifyEnhancedNavigationListners | undefined;
8+
let enhancedNavigationListener: typeof notifyEnhancedNavigationListeners | undefined;
99

1010
/**
1111
* Checks if a click event corresponds to an <a> tag referencing a URL within the base href, and that interception
@@ -52,6 +52,18 @@ export function isSamePageWithHash(absoluteHref: string): boolean {
5252
return url.hash !== '' && location.origin === url.origin && location.pathname === url.pathname && location.search === url.search;
5353
}
5454

55+
export function isForSamePath(url1: string, url2: string) {
56+
// We are going to use the scheme, host, port and path to determine if the two URLs are compatible.
57+
// We do not account for the query string as we want to allow for the query string to change.
58+
// (Blazor doesn't use the query string for routing purposes).
59+
const parsedUrl1 = new URL(url1);
60+
const parsedUrl2 = new URL(url2);
61+
return parsedUrl1.protocol === parsedUrl2.protocol
62+
&& parsedUrl1.host === parsedUrl2.host
63+
&& parsedUrl1.port === parsedUrl2.port
64+
&& parsedUrl1.pathname === parsedUrl2.pathname;
65+
}
66+
5567
export function performScrollToElementOnTheSamePage(absoluteHref : string): void {
5668
const hashIndex = absoluteHref.indexOf('#');
5769
if (hashIndex === absoluteHref.length - 1) {
@@ -70,7 +82,7 @@ export function attachEnhancedNavigationListener(listener: typeof enhancedNaviga
7082
enhancedNavigationListener = listener;
7183
}
7284

73-
export function notifyEnhancedNavigationListners(internalDestinationHref: string, interceptedLink: boolean) {
85+
export function notifyEnhancedNavigationListeners(internalDestinationHref: string, interceptedLink: boolean) {
7486
enhancedNavigationListener?.(internalDestinationHref, interceptedLink);
7587
}
7688

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: 21 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,24 @@ 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+
// When interactivity is enabled, functionality is handled via JS interop.
62+
// We don't need to render anything to the page in that case.
63+
// In non-interactive scenarios, a custom element is rendered so that
64+
// JS logic can find it and focus the element matching the specified
65+
// selector.
66+
return;
67+
}
68+
69+
builder.OpenElement(0, CustomElementName);
70+
builder.AddAttribute(1, "selector", Selector);
71+
builder.CloseElement();
72+
}
73+
5374
/// <inheritdoc />
5475
protected override async Task OnAfterRenderAsync(bool firstRender)
5576
{

0 commit comments

Comments
 (0)