Asymmetrik/ngx-starter

View on GitHub
src/app/core/custom_viewport_scroller.ts

Summary

Maintainability
A
0 mins
Test Coverage
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',
    factory: () => 'app-content'
});

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

    readonly #scrollElementID = inject(SCROLL_ELEMENT);
    readonly #document = inject(DOCUMENT);
    readonly #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 scrollEl = this.getScrollElement();
        if (scrollEl) {
            const rect = el.getBoundingClientRect();
            const left = rect.left + this.#window.scrollX;
            const top = rect.top + this.#window.scrollY;
            const offset = this.#offset();
            scrollEl.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;
}