import type { JSX } from "@solidjs/web"; import { type Accessor, createEffect, createMemo, createSignal, flush, onCleanup, untrack, } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { createControlledState } from "../../internals/create-controlled-state"; 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 { useInteractions } from "../../internals/floating-ui/hooks/use-interactions"; 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 { useIsoLayoutEffect } from "../../internals/use-iso-layout-effect"; import { useTransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { CLOSE_DELAY } from "../constants"; import { createPreviewCard } from "../create-preview-card"; import { type PreviewCardInstantType, type PreviewCardRootChangeEventDetails, type PreviewCardRootChangeEventReason, PreviewCardRootContext, type PreviewCardRootContextValue, type PreviewCardRootState, usePreviewCardRootContext, } from "../preview-card-root-context"; import { type PreviewCardHandle, PreviewCardStore, } from "../store/preview-card-store"; export type { PreviewCardRootChangeEventDetails, PreviewCardRootChangeEventReason, PreviewCardRootState, }; /** * Imperative actions surface written into `actionsRef` on ``. * Mirrors React's `PreviewCardRoot.Actions`. */ export interface PreviewCardRootActions { close: () => void; unmount: () => void; } /** * Render-function child variant — mirrors React's `PayloadChildRenderFunction`. * Called with the active trigger's `payload`. */ export type PreviewCardPayloadChildRenderFunction = (ctx: { payload: Payload | undefined; }) => JSX.Element; export interface PreviewCardRootProps { /** * Controlled open state. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onOpenChange}. When omitted, the preview card 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 (hover, focus, Escape, outside * press, etc.). Calling `eventDetails.cancel()` keeps the preview * card's own (uncontrolled) state unchanged, mirroring React Base UI; * controlled consumers simply don't update their signal. */ onOpenChange?: | ((open: boolean, eventDetails: PreviewCardRootChangeEventDetails) => void) | undefined; onOpenChangeComplete?: ((open: boolean) => void) | undefined; /** * Callback-ref (or `{ current }`) variant of React's `actionsRef`. Solid * deviation: React exposes this as `RefObject`; the Solid port * accepts either form. */ actionsRef?: | ((actions: PreviewCardRootActions | null) => void) | { current: PreviewCardRootActions | null } | undefined; /** Handle for detached triggers / imperative control. */ handle?: PreviewCardHandle | undefined; /** Active trigger id (controlled). */ triggerId?: string | null | undefined; children?: JSX.Element | PreviewCardPayloadChildRenderFunction; } /** * Inner implementation — split so we can wrap the tree in `` * only when we're the top-most root (matches React's * `PreviewCardRoot` → `PreviewCardRootComponent` shape). */ function PreviewCardRootComponent( props: PreviewCardRootProps, ): JSX.Element { // Adopt the handle's store if supplied; otherwise own one. const store = (props.handle?.store ?? new PreviewCardStore()) as | PreviewCardStore | PreviewCardStore; // Controlled / uncontrolled open state (Base UI `open` / `defaultOpen` // contract): the `open` prop wins while its resolved value is defined; // otherwise an internal signal (seeded once from `defaultOpen`) backs // the state and the root's `setOpen` (below) writes it. 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 PreviewCard primitive — owns open (pass-through) and // disabled. The wrapper layers floating-UI, transitions, dismiss, portal, // and per-trigger hover delays on top. // // onOpenChange is NOT wired here — the wrapper's own setOpen (below) is // the sole writer so it can thread the full change-event details through // the floating-UI pipeline. The primitive's setOpen is unused in the // wrapper path. const previewCardPrimitive = createPreviewCard({ open: resolvedOpen, }); const open = previewCardPrimitive.open; const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => false, ); // Popup element tracking — the ref mirror is consumed by // `useOpenChangeComplete` so a pending CSS exit animation can hold the // popup in the DOM until it settles. const popupRef: MutableRef = { current: null }; const [popupElement, setPopupElementInternal] = createSignal(null, { ownedWrite: true }); const setPopupElement = (el: HTMLElement | null): void => { popupRef.current = el; setPopupElementInternal(el); }; useOpenChangeComplete({ open, ref: popupRef, onComplete: () => { if (!open()) { setMounted(false); } }, }); // Fire onOpenChangeComplete after the transition settles (or immediately // in happy-dom where `Animation.finished` resolves on the next microtask). createEffect( () => ({ open: open(), status: transitionStatus() }), ({ open: o, status }) => { if (status === undefined) { props.onOpenChangeComplete?.(o); } }, ); // instantType — emitted as `data-instant` on popup/arrow when focus or // dismiss drives the open/close transition. const [instantType, setInstantType] = createSignal( undefined, { ownedWrite: true }, ); // Active trigger id / element. `triggerId` is a controlled override. const [activeTriggerIdInternal, setActiveTriggerIdInternal] = createSignal< string | null >(null, { ownedWrite: true }); const activeTriggerId = createMemo(() => { const controlled = props.triggerId; if (controlled !== undefined) return controlled; return activeTriggerIdInternal(); }); const setActiveTriggerId = (id: string | null): void => { setActiveTriggerIdInternal(id); }; 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); }, ); const [payload, setPayload] = createSignal(undefined, { ownedWrite: true, }); // Clear payload when the card is open but no active trigger is set (React // source mirrors this: if `activeTriggerId == null && open`, payload is // reset so the render-function child doesn't leak stale data). createEffect( () => ({ open: open(), id: activeTriggerId() }), ({ open: o, id }) => { if (o && id == null) { setPayload(undefined); } }, ); // Written by the active trigger; reserved for floating-hover close timing. const closeDelayRef: MutableRef = { current: CLOSE_DELAY }; // Floating-UI shared store. const floatingRootContext = useFloatingRootContext({ open: untrack(() => open()), onOpenChange: (nextOpen, details) => { setOpen(nextOpen, details as PreviewCardRootChangeEventDetails); }, }); // `floatingRootContext.sync` / `.update` may read internal store state // for prev-value equality. Wrap each apply in `untrack` to keep // `[STRICT_READ_UNTRACKED]` quiet. createEffect( () => open(), (next) => untrack(() => { floatingRootContext.sync("open", next); }), ); // Sync reference / floating nodes into the floating store on the same // turn as the ref callback (layout effect) so `useHover` can arm // listeners before `mouseenter` is lost to microtask batching. useIsoLayoutEffect( () => triggerElement(), (el) => untrack(() => { floatingRootContext.sync("domReferenceElement", el); floatingRootContext.sync("referenceElement", el); }), ); useIsoLayoutEffect( () => popupElement(), (el) => untrack(() => { floatingRootContext.sync("floatingElement", el); }), ); createEffect( () => transitionStatus(), (status) => untrack(() => { floatingRootContext.update({ transitionStatus: status }); }), ); // setOpen — threads instantType + active-trigger bookkeeping off the // change-event reason, the same way the React store does. const setOpen = ( next: boolean, details: PreviewCardRootChangeEventDetails, ): void => { const reason = details.reason; const isFocusOpen = next && reason === REASONS.triggerFocus; const isDismissClose = !next && (reason === REASONS.triggerPress || reason === REASONS.escapeKey || reason === REASONS.outsidePress); if (isFocusOpen) { setInstantType("focus"); } else if (isDismissClose) { setInstantType("dismiss"); } else if (reason === REASONS.triggerHover) { setInstantType(undefined); } // Base UI change flow (`PreviewCardStore.setOpen`): fire // `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). props.onOpenChange?.(next, details); if (details.isCanceled) return; setUncontrolledOpen(next); // Keep the floating store's `open` slot in sync immediately so hover // handlers reading `store.state.open` and the positioner see the new // value in the same turn (Solid 2 batches signal writes by default). untrack(() => { floatingRootContext.sync("open", next); }); flush(); if (details.trigger && (details.trigger as HTMLElement).id) { const id = (details.trigger as HTMLElement).id; setActiveTriggerIdInternal(id); setTriggerElementInternal(details.trigger as HTMLElement); } }; // Wire the store imperative bridge. store.attachSetOpen((next, details) => { setOpen(next, details); }); createEffect( () => open(), (o) => store.setOpenMirror(o), ); onCleanup(() => store.attachSetOpen(null)); // Implicit active-trigger selection: when the card opens and no explicit // trigger id has been set, pick the sole registered trigger. 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); } }), ); // Interaction hooks — dismiss only at the root level. Hover (reference + // floating) lives on the trigger and popup, respectively, since // preview-card tracks per-trigger open/close delays. const dismiss = useDismiss(floatingRootContext); const interactions = useInteractions([dismiss]); const state: Accessor = createMemo(() => ({ open: open(), })); // actionsRef wiring. const makeImperativeDetails = (): PreviewCardRootChangeEventDetails => createChangeEventDetails( REASONS.imperativeAction as PreviewCardRootChangeEventReason, ) as PreviewCardRootChangeEventDetails; const actions: PreviewCardRootActions = { 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: PreviewCardRootContextValue = { open, mounted, transitionStatus, instantType, setInstantType, state, setOpen, floatingRootContext, interactions, setTriggerElement, triggerElement, setPopupElement, popupElement, popupRef, store: store as PreviewCardStore, activeTriggerId, setActiveTriggerId, payload, setPayload, closeDelayRef, }; const resolveChildren = (): JSX.Element => { const ch = props.children; if (typeof ch === "function") { return (ch as PreviewCardPayloadChildRenderFunction)({ payload: payload() as Payload | undefined, }); } return ch as JSX.Element; }; return ( {resolveChildren()} ); } /** * Groups all parts of the preview card. Doesn't render its own HTML element — * the root is a pure context provider. * * Wraps the component tree in `` unless we're already nested * inside one (matches Base UI React's behavior so cross-tree events — like * dismiss-on-outside-press — propagate correctly for preview cards rendered * inside other popups). */ export function PreviewCardRoot( props: PreviewCardRootProps, ): JSX.Element { if ( usePreviewCardRootContext(true) != null || useFloatingParentNodeId() != null ) { return ; } return ( ); }