import type { JSX } from "@solidjs/web"; /** * Stage 5.2 port of Base UI React's `FloatingFocusManager` * (`base-ui/packages/react/src/floating-ui-react/components/FloatingFocusManager.tsx`). * * Despite living at `components/FocusTrap.tsx`-style paths in older * drafts, the React source filename used here is `FloatingFocusManager.tsx` * — confirmed by the `export function FloatingFocusManager(...)` declaration. * * Feature parity (1:1 with React): * - Invisible `FocusGuard` sentinels (before / after the floating subtree) * that catch Tab / Shift+Tab at the trap boundaries and redirect focus * (wrap-around inside a modal; jump to the next / previous tabbable outside * the portal for non-modal). * - `initialFocus`: `boolean | HTMLElement | RefObject | * ((type) => ...)`. `false` skips auto-focus; default picks the first * tabbable element inside the floating container (or the container * itself when empty). * - `returnFocus`: same shape — restores focus to the trigger (or the * `getPreviouslyFocusedElement()` fallback) when the floating element * unmounts / closes. Virtual clicks (and `mouseleave`-driven hover close) * skip return-focus to avoid unwanted viewport scrolls. * - `restoreFocus`: `boolean | 'popup'`. When focus gets lost inside the * subtree (e.g. the previously focused element is removed mid-session), * restores to the last tabbable — or directly to the floating container * with `'popup'`. * - `modal`: modal (true) traps focus using `markOthers` — siblings get * `aria-hidden="true"` plus the `data-quoin-inert` marker. Non-modal * (false) only manages initial / return focus + optional * `closeOnFocusOut`. * - `closeOnFocusOut`: when focus escapes the floating subtree (and the * target isn't a sibling trigger, portal guard, child floating node, or * ancestor combobox), dispatches `setOpen(false, ..., 'focus-out')`. * - `disabled`: full no-op pass-through. * - `nextFocusableElement` / `previousFocusableElement`: overrides for the * outside Tab / Shift+Tab targets (used by combobox / navigation-menu). * - `beforeContentFocusGuardRef`: external ref to the before-guard so * consumers can focus the popup programmatically. * - `externalTree`: escape-hatch `FloatingTreeStore` override (matches * `useDismiss`). * - `getInsideElements`: additional elements to treat as part of the * floating subtree for `aria-hidden` / focus-escape calculations. * - Nested-tree pause: the effect that installs document listeners is * keyed on `open` and on the floating-focus element; child * `FloatingFocusManager`s nest inside the same tree and each install * their own listeners — the parent's `closeOnFocusOut` check excludes * descendants via `getNodeChildren()`, so a focus move into a nested * open floating element does NOT trigger the parent's close-on-focus-out. * This mirrors React 1:1. * * ### React → Solid deviations * * 1. `useRef` → `MutableRef` (plain `{ current }` shape). * 2. `useEffect` → `createEffect` + `onCleanup`. Each original React * effect is mapped to a Solid `createEffect` whose tracked reads * replace the React dep array. * 3. `useLayoutEffect` → `useIsoLayoutEffect` (alias of * `createRenderEffect`), which runs compute / apply synchronously * during the render phase — matching `useLayoutEffect` timing. * 4. `useMergedRefs` has no Solid equivalent — ref callbacks are just * plain functions here. Each guard sets its own internal slot plus * propagates to the consumer's `beforeContentFocusGuardRef` or the * `usePortalContext()` before/after-inside refs. * 5. `useValueAsRef` collapses to plain variables captured by closure * — Solid component bodies don't re-render, so the props reference * captured at setup is the always-current one. For props that might * be reactive (Solid signals on parent components), we read them * through a stable callback. * 6. `useStableCallback` → `createStableCallback`. * 7. Shadow-DOM-safe `contains()` / `getTarget` / `activeElement` from * `src/internals/dom/*`. * 8. React's `FocusGuard` component is re-implemented inline as the * `FocusGuard` helper (no separate file — it's tiny). * 9. The React port imports `usePortalContext` from `./FloatingPortal`. * The Solid port's `FloatingPortal` is being ported in parallel * (Stage 5.1); to keep this file unblocked, we declare a local * `FloatingPortalContext` (a null-default `createContext`) + * `usePortalContext` helper here. When the sibling port lands it * will re-export these (or adopt the same shape) so the import path * stays stable. Follow-up: point `FloatingPortal.tsx` at this * context or vice-versa once both are merged. * 10. The `openInteractionType` prop is accepted; our `InteractionType` * type mirrors Base UI React's `useEnhancedClickHandler`-declared * union. Because `@base-ui/utils` isn't available in the Solid port, * we inline the union here. * * Reason strings dispatched: * - `store.setOpen(false, createChangeEventDetails(REASONS.focusOut, event))` * on focus-out (matches `"focus-out"` — see `REASONS.focusOut` in * `src/internals/change-event-details.ts`). * * Deferrals: real-browser :focus-visible, real focus-scroll prevention * probing inside iframes, portal-sentinel integration validation. */ import { getNodeName, isHTMLElement, isWebKit } from "@floating-ui/utils/dom"; import { createEffect, onCleanup } from "solid-js"; import { createChangeEventDetails, REASONS } from "../../change-event-details"; import { activeElement } from "../../dom/active-element"; import { contains } from "../../dom/contains"; import { getTarget } from "../../dom/get-target"; import { ownerDocument } from "../../dom/owner-document"; import { ownerWindow } from "../../dom/owner-window"; import { FocusGuard } from "../../popups/focus-guard"; import { createStableCallback } from "../../stable-callback"; import { AnimationFrame } from "../../use-animation-frame"; import { useIsoLayoutEffect } from "../../use-iso-layout-effect"; import { Timeout } from "../../use-timeout"; import type { FloatingContext, FloatingRootContext, MutableRef, } from "../types"; import { createAttribute } from "../utils/create-attribute"; import { getFloatingFocusElement, isTypeableCombobox, isTypeableElement, } from "../utils/element"; import { enqueueFocus } from "../utils/enqueue-focus"; import { isVirtualClick, isVirtualPointerEvent, stopEvent, } from "../utils/event"; import { markOthers } from "../utils/mark-others"; import { getNodeAncestors, getNodeChildren } from "../utils/nodes"; import { type FocusableElement, focusable, getNextTabbable, getPreviousTabbable, isOutsideEvent, isTabbable, tabbable, } from "../utils/tabbable"; import { usePortalContext } from "./floating-portal"; import type { FloatingUIOpenChangeDetails } from "./floating-root-store"; import { useFloatingTree } from "./floating-tree"; import type { FloatingTreeStore } from "./floating-tree-store"; // --------------------------------------------------------------------------- // Interaction type — inlined from Base UI React's `useEnhancedClickHandler`. // --------------------------------------------------------------------------- /** * Interaction type used to open / close a floating element. Mirrors * `InteractionType` from `@base-ui/utils/useEnhancedClickHandler`. */ export type InteractionType = "mouse" | "touch" | "pen" | "keyboard" | ""; // --------------------------------------------------------------------------- // Local helpers — 1:1 with the React source. // --------------------------------------------------------------------------- function resolveRef( maybeRef: T | MutableRef | null | undefined, ): T | null { if (maybeRef == null) { return null; } if (typeof maybeRef === "object" && "current" in maybeRef) { return maybeRef.current; } return maybeRef as T; } function getEventType( event: Event, lastInteractionType?: InteractionType, ): InteractionType { const win = ownerWindow(getTarget(event) as Node | null) as Window & typeof globalThis; if (event instanceof win.KeyboardEvent) { return "keyboard"; } if (event instanceof win.FocusEvent) { return lastInteractionType || "keyboard"; } if ("pointerType" in event) { return ( ((event as PointerEvent).pointerType as InteractionType) || "keyboard" ); } if ("touches" in event) { return "touch"; } if (event instanceof win.MouseEvent) { return ( lastInteractionType || ((event as MouseEvent).detail === 0 ? "keyboard" : "mouse") ); } return ""; } const LIST_LIMIT = 20; let previouslyFocusedElements: WeakRef[] = []; function clearDisconnectedPreviouslyFocusedElements(): void { previouslyFocusedElements = previouslyFocusedElements.filter( (entry) => entry.deref()?.isConnected, ); } function addPreviouslyFocusedElement( element: Element | null | undefined, ): void { clearDisconnectedPreviouslyFocusedElements(); if (element && getNodeName(element) !== "body") { previouslyFocusedElements.push(new WeakRef(element)); if (previouslyFocusedElements.length > LIST_LIMIT) { previouslyFocusedElements = previouslyFocusedElements.slice(-LIST_LIMIT); } } } function getPreviouslyFocusedElement(): Element | undefined { clearDisconnectedPreviouslyFocusedElements(); return previouslyFocusedElements[ previouslyFocusedElements.length - 1 ]?.deref(); } function getFirstTabbableElement(container: Element | null): Element | null { if (!container) { return null; } if (isTabbable(container)) { return container; } return tabbable(container)[0] || container; } function isFocusable(element: Element | null): boolean { if (!element?.isConnected) { return false; } if (typeof (element as HTMLElement).checkVisibility === "function") { return (element as HTMLElement).checkVisibility(); } // Fall back to the bounding-client-rect heuristic. `isElementVisible` lives // in `utils/composite.ts` but isn't re-exported by the element bundle — // accessing the raw rect here keeps the dependency graph small. const rect = (element as HTMLElement).getBoundingClientRect?.(); return !!rect && rect.width > 0 && rect.height > 0; } function handleTabIndex( floatingFocusElement: HTMLElement, orderRef: MutableRef>, ): void { if ( floatingFocusElement.hasAttribute("tabindex") && !floatingFocusElement.hasAttribute("data-tabindex") ) { return; } if ( !orderRef.current.includes("floating") && !floatingFocusElement.getAttribute("role")?.includes("dialog") ) { return; } const focusableElements = focusable(floatingFocusElement); const tabbableContent = focusableElements.filter((element) => { const dataTabIndex = element.getAttribute("data-tabindex") || ""; return ( isTabbable(element) || (element.hasAttribute("data-tabindex") && !dataTabIndex.startsWith("-")) ); }); const tabIndex = floatingFocusElement.getAttribute("tabindex"); if (orderRef.current.includes("floating") || tabbableContent.length === 0) { if (tabIndex !== "0") { floatingFocusElement.setAttribute("tabindex", "0"); } } else if ( tabIndex !== "-1" || (floatingFocusElement.hasAttribute("data-tabindex") && floatingFocusElement.getAttribute("data-tabindex") !== "-1") ) { floatingFocusElement.setAttribute("tabindex", "-1"); floatingFocusElement.setAttribute("data-tabindex", "-1"); } } // --------------------------------------------------------------------------- // `FloatingFocusManager` // // Trap-boundary `FocusGuard` sentinels come from the shared module at // `src/internals/popups/focus-guard.tsx` (imported above), which handles // the Safari `role="button"` VoiceOver workaround that the earlier inline // helper deferred. // --------------------------------------------------------------------------- export interface FloatingFocusManagerProps { children: JSX.Element; /** * The floating context returned from `useFloatingRootContext`. */ context: FloatingRootContext | FloatingContext; /** * The interaction type used to open the floating element. */ openInteractionType?: InteractionType | null | undefined; /** * Whether or not the focus manager should be disabled. Useful to delay * focus management until after a transition completes or some other * conditional state. * @default false */ disabled?: boolean | undefined; /** * Determines the element to focus when the floating element is opened. * * - `false`: Do not move focus. * - `true`: Move focus based on the default behavior (first tabbable * element or floating element). * - `HTMLElement` / `RefObject`: Move focus to that element. * - `function`: Called with the interaction type; returns an element, * `true`, `null`, `false`, or `undefined` to control the behavior. * @default true */ initialFocus?: | boolean | HTMLElement | MutableRef | ((openType: InteractionType) => boolean | HTMLElement | null | undefined) | undefined; /** * Determines the element to focus when the floating element is closed. * * Same shape as `initialFocus`. * @default true */ returnFocus?: | boolean | HTMLElement | MutableRef | ((closeType: InteractionType) => boolean | HTMLElement | null | undefined) | undefined; /** * Determines where focus should be restored if focus inside the floating * element is lost. * * - `true`: nearest tabbable inside the floating tree. * - `'popup'`: directly to the floating container. * - `false`: no restoration. * @default false */ restoreFocus?: boolean | "popup" | undefined; /** * Determines if focus is "modal", trapping focus fully inside the * floating element. * @default true */ modal?: boolean | undefined; /** * Whether focusout listeners controlling close-on-focus-out are attached * to the reference + floating elements. Mostly affects non-modal focus * management. * @default true */ closeOnFocusOut?: boolean | undefined; /** Overrides the Tab-forward target outside the floating element. */ nextFocusableElement?: | HTMLElement | MutableRef | null | undefined; /** Overrides the Shift+Tab target outside the floating element. */ previousFocusableElement?: | HTMLElement | MutableRef | null | undefined; /** * Ref to the focus guard preceding the floating content. Useful for * programmatic popup focus. */ beforeContentFocusGuardRef?: MutableRef | undefined; /** * External `FloatingTreeStore` to use when the context-provided one is * not reachable. */ externalTree?: FloatingTreeStore | undefined; /** * Additional elements that should be treated as part of the floating * subtree even if rendered outside the floating element itself. */ getInsideElements?: (() => Array) | undefined; } const CLICK_TRIGGER_IDENTIFIER = "data-quoin-click-trigger"; /** * Provides focus management for the floating element. * @see https://floating-ui.com/docs/FloatingFocusManager * @internal */ export function FloatingFocusManager( props: FloatingFocusManagerProps, ): JSX.Element { // Resolve-once destructuring with defaults (Solid component bodies // run once; reactive prop reads happen through the store). const disabled = () => props.disabled ?? false; const initialFocus = () => props.initialFocus ?? true; const returnFocusProp = () => props.returnFocus ?? true; const restoreFocus = () => props.restoreFocus ?? false; const modal = () => props.modal ?? true; const closeOnFocusOut = () => props.closeOnFocusOut ?? true; const openInteractionType = () => props.openInteractionType ?? ""; const rootStore = () => "rootStore" in props.context ? props.context.rootStore : props.context; const dataRef = rootStore().context.dataRef; const events = rootStore().context.events; const getNodeId = createStableCallback<() => string | undefined>( () => () => dataRef.current.floatingContext?.nodeId, ); const ignoreInitialFocus = () => initialFocus() === false; const isUntrappedTypeableCombobox = () => isTypeableCombobox(rootStore().useState("domReferenceElement")) && ignoreInitialFocus(); const orderRef: MutableRef> = { current: ["content"], }; const tree = useFloatingTree(props.externalTree); const portalContext = usePortalContext(); const preventReturnFocusRef: MutableRef = { current: false }; const isPointerDownRef: MutableRef = { current: false }; const pointerDownOutsideRef: MutableRef = { current: false }; const lastFocusedTabbableRef: MutableRef = { current: null, }; const closeTypeRef: MutableRef = { current: "" }; const lastInteractionTypeRef: MutableRef = { current: "" }; const beforeGuardRef: MutableRef = { current: null }; const afterGuardRef: MutableRef = { current: null }; const blurTimeout = new Timeout(); const pointerDownTimeout = new Timeout(); const restoreFocusFrame = new AnimationFrame(); onCleanup(() => { blurTimeout.clear(); pointerDownTimeout.clear(); restoreFocusFrame.cancel(); }); const isInsidePortal = () => portalContext != null; const getFloatingFocus = () => getFloatingFocusElement(rootStore().useState("floatingElement")); const getTabbableContent = createStableCallback< (container?: Element | null) => FocusableElement[] >( () => (container: Element | null = getFloatingFocus()) => container ? tabbable(container) : [], ); const getResolvedInsideElements = createStableCallback<() => Element[]>( () => () => props .getInsideElements?.() .filter((element): element is Element => element != null) ?? [], ); // Prevent Tab from escaping the modal when there are no tabbable elements. // NOTE: in Solid 2, `onCleanup` registered inside the EFFECT function of the // two-arg `createEffect(compute, effect)` form does not fire on dispose. // All setup + onCleanup therefore lives in the COMPUTE function; the effect // argument is a no-op. createEffect( () => { const floatingFocusElement = getFloatingFocus(); const isDisabled = disabled(); const isModal = modal(); if (isDisabled || !isModal) { return undefined; } function onKeyDown(event: KeyboardEvent) { if (event.key === "Tab") { if ( contains( floatingFocusElement, activeElement(ownerDocument(floatingFocusElement)), ) && getTabbableContent().length === 0 && !isUntrappedTypeableCombobox() ) { stopEvent(event); } } } const doc = ownerDocument(floatingFocusElement); doc.addEventListener("keydown", onKeyDown); onCleanup(() => { doc.removeEventListener("keydown", onKeyDown); }); return undefined; }, () => {}, ); // Track pointer / keyboard interactions to disambiguate focus origins. createEffect( () => { const open = rootStore().useState("open"); const floating = rootStore().useState("floatingElement"); const floatingFocusElement = getFloatingFocus(); const domReference = rootStore().useState("domReferenceElement"); const isDisabled = disabled(); if (isDisabled || !open) { return undefined; } const doc = ownerDocument(floatingFocusElement); function clearPointerDownOutside() { pointerDownOutsideRef.current = false; } function onPointerDown(event: PointerEvent) { const target = getTarget(event) as Element | null; const insideElements = getResolvedInsideElements(); const pointerTargetInside = contains(floating, target) || contains(domReference, target) || contains(portalContext?.portalNode ?? null, target) || insideElements.some( (element) => element === target || contains(element, target), ); pointerDownOutsideRef.current = !pointerTargetInside; lastInteractionTypeRef.current = (event.pointerType as InteractionType) || "keyboard"; if (target?.closest?.(`[${CLICK_TRIGGER_IDENTIFIER}]`)) { isPointerDownRef.current = true; } } function onKeyDown() { lastInteractionTypeRef.current = "keyboard"; } doc.addEventListener("pointerdown", onPointerDown, true); doc.addEventListener("pointerup", clearPointerDownOutside, true); doc.addEventListener("pointercancel", clearPointerDownOutside, true); doc.addEventListener("keydown", onKeyDown, true); onCleanup(() => { doc.removeEventListener("pointerdown", onPointerDown, true); doc.removeEventListener("pointerup", clearPointerDownOutside, true); doc.removeEventListener("pointercancel", clearPointerDownOutside, true); doc.removeEventListener("keydown", onKeyDown, true); }); return undefined; }, () => {}, ); // Close on focus out and restore focus within the floating tree when // needed. createEffect( () => { const floating = rootStore().useState("floatingElement"); const domReference = rootStore().useState("domReferenceElement"); const floatingFocusElement = getFloatingFocus(); const isDisabled = disabled(); const isCloseOnFocusOut = closeOnFocusOut(); if (isDisabled || !isCloseOnFocusOut) { return undefined; } const doc = ownerDocument(floatingFocusElement); function handlePointerDown() { isPointerDownRef.current = true; pointerDownTimeout.start(0, () => { isPointerDownRef.current = false; }); } function handleFocusIn(event: FocusEvent) { const target = getTarget(event) as FocusableElement | null; if (isTabbable(target)) { lastFocusedTabbableRef.current = target; } } function handleFocusOutside(event: FocusEvent) { const relatedTarget = event.relatedTarget as HTMLElement | null; const currentTarget = event.currentTarget; const target = getTarget(event) as HTMLElement | null; queueMicrotask(() => { const nodeId = getNodeId(); const triggers = rootStore().context.triggerElements; const insideElements = getResolvedInsideElements(); const isRelatedFocusGuard = !!relatedTarget?.hasAttribute(createAttribute("focus-guard")) && [ beforeGuardRef.current, afterGuardRef.current, portalContext?.beforeInsideRef.current ?? null, portalContext?.afterInsideRef.current ?? null, portalContext?.beforeOutsideRef.current ?? null, portalContext?.afterOutsideRef.current ?? null, resolveRef(props.previousFocusableElement), resolveRef(props.nextFocusableElement), ].includes(relatedTarget); const movedToUnrelatedNode = !( contains(domReference, relatedTarget) || contains(floating, relatedTarget) || contains(relatedTarget, floating) || contains(portalContext?.portalNode ?? null, relatedTarget) || insideElements.some( (element) => element === relatedTarget || contains(element, relatedTarget), ) || (relatedTarget != null && triggers.hasElement(relatedTarget)) || triggers.hasMatchingElement((trigger) => contains(trigger, relatedTarget), ) || isRelatedFocusGuard || (tree && (getNodeChildren(tree.nodesRef.current, nodeId).find( (node) => contains(node.context?.elements.floating, relatedTarget) || contains(node.context?.elements.domReference, relatedTarget), ) || getNodeAncestors(tree.nodesRef.current, nodeId).find( (node) => [ node.context?.elements.floating, getFloatingFocusElement(node.context?.elements.floating), ].includes(relatedTarget) || node.context?.elements.domReference === relatedTarget, ))) ); if (currentTarget === domReference && floatingFocusElement) { handleTabIndex(floatingFocusElement, orderRef); } // Restore focus to a safe slot inside the floating tree when // focus is lost (e.g. active element removed from the DOM). if ( restoreFocus() && currentTarget !== domReference && !isFocusable(target) && activeElement(doc) === doc.body ) { if (isHTMLElement(floatingFocusElement)) { floatingFocusElement.focus(); if (restoreFocus() === "popup") { restoreFocusFrame.request(() => { floatingFocusElement.focus(); }); return; } } const tabbableContent = getTabbableContent() as Array; const prevTabbable = lastFocusedTabbableRef.current; const nodeToFocus = (prevTabbable && tabbableContent.includes(prevTabbable) ? prevTabbable : null) || tabbableContent[tabbableContent.length - 1] || floatingFocusElement; if (isHTMLElement(nodeToFocus)) { (nodeToFocus as HTMLElement).focus(); } } if (dataRef.current.insideReactTree) { dataRef.current.insideReactTree = false; return; } if ( (isUntrappedTypeableCombobox() ? true : !modal()) && relatedTarget && movedToUnrelatedNode && !isPointerDownRef.current && (isUntrappedTypeableCombobox() || relatedTarget !== getPreviouslyFocusedElement()) ) { preventReturnFocusRef.current = true; rootStore().setOpen( false, createChangeEventDetails(REASONS.focusOut, event), ); } }); } function markInsideReactTree() { if (pointerDownOutsideRef.current) { return; } dataRef.current.insideReactTree = true; blurTimeout.start(0, () => { dataRef.current.insideReactTree = false; }); } const domReferenceElement = isHTMLElement(domReference) ? domReference : null; if (!floating && !domReferenceElement) { return; } const cleanups: Array<() => void> = []; if (domReferenceElement) { domReferenceElement.addEventListener( "focusout", handleFocusOutside as EventListener, ); domReferenceElement.addEventListener("pointerdown", handlePointerDown); cleanups.push(() => { domReferenceElement.removeEventListener( "focusout", handleFocusOutside as EventListener, ); domReferenceElement.removeEventListener( "pointerdown", handlePointerDown, ); }); } if (floating) { floating.addEventListener("focusin", handleFocusIn as EventListener); floating.addEventListener( "focusout", handleFocusOutside as EventListener, ); cleanups.push(() => { floating.removeEventListener( "focusin", handleFocusIn as EventListener, ); floating.removeEventListener( "focusout", handleFocusOutside as EventListener, ); }); if (portalContext) { floating.addEventListener( "focusout", markInsideReactTree as EventListener, true, ); cleanups.push(() => { floating.removeEventListener( "focusout", markInsideReactTree as EventListener, true, ); }); } } onCleanup(() => { for (const cleanup of cleanups) { cleanup(); } }); return undefined; }, () => {}, ); // Hide everything outside the floating tree from assistive tech while // open (modal) or keep the combobox reference exposed (untrapped combobox). createEffect( () => { const open = rootStore().useState("open"); const floating = rootStore().useState("floatingElement"); const domReference = rootStore().useState("domReferenceElement"); const isDisabled = disabled(); const isModal = modal(); if (isDisabled || !floating || !open) { return undefined; } const portalNodes = Array.from( portalContext?.portalNode?.querySelectorAll( `[${createAttribute("portal")}]`, ) || [], ); const ancestors = tree ? getNodeAncestors(tree.nodesRef.current, getNodeId()) : []; const rootAncestorComboboxDomReference = ancestors.find((node) => isTypeableCombobox(node.context?.elements.domReference || null), )?.context?.elements.domReference; const controlInsideElements = [ floating, ...portalNodes, beforeGuardRef.current, afterGuardRef.current, portalContext?.beforeOutsideRef.current ?? null, portalContext?.afterOutsideRef.current ?? null, ...getResolvedInsideElements(), ]; const insideElements = [ ...controlInsideElements, rootAncestorComboboxDomReference, resolveRef(props.previousFocusableElement), resolveRef(props.nextFocusableElement), isUntrappedTypeableCombobox() ? domReference : null, ].filter((x): x is Element => x != null); const ariaHiddenCleanup = markOthers(insideElements, { ariaHidden: isModal || isUntrappedTypeableCombobox(), mark: false, }); const markerInsideElements = [floating, ...portalNodes].filter( (x): x is Element => x != null, ); const markerCleanup = markOthers(markerInsideElements); onCleanup(() => { markerCleanup(); ariaHiddenCleanup(); }); return undefined; }, () => {}, ); // Focus the initial element when the floating element opens. useIsoLayoutEffect( () => { const open = rootStore().useState("open"); const floatingFocusElement = getFloatingFocus(); if (!open || disabled() || !isHTMLElement(floatingFocusElement)) { return undefined; } const doc = ownerDocument(floatingFocusElement); const prevFocus = activeElement(doc); queueMicrotask(() => { const initialFocusValueOrFn = initialFocus(); const resolvedInitialFocus = typeof initialFocusValueOrFn === "function" ? initialFocusValueOrFn(openInteractionType() || "") : initialFocusValueOrFn; if ( resolvedInitialFocus === undefined || resolvedInitialFocus === false ) { return; } const focusAlreadyInsideFloatingEl = contains( floatingFocusElement, prevFocus, ); if (focusAlreadyInsideFloatingEl) { return; } let focusableElements: Array | null = null; const getDefaultFocusElement = () => { if (focusableElements == null) { focusableElements = getTabbableContent(floatingFocusElement); } return focusableElements[0] || floatingFocusElement; }; let elToFocus: FocusableElement | null | undefined; if (resolvedInitialFocus === true || resolvedInitialFocus === null) { elToFocus = getDefaultFocusElement(); } else { elToFocus = resolveRef( resolvedInitialFocus as | HTMLElement | MutableRef, ); } elToFocus = elToFocus || getDefaultFocusElement(); // `preventScroll: true` unconditionally (matches @floating-ui/react). // The floating element is placed by the positioner, not by the // browser's scroll-into-view — so focusing initial content (e.g. an // inside a popover) must NOT scroll the page. Gating this on // `elToFocus === floatingFocusElement` (the old port) let inner-input // focus jump the page to the top while the popup was still at (0,0) // pre-position. enqueueFocus(elToFocus, { preventScroll: true, }); }); return undefined; }, () => {}, ); // Track return-focus targets and restore focus on unmount / close. useIsoLayoutEffect( () => { const floatingFocusElement = getFloatingFocus(); const floating = rootStore().useState("floatingElement"); const domReference = rootStore().useState("domReferenceElement"); if (disabled() || !floatingFocusElement) { return undefined; } const doc = ownerDocument(floatingFocusElement); const prevFocus = activeElement(doc); addPreviouslyFocusedElement(prevFocus); function onOpenChangeLocal(details: FloatingUIOpenChangeDetails) { if (!details.open) { closeTypeRef.current = getEventType( details.nativeEvent, lastInteractionTypeRef.current, ); } if ( details.reason === REASONS.triggerHover && details.nativeEvent.type === "mouseleave" ) { preventReturnFocusRef.current = true; } if (details.reason !== REASONS.outsidePress) { return; } if (details.nested) { preventReturnFocusRef.current = false; } else if ( isVirtualClick(details.nativeEvent as MouseEvent) || isVirtualPointerEvent(details.nativeEvent as PointerEvent) ) { preventReturnFocusRef.current = false; } else { let isPreventScrollSupported = false; ownerDocument(floatingFocusElement) .createElement("div") .focus({ get preventScroll() { isPreventScrollSupported = true; return false; }, }); preventReturnFocusRef.current = !isPreventScrollSupported; } } events.on("openchange", onOpenChangeLocal); function getReturnElement(): Element | null { const returnFocusValueOrFn = returnFocusProp(); let resolvedReturnFocusValue = typeof returnFocusValueOrFn === "function" ? returnFocusValueOrFn(closeTypeRef.current) : returnFocusValueOrFn; if ( resolvedReturnFocusValue === undefined || resolvedReturnFocusValue === false ) { return null; } if (resolvedReturnFocusValue === null) { resolvedReturnFocusValue = true; } if (typeof resolvedReturnFocusValue === "boolean") { const el = domReference || getPreviouslyFocusedElement(); return el && (el as HTMLElement).isConnected ? (el as HTMLElement) : null; } const fallback = domReference || getPreviouslyFocusedElement(); return ( resolveRef( resolvedReturnFocusValue as | HTMLElement | MutableRef, ) || (fallback as HTMLElement | null) || null ); } onCleanup(() => { events.off("openchange", onOpenChangeLocal); const activeEl = activeElement(doc); const insideElements = getResolvedInsideElements(); const isFocusInsideFloatingTree = contains(floating, activeEl) || insideElements.some( (element) => element === activeEl || contains(element, activeEl), ) || (!!tree && getNodeChildren(tree.nodesRef.current, getNodeId(), false).some( (node) => contains(node.context?.elements.floating, activeEl), )); const returnFocusValueOrFn = returnFocusProp(); const returnElement = getReturnElement(); queueMicrotask(() => { const tabbableReturnElement = getFirstTabbableElement(returnElement); const hasExplicitReturnFocus = typeof returnFocusValueOrFn !== "boolean"; if ( returnFocusValueOrFn && !preventReturnFocusRef.current && isHTMLElement(tabbableReturnElement) && (!hasExplicitReturnFocus && tabbableReturnElement !== activeEl && activeEl !== doc.body ? isFocusInsideFloatingTree : true) ) { (tabbableReturnElement as HTMLElement).focus({ preventScroll: true, }); } preventReturnFocusRef.current = false; }); }); return undefined; }, () => {}, ); // Safari scroll-to-bottom workaround: blur inputs inside the popup // before it unmounts. useIsoLayoutEffect( () => ({ open: rootStore().useState("open"), floating: rootStore().useState("floatingElement"), }), ({ open, floating }) => { if (!isWebKit() || open || !floating) { return; } const activeEl = activeElement(ownerDocument(floating)); if (!isHTMLElement(activeEl) || !isTypeableElement(activeEl)) { return; } if (contains(floating, activeEl)) { (activeEl as HTMLElement).blur(); } }, ); // Synchronize the `context` / `modal` value to the portal context so // the portal can choose whether to render its own outer guards. useIsoLayoutEffect( // Compute snapshots all reactive inputs so apply runs without // re-reading anything; apply returns its cleanup instead of // calling `onCleanup` (apply scope is unowned). () => ({ open: rootStore().useState("open"), domReference: rootStore().useState("domReferenceElement"), isDisabled: disabled(), isModal: modal(), isCloseOnFocusOut: closeOnFocusOut(), }), ({ open, domReference, isDisabled, isModal, isCloseOnFocusOut }) => { if (isDisabled || !portalContext) { return undefined; } portalContext.setFocusManagerState({ modal: isModal, closeOnFocusOut: isCloseOnFocusOut, open, onOpenChange: rootStore().setOpen, domReference, }); return () => { portalContext.setFocusManagerState(null); }; }, ); // Keep the floating element's tabIndex in sync. useIsoLayoutEffect( () => ({ floatingFocus: getFloatingFocus(), isDisabled: disabled() }), ({ floatingFocus, isDisabled }) => { if (isDisabled || !floatingFocus) { return undefined; } handleTabIndex(floatingFocus, orderRef); return () => { queueMicrotask(clearDisconnectedPreviouslyFocusedElements); }; }, ); // Guard ref callbacks — propagate internal slot + optional external ref // + portal-context sentinel refs. function setBeforeGuardRef(el: HTMLSpanElement | null): void { beforeGuardRef.current = el; if (props.beforeContentFocusGuardRef) { props.beforeContentFocusGuardRef.current = el; } if (portalContext?.beforeInsideRef) { portalContext.beforeInsideRef.current = el; } } function setAfterGuardRef(el: HTMLSpanElement | null): void { afterGuardRef.current = el; if (portalContext?.afterInsideRef) { portalContext.afterInsideRef.current = el; } } const shouldRenderGuards = () => !disabled() && (modal() ? !isUntrappedTypeableCombobox() : true) && (isInsidePortal() || modal()); function onBeforeGuardFocus(event: FocusEvent): void { if (modal()) { const els = getTabbableContent(); // Synchronous focus: under real-browser CDP Tab dispatch the browser // moves focus to the guard and the test / next key event // reads `document.activeElement` inside the same tick. React's port // uses rAF here, but its tests run under `userEvent` which awaits a // frame automatically; native input has no such wait, so the // wrap-around must complete before the handler returns. enqueueFocus(els[els.length - 1] ?? null, { sync: true }); } else if (portalContext?.portalNode) { preventReturnFocusRef.current = false; if (isOutsideEvent(event, portalContext.portalNode)) { const nextTabbable = getNextTabbable( rootStore().useState("domReferenceElement"), ); nextTabbable?.focus(); } else { resolveRef( (props.previousFocusableElement ?? portalContext.beforeOutsideRef) as | HTMLElement | MutableRef, )?.focus(); } } } function onAfterGuardFocus(event: FocusEvent): void { if (modal()) { enqueueFocus(getTabbableContent()[0] ?? null, { sync: true }); } else if (portalContext?.portalNode) { if (closeOnFocusOut()) { preventReturnFocusRef.current = true; } if (isOutsideEvent(event, portalContext.portalNode)) { const prevTabbable = getPreviousTabbable( rootStore().useState("domReferenceElement"), ); prevTabbable?.focus(); } else { resolveRef( (props.nextFocusableElement ?? portalContext.afterOutsideRef) as | HTMLElement | MutableRef, )?.focus(); } } } return ( <> {shouldRenderGuards() && ( )} {props.children} {shouldRenderGuards() && ( )} ); }