Skip to content

Commit c0f5ba3

Browse files
crisbetoAndrewKushnir
authored andcommitted
fix(common): viewport scroller not finding elements inside the shadow DOM (#41644)
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. PR Close #41644
1 parent dad42c8 commit c0f5ba3

File tree

2 files changed

+119
-27
lines changed

2 files changed

+119
-27
lines changed

packages/common/src/viewport_scroller.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,14 @@ export class BrowserViewportScroller implements ViewportScroller {
123123
// TODO(atscott): The correct behavior for `getElementsByName` would be to also verify that the
124124
// element is an anchor. However, this could be considered a breaking change and should be
125125
// done in a major version.
126-
const elSelected: HTMLElement|undefined =
127-
this.document.getElementById(target) ?? this.document.getElementsByName(target)[0];
128-
if (elSelected === undefined) {
129-
return;
130-
}
126+
const elSelected = findAnchorFromDocument(this.document, target);
131127

132-
this.scrollToElement(elSelected);
133-
// After scrolling to the element, the spec dictates that we follow the focus steps for the
134-
// target. Rather than following the robust steps, simply attempt focus.
135-
this.attemptFocus(elSelected);
128+
if (elSelected) {
129+
this.scrollToElement(elSelected);
130+
// After scrolling to the element, the spec dictates that we follow the focus steps for the
131+
// target. Rather than following the robust steps, simply attempt focus.
132+
this.attemptFocus(elSelected);
133+
}
136134
}
137135

138136
/**
@@ -214,6 +212,40 @@ function getScrollRestorationProperty(obj: any): PropertyDescriptor|undefined {
214212
return Object.getOwnPropertyDescriptor(obj, 'scrollRestoration');
215213
}
216214

215+
function findAnchorFromDocument(document: Document, target: string): HTMLElement|null {
216+
const documentResult = document.getElementById(target) || document.getElementsByName(target)[0];
217+
218+
if (documentResult) {
219+
return documentResult;
220+
}
221+
222+
// `getElementById` and `getElementsByName` won't pierce through the shadow DOM so we
223+
// have to traverse the DOM manually and do the lookup through the shadow roots.
224+
if (typeof document.createTreeWalker === 'function' && document.body &&
225+
((document.body as any).createShadowRoot || document.body.attachShadow)) {
226+
const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
227+
let currentNode = treeWalker.currentNode as HTMLElement | null;
228+
229+
while (currentNode) {
230+
const shadowRoot = currentNode.shadowRoot;
231+
232+
if (shadowRoot) {
233+
// Note that `ShadowRoot` doesn't support `getElementsByName`
234+
// so we have to fall back to `querySelector`.
235+
const result =
236+
shadowRoot.getElementById(target) || shadowRoot.querySelector(`[name="${target}"]`);
237+
if (result) {
238+
return result;
239+
}
240+
}
241+
242+
currentNode = treeWalker.nextNode() as HTMLElement | null;
243+
}
244+
}
245+
246+
return null;
247+
}
248+
217249
/**
218250
* Provides an empty implementation of the viewport scroller.
219251
*/

packages/common/test/viewport_scroller_spec.ts

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {describe, expect, it} from '@angular/core/testing/src/testing_internal';
10+
import {browserDetection} from '@angular/platform-browser/testing/src/browser_util';
1011
import {BrowserViewportScroller, ViewportScroller} from '../src/viewport_scroller';
1112

1213
describe('BrowserViewportScroller', () => {
@@ -44,44 +45,103 @@ describe('BrowserViewportScroller', () => {
4445
// Testing scroll behavior does not make sense outside a browser
4546
if (isNode) return;
4647
const anchor = 'anchor';
47-
let tallItem: HTMLDivElement;
48-
let el: HTMLAnchorElement;
4948
let scroller: BrowserViewportScroller;
5049

5150
beforeEach(() => {
5251
scroller = new BrowserViewportScroller(document, window);
5352
scroller.scrollToPosition([0, 0]);
54-
55-
tallItem = document.createElement('div');
56-
tallItem.style.height = '3000px';
57-
document.body.appendChild(tallItem);
58-
59-
el = document.createElement('a');
60-
el.innerText = 'some link';
61-
el.href = '#';
62-
document.body.appendChild(el);
63-
});
64-
65-
afterEach(() => {
66-
document.body.removeChild(tallItem);
67-
document.body.removeChild(el);
6853
});
6954

7055
it('should scroll when element with matching id is found', () => {
71-
el.id = anchor;
56+
const {anchorNode, cleanup} = createTallElement();
57+
anchorNode.id = anchor;
7258
scroller.scrollToAnchor(anchor);
7359
expect(scroller.getScrollPosition()[1]).not.toEqual(0);
60+
cleanup();
7461
});
7562

7663
it('should scroll when anchor with matching name is found', () => {
77-
el.name = anchor;
64+
const {anchorNode, cleanup} = createTallElement();
65+
anchorNode.name = anchor;
7866
scroller.scrollToAnchor(anchor);
7967
expect(scroller.getScrollPosition()[1]).not.toEqual(0);
68+
cleanup();
8069
});
8170

8271
it('should not scroll when no matching element is found', () => {
72+
const {cleanup} = createTallElement();
8373
scroller.scrollToAnchor(anchor);
8474
expect(scroller.getScrollPosition()[1]).toEqual(0);
75+
cleanup();
8576
});
77+
78+
it('should scroll when element with matching id is found inside the shadow DOM', () => {
79+
// This test is only relevant for browsers that support shadow DOM.
80+
if (!browserDetection.supportsShadowDom) {
81+
return;
82+
}
83+
84+
const {anchorNode, cleanup} = createTallElementWithShadowRoot();
85+
anchorNode.id = anchor;
86+
scroller.scrollToAnchor(anchor);
87+
expect(scroller.getScrollPosition()[1]).not.toEqual(0);
88+
cleanup();
89+
});
90+
91+
it('should scroll when anchor with matching name is found inside the shadow DOM', () => {
92+
// This test is only relevant for browsers that support shadow DOM.
93+
if (!browserDetection.supportsShadowDom) {
94+
return;
95+
}
96+
97+
const {anchorNode, cleanup} = createTallElementWithShadowRoot();
98+
anchorNode.name = anchor;
99+
scroller.scrollToAnchor(anchor);
100+
expect(scroller.getScrollPosition()[1]).not.toEqual(0);
101+
cleanup();
102+
});
103+
104+
function createTallElement() {
105+
const tallItem = document.createElement('div');
106+
tallItem.style.height = '3000px';
107+
document.body.appendChild(tallItem);
108+
const anchorNode = createAnchorNode();
109+
document.body.appendChild(anchorNode);
110+
111+
return {
112+
anchorNode,
113+
cleanup: () => {
114+
document.body.removeChild(tallItem);
115+
document.body.removeChild(anchorNode);
116+
}
117+
};
118+
}
119+
120+
function createTallElementWithShadowRoot() {
121+
const tallItem = document.createElement('div');
122+
tallItem.style.height = '3000px';
123+
document.body.appendChild(tallItem);
124+
125+
const elementWithShadowRoot = document.createElement('div');
126+
const shadowRoot = elementWithShadowRoot.attachShadow({mode: 'open'});
127+
const anchorNode = createAnchorNode();
128+
shadowRoot.appendChild(anchorNode);
129+
document.body.appendChild(elementWithShadowRoot);
130+
131+
return {
132+
anchorNode,
133+
cleanup: () => {
134+
document.body.removeChild(tallItem);
135+
document.body.removeChild(elementWithShadowRoot);
136+
}
137+
};
138+
}
139+
140+
function createAnchorNode() {
141+
const anchorNode = document.createElement('a');
142+
anchorNode.innerText = 'some link';
143+
anchorNode.href = '#';
144+
return anchorNode;
145+
}
86146
});
87147
});

0 commit comments

Comments
 (0)