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.
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]);
}Heads up: Radix solves this with a DismissableLayer tree. Worth reading their implementation before we reinvent it — the branch pruning logic is subtle.
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.