Skip to content

Commit

Permalink
fix(common): viewport scroller not finding elements inside the shadow…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
crisbeto committed Apr 15, 2021
1 parent 5332a4a commit 90fe285
Show file tree
Hide file tree
Showing 2 changed files with 114 additions and 27 deletions.
50 changes: 41 additions & 9 deletions packages/common/src/viewport_scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

/**
Expand Down Expand Up @@ -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.
*/
Expand Down
91 changes: 73 additions & 18 deletions packages/common/test/viewport_scroller_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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;
}
});
});

0 comments on commit 90fe285

Please sign in to comment.