diff --git a/packages/common/src/viewport_scroller.ts b/packages/common/src/viewport_scroller.ts index a8061f62d0..69b9731842 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 58ffb5bb1d..68c0518e36 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,103 @@ 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', () => { + // This test is only relevant for browsers that support shadow DOM. + if (!browserDetection.supportsShadowDom) { + return; + } + + 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; + } }); });