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(