From 90fe28571eeaf63eafc5b0862ea2acef37016b42 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 15 Apr 2021 16:48:05 +0200 Subject: [PATCH] fix(common): viewport scroller not finding elements inside the shadow DOM The `ViewportScroller` figures out which element to scroll into view using `document.getElementById`. The problem is that it won't find elements inside the shadow DOM. These changes add some extra logic that goes through all the shadow roots to look for the element. Fixes #41470. --- packages/common/src/viewport_scroller.ts | 50 ++++++++-- .../common/test/viewport_scroller_spec.ts | 91 +++++++++++++++---- 2 files changed, 114 insertions(+), 27 deletions(-) diff --git a/packages/common/src/viewport_scroller.ts b/packages/common/src/viewport_scroller.ts index a8061f62d00df2..69b97318426ee5 100644 --- a/packages/common/src/viewport_scroller.ts +++ b/packages/common/src/viewport_scroller.ts @@ -123,16 +123,14 @@ export class BrowserViewportScroller implements ViewportScroller { // TODO(atscott): The correct behavior for `getElementsByName` would be to also verify that the // element is an anchor. However, this could be considered a breaking change and should be // done in a major version. - const elSelected: HTMLElement|undefined = - this.document.getElementById(target) ?? this.document.getElementsByName(target)[0]; - if (elSelected === undefined) { - return; - } + const elSelected = findAnchorFromDocument(this.document, target); - 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. - this.attemptFocus(elSelected); + 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. + this.attemptFocus(elSelected); + } } /** @@ -214,6 +212,40 @@ function getScrollRestorationProperty(obj: any): PropertyDescriptor|undefined { return Object.getOwnPropertyDescriptor(obj, 'scrollRestoration'); } +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 && + ((document.body as any).createShadowRoot || document.body.attachShadow)) { + 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; +} + /** * Provides an empty implementation of the viewport scroller. */ diff --git a/packages/common/test/viewport_scroller_spec.ts b/packages/common/test/viewport_scroller_spec.ts index 58ffb5bb1d074e..8a5dfd5497b6c8 100644 --- a/packages/common/test/viewport_scroller_spec.ts +++ b/packages/common/test/viewport_scroller_spec.ts @@ -7,6 +7,7 @@ */ import {describe, expect, it} from '@angular/core/testing/src/testing_internal'; +import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; import {BrowserViewportScroller, ViewportScroller} from '../src/viewport_scroller'; describe('BrowserViewportScroller', () => { @@ -44,44 +45,98 @@ describe('BrowserViewportScroller', () => { // Testing scroll behavior does not make sense outside a browser if (isNode) return; const anchor = 'anchor'; - let tallItem: HTMLDivElement; - let el: HTMLAnchorElement; let scroller: BrowserViewportScroller; beforeEach(() => { scroller = new BrowserViewportScroller(document, window); scroller.scrollToPosition([0, 0]); - - tallItem = document.createElement('div'); - tallItem.style.height = '3000px'; - document.body.appendChild(tallItem); - - el = document.createElement('a'); - el.innerText = 'some link'; - el.href = '#'; - document.body.appendChild(el); - }); - - afterEach(() => { - document.body.removeChild(tallItem); - document.body.removeChild(el); }); it('should scroll when element with matching id is found', () => { - el.id = anchor; + const {anchorNode, cleanup} = createTallElement(); + anchorNode.id = anchor; scroller.scrollToAnchor(anchor); expect(scroller.getScrollPosition()[1]).not.toEqual(0); + cleanup(); }); it('should scroll when anchor with matching name is found', () => { - el.name = anchor; + const {anchorNode, cleanup} = createTallElement(); + anchorNode.name = anchor; scroller.scrollToAnchor(anchor); expect(scroller.getScrollPosition()[1]).not.toEqual(0); + cleanup(); }); it('should not scroll when no matching element is found', () => { + const {cleanup} = createTallElement(); scroller.scrollToAnchor(anchor); expect(scroller.getScrollPosition()[1]).toEqual(0); + cleanup(); + }); + + it('should scroll when element with matching id is found inside the shadow DOM', () => { + // This test is only relevant for browsers that support shadow DOM. + if (!browserDetection.supportsShadowDom) { + return; + } + + const {anchorNode, cleanup} = createTallElementWithShadowRoot(); + anchorNode.id = anchor; + scroller.scrollToAnchor(anchor); + expect(scroller.getScrollPosition()[1]).not.toEqual(0); + cleanup(); + }); + + it('should scroll when anchor with matching name is found inside the shadow DOM', () => { + const {anchorNode, cleanup} = createTallElementWithShadowRoot(); + anchorNode.name = anchor; + scroller.scrollToAnchor(anchor); + expect(scroller.getScrollPosition()[1]).not.toEqual(0); + cleanup(); }); + + function createTallElement() { + const tallItem = document.createElement('div'); + tallItem.style.height = '3000px'; + document.body.appendChild(tallItem); + const anchorNode = createAnchorNode(); + document.body.appendChild(anchorNode); + + return { + anchorNode, + cleanup: () => { + document.body.removeChild(tallItem); + document.body.removeChild(anchorNode); + } + }; + } + + function createTallElementWithShadowRoot() { + const tallItem = document.createElement('div'); + tallItem.style.height = '3000px'; + document.body.appendChild(tallItem); + + const elementWithShadowRoot = document.createElement('div'); + const shadowRoot = elementWithShadowRoot.attachShadow({mode: 'open'}); + const anchorNode = createAnchorNode(); + shadowRoot.appendChild(anchorNode); + document.body.appendChild(elementWithShadowRoot); + + return { + anchorNode, + cleanup: () => { + document.body.removeChild(tallItem); + document.body.removeChild(elementWithShadowRoot); + } + }; + } + + function createAnchorNode() { + const anchorNode = document.createElement('a'); + anchorNode.innerText = 'some link'; + anchorNode.href = '#'; + return anchorNode; + } }); });