Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hash routing to named element #47320

Merged
merged 37 commits into from
Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
be36fee
implemented scrolling to location hash
surayya-MS Mar 20, 2023
65133e0
BlazorServerApp test hash
surayya-MS Mar 20, 2023
176153a
Merge branch 'main' into hashNav
surayya-MS Mar 20, 2023
91d7c04
Revert "BlazorServerApp test hash"
surayya-MS Mar 20, 2023
67c44c7
remove spaces
surayya-MS Mar 20, 2023
59fc588
change Router OnAfterRenderAsync
surayya-MS Mar 20, 2023
dbdbbd8
fix
surayya-MS Mar 20, 2023
36cf879
small fix
surayya-MS Mar 20, 2023
e67eebc
implemented IScrollToLocationHash; added e2e tests
surayya-MS Mar 27, 2023
8763e5b
deleted IScrollToLocationHash; implemented scrolling on the client side
surayya-MS Mar 30, 2023
82e3726
small fix
surayya-MS Mar 30, 2023
dab0f04
small fix
surayya-MS Mar 30, 2023
ea8c152
fix focusOnNavigate
surayya-MS Mar 31, 2023
0fdf009
change timer to 5 seconds
surayya-MS Mar 31, 2023
a52fc5b
Update src/Components/Web.JS/src/DomWrapper.ts
surayya-MS Mar 31, 2023
4ccbce4
Update src/Components/Web.JS/src/DomWrapper.ts
surayya-MS Mar 31, 2023
58b7a55
Update src/Components/Web.JS/src/Rendering/Renderer.ts
surayya-MS Mar 31, 2023
e8363c3
Revert "Update src/Components/Web.JS/src/Rendering/Renderer.ts"
surayya-MS Apr 5, 2023
5f595e8
Revert "Update src/Components/Web.JS/src/DomWrapper.ts"
surayya-MS Apr 5, 2023
184d6eb
Revert "Update src/Components/Web.JS/src/DomWrapper.ts"
surayya-MS Apr 5, 2023
51d3371
Revert "change timer to 5 seconds"
surayya-MS Apr 5, 2023
cf5298b
Revert "fix focusOnNavigate"
surayya-MS Apr 5, 2023
d5c741d
Revert "small fix"
surayya-MS Apr 5, 2023
85bc518
Revert "small fix"
surayya-MS Apr 5, 2023
eecefec
Revert "deleted IScrollToLocationHash; implemented scrolling on the c…
surayya-MS Apr 5, 2023
c3bc18c
back to Router design;
surayya-MS Apr 5, 2023
90efca2
Merge branch 'main' into hashNav
surayya-MS Apr 5, 2023
1dcd3aa
Add pathbase support to Photino WebView so we can run the router E2E …
SteveSandersonMS Apr 5, 2023
1a30e2b
Merge branch 'hashNav' of https://github.com/surayya-MS/aspnetcore in…
surayya-MS Apr 6, 2023
2d93d36
Add pathbase support to Photino WebView so we can run the router E2E …
SteveSandersonMS Apr 5, 2023
2786e53
implemented WebViewScrollToLocationHash
SteveSandersonMS Apr 5, 2023
20ae2da
Merge branch 'hashNav' of https://github.com/surayya-MS/aspnetcore in…
surayya-MS Apr 6, 2023
4bb685e
small fix
surayya-MS Apr 6, 2023
bdaf535
include _index in history.push()
surayya-MS Apr 6, 2023
697d8be
Update src/Components/Server/src/Circuits/RemoteScrollToLocationHash.cs
surayya-MS Apr 6, 2023
2617145
1. Use StringComparison.Ordinal when searching for '#'
surayya-MS Apr 6, 2023
2923ba6
Merge branch 'hashNav' of https://github.com/surayya-MS/aspnetcore in…
surayya-MS Apr 6, 2023
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
2 changes: 2 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.
Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
*REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri!
Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri!
Microsoft.AspNetCore.Components.Routing.IScrollToLocationHash
Microsoft.AspNetCore.Components.Routing.IScrollToLocationHash.RefreshScrollPositionForHash(string! locationAbsolute) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.Rendering.ComponentState
Microsoft.AspNetCore.Components.Rendering.ComponentState.Component.get -> Microsoft.AspNetCore.Components.IComponent!
Microsoft.AspNetCore.Components.Rendering.ComponentState.ComponentId.get -> int
Expand Down
17 changes: 17 additions & 0 deletions src/Components/Components/src/Routing/IScrollToLocationHash.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Routing;

