import type { JSX } from "@solidjs/web"; /** * Stage 5 step 1 — Solid port of Base UI React's `FloatingPortal` * (`packages/react/src/floating-ui-react/components/FloatingPortal.tsx`). * * Responsibilities: * * - Renders its `children` into a target element (by default `document.body`), * via `@solidjs/web`'s `Portal`. The Solid wrap means we inherit * framework-maintained Portal semantics (owner-tree scoping, hydration * boundary, `` unmount tear-down) for free. * * - Exposes `FloatingPortalContext` (the React source calls the same context * `PortalContext`) with `portalNode`, `setFocusManagerState`, and four * sentinel refs (`beforeOutside`, `afterOutside`, `beforeInside`, * `afterInside`). The `FloatingFocusManager` port (Stage 5 step 2) reads * these to route tab traversal across the portal boundary. * * - CSP-nonce aware. When a `` is present, the portal wrapper * `
` and any injected sentinel ``s carry * the same `nonce=` attribute so strict-CSP consumers don't trip the * browser's inline-element gate. * * - Extended target resolution. The React source accepts an element, a ref, * `null`, or nothing (→ `document.body`). The Solid port widens this to * also accept a CSS selector string and a zero-argument function returning * an element, matching the public `@floating-ui/react` `FloatingPortal` * ergonomics. * * - Optional auto-created shared root. When `id` is supplied and no element * with that id already exists under the resolved container, the portal * creates a `
` inside `document.body`. A module-level * ref-count keeps the auto-created root alive while any `FloatingPortal` * with the same `id` is mounted, and tears it down on last unmount. * External nodes that already live in the DOM are never removed. * * Port notes / deviations from React: * * - React's `useFloatingPortalNode` returns `{ portalNode, portalSubtree }` * so the caller can render the subtree in its JSX tree. The Solid port * collapses that into a single `` + inner wrapper div — Solid's * Portal mounts its children into the target DOM subtree already, so the * two-hop dance (React portals the wrapper, then portals the children * into the wrapper) isn't needed. * - `useFloatingPortalNode` is exposed as a hook that returns an accessor * for the current portal node. It reads from `FloatingPortalContext`, so * it's only meaningful inside a mounted `FloatingPortal`. * - The FocusGuard helper's Safari `role="button"` dance is dropped — the * Solid port doesn't ship `isSafari` under `detect-browser.ts` and the * guards here are structurally `tabindex=0` visually-hidden spans. AT * parity with Safari's VoiceOver can be restored when a shared Solid * `FocusGuard` lands. * - `focusInsideDisabledRef` / `enableFocusInside` / `disableFocusInside` * wiring from the React source is preserved so focus traps can dock * against the portal boundary. */ import { getParentNode, isElement, isNode } from "@floating-ui/utils/dom"; import { Portal } from "@solidjs/web"; import { createContext, createEffect, createSignal, useContext, } from "solid-js"; import { useCspNonce } from "../../../csp-provider/csp-context"; import { createChangeEventDetails } from "../../change-event-details"; import { FocusGuard } from "../../popups/focus-guard"; import { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import type { MutableRef } from "../types"; import { createAttribute } from "../utils/create-attribute"; import { disableFocusInside, enableFocusInside, getNextTabbable, getPreviousTabbable, isOutsideEvent, } from "../utils/tabbable"; /** * Upward-flowing state written by a `FloatingFocusManager` that is mounted * as a descendant of the portal. The portal uses this to decide whether to * render tab-guard sentinels and where to bounce focus on guard activation. */ export interface FloatingFocusManagerState { modal: boolean; open: boolean; onOpenChange( open: boolean, details: { reason?: string | undefined; event?: Event | undefined; }, ): void; domReference: Element | null; closeOnFocusOut: boolean; } /** * Context value shared with descendants of ``. */ export interface FloatingPortalContextValue { /** * The resolved portal wrapper `
`. `null` until the portal mounts. */ portalNode: HTMLElement | null; /** * Setter for focus-manager state — called by the focus-trap descendant * to advertise its modal/open state up to the portal so guards can be * rendered appropriately. */ setFocusManagerState(state: FloatingFocusManagerState | null): void; /** * Ref to the outer-boundary `before` sentinel (the guard that sits * before the portal subtree in DOM order). */ beforeOutsideRef: MutableRef; /** * Ref to the outer-boundary `after` sentinel (the guard that sits * after the portal subtree in DOM order). */ afterOutsideRef: MutableRef; /** * Ref to the inner-boundary `before` sentinel — focus-trap wires the * first tabbable of the floating element after this. */ beforeInsideRef: MutableRef; /** * Ref to the inner-boundary `after` sentinel — focus-trap wires the * last tabbable of the floating element before this. */ afterInsideRef: MutableRef; /** * This portal's node in the logical portal ancestry chain (see * `isInsideNestedPortal`). Nested portals link to it as their parent. */ ancestryNode: PortalAncestryNode; } const FloatingPortalContext = createContext( null, ); /** * Read the nearest `FloatingPortalContext`. Returns `null` when used * outside a mounted ``. */ export function usePortalContext(): FloatingPortalContextValue | null { return useContext(FloatingPortalContext); } /** * Returns the resolved portal node (the wrapper `
`) of the nearest * ``. Returns `null` when used outside a mounted portal or * before the portal has committed. */ export function useFloatingPortalNode(): () => HTMLElement | null { const ctx = usePortalContext(); return () => ctx?.portalNode ?? null; } const PORTAL_ATTR = createAttribute("portal"); /** * Logical portal ancestry — the Solid analogue of Base UI React's * `insideReactTree` latch. React marks presses via synthetic capture * handlers on the popup (`onClickCapture` etc. in `useDismiss`'s floating * bag), and those fire even for presses inside PORTALED descendants * because React portals propagate synthetic events through the component * tree. Solid events follow the DOM tree, so a press inside a nested * portaled popup (a Select popup inside a Dialog, say) never reaches the * dialog popup's handlers and would read as an outside press, dismissing * the ancestor overlay. * * Instead, every `` records its logical parent portal — * the nearest portal in the COMPONENT tree, which context preserves even * across DOM portals — and stamps its wrapper element with that chain. * Dismissal hooks then ask "is the press target inside a portal that is a * logical DESCENDANT of my own portal?" (`isInsideNestedPortal`), * recovering the logical-tree walk Base UI gets from React for free. */ export interface PortalAncestryNode { parent: PortalAncestryNode | null; } const portalAncestry = new WeakMap(); /** * Walk up from `start` (crossing shadow boundaries) to the nearest * `` wrapper and return its ancestry node, or `null` when * the element is not inside any quoin portal. */ function nearestPortalAncestryNode( start: Element | null, ): PortalAncestryNode | null { let el: Element | null = start; while (el) { const node = portalAncestry.get(el); if (node) { return node; } const parent = getParentNode(el); if (parent === el || !isElement(parent)) { return null; } el = parent as Element; } return null; } /** * Whether `target` sits inside a portal that is a logical (component-tree) * descendant of the portal hosting `ownElement`. Strict descendants only: * a press inside our own portal wrapper but not in a nested portal (e.g. * on the dialog's internal backdrop or viewport) is still an outside * press for the popup itself. */ export function isInsideNestedPortal( target: EventTarget | null, ownElement: Element | null, ): boolean { if (!ownElement || !isElement(target)) { return false; } const own = nearestPortalAncestryNode(ownElement); if (!own) { return false; } const targetNode = nearestPortalAncestryNode(target as Element); if (!targetNode || targetNode === own) { return false; } for (let node = targetNode.parent; node; node = node.parent) { if (node === own) { return true; } } return false; } /** * Target the user can supply. Ports the React source's `container` shape * (element / ref / null / undefined) and adds: * * - a CSS selector string (resolved via `document.querySelector`); * - a zero-arg function returning any of the above. */ export type FloatingPortalTarget = | HTMLElement | ShadowRoot | string | MutableRef | (() => HTMLElement | ShadowRoot | string | null | undefined) | null | undefined; // Module-level ref-count for auto-created `id` roots. Keyed by id; value is // the count of `FloatingPortal` instances currently mounted that are using // an auto-created root with that id. The fourth tuple entry is the element // itself so we can tear it down when the count drops to zero. const autoRootRegistry = new Map< string, { element: HTMLElement; refCount: number; ownerDocument: Document } >(); function isShadowRoot(value: unknown): value is ShadowRoot { return typeof ShadowRoot !== "undefined" && value instanceof ShadowRoot; } function resolveTarget( target: FloatingPortalTarget, ): HTMLElement | ShadowRoot | null { if (target == null) { return null; } // Function form — unwrap once, then re-resolve. if (typeof target === "function") { const inner = target(); return resolveTarget(inner); } if (typeof target === "string") { if (typeof document === "undefined") { return null; } const found = document.querySelector(target); return (found as HTMLElement | null) ?? null; } if (isNode(target) || isShadowRoot(target)) { return target as HTMLElement | ShadowRoot; } // Ref-like object with `current`. if ("current" in target) { const current = target.current; if (current == null) { return null; } return current; } return null; } export interface FloatingPortalProps { /** * Portal children. Mounted into the resolved target element (by default * `document.body`). */ children?: JSX.Element; /** * Parent element (or selector, ref, or thunk) to render the portal * wrapper into. Defaults to `document.body`. React's `container` name is * honored below as an alias. */ root?: FloatingPortalTarget; /** * Alias for `root`, matching the Base UI React source's prop name. * If both are supplied, `root` takes precedence. */ container?: FloatingPortalTarget; /** * Optional id. If supplied, the portal wrapper is attached inside an * element with this id under the resolved root. If no such element * exists, one is auto-created (and ref-counted across instances — the * last unmount removes the auto-created root). */ id?: string | undefined; /** * Override the automatic guard-rendering heuristic. When unset, guards * render whenever a non-modal focus-manager is mounted and open. */ renderGuards?: boolean | undefined; /** * Extra attributes / style forwarded onto the portal wrapper `
`. */ wrapperProps?: JSX.HTMLAttributes | undefined; } /** * Portals the floating element into the body (or a supplied target), * carrying CSP nonce and focus-guard plumbing for strict-CSP consumers and * `FloatingFocusManager` tab traversal. * * @see https://floating-ui.com/docs/FloatingPortal */ export function FloatingPortal(props: FloatingPortalProps): JSX.Element { const parentPortal = usePortalContext(); const nonce = useCspNonce(); // Mutable mirror of the portal node — reads in the context getter return // the latest value even before Solid 2's signal flush lands (same pattern // as the toast store). The signal exists to wake reactive // consumers (effects, memos) when the mount completes. const portalNodeRef: MutableRef = { current: null }; const [portalNodeSignal, setPortalNodeSignal] = createSignal(null, { equals: false }); const setPortalNode = (node: HTMLElement | null) => { portalNodeRef.current = node; setPortalNodeSignal(node); }; const portalNode = () => portalNodeSignal(); // `ownedWrite: true` — `setFocusManagerState` is invoked from a descendant // ``'s `useIsoLayoutEffect` (a `createRenderEffect` // apply function), which is owned scope. Under Solid 2's bundled runtime, // writing to a signal from owned scope during the initial insert of the // surrounding ``'s children aborts the pending `insert()` — the // wrapper `
`'s ref callback fires but the rest of the subtree never // attaches to `document.body`. This is the root cause of an issue where // the portal looks like it's mounting (trigger flips // `data-popup-open`, `mounted()` is true, `` is true, FloatingPortal // body runs) but the portal subtree never materializes. Opting into // `ownedWrite` lets the descendant-effect write land without disturbing // the ongoing insert. const [focusManagerState, setFocusManagerState] = createSignal(null, { ownedWrite: true }); // Sentinel refs — mutable slots so the focus-manager descendant can // read them without a reactive dependency. const beforeOutsideRef: MutableRef = { current: null, }; const afterOutsideRef: MutableRef = { current: null, }; const beforeInsideRef: MutableRef = { current: null, }; const afterInsideRef: MutableRef = { current: null, }; const focusInsideDisabledRef: MutableRef = { current: false }; // Resolve mount target reactively — string selectors / thunks re-run on // each access, so if the prop value flips between renders we see it. const resolveMount = (): Element | undefined => { const explicit = props.root ?? props.container; // Walk up to the parent portal node if nothing explicit and a parent // portal is mounted — matches React's `parentPortalNode ?? body`. const resolved = resolveTarget(explicit) ?? parentPortal?.portalNode ?? (typeof document !== "undefined" ? document.body : null); if (!resolved) { return undefined; } // Solid's `` accepts `Element`; when a ShadowRoot is handed // in we coerce via `.host`-less `as Element` cast because happy-dom // treats ShadowRoot as Element-alike. return resolved as Element; }; // If an `id` is supplied, find-or-create the shared root under the // resolved target, ref-counted so the last-unmount tears down the // auto-created node. Handled in a layout effect so the DOM work runs // before children mount into the wrapper. let autoCreatedId: string | null = null; useIsoLayoutEffect( () => undefined, () => { const id = props.id; if (!id || typeof document === "undefined") { return; } const existing = document.getElementById(id); if (existing && !autoRootRegistry.has(id)) { // External node (not one we've auto-created). Leave it alone // on cleanup — first-creator-wins. return; } const registryEntry = autoRootRegistry.get(id); if (registryEntry) { registryEntry.refCount += 1; autoCreatedId = id; } else { const target = resolveMount(); const parent = (target as HTMLElement | undefined) ?? document.body; const div = document.createElement("div"); div.id = id; const nonceValue = nonce(); if (nonceValue) { div.setAttribute("nonce", nonceValue); } parent.appendChild(div); autoRootRegistry.set(id, { element: div, refCount: 1, ownerDocument: document, }); autoCreatedId = id; } return () => { if (!autoCreatedId) { return; } const entry = autoRootRegistry.get(autoCreatedId); if (!entry) { return; } entry.refCount -= 1; if (entry.refCount <= 0) { entry.element.parentNode?.removeChild(entry.element); autoRootRegistry.delete(autoCreatedId); } }; }, ); // Logical parent link for nested-portal dismissal checks. The parent is // resolved through context, so the chain follows the component tree even // though the wrapper DOM lands wherever `resolveMount()` points. const ancestryNode: PortalAncestryNode = { parent: parentPortal?.ancestryNode ?? null, }; // Ref callback for the portal wrapper `
`. Fires once the element // is attached to the DOM subtree inside the Solid ``. const attachWrapper = (element: HTMLDivElement) => { portalAncestry.set(element, ancestryNode); setPortalNode(element); }; // React's focusin/focusout capture — disable focus inside the portal // until it's actually entered via tabbing/mouse. createEffect( () => ({ node: portalNode(), modal: focusManagerState()?.modal }), ({ node, modal }) => { if (!node || modal) { return; } function onFocus(event: FocusEvent) { if (node && event.relatedTarget && isOutsideEvent(event)) { if (event.type === "focusin") { if (focusInsideDisabledRef.current) { enableFocusInside(node); focusInsideDisabledRef.current = false; } } else { disableFocusInside(node); focusInsideDisabledRef.current = true; } } } node.addEventListener("focusin", onFocus, true); node.addEventListener("focusout", onFocus, true); return () => { node.removeEventListener("focusin", onFocus, true); node.removeEventListener("focusout", onFocus, true); }; }, ); // Mirror React's `open === false` path: re-enable focus when the // managed surface closes. createEffect( () => ({ node: portalNode(), open: focusManagerState()?.open }), ({ node, open }) => { if (!node || open !== false) { return; } enableFocusInside(node); focusInsideDisabledRef.current = false; }, ); const shouldRenderGuards = (): boolean => { if (typeof props.renderGuards === "boolean") { return props.renderGuards; } const state = focusManagerState(); return !!state && !state.modal && state.open && portalNode() !== null; }; const contextValue: FloatingPortalContextValue = { // Read through the mutable mirror so downstream consumers that peek // synchronously (e.g. `useFloatingPortalNode` called from a probe) // see the value as soon as the ref callback has fired, without // waiting for Solid 2's signal flush. get portalNode() { return portalNodeRef.current; }, setFocusManagerState: (state) => setFocusManagerState(state), beforeOutsideRef, afterOutsideRef, beforeInsideRef, afterInsideRef, ancestryNode, }; // Apply nonce to injected visually-hidden sentinel spans. Kept as an // inline style literal (Solid spreads JSX.CSSProperties) — matches // React's `ownerVisuallyHidden` import. The focus-guard sentinels // themselves now come from the shared `FocusGuard` module (which // uses `VISUALLY_HIDDEN_STYLE` internally); only the non-guard // `aria-owns` span below uses this inline literal. const hiddenStyle: JSX.CSSProperties = { border: 0, clip: "rect(0 0 0 0)", height: "1px", margin: "-1px", overflow: "hidden", padding: 0, position: "absolute", "white-space": "nowrap", width: "1px", }; return ( {shouldRenderGuards() ? ( { beforeOutsideRef.current = el; }} data-type="outside" nonce={nonce()} onFocus={(event) => { const node = portalNode(); if (!node) { return; } if (isOutsideEvent(event, node)) { beforeInsideRef.current?.focus(); } else { const state = focusManagerState(); const domReference = state ? state.domReference : null; const prevTabbable = getPreviousTabbable(domReference); prevTabbable?.focus(); } }} /> ) : null} {shouldRenderGuards() && portalNode() ? ( ) : null}
{props.children}
{shouldRenderGuards() ? ( { afterOutsideRef.current = el; }} data-type="outside" nonce={nonce()} onFocus={(event) => { const node = portalNode(); if (!node) { return; } if (isOutsideEvent(event, node)) { afterInsideRef.current?.focus(); } else { const state = focusManagerState(); const domReference = state ? state.domReference : null; const nextTabbable = getNextTabbable(domReference); nextTabbable?.focus(); if (state?.closeOnFocusOut) { state.onOpenChange( false, createChangeEventDetails("focus-out", event), ); } } }} /> ) : null}
); } export { FloatingPortalContext };