From a95f067376e136fee4de9f653fb1675be6c34327 Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 08:29:49 +0000 Subject: [PATCH] fix(overlay): follow the pointer inside a shadow root while picking Moving between elements in one shadow root fires no mouseover at the document, so the pick highlight stayed on the first component. The picker now also listens for pointermove and redraws only when the component under the pointer changes. --- .../src/__tests__/component-pick.test.ts | 31 +++++++++++++++++++ packages/devtools/src/component-pick.ts | 15 +++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/devtools/src/__tests__/component-pick.test.ts b/packages/devtools/src/__tests__/component-pick.test.ts index 1818b359..8c370a3d 100644 --- a/packages/devtools/src/__tests__/component-pick.test.ts +++ b/packages/devtools/src/__tests__/component-pick.test.ts @@ -110,6 +110,37 @@ describe('startComponentPick', () => { expect(result.ok && elementById(result.id)).toBe(card); }); + it('follows the pointer between components inside one shadow root', async () => { + document.body.innerHTML = ``; + const root = document.querySelector('app-root')!; + const shell = document.querySelector('app-shell')!; + shell.attachShadow({ mode: 'open' }).innerHTML = + 'Map'; + const card = shell.shadowRoot!.querySelector('app-trip-card')!; + const map = shell.shadowRoot!.querySelector('app-map')!; + const instances = new Map([ + [root, {}], + [shell, {}], + [card, new _TripCard()], + [map, {}], + ]); + const ng: ComponentDebugNg = { getComponent: (el) => instances.get(el) ?? null }; + const highlight = { show: vi.fn(), clear: vi.fn() }; + const pick = startComponentPick({ getNg: () => ng, highlight, timeoutMs: 1000 }); + const move = (el: Element) => + el.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, composed: true })); + + move(card.querySelector('button')!); + expect(highlight.show).toHaveBeenLastCalledWith(card); + move(map.querySelector('b')!); + expect(highlight.show).toHaveBeenLastCalledWith(map); + // Moving within the same component does not redraw the box. + move(map.querySelector('b')!); + expect(highlight.show).toHaveBeenCalledTimes(2); + pick.cancel(); + await pick.result; + }); + it('ends on Escape, on cancel and after the timeout', async () => { const first = setup(); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); diff --git a/packages/devtools/src/component-pick.ts b/packages/devtools/src/component-pick.ts index 164a6a47..bf9f7727 100644 --- a/packages/devtools/src/component-pick.ts +++ b/packages/devtools/src/component-pick.ts @@ -38,13 +38,22 @@ export function startComponentPick(options: PickOptions): { let finish: (result: ComponentPickResult) => void = () => {}; const result = new Promise((resolve) => { + // Moving between elements inside one shadow root fires no mouseover at the + // document, since both ends retarget to the host, so pointermove also updates + // the box. It is only redrawn when the component under the pointer changes. + let shown: Element | null | undefined; const onHover = (event: Event) => { const host = hostOf(event); - if (host instanceof HTMLElement) options.highlight.show(host); + const next = host instanceof HTMLElement ? host : null; + if (next === shown) return; + shown = next; + if (next) options.highlight.show(next); else options.highlight.clear(); }; const onLeave = (event: MouseEvent) => { - if (!event.relatedTarget) options.highlight.clear(); + if (event.relatedTarget) return; + shown = undefined; + options.highlight.clear(); }; const onClick = (event: Event) => { if (ownElement(event.target)) return; @@ -77,6 +86,7 @@ export function startComponentPick(options: PickOptions): { finish = () => {}; doc.removeEventListener('click', onClick, true); doc.removeEventListener('mouseover', onHover, true); + doc.removeEventListener('pointermove', onHover, true); doc.removeEventListener('mouseout', onLeave, true); doc.removeEventListener('keydown', onKey, true); clearTimeout(timer); @@ -87,6 +97,7 @@ export function startComponentPick(options: PickOptions): { popup?.setAttribute('data-picking', ''); doc.addEventListener('click', onClick, true); doc.addEventListener('mouseover', onHover, true); + doc.addEventListener('pointermove', onHover, true); doc.addEventListener('mouseout', onLeave, true); doc.addEventListener('keydown', onKey, true); });