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); });