diff --git a/.changeset/interaction-shadow-dom-target.md b/.changeset/interaction-shadow-dom-target.md new file mode 100644 index 000000000..e4a441111 --- /dev/null +++ b/.changeset/interaction-shadow-dom-target.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/interaction": patch +--- + +fix: resolve interact-outside targets across shadow boundaries diff --git a/packages/interaction/src/index.ts b/packages/interaction/src/index.ts index baa26fbdf..ddf29e92a 100644 --- a/packages/interaction/src/index.ts +++ b/packages/interaction/src/index.ts @@ -282,6 +282,42 @@ export function createHideOutside(options: CreateHideOutsideOptions): void { ); } +/** + * The element an event actually originated from. + * + * `event.target` is retargeted at every shadow boundary the event crosses, so a listener on the + * document reports the outermost shadow *host* rather than the element that was interacted with. + * `composedPath()[0]` is that element; for an event that crosses no boundary the two are identical, + * so this is a no-op outside shadow DOM. + */ +function getEventTarget(event: Event): Element | null { + const path = typeof event.composedPath === "function" ? event.composedPath() : undefined; + const target = path?.[0] ?? event.target; + + return target instanceof Element ? target : null; +} + +/** + * `Node.prototype.contains`, but able to see through shadow boundaries. + * + * `contains` only walks the node tree it is called on, so it answers `false` for a `child` inside a + * shadow root — including `document.contains(elementInAShadowRoot)`. When this walk reaches the top + * of a shadow tree it continues from that tree's host, answering a containment question about the + * *rendered* page rather than about one node tree. + */ +function containsComposed(parent: Node, child: Node | null): boolean { + let node: Node | null = child; + + while (node) { + if (parent === node || parent.contains(node)) return true; + + const root = node.getRootNode(); + node = root instanceof ShadowRoot ? root.host : null; + } + + return false; +} + /** Detail payload carried by every outside-interaction `CustomEvent`. */ export type EventDetails = { /** The original DOM event that triggered the outside interaction. */ @@ -399,16 +435,20 @@ export function makeInteractOutside( // a *new* instance that opened in that same window — misreporting them // as outside interactions on this now-orphaned instance. if (!el.isConnected) return false; - const target = e.target as Element | null; - if (!(target instanceof Element)) return false; - if (!ownerDoc.contains(target)) return false; - if (el.contains(target)) return false; + // Resolved through `composedPath()`, not `e.target`: these listeners are on the document, so an + // interaction inside a shadow tree reports that tree's host instead. `el` rendered inside a + // shadow root would see every one of its own clicks as the host — an ancestor, not a descendant + // — and read them as outside interactions, dismissing itself on pointerdown. + const target = getEventTarget(e); + if (!target) return false; + if (!containsComposed(ownerDoc, target)) return false; + if (containsComposed(el, target)) return false; return !(options.shouldExcludeElement?.(target) ?? false); }; const onPointerDown = (e: PointerEvent) => { const handler = () => { - const target = e.target as Element | null; + const target = getEventTarget(e); if (!target || !isEventOutside(e)) return; target.addEventListener( @@ -438,7 +478,7 @@ export function makeInteractOutside( }; const onFocusIn = (e: FocusEvent) => { - const target = e.target as Element | null; + const target = getEventTarget(e); if (!target || !isEventOutside(e)) return; target.addEventListener( diff --git a/packages/interaction/test/interact-outside.test.ts b/packages/interaction/test/interact-outside.test.ts index 7cf638f7f..00aec29e2 100644 --- a/packages/interaction/test/interact-outside.test.ts +++ b/packages/interaction/test/interact-outside.test.ts @@ -307,4 +307,110 @@ describe("createInteractOutside", () => { second.cleanup(); }); }); + + describe("Shadow DOM", () => { + // These listeners live on the document, so an event originating inside a shadow tree is + // retargeted to that tree's host before it arrives. A watched element rendered inside a shadow + // root would therefore see every one of its own interactions reported as the host — an + // *ancestor* of it, not a descendant — so `el.contains(target)` read false and the element + // treated its own content as outside. The visible symptom is a popover that dismisses when you + // click inside it, and a trigger that closes and immediately reopens. + function setupShadowTest(extraProps: Partial = {}) { + const onFocusOutside = vi.fn(); + const onPointerDownOutside = vi.fn(); + const onInteractOutside = vi.fn(); + + const host = document.createElement("div"); + document.body.appendChild(host); + const shadowRoot = host.attachShadow({ mode: "open" }); + + // A child of the watched element, not the element itself: only a descendant is deep enough + // for retargeting to change the answer. + const inside = createElement("div", "inside"); + const insideChild = createElement("div", "inside-child"); + inside.appendChild(insideChild); + shadowRoot.appendChild(inside); + + // A sibling in the same shadow root — genuinely outside, and must stay that way. + const outside = createElement("div", "outside"); + shadowRoot.appendChild(outside); + + const dispose = createRoot(d => { + createInteractOutside( + { onFocusOutside, onPointerDownOutside, onInteractOutside, ...extraProps }, + () => inside, + ); + return d; + }); + + flush(); + vi.runAllTimers(); + + return { + mocks: { onFocusOutside, onPointerDownOutside, onInteractOutside }, + inside, + insideChild, + outside, + cleanup: () => { + dispose(); + host.remove(); + }, + }; + } + + it("does not trigger on pointerdown inside the watched element's own shadow content", () => { + const { mocks, insideChild, cleanup } = setupShadowTest(); + + insideChild.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, composed: true, pointerType: "mouse" }), + ); + + expect(mocks.onPointerDownOutside).not.toHaveBeenCalled(); + expect(mocks.onInteractOutside).not.toHaveBeenCalled(); + cleanup(); + }); + + it("does not trigger when focus moves into the watched element's own shadow content", () => { + const { mocks, insideChild, cleanup } = setupShadowTest(); + + insideChild.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true })); + + expect(mocks.onFocusOutside).not.toHaveBeenCalled(); + expect(mocks.onInteractOutside).not.toHaveBeenCalled(); + cleanup(); + }); + + it("still triggers on pointerdown on a sibling in the same shadow root", () => { + const { mocks, outside, cleanup } = setupShadowTest(); + + outside.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, composed: true, pointerType: "mouse" }), + ); + + expect(mocks.onPointerDownOutside).toHaveBeenCalledTimes(1); + expect(mocks.onInteractOutside).toHaveBeenCalledTimes(1); + cleanup(); + }); + + it("passes the retargeted element, not the shadow host, to shouldExcludeElement", () => { + // The mechanism behind kobaltedev/kobalte#445: a consumer excludes its trigger via + // `shouldExcludeElement`, but was handed the shadow host, which never matches the trigger — + // so the layer dismissed on the very interaction that was meant to be exempt. + const seen: Element[] = []; + const { outside, cleanup } = setupShadowTest({ + shouldExcludeElement: el => { + seen.push(el); + return false; + }, + }); + + outside.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, composed: true, pointerType: "mouse" }), + ); + + expect(seen).toContain(outside); + expect(seen.some(el => el.shadowRoot != null)).toBe(false); + cleanup(); + }); + }); });