Skip to content

Commit 43f8994

Browse files
web-padawanclaudetomivirkki
authored
fix: walk slot boundaries to find focused virtualizer row (#11653)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Tomi Virkki <virkki@vaadin.com>
1 parent 6b0a136 commit 43f8994

2 files changed

Lines changed: 113 additions & 5 deletions

File tree

packages/component-base/src/virtualizer-iron-list-adapter.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -515,11 +515,19 @@ export class IronListAdapter {
515515

516516
/** @private */
517517
__getFocusedElement(visibleElements = this.__getVisibleElements()) {
518-
return visibleElements.find(
519-
(element) =>
520-
element.contains(this.elementsContainer.getRootNode().activeElement) ||
521-
element.contains(this.scrollTarget.getRootNode().activeElement),
522-
);
518+
// `document.activeElement` retargets to the outermost shadow host when
519+
// focus lives in a nested shadow tree. Descend through nested shadow
520+
// roots' `activeElement`s to reach the real focused node, then walk up
521+
// the flattened tree (via `assignedSlot`/`parentNode`/`host`) until a
522+
// visible row is reached.
523+
let node = document.activeElement;
524+
while (node?.shadowRoot?.activeElement) {
525+
node = node.shadowRoot.activeElement;
526+
}
527+
while (node && !visibleElements.includes(node)) {
528+
node = node.assignedSlot || node.parentNode || node.host;
529+
}
530+
return node;
523531
}
524532

525533
/** @private */

packages/component-base/test/virtualizer-reorder-elements.test.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,104 @@ describe('reorder elements', () => {
202202
expect(scrollTarget.scrollTop).to.equal(scrollTop);
203203
});
204204
});
205+
206+
// Regression tests for vaadin/web-components#11639. When the virtualizer
207+
// lives inside a shadow root, the focused row must be located via the
208+
// shadow root's `activeElement` (focus inside the shadow tree) or by
209+
// walking the flattened ancestors via `assignedSlot` from the scroll
210+
// target's root activeElement (focus on slotted light-DOM content).
211+
describe('focused element detection in shadow tree', () => {
212+
beforeEach(() => {
213+
// Remove default scroll target
214+
scrollTarget.remove();
215+
clock.restore();
216+
217+
scrollTarget = fixtureSync('<div style="height: 100px;"></div>');
218+
const shadowRoot = scrollTarget.attachShadow({ mode: 'open' });
219+
shadowRoot.innerHTML = `
220+
<style>:host { display: block; height: 100%; }</style>
221+
<div id="scrollTarget" style="height: 100%; overflow: auto;">
222+
<div id="container"></div>
223+
</div>
224+
`;
225+
226+
virtualizer = new Virtualizer({
227+
createElements: (count) =>
228+
Array.from(Array(count)).map(() => {
229+
const row = document.createElement('div');
230+
const slot = document.createElement('slot');
231+
slot.name = `cell-${scrollTarget.children.length}`;
232+
row.appendChild(slot);
233+
234+
const cellContent = document.createElement('div');
235+
cellContent.slot = slot.name;
236+
cellContent.tabIndex = 0;
237+
scrollTarget.appendChild(cellContent);
238+
239+
return row;
240+
}),
241+
updateElement: (row, index) => {
242+
row.index = index;
243+
row.id = `row-${index}`;
244+
const cellContent = row.querySelector('slot').assignedNodes()[0];
245+
cellContent.textContent = index;
246+
},
247+
scrollTarget: shadowRoot.getElementById('scrollTarget'),
248+
scrollContainer: shadowRoot.getElementById('container'),
249+
reorderElements: true,
250+
});
251+
virtualizer.size = 100;
252+
elementsContainer = shadowRoot.getElementById('container');
253+
});
254+
255+
it('should not detach a focused row on reorder', async () => {
256+
// The row that will receive focus (should not get detached)
257+
const rowContainingFocus = elementsContainer.children[4];
258+
// Focus the row directly. The row lives inside the shadow tree, so
259+
// `document.activeElement` resolves to the shadow host, not the row.
260+
rowContainingFocus.tabIndex = 0;
261+
rowContainingFocus.focus();
262+
263+
// Scroll and collect the detached elements
264+
const detachedElements = await scrollRecycle();
265+
// Expect the focused row to not have been detached
266+
expect(detachedElements).not.to.include(rowContainingFocus);
267+
});
268+
269+
it('should not detach a row whose slotted content has focus on reorder', async () => {
270+
// The row that will include the focused cell (should not get detached)
271+
const rowContainingFocus = elementsContainer.children[4];
272+
// Focus the slotted cell content on the row
273+
rowContainingFocus.querySelector('slot').assignedNodes()[0].focus();
274+
275+
// Scroll and collect the detached elements
276+
const detachedElements = await scrollRecycle();
277+
// Expect the row containing focus to not have been detached
278+
expect(detachedElements).not.to.include(rowContainingFocus);
279+
});
280+
281+
// When the entire virtualizer host is itself nested inside another
282+
// shadow root, `document.activeElement` retargets to the outermost
283+
// host. The scroll target's root activeElement remains the actual
284+
// slotted focused element and must be used as the walk's starting
285+
// point.
286+
it('should not detach a row whose slotted content has focus when wrapped in an outer shadow', async () => {
287+
// Move the virtualizer host into an outer shadow root, keeping the
288+
// slotted cell contents (currently in `scrollTarget`'s light DOM)
289+
// wrapped inside the outer shadow as well.
290+
const outerHost = fixtureSync('<div></div>');
291+
const outerShadow = outerHost.attachShadow({ mode: 'open' });
292+
outerShadow.appendChild(scrollTarget);
293+
294+
// The row that will include the focused cell (should not get detached)
295+
const rowContainingFocus = elementsContainer.children[4];
296+
// Focus the slotted cell content on the row
297+
rowContainingFocus.querySelector('slot').assignedNodes()[0].focus();
298+
299+
// Scroll and collect the detached elements
300+
const detachedElements = await scrollRecycle();
301+
// Expect the row containing focus to not have been detached
302+
expect(detachedElements).not.to.include(rowContainingFocus);
303+
});
304+
});
205305
});

0 commit comments

Comments
 (0)