/// <summary>
/// Contract to setup scroll to location hash.
/// </summary>
public interface IScrollToLocationHash
{
/// <summary>
/// Refreshes scroll position for hash on the client.
/// </summary>
/// <param name="locationAbsolute">Absolute URL of location</param>
/// <returns>A <see cref="Task" /> that represents the asynchronous operation.</returns>
Task RefreshScrollPositionForHash(string locationAbsolute);
}
22 changes: 19 additions & 3 deletions src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary
bool _navigationInterceptionEnabled;
ILogger<Router> _logger;

private Type? _updateScrollPositionForHashLastHandlerType;
private bool _updateScrollPositionForHash;

private CancellationTokenSource _onNavigateCts;

private Task _previousOnNavigateTask = Task.CompletedTask;
Expand All @@ -38,6 +41,8 @@ static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary

[Inject] private INavigationInterception NavigationInterception { get; set; }

[Inject] private IScrollToLocationHash ScrollToLocationHash { get; set; }

[Inject] private ILoggerFactory LoggerFactory { get; set; }

/// <summary>
Expand Down Expand Up @@ -206,6 +211,13 @@ internal virtual void Refresh(bool isNavigationIntercepted)
context.Handler,
context.Parameters ?? _emptyParametersDictionary);
_renderHandle.Render(Found(routeData));

// If you navigate to a different page, then after the next render we'll update the scroll position
if (context.Handler != _updateScrollPositionForHashLastHandlerType)
{
_updateScrollPositionForHashLastHandlerType = context.Handler;
_updateScrollPositionForHash = true;
}
}
else
{
Expand Down Expand Up @@ -276,15 +288,19 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args)
}
}

Task IHandleAfterRender.OnAfterRenderAsync()
async Task IHandleAfterRender.OnAfterRenderAsync()
{
if (!_navigationInterceptionEnabled)
{
_navigationInterceptionEnabled = true;
return NavigationInterception.EnableNavigationInterceptionAsync();
await NavigationInterception.EnableNavigationInterceptionAsync();
}

return Task.CompletedTask;
if (_updateScrollPositionForHash)
{
_updateScrollPositionForHash = false;
await ScrollToLocationHash.RefreshScrollPositionForHash(_locationAbsolute);
}
}

private static partial class Log
Expand Down
11 changes: 11 additions & 0 deletions src/Components/Components/test/Routing/RouterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public RouterTest()
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
services.AddSingleton<NavigationManager>(_navigationManager);
services.AddSingleton<INavigationInterception, TestNavigationInterception>();
services.AddSingleton<IScrollToLocationHash, TestScrollToLocationHash>();
var serviceProvider = services.BuildServiceProvider();

_renderer = new TestRenderer(serviceProvider);
Expand Down Expand Up @@ -220,6 +221,16 @@ public Task EnableNavigationInterceptionAsync()
}
}

internal sealed class TestScrollToLocationHash : IScrollToLocationHash
{
public static readonly TestScrollToLocationHash Instance = new TestScrollToLocationHash();

public Task RefreshScrollPositionForHash(string locationAbsolute)
{
return Task.CompletedTask;
}
}

[Route("feb")]
public class FebComponent : ComponentBase { }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection
services.TryAddScoped<NavigationManager, HttpNavigationManager>();
services.TryAddScoped<IJSRuntime, UnsupportedJavaScriptRuntime>();
services.TryAddScoped<INavigationInterception, UnsupportedNavigationInterception>();
services.TryAddScoped<IScrollToLocationHash, UnsupportedScrollToLocationHash>();
services.TryAddScoped<ComponentStatePersistenceManager>();
services.TryAddScoped<PersistentComponentState>(sp => sp.GetRequiredService<ComponentStatePersistenceManager>().State);
services.TryAddScoped<IErrorBoundaryLogger, PrerenderingErrorBoundaryLogger>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Routing;

namespace Microsoft.AspNetCore.Components.Endpoints;

