diff --git a/packages/devtools/src/__tests__/component-pick.test.ts b/packages/devtools/src/__tests__/component-pick.test.ts index 2785bafd..1818b359 100644 --- a/packages/devtools/src/__tests__/component-pick.test.ts +++ b/packages/devtools/src/__tests__/component-pick.test.ts @@ -82,6 +82,34 @@ describe('startComponentPick', () => { }); }); + it('picks a component inside the shadow root of a ShadowDom component', async () => { + document.body.innerHTML = ``; + const root = document.querySelector('app-root')!; + const shell = document.querySelector('app-shell')!; + shell.attachShadow({ mode: 'open' }).innerHTML = + ''; + const card = shell.shadowRoot!.querySelector('app-trip-card')!; + class _Shell {} + const instances = new Map([ + [root, {}], + [shell, new _Shell()], + [card, new _TripCard()], + ]); + 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 button = card.querySelector('button')!; + + button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, composed: true })); + expect(highlight.show).toHaveBeenLastCalledWith(card); + button.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }), + ); + const result = await pick.result; + expect(result).toMatchObject({ ok: true, name: 'TripCard', tag: 'app-trip-card' }); + expect(result.ok && elementById(result.id)).toBe(card); + }); + 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 ece6adc6..164a6a47 100644 --- a/packages/devtools/src/component-pick.ts +++ b/packages/devtools/src/component-pick.ts @@ -28,15 +28,18 @@ export function startComponentPick(options: PickOptions): { const popup = doc.getElementById(POPUP_ROOT_ID); const ownElement = (target: EventTarget | null) => target instanceof Element && !!popup && popup.contains(target); - const hostOf = (target: EventTarget | null) => - target instanceof Element && !ownElement(target) - ? componentHostOf(options.getNg(), target) - : null; + // At the document, an event from inside a shadow root targets its host, so + // the element under the pointer is the first entry of the composed path. + const hostOf = (event: Event) => { + if (ownElement(event.target)) return null; + const target = event.composedPath()[0] ?? event.target; + return target instanceof Element ? componentHostOf(options.getNg(), target) : null; + }; let finish: (result: ComponentPickResult) => void = () => {}; const result = new Promise((resolve) => { const onHover = (event: Event) => { - const host = hostOf(event.target); + const host = hostOf(event); if (host instanceof HTMLElement) options.highlight.show(host); else options.highlight.clear(); }; @@ -47,7 +50,7 @@ export function startComponentPick(options: PickOptions): { if (ownElement(event.target)) return; event.preventDefault(); event.stopPropagation(); - const host = hostOf(event.target); + const host = hostOf(event); const instance = host ? options.getNg()?.getComponent?.(host) : null; const ctor = (instance as { constructor?: unknown } | null)?.constructor; finish(