Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/interaction-shadow-dom-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/interaction": patch
---

fix: resolve interact-outside targets across shadow boundaries
52 changes: 46 additions & 6 deletions packages/interaction/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
/** The original DOM event that triggered the outside interaction. */
Expand Down Expand Up @@ -399,16 +435,20 @@ export function makeInteractOutside<T extends Element>(
// 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(
Expand Down Expand Up @@ -438,7 +478,7 @@ export function makeInteractOutside<T extends Element>(
};

const onFocusIn = (e: FocusEvent) => {
const target = e.target as Element | null;
const target = getEventTarget(e);
if (!target || !isEventOutside(e)) return;

target.addEventListener(
Expand Down
106 changes: 106 additions & 0 deletions packages/interaction/test/interact-outside.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateInteractOutsideProps> = {}) {
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();
});
});
});