internal sealed class UnsupportedScrollToLocationHash : IScrollToLocationHash
{
public Task RefreshScrollPositionForHash(string locationAbsolute)
{
throw new InvalidOperationException("Scroll to location hash calls cannot be issued during server-side static rendering, because the page has not yet loaded in the browser.");
}
}
2 changes: 2 additions & 0 deletions src/Components/Server/src/Circuits/CircuitFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ public async ValueTask<CircuitHost> CreateCircuitHostAsync(

var navigationManager = (RemoteNavigationManager)scope.ServiceProvider.GetRequiredService<NavigationManager>();
var navigationInterception = (RemoteNavigationInterception)scope.ServiceProvider.GetRequiredService<INavigationInterception>();
var scrollToLocationHash = (RemoteScrollToLocationHash)scope.ServiceProvider.GetRequiredService<IScrollToLocationHash>();
if (client.Connected)
{
navigationManager.AttachJsRuntime(jsRuntime);
navigationManager.Initialize(baseUri, uri);

navigationInterception.AttachJSRuntime(jsRuntime);
scrollToLocationHash.AttachJSRuntime(jsRuntime);
}
else
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Routing;
using Microsoft.JSInterop;
using Interop = Microsoft.AspNetCore.Components.Web.BrowserNavigationManagerInterop;

namespace Microsoft.AspNetCore.Components.Server.Circuits;

internal sealed class RemoteScrollToLocationHash : IScrollToLocationHash
{
private IJSRuntime _jsRuntime;

public void AttachJSRuntime(IJSRuntime jsRuntime)
{
if (HasAttachedJSRuntime)
{
throw new InvalidOperationException("JSRuntime has already been initialized.");
}

_jsRuntime = jsRuntime;
}

public bool HasAttachedJSRuntime => _jsRuntime != null;

public async Task RefreshScrollPositionForHash(string locationAbsolute)
{
if (!HasAttachedJSRuntime)
{
// We should generally never get here in the ordinary case. Router will only call this API once pre-rendering is complete.
// This would guard any unusual usage of this API.
throw new InvalidOperationException("Navigation commands can not be issued at this time. This is because the component is being " +
"prerendered and the page has not yet loaded in the browser or because the circuit is currently disconnected. " +
"Components must wrap any navigation calls in conditional logic to ensure those navigation calls are not " +
"attempted during prerendering or while the client is disconnected.");
}

var hashIndex = locationAbsolute.IndexOf("#", StringComparison.CurrentCultureIgnoreCase);

if (hashIndex > -1 && locationAbsolute.Length > hashIndex + 1)
{
var elementId = locationAbsolute[(hashIndex + 1)..];

await _jsRuntime.InvokeVoidAsync(Interop.ScrollToElement, elementId).AsTask();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti
services.AddScoped<NavigationManager, RemoteNavigationManager>();
services.AddScoped<IJSRuntime, RemoteJSRuntime>();
services.AddScoped<INavigationInterception, RemoteNavigationInterception>();
services.AddScoped<IScrollToLocationHash, RemoteScrollToLocationHash>();
services.TryAddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();

services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<CircuitOptions>, CircuitOptionsJSInteropDetailedErrorsConfiguration>());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ internal static class BrowserNavigationManagerInterop
public const string NavigateTo = Prefix + "navigateTo";

public const string SetHasLocationChangingListeners = Prefix + "setHasLocationChangingListeners";

public const string ScrollToElement = Prefix + "scrollToElement";
}
17 changes: 14 additions & 3 deletions src/Components/Web.JS/src/DomWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import '@microsoft/dotnet-js-interop';

export const domFunctions = {
focus,
focusBySelector,
focusOnNavigate
};

function focus(element: HTMLOrSVGElement, preventScroll: boolean): void {
Expand All @@ -22,7 +22,12 @@ function focus(element: HTMLOrSVGElement, preventScroll: boolean): void {
}
}

function focusBySelector(selector: string): void {
function focusOnNavigate(selector: string): void {
const preventScroll = location.hash.length > 1 && elementExists(location.hash.slice(1));
focusBySelector(selector, preventScroll);
}

function focusBySelector(selector: string, preventScroll: boolean): void {
const element = document.querySelector(selector) as HTMLElement;
if (element) {
// If no explicit tabindex is defined, mark it as programmatically-focusable.
Expand All @@ -32,6 +37,12 @@ function focusBySelector(selector: string): void {
element.tabIndex = -1;
}

element.focus();
element.focus({ preventScroll: preventScroll });
}
}

function elementExists(identifier: string): boolean {
const element = document.getElementById(identifier)
|| document.getElementsByName(identifier)[0];
return !!element;
}
52 changes: 48 additions & 4 deletions src/Components/Web.JS/src/Services/NavigationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const internalFunctions = {
navigateTo: navigateToFromDotNet,
getBaseURI: (): string => document.baseURI,
getLocationHref: (): string => location.href,
scrollToElement
};

function listenForNavigationEvents(
Expand All @@ -53,6 +54,18 @@ function setHasLocationChangingListeners(hasListeners: boolean) {
hasLocationChangingEventListeners = hasListeners;
}

export function scrollToElement(identifier: string): boolean {
const element = document.getElementById(identifier)
|| document.getElementsByName(identifier)[0];

if (element) {
element.scrollIntoView();
return true;
}

return false;
}

export function attachToEventDelegator(eventDelegator: EventDelegator): void {
// We need to respond to clicks on <a> elements *after* the EventDelegator has finished
// running its simulated bubbling process so that we can respect any preventDefault requests.
Expand All @@ -76,8 +89,13 @@ export function attachToEventDelegator(eventDelegator: EventDelegator): void {
const anchorTarget = findAnchorTarget(event);

if (anchorTarget && canProcessAnchor(anchorTarget)) {
const href = anchorTarget.getAttribute('href')!;
const absoluteHref = toAbsoluteUri(href);
let anchorHref = anchorTarget.getAttribute('href')!;
if (anchorHref.startsWith('#')) {
// Preserve the existing URL but set the hash to match the link that was clicked
anchorHref = `${location.origin}${location.pathname}${location.search}${anchorHref}`;
}

const absoluteHref = toAbsoluteUri(anchorHref);

if (isWithinBaseUriSpace(absoluteHref)) {
event.preventDefault();
Expand All @@ -87,6 +105,23 @@ export function attachToEventDelegator(eventDelegator: EventDelegator): void {
});
}

function isSamePageWithHash(absoluteHref: string): boolean {
const hashIndex = absoluteHref.indexOf('#');
return hashIndex > -1 && location.href.replace(location.hash, '') === absoluteHref.substring(0, hashIndex);
}

function performScrollToElementOnTheSamePage(absoluteHref : string, replace: boolean, state: string | undefined = undefined): void {
saveToBrowserHistory(absoluteHref, replace, state);

const hashIndex = absoluteHref.indexOf('#');
if (hashIndex == absoluteHref.length - 1) {
return;
}

const identifier = absoluteHref.substring(hashIndex + 1);
scrollToElement(identifier);
}

// For back-compat, we need to accept multiple overloads
export function navigateTo(uri: string, options: NavigationOptions): void;
export function navigateTo(uri: string, forceLoad: boolean): void;
Expand Down Expand Up @@ -138,6 +173,11 @@ function performExternalNavigation(uri: string, replace: boolean) {
async function performInternalNavigation(absoluteInternalHref: string, interceptedLink: boolean, replace: boolean, state: string | undefined = undefined, skipLocationChangingCallback = false) {
ignorePendingNavigation();

if (isSamePageWithHash(absoluteInternalHref)) {
performScrollToElementOnTheSamePage(absoluteInternalHref, replace, state);
return;
}

if (!skipLocationChangingCallback && hasLocationChangingEventListeners) {
const shouldContinueNavigation = await notifyLocationChanging(absoluteInternalHref, state, interceptedLink);
if (!shouldContinueNavigation) {
Expand All @@ -152,6 +192,12 @@ async function performInternalNavigation(absoluteInternalHref: string, intercept
// we render the new page. As a best approximation, wait until the next batch.
resetScrollAfterNextBatch();

saveToBrowserHistory(absoluteInternalHref, replace, state);

await notifyLocationChanged(interceptedLink);
}

function saveToBrowserHistory(absoluteInternalHref: string, replace: boolean, state: string | undefined = undefined): void {
if (!replace) {
currentHistoryIndex++;
history.pushState({
Expand All @@ -164,8 +210,6 @@ async function performInternalNavigation(absoluteInternalHref: string, intercept
_index: currentHistoryIndex,
}, /* ignored title */ '', absoluteInternalHref);
}

await notifyLocationChanged(interceptedLink);
}

function navigateHistoryWithoutPopStateCallback(delta: number): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web/src/DomWrapperInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ internal static class DomWrapperInterop

public const string Focus = Prefix + "focus";

public const string FocusBySelector = Prefix + "focusBySelector";
public const string FocusOnNavigate = Prefix + "focusOnNavigate";
}
2 changes: 1 addition & 1 deletion src/Components/Web/src/Routing/FocusOnNavigate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
if (_focusAfterRender)
{
_focusAfterRender = false;
await JSRuntime.InvokeVoidAsync(DomWrapperInterop.FocusBySelector, Selector);
await JSRuntime.InvokeVoidAsync(DomWrapperInterop.FocusOnNavigate, Selector);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ internal void InitializeDefaultServices()
Services.AddSingleton<IJSRuntime>(DefaultWebAssemblyJSRuntime.Instance);
Services.AddSingleton<NavigationManager>(WebAssemblyNavigationManager.Instance);
Services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance);
Services.AddSingleton<IScrollToLocationHash>(WebAssemblyScrollToLocationHash.Instance);
Services.AddSingleton(new LazyAssemblyLoader(DefaultWebAssemblyJSRuntime.Instance));
Services.AddSingleton<ComponentStatePersistenceManager>();
Services.AddSingleton<PersistentComponentState>(sp => sp.GetRequiredService<ComponentStatePersistenceManager>().State);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ internal interface IInternalJSImportMethods

void NavigationManager_EnableNavigationInterception();

void NavigationManager_ScrollToElement(string id);

string NavigationManager_GetLocationHref();

string NavigationManager_GetBaseUri();
Expand Down
Loading