-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: Add ViewportScroller implementation to properly handle scrolling…
… 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
Showing
5 changed files
with
223 additions
and
4 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters