🛠️
LNUI-703Rework Dialog focus trap to support nested portals
289 / 291

Rework Dialog focus trap to support nested portals

Context

The current focus trap in Dialog relies on a hand-rolled focusin listener. It breaks as soon as a nested portal (Select, Combobox, DatePicker) renders its content outside the dialog subtree: focus is yanked back to the dialog and the nested widget closes.

Proposed approach

Track an allowlist of portal roots registered through context. Any element inside a registered root is treated as part of the dialog for focus containment purposes.

const PortalRootContext = createContext<Set<HTMLElement>>(new Set());

export function useDialogPortalRoot(node: HTMLElement | null) {
   const roots = useContext(PortalRootContext);
   useEffect(() => {
      if (!node) return;
      roots.add(node);
      return () => void roots.delete(node);
   }, [node, roots]);
}

Acceptance criteria

Select opened inside a Dialog keeps focus on its listbox
Nested Dialog (2 levels) traps focus on the topmost layer
Escape closes only the topmost layer
VoiceOver / NVDA announce the dialog correctly

LNUI-643Prevent layout shift when scrollbar appears with Dialogprevious scrollbar layout-shift fix touches the same overlay code

Activity

sophia.reed created the issue· 12d ago
sophia.reed added label Bug· 12d ago
mason.carter moved from Todo to In Progress· 9d ago
ssophia.reed8d ago

Heads up: Radix solves this with a DismissableLayer tree. Worth reading their implementation before we reinvent it — the branch pruning logic is subtle.

👍 3
mmason.carter6d ago

Agreed. I kept the context registry approach but mirrored their layer ordering. Draft PR is up, the two remaining checkboxes need the screen-reader pass.