Skip to content

onFocusOutside is not detected properly in a shadow root #3088

Description

@mbeckem

🐛 Bug report

Typically popovers, menus etc. will close when an interaction outside the widget is detected.
This includes tabbing to another dom element outside the widget, as can be seen, for example, in Chakra's popover samples.
I noticed that focus changes due to tabbing are not detected properly when the widgets are within a shadow root.

💥 Steps to reproduce

I'll briefly describe the error here (also see notes below). Let me know if you need a full reproduction.

  1. Mount a popover or menu (default settings, including "close on interact outside") and another unrelated button into the same shadow root.
  2. Open the popover via keyboard interaction
  3. Explicitly focus the button, e.g. via timeout + buttonRef.current.focus()

🧐 Expected behavior

The popover should have closed, but it remains open.

🧭 Possible Solution

The "focus outside" logic in @zag-js/interact-outside registers event handlers for the focusin event on global dom nodes (document , frames, etc.). Typically, the focusin event bubbles and triggers for any focus change within a node's subtree, so this is correct when no shadow root is involved.

It seems that the focusin event is not propaged to the parents of a shadow root when tabbing between nodes within the same shadow root. This was surprising to me, and I could not find any definite documentation about this circumstance, but I have attached a simple html file below that demonstrates the problem (tested in both firefox and chrome).

I can confirm that the following patch against @zag-js/interact-outside@1.35.3 seems to fix the immediate problem.
I'm not sure whether this might produce additional problems (events may now be handled twice: once in the shadow root, once in the document in some circumstances):

diff --git a/dist/index.mjs b/dist/index.mjs
index 31c63ae79fd927a8edd7bb1e535acf5f74ff29cc..b2f3c88aac4c8075427e9ba855694b75d1b1f4b9 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -122,8 +122,8 @@ function trackInteractOutsideImpl(node, options) {
   }, 0);
   function onFocusin(event) {
     const func = defer ? raf : (v) => v();
+    const composedPath = event?.composedPath?.() ?? [event?.target];
     func(() => {
-      const composedPath = event?.composedPath?.() ?? [event?.target];
       const target = isInShadowRoot ? composedPath[0] : getEventTarget(event);
       if (!node || !isEventOutside(event, target)) return;
       if (onFocusOutside || onInteractOutside) {
@@ -146,6 +146,7 @@ function trackInteractOutsideImpl(node, options) {
     cleanups.add(addDomEvent(doc, "focusin", onFocusin, true));
     cleanups.add(parentWin.addEventListener("focusin", onFocusin, true));
     cleanups.add(frames.addEventListener("focusin", onFocusin, true));
+    isInShadowRoot && cleanups.add(addDomEvent(node?.getRootNode(), "focusin", onFocusin, true));
   }
   return () => {
     clearTimeout(timer);

🌍 System information

Software Version(s)
Zag Version 1.35.3
Browser Chrome and Firefox
Operating System Ubuntu 25.10

📝 Additional information

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions