import type { JSX } from "@solidjs/web"; import { createEffect, createMemo, createSignal, onCleanup, untrack, } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { createControlledState } from "../../internals/create-controlled-state"; import type { FloatingRootStore } from "../../internals/floating-ui/components/floating-root-store"; import { FloatingTree, useFloatingParentNodeId, } from "../../internals/floating-ui/components/floating-tree"; import { useDismiss } from "../../internals/floating-ui/hooks/use-dismiss"; import { useFloatingRootContext } from "../../internals/floating-ui/hooks/use-floating-root-context"; import { type UseInteractionsReturn, useInteractions, } from "../../internals/floating-ui/hooks/use-interactions"; import { useRole } from "../../internals/floating-ui/hooks/use-role"; import type { MutableRef } from "../../internals/floating-ui/types"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { useOpenChangeComplete } from "../../internals/popups/use-open-change-complete"; import { useTransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { createPopover } from "../create-popover"; import { type PopoverModalKind, type PopoverRootChangeEventDetails, type PopoverRootChangeEventReason, PopoverRootContext, type PopoverRootContextValue, usePopoverRootContext, } from "../popover-root-context"; import { type PopoverHandle, PopoverStore } from "../store/popover-store"; export type { PopoverModalKind, PopoverRootChangeEventDetails, PopoverRootChangeEventReason, }; /** * Imperative actions surface written into `actionsRef` on ``. * Mirrors React's `PopoverRoot.Actions`. */ export interface PopoverRootActions { /** Close the popover imperatively. */ close: () => void; /** * Force-unmount the popover subtree immediately. Useful for JS-animated * closes where the consumer wants to guarantee DOM removal after their * animation finishes. Solid port note: currently wired as a synchronous * close; no separate "prevent unmount on close" state is tracked yet, * so `unmount()` is equivalent to `close()` until the full transition- * aware path lands. */ unmount: () => void; } /** * Render-function variant for popover children — mirrors React's * `PayloadChildRenderFunction`. Called with the active trigger's * `payload`. */ export type PopoverPayloadChildRenderFunction = (ctx: { payload: Payload | undefined; }) => JSX.Element; export interface PopoverRootProps { /** * Controlled open state. While its resolved value is not `undefined` * the consumer owns the state — pair with `onOpenChange`. When * omitted, the popover manages its own open state (seeded from * {@link defaultOpen}), matching React Base UI. */ open?: MaybeAccessor; /** * Initial open state when uncontrolled. Read once at mount. * @default false */ defaultOpen?: MaybeAccessor; /** * Fires on every open/close request (trigger press, outside press, * Escape, Close button, etc.). Calling `eventDetails.cancel()` keeps * the popover's own (uncontrolled) state unchanged, mirroring React * Base UI; controlled consumers simply don't update their signal. */ onOpenChange?: | ((open: boolean, eventDetails: PopoverRootChangeEventDetails) => void) | undefined; /** * Fires once the open/close transition has settled. Currently wired * synchronously on open/close — see the root's implementation notes for * the transition-aware follow-up. */ onOpenChangeComplete?: ((open: boolean) => void) | undefined; /** * Modal surface kind. * * - `false` (default): no focus trap, no scroll lock, no backdrop. * - `true`: focus is trapped in the popup AND outside pointer interactions * are blocked via the internal backdrop. * - `'trap-focus'`: focus is trapped but outside pointer interaction * remains enabled. * * @default false */ modal?: MaybeAccessor; /** * Callback ref for imperative actions. Call with the resulting * `{ close, unmount }` on mount to retain a handle that later closes * or unmounts the popover programmatically. Solid deviation: * React exposes this as `RefObject`; the Solid * port uses the idiomatic callback-ref shape to avoid the * `ref.current` allocation-at-component-boundary footgun. */ actionsRef?: | ((actions: PopoverRootActions | null) => void) | { current: PopoverRootActions | null } | undefined; /** * Handle to connect the root with detached `` * components. Allows external triggers to drive the popover's open * state and to associate multiple triggers with one popover. */ handle?: PopoverHandle | undefined; /** * ID of the active trigger. Pair with `open` for controlled * multi-trigger scenarios. */ triggerId?: string | null | undefined; /** * The content of the popover. May be a plain child or a render * function `({ payload }) => JSX.Element` — the render-function form * receives the active trigger's `payload`. */ children?: JSX.Element | PopoverPayloadChildRenderFunction; } /** * Groups all parts of the popover. Doesn't render its own HTML element — * the root is a pure context provider. */ function PopoverRootComponent( props: PopoverRootProps, ): JSX.Element { // Adopt the handle's store if supplied; otherwise own one. const store = (props.handle?.store ?? new PopoverStore()) as | PopoverStore | PopoverStore; // Handle-owned internal signal. When `handle` is provided and the // consumer does not supply `open` / `onOpenChange`, the handle's // imperative calls write here so the popover actually opens/closes. // Controlled / uncontrolled open state (Base UI `open` / `defaultOpen` // contract): the `open` prop wins while its resolved value is defined; // otherwise the internal signal (seeded once from `defaultOpen`, also // written by handles) backs the state. const [resolvedOpen, setUncontrolledOpen] = createControlledState({ controlled: () => { const controlled = readOption(props.open); return controlled === undefined ? undefined : Boolean(controlled); }, default: () => readOption(props.defaultOpen), fallback: false, name: "", state: "open", }); // Compose the headless Popover primitive — owns open (pass-through), // disabled, modal. The wrapper layers floating-UI, focus trap, portal, // transitions, dismiss, trigger registry, handle, and actionsRef on top. // onOpenChange is NOT wired here — the wrapper's own setOpen (below) is // the sole writer so it can thread reason/event details through the full // floating-UI pipeline. const popoverPrimitive = createPopover({ open: resolvedOpen, modal: () => readOption(props.modal) ?? false, }); const open = popoverPrimitive.open; const modal = popoverPrimitive.modal; // Mount / transition machinery. const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => false, ); // Defer unmount until any CSS exit animation on the popup has finished. // Mirrors the tabs-panel `useOpenChangeComplete` pattern. // Previously the popover never called `setMounted(false)` at // all — `mounted` flipped to `true` on first open and stayed there, so // the popup subtree effectively lived forever. Threading // `useOpenChangeComplete` here introduces the missing call: when `open` // flips false the hook waits for `Animation.finished` on the popup ref // (populated by `` via `popupRef.current`), then drops // `mounted`. The hook is a no-op when `getAnimations()` returns an empty // list, so the static case closes on the next microtask. const popupRef: MutableRef = { current: null }; useOpenChangeComplete({ open, ref: popupRef, onComplete: () => { if (!open()) { setMounted(false); } }, }); // Fire onOpenChangeComplete after the transition settles (or immediately // when a transition isn't present in happy-dom). Mirrors React's // `useOpenChangeComplete`. createEffect( () => ({ open: open(), status: transitionStatus() }), ({ open: o, status }) => { if (status === undefined) { props.onOpenChangeComplete?.(o); } }, ); // Title / description ids. const [titleId, setTitleId] = createSignal(undefined, { ownedWrite: true, }); const [descriptionId, setDescriptionId] = createSignal( undefined, { ownedWrite: true }, ); // Active trigger id / element. Controlled via `triggerId`. const [activeTriggerIdInternal, setActiveTriggerIdInternal] = createSignal< string | null >(null, { ownedWrite: true }); const activeTriggerId = createMemo(() => { // `triggerId === undefined` → uncontrolled; fall back to internal. const controlled = props.triggerId; if (controlled !== undefined) { return controlled; } return activeTriggerIdInternal(); }); const setActiveTriggerId = (id: string | null): void => { setActiveTriggerIdInternal(id); }; // Active trigger element — matches React's `activeTriggerElement` slot. // When a `triggerId` prop is controlled, we look it up through the // store's trigger registry each time `activeTriggerId` changes. const [triggerElement, setTriggerElementInternal] = createSignal(null, { ownedWrite: true }); const setTriggerElement = (el: HTMLElement | null): void => { setTriggerElementInternal(el); }; // Sync element lookup when the controlled `triggerId` changes. createEffect( () => activeTriggerId(), (id) => { if (id === null) return; const el = store.context.triggerElements.getById(id) as | HTMLElement | null | undefined; if (el) setTriggerElementInternal(el); }, ); // Payload — written by the active trigger. const [payload, setPayload] = createSignal(undefined, { ownedWrite: true, }); // `setOpen` mirrors Base UI's `PopoverStore.setOpen`: fire // `props.onOpenChange`, honor `details.cancel()`, then write the // uncontrolled state (ignored for reads while the `open` prop is // controlled — the consumer's signal write is the source of truth). const setOpen = ( next: boolean, details: PopoverRootChangeEventDetails, ): void => { props.onOpenChange?.(next, details); if (details.isCanceled) return; setUncontrolledOpen(next); // Mirror to store trigger tracking. if (details.trigger?.id) { setActiveTriggerIdInternal(details.trigger.id); setTriggerElementInternal(details.trigger as HTMLElement); } }; // Floating-UI integration. const floatingRootContext: FloatingRootStore = useFloatingRootContext({ open: untrack(open), onOpenChange: (nextOpen, details) => { setOpen(nextOpen, details as PopoverRootChangeEventDetails); }, }); createEffect( () => ({ openValue: open(), trigger: triggerElement(), // Capture transitionStatus in compute (snapshot pattern) so the // apply scope doesn't trip `[STRICT_READ_UNTRACKED]`. status: transitionStatus(), }), ({ openValue, trigger, status }) => untrack(() => { floatingRootContext.update({ open: openValue, referenceElement: trigger, domReferenceElement: trigger, transitionStatus: status, }); }), ); // `floatingRootContext.update` does internal reactive reads (writes to // store fields read prev values for equality). Untrack the apply body // so those reads don't trip `[STRICT_READ_UNTRACKED]`. createEffect( () => transitionStatus(), (status) => untrack(() => { floatingRootContext.update({ transitionStatus: status }); }), ); // ------------------------------------------------------------------ // Interaction hooks // ------------------------------------------------------------------ const dismiss = useDismiss(floatingRootContext, { outsidePressEvent: { get mouse() { return modal() === "trap-focus" ? "sloppy" : "intentional"; }, touch: "sloppy", }, }); const role = useRole(floatingRootContext, { role: "dialog" }); const interactions: UseInteractionsReturn = useInteractions([dismiss, role]); // Wire the store's imperative setOpen. store.attachSetOpen((next, details) => { setOpen(next, details); }); // Keep the store's open mirror in sync for synchronous `handle.isOpen` // reads. createEffect( () => open(), (o) => { store.setOpenMirror(o); }, ); onCleanup(() => { store.attachSetOpen(null); }); // Implicit active-trigger selection: when popover opens and no explicit // trigger id has been set, pick the sole registered trigger. The reads // of `activeTriggerId()` and the trigger registry are snapshots taken // during the open transition; `untrack` keeps them quiet under the // effect-apply scope. createEffect( () => open(), (o) => untrack(() => { if (!o) return; if (activeTriggerId() !== null) return; if (store.context.triggerElements.size !== 1) return; const entry = store.context.triggerElements.entries().next(); if (!entry.done) { const [id, el] = entry.value; setActiveTriggerIdInternal(id); setTriggerElementInternal(el as HTMLElement); } }), ); // actionsRef wiring. const makeImperativeDetails = (): PopoverRootChangeEventDetails => createChangeEventDetails( REASONS.imperativeAction as PopoverRootChangeEventReason, ) as PopoverRootChangeEventDetails; const actions: PopoverRootActions = { close: () => setOpen(false, makeImperativeDetails()), unmount: () => setOpen(false, makeImperativeDetails()), }; const ref = props.actionsRef; if (typeof ref === "function") { ref(actions); onCleanup(() => ref(null)); } else if (ref && typeof ref === "object") { ref.current = actions; onCleanup(() => { ref.current = null; }); } const contextValue: PopoverRootContextValue = { open, mounted, transitionStatus, modal, titleId, setTitleId, descriptionId, setDescriptionId, floatingRootContext, interactions, setOpen, popupRef, triggerElement, setTriggerElement, store: store as PopoverStore, activeTriggerId, setActiveTriggerId, payload, setPayload, }; // Resolve children — render function form receives `{ payload }`. const resolveChildren = (): JSX.Element => { const ch = props.children; if (typeof ch === "function") { return (ch as PopoverPayloadChildRenderFunction)({ payload: payload() as Payload | undefined, }); } return ch as JSX.Element; }; return ( {resolveChildren()} ); } /** * Groups all parts of the popover. */ export function PopoverRoot( props: PopoverRootProps, ): JSX.Element { if ( usePopoverRootContext(true) != null || useFloatingParentNodeId() != null ) { return ; } return ( ); }