/** * Stage 4.2 port of Base UI React's `useFocus` hook. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useFocus.ts` * * Opens the floating element while the reference element has focus, and * closes it on blur. Respects `visibleOnly` via `matchesFocusVisible`, * handles window-blur re-entry edge cases, and exposes a `delay` option * for keyboard-triggered opens. * * ### React → Solid deviations * * 1. React `useRef` → plain closure-captured `let` bindings. Solid has * no re-render cycle, so component-scoped mutables are just variables * in the function body. * * 2. Two `useEffect`s in the React source become two `createEffect`s * here. Listener install/remove is wrapped in `onCleanup`. * * 3. React's `addEventListener` + `mergeCleanups` helpers from * `@base-ui/utils` are replaced with direct `addEventListener` + * manual cleanup arrays. Shape-equivalent. * * 4. The `delay` option accepts `number | (() => number | undefined)` — * the function form is invoked at focus-time so the latest value * wins. Same as React. * * 5. `:focus-visible` matching in happy-dom is unreliable; the * shared `matchesFocusVisible` helper short-circuits to `true` * inside the test env. */ import { isElement, isHTMLElement } from "@floating-ui/utils/dom"; import { createEffect, untrack } from "solid-js"; import type { BaseUIChangeEventDetails } from "../../change-event-details"; import { createChangeEventDetails, REASONS } from "../../change-event-details"; import { contains } from "../../dom/contains"; import { ownerDocument } from "../../dom/owner-document"; import { useTimeout } from "../../use-timeout"; import type { FloatingUIOpenChangeDetails } from "../components/floating-root-store"; import { isMac, isSafari } from "../detect-browser"; import type { ElementProps, FloatingContext, FloatingRootContext, } from "../types"; import { createAttribute } from "../utils/create-attribute"; import { activeElement, getTarget, isTargetInsideEnabledTrigger, isTypeableElement, matchesFocusVisible, } from "../utils/element"; // `isMac && isSafari` gate, mirroring the React source. Safari fails to match // `:focus-visible` when focus arrives from outside the document, so the // Mac-Safari branches fall back to keyboard-modality tracking. On every other // browser this is `false` and the happy-path runs. const isMacSafari = isMac && isSafari; export interface UseFocusProps { /** * Whether the Hook is enabled, including all internal Effects and event * handlers. * @default true */ enabled?: boolean | undefined; /** * Waits for the specified time before opening. * @default undefined */ delay?: number | (() => number | undefined) | undefined; } /** * Opens the floating element while the reference element has focus, like CSS * `:focus`. * @see https://floating-ui.com/docs/useFocus */ export function useFocus( context: FloatingRootContext | FloatingContext, props: UseFocusProps = {}, ): ElementProps { const store = "rootStore" in context ? context.rootStore : context; const { events, dataRef } = store.context; // `enabled` is read lazily so consumers can pass a reactive getter // (e.g. `get enabled() { return !disabled(); }`) and have the hook // honor flips. Body-time destructure would freeze the value AND // invoke the getter eagerly under strict-read. const isEnabled = (): boolean => props.enabled === undefined ? true : props.enabled; const delay = untrack(() => props.delay); // Closure state — React `useRef` equivalents. let blockFocus = false; let blockedReference: Element | null = null; let keyboardModality = true; const timeout = useTimeout(); // Window-level listeners: blur / keydown / pointerdown. React installs // these inside a `useEffect`; we do the same via `createEffect`'s // split-effect shape (Solid 2 requires `(compute, apply)`). createEffect( // Compute reads `domReferenceElement` so the effect re-installs // listeners against the right window when the reference moves // between owner-documents (e.g. iframe portals). Apply receives // the element through its argument — no reactive reads in apply. () => ({ domReference: store.state.domReferenceElement, enabled: isEnabled(), }), ({ domReference, enabled }) => { if (!enabled) return undefined; const win: Window = (domReference as Element | null)?.ownerDocument?.defaultView ?? window; function onBlur() { const currentDomReference = store.state.domReferenceElement; if ( !store.state.open && isHTMLElement(currentDomReference) && currentDomReference === activeElement(ownerDocument(currentDomReference)) ) { blockFocus = true; } } function onKeyDown() { keyboardModality = true; } function onPointerDown() { keyboardModality = false; } win.addEventListener("blur", onBlur); if (isMacSafari) { win.addEventListener("keydown", onKeyDown, true); win.addEventListener("pointerdown", onPointerDown, true); } return () => { win.removeEventListener("blur", onBlur); if (isMacSafari) { win.removeEventListener("keydown", onKeyDown, true); win.removeEventListener("pointerdown", onPointerDown, true); } }; }, ); // `openchange` listener — when the popup closes via escape / press, block // the reference from re-opening on the ensuing focus event. createEffect( () => isEnabled(), (enabled) => { if (!enabled) return undefined; function onOpenChangeLocal(details: FloatingUIOpenChangeDetails) { if ( details.reason === REASONS.triggerPress || details.reason === REASONS.escapeKey ) { const referenceElement = store.state.domReferenceElement; if (isElement(referenceElement)) { blockedReference = referenceElement as Element; blockFocus = true; } } } events.on("openchange", onOpenChangeLocal); return () => { events.off("openchange", onOpenChangeLocal); }; }, ); const reference: ElementProps["reference"] = { onMouseLeave() { if (!isEnabled()) return; blockFocus = false; blockedReference = null; }, onFocus(event: FocusEvent) { if (!isEnabled()) return; const focusTarget = event.currentTarget as Element; if (blockFocus) { if (blockedReference === focusTarget) { return; } blockFocus = false; blockedReference = null; } const target = getTarget(event); if (isElement(target)) { // Safari fails to match `:focus-visible` if focus was // initially outside the document. if (isMacSafari && !event.relatedTarget) { if (!keyboardModality && !isTypeableElement(target)) { return; } } else if (!matchesFocusVisible(target as Element)) { return; } } const movedFromOtherEnabledTrigger = isTargetInsideEnabledTrigger( event.relatedTarget as EventTarget | null, store.context.triggerElements, ); const nativeEvent = event; const currentTarget = event.currentTarget as HTMLElement; const delayValue = typeof delay === "function" ? delay() : delay; if ( (store.state.open && movedFromOtherEnabledTrigger) || delayValue === 0 || delayValue === undefined ) { store.setOpen( true, createChangeEventDetails( REASONS.triggerFocus, nativeEvent, currentTarget, ) as BaseUIChangeEventDetails, ); return; } timeout.start(delayValue, () => { if (blockFocus) return; store.setOpen( true, createChangeEventDetails( REASONS.triggerFocus, nativeEvent, currentTarget, ) as BaseUIChangeEventDetails, ); }); }, onBlur(event: FocusEvent) { if (!isEnabled()) return; blockFocus = false; blockedReference = null; const relatedTarget = event.relatedTarget; const nativeEvent = event; // Hit the non-modal focus management portal guard. Focus will // be moved into the floating element immediately after. const movedToFocusGuard = isElement(relatedTarget) && (relatedTarget as Element).hasAttribute( createAttribute("focus-guard"), ) && (relatedTarget as Element).getAttribute("data-type") === "outside"; // Wait for the window blur listener to fire. timeout.start(0, () => { const domReference = store.state.domReferenceElement; const activeEl = activeElement(ownerDocument(domReference)); // Focus left the page, keep it open. if (!relatedTarget && activeEl === domReference) { return; } // When focusing the reference element (e.g. regular click), // then clicking into the floating element, prevent it from // hiding. Note: it must be focusable, e.g. `tabindex="-1"`. if ( contains( dataRef.current.floatingContext?.refs.floating.current ?? null, activeEl, ) || contains(domReference, activeEl) || movedToFocusGuard ) { return; } // If the next focused element is one of the triggers, do // not close the floating element. The focus handler of that // trigger will handle the open state. const nextFocusedElement = relatedTarget ?? activeEl; if ( isTargetInsideEnabledTrigger( nextFocusedElement as EventTarget | null, store.context.triggerElements, ) ) { return; } store.setOpen( false, createChangeEventDetails( REASONS.triggerFocus, nativeEvent, ) as BaseUIChangeEventDetails, ); }); }, }; return { reference, trigger: reference }; }