Skip to content

Commit

Permalink
fix: Add ViewportScroller implementation to properly handle scrolling…
Browse files Browse the repository at this point in the history
… on navigation events

Currently, scroll position doesn't always get reset properly when navigating routes.
Default angular behavior assumes that scrolling applies to the entire page.  When the primary scrolling viewport is a subelement (as it is here), a custom viewport scroller is needed.
Implementation is taking from the default `BrowserViewportScroller`, but operates on the configured element vs. the window.
Provider function provided for simply configuration.
  • Loading branch information
jrassa committed Sep 8, 2023
1 parent b751f1f commit 0495862
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 4 deletions.
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@angular/router": "^15.2.9",
"@fortawesome/fontawesome-free": "^6.2.1",
"@ng-select/ng-select": "~10.0.4",
"@ng-web-apis/common": "^3.0.2",
"@ngneat/until-destroy": "~9.0.0",
"bootstrap": "4.6.1",
"lodash": "~4.17.21",
Expand Down
21 changes: 18 additions & 3 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { provideHttpClient, withInterceptors, withInterceptorsFromDi } from '@an
import { importProvidersFrom } from '@angular/core';
import { ApplicationConfig } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { TitleStrategy, provideRouter, withHashLocation } from '@angular/router';
import {
TitleStrategy,
provideRouter,
withHashLocation,
withInMemoryScrolling
} from '@angular/router';

import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import { ModalModule } from 'ngx-bootstrap/modal';
Expand All @@ -14,7 +19,12 @@ import { euaInterceptor } from './core/auth/eua.interceptor';
import { signinInterceptor } from './core/auth/signin.interceptor';
import { masqueradeInterceptor } from './core/masquerade/masquerade.interceptor';
import { PageTitleStrategy } from './core/page-title.strategy';
import { provideAppConfig, provideCoreRoutes, provideNavigationService } from './core/provider';
import {
provideAppConfig,
provideCoreRoutes,
provideNavigationService,
provideViewportScroller
} from './core/provider';
import { provideExampleRoutes } from './site/example/provider';

export const appConfig: ApplicationConfig = {
Expand Down Expand Up @@ -42,7 +52,12 @@ export const appConfig: ApplicationConfig = {
withInterceptorsFromDi()
),
provideCdkDialog(),
provideRouter([], withHashLocation()),
provideRouter(
[],
withHashLocation(),
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })
),
provideViewportScroller(),
provideCoreRoutes(),
provideExampleRoutes(),
provideAppConfig(),
Expand Down
162 changes: 162 additions & 0 deletions src/app/core/custom_viewport_scroller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { DOCUMENT, ViewportScroller } from '@angular/common';
import { InjectionToken, inject } from '@angular/core';

import { WINDOW } from '@ng-web-apis/common';

export const SCROLL_ELEMENT = new InjectionToken<string>('SCROLL_ELEMENT', {
providedIn: 'root',
useFactory: () => 'app-content'
});

/**
* Modified version of BrowserViewportScroller from @angular/common
* Manages the scroll position for scrollElement.
*/
export class CustomViewportScroller implements ViewportScroller {
private offset: () => [number, number] = () => [0, 0];

private scrollElementID = inject(SCROLL_ELEMENT);
private document = inject(DOCUMENT);
private window = inject(WINDOW);

/**
* Configures the top offset used when scrolling to an anchor.
* @param offset A position in screen coordinates (a tuple with x and y values)
* or a function that returns the top offset position.
*
*/
setOffset(offset: [number, number] | (() => [number, number])): void {
if (Array.isArray(offset)) {
this.offset = () => offset;
} else {
this.offset = offset;
}
}

/**
* Retrieves the current scroll position.
* @returns The position in screen coordinates.
*/
getScrollPosition(): [number, number] {
const scrollEl = this.getScrollElement();
if (scrollEl && this.supportsScrolling()) {
return [scrollEl.scrollLeft, scrollEl.scrollTop];
} else {
return [0, 0];
}
}

/**
* Sets the scroll position.
* @param position The new position in screen coordinates.
*/
scrollToPosition(position: [number, number]): void {
const scrollEl = this.getScrollElement();
if (scrollEl && this.supportsScrolling()) {
scrollEl.scrollTo(position[0], position[1]);
}
}

/**
* Scrolls to an element and attempts to focus the element.
*
* Note that the function name here is misleading in that the target string may be an ID for a
* non-anchor element.
*
* @param target The ID of an element or name of the anchor.
*
* @see https://html.spec.whatwg.org/#the-indicated-part-of-the-document
* @see https://html.spec.whatwg.org/#scroll-to-fragid
*/
scrollToAnchor(target: string): void {
if (this.getScrollElement() && !this.supportsScrolling()) {
return;
}

const elSelected = findAnchorFromDocument(this.document, target);

if (elSelected) {
this.scrollToElement(elSelected);
// After scrolling to the element, the spec dictates that we follow the focus steps for the
// target. Rather than following the robust steps, simply attempt focus.
//
// @see https://html.spec.whatwg.org/#get-the-focusable-area
// @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/focus
// @see https://html.spec.whatwg.org/#focusable-area
elSelected.focus();
}
}

/**
* Disables automatic scroll restoration provided by the browser.
*/
setHistoryScrollRestoration(scrollRestoration: 'auto' | 'manual'): void {
if (this.getScrollElement() && this.supportsScrolling()) {
this.window.history.scrollRestoration = scrollRestoration;
}
}

/**
* Scrolls to an element using the native offset and the specified offset set on this scroller.
*
* The offset can be used when we know that there is a floating header and scrolling naively to an
* element (ex: `scrollIntoView`) leaves the element hidden behind the floating header.
*/
private scrollToElement(el: HTMLElement): void {
const rect = el.getBoundingClientRect();
const left = rect.left + this.window.scrollX;
const top = rect.top + this.window.scrollY;
const offset = this.offset();
this.getScrollElement().scrollTo(left - offset[0], top - offset[1]);
}

private supportsScrolling(): boolean {
try {
return !!this.window && !!this.window.scrollTo && 'pageXOffset' in this.window;
} catch {
return false;
}
}

private getScrollElement(): Element | null {
return this.document.querySelector(`#${this.scrollElementID}`);
}
}

function findAnchorFromDocument(document: Document, target: string): HTMLElement | null {
const documentResult = document.getElementById(target) || document.getElementsByName(target)[0];

if (documentResult) {
return documentResult;
}

// `getElementById` and `getElementsByName` won't pierce through the shadow DOM so we
// have to traverse the DOM manually and do the lookup through the shadow roots.
if (
typeof document.createTreeWalker === 'function' &&
document.body &&
typeof document.body.attachShadow === 'function'
) {
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
let currentNode = treeWalker.currentNode as HTMLElement | null;

while (currentNode) {
const shadowRoot = currentNode.shadowRoot;

if (shadowRoot) {
// Note that `ShadowRoot` doesn't support `getElementsByName`
// so we have to fall back to `querySelector`.
const result =
shadowRoot.getElementById(target) ||
shadowRoot.querySelector(`[name="${target}"]`);
if (result) {
return result;
}
}

currentNode = treeWalker.nextNode() as HTMLElement | null;
}
}

return null;
}
21 changes: 20 additions & 1 deletion src/app/core/provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { APP_INITIALIZER, makeEnvironmentProviders } from '@angular/core';
import { ViewportScroller } from '@angular/common';
import { APP_INITIALIZER, Provider, makeEnvironmentProviders } from '@angular/core';
import { ROUTES } from '@angular/router';

import { firstValueFrom } from 'rxjs';
Expand All @@ -7,6 +8,7 @@ import { tap } from 'rxjs/operators';
import { ADMIN_TOPICS } from './admin/admin-topic.model';
import { ConfigService } from './config.service';
import { CORE_ROUTES } from './core-routes';
import { CustomViewportScroller, SCROLL_ELEMENT } from './custom_viewport_scroller';
import { GettingStartedHelpComponent } from './help/getting-started/getting-started-help.component';
import { HELP_TOPICS } from './help/help-topic.component';
import { NavigationService } from './navigation.service';
Expand Down Expand Up @@ -119,3 +121,20 @@ export function provideNavigationService() {
}
]);
}

export function provideViewportScroller(scrollElementID?: string) {
const providers: Provider[] = [
{
provide: ViewportScroller,
useClass: CustomViewportScroller
}
];
if (scrollElementID) {
providers.push({
provide: SCROLL_ELEMENT,
useFactory: () => scrollElementID
});
}

return makeEnvironmentProviders(providers);
}

0 comments on commit 0495862

Please sign in to comment.