/** * `FloatingRootStore` — Solid-native. * * Backed by per-key `createSignal`s exposed via a getter-bag * `state` proxy. Property reads on `store.state` track per-key * when invoked inside a reactive context; outside one, they * return the current value non-reactively. Writes do NOT flush: * they batch to the next microtask per Solid 2.0's default, and * every consumer reads through a reactive surface that settles on * that flush. Imperative call sites needing same-tick visibility * (chiefly tests) must call `flush()` themselves before reading * back. Each signal is constructed with * `ownedWrite: true` because writers are routinely invoked from * inside reactive owners (Solid 2.0 dev would otherwise raise * `SIGNAL_WRITE_IN_OWNED_SCOPE`). * * ### Public contract * * - `state` — reactive proxy. Read `state.open` etc.; tracks * per-key in reactive scopes. * - `context` — non-reactive bag (`dataRef`, `events`, `nested`, * `triggerElements`). Fields here are intentionally imperative. * - `useState(key)` — convenience wrapper. Returns `state[key]` * (with the `referenceElement → positionReference` fallback). * Equivalent in tracking behavior to a direct `state.*` read. * - `useStateAccessor(key)` — returns `() => useState(key)`. * - `sync(key, value)` / `set(key, value)` — identity-compare * setters. No-op on unchanged. * - `update(partial)` — shallow merge; no-op if nothing changed. * - `getSnapshot()` — alias for `state`. Same reference; * tracking still depends on the caller's context. * - `syncOpenEvent` / `dispatchOpenChange` / `setOpen` — * open-change dispatch. */ import { type Accessor, createSignal, type Setter, untrack } from "solid-js"; import type { BaseUIChangeEventDetails } from "../../change-event-details"; import type { TransitionStatus } from "../../use-transition-status/use-transition-status"; import type { PopupTriggerMap } from "../popup-trigger-map"; import type { ContextData, FloatingEvents, MutableRef, ReferenceType, } from "../types"; import { createEventEmitter } from "../utils/create-event-emitter"; import { isClickLikeEvent } from "../utils/event"; export interface FloatingUIOpenChangeDetails { open: boolean; reason: string; nativeEvent: Event; nested: boolean; triggerElement?: Element | undefined; } // --------------------------------------------------------------------------- // State + context shapes // --------------------------------------------------------------------------- export interface FloatingRootState { open: boolean; transitionStatus: TransitionStatus | undefined; domReferenceElement: Element | null; referenceElement: ReferenceType | null; floatingElement: HTMLElement | null; positionReference: ReferenceType | null; floatingId: string | undefined; } export interface FloatingRootStoreContext { onOpenChange: | ((open: boolean, eventDetails: BaseUIChangeEventDetails) => void) | undefined; readonly dataRef: MutableRef; readonly events: FloatingEvents; nested: boolean; readonly triggerElements: PopupTriggerMap; } export interface FloatingRootStoreOptions { open: boolean; transitionStatus: TransitionStatus | undefined; referenceElement: ReferenceType | null; floatingElement: HTMLElement | null; triggerElements: PopupTriggerMap; floatingId: string | undefined; /** * When `true`, `setOpen` only forwards to `onOpenChange` — the owning * popup store handles `dispatchOpenChange`. Matches the React source. */ syncOnly: boolean; nested: boolean; onOpenChange: | ((open: boolean, eventDetails: BaseUIChangeEventDetails) => void) | undefined; } // --------------------------------------------------------------------------- // `FloatingRootStore` // --------------------------------------------------------------------------- export type FloatingRootStateView = { readonly [K in keyof FloatingRootState]: FloatingRootState[K]; }; export class FloatingRootStore { // Per-key signals. `ownedWrite: true` because writers are routinely // invoked from inside reactive owners (effects, computeds) that observe // other parts of state — Solid 2.0's dev runtime would otherwise raise // `SIGNAL_WRITE_IN_OWNED_SCOPE`. private readonly _open: Accessor; private readonly _setOpenSignal: Setter; private readonly _transitionStatus: Accessor; private readonly _setTransitionStatus: Setter; private readonly _referenceElement: Accessor; private readonly _setReferenceElement: Setter; private readonly _floatingElement: Accessor; private readonly _setFloatingElement: Setter; private readonly _positionReference: Accessor; private readonly _setPositionReference: Setter; private readonly _domReferenceElement: Accessor; private readonly _setDomReferenceElement: Setter; private readonly _floatingId: Accessor; private readonly _setFloatingId: Setter; private readonly _stateProxy: FloatingRootStateView; public readonly context: FloatingRootStoreContext; private readonly syncOnly: boolean; constructor(options: FloatingRootStoreOptions) { const { syncOnly, nested, onOpenChange, triggerElements, referenceElement, floatingElement, floatingId, open, transitionStatus, } = options; [this._open, this._setOpenSignal] = createSignal(open, { name: "floating-root-store.open", ownedWrite: true, }); [this._transitionStatus, this._setTransitionStatus] = createSignal< TransitionStatus | undefined >(transitionStatus, { name: "floating-root-store.transitionStatus", ownedWrite: true, }); [this._referenceElement, this._setReferenceElement] = createSignal(referenceElement, { name: "floating-root-store.referenceElement", ownedWrite: true, }); [this._floatingElement, this._setFloatingElement] = createSignal(floatingElement, { name: "floating-root-store.floatingElement", ownedWrite: true, }); [this._positionReference, this._setPositionReference] = createSignal(referenceElement, { name: "floating-root-store.positionReference", ownedWrite: true, }); [this._domReferenceElement, this._setDomReferenceElement] = createSignal(referenceElement as Element | null, { name: "floating-root-store.domReferenceElement", ownedWrite: true, }); [this._floatingId, this._setFloatingId] = createSignal( floatingId, { name: "floating-root-store.floatingId", ownedWrite: true, }, ); const self = this; this._stateProxy = { get open() { return self._open(); }, get transitionStatus() { return self._transitionStatus(); }, get referenceElement() { return self._referenceElement(); }, get floatingElement() { return self._floatingElement(); }, get positionReference() { return self._positionReference(); }, get domReferenceElement() { return self._domReferenceElement(); }, get floatingId() { return self._floatingId(); }, }; this.context = { onOpenChange, dataRef: { current: {} as ContextData }, events: createEventEmitter(), nested, triggerElements, }; this.syncOnly = syncOnly; } private _getAccessor( key: K, ): Accessor { switch (key) { case "open": return this._open as Accessor; case "transitionStatus": return this._transitionStatus as Accessor; case "referenceElement": return this._referenceElement as Accessor; case "floatingElement": return this._floatingElement as Accessor; case "positionReference": return this._positionReference as Accessor; case "domReferenceElement": return this._domReferenceElement as Accessor; case "floatingId": return this._floatingId as Accessor; default: { const _exhaustive: never = key; throw new Error(`Unknown key: ${String(_exhaustive)}`); } } } private _writeKey( key: K, value: FloatingRootState[K], ): void { switch (key) { case "open": this._setOpenSignal(value as boolean); return; case "transitionStatus": this._setTransitionStatus(value as TransitionStatus | undefined); return; case "referenceElement": this._setReferenceElement(value as ReferenceType | null); return; case "floatingElement": this._setFloatingElement(value as HTMLElement | null); return; case "positionReference": this._setPositionReference(value as ReferenceType | null); return; case "domReferenceElement": this._setDomReferenceElement(value as Element | null); return; case "floatingId": this._setFloatingId(value as string | undefined); return; default: { const _exhaustive: never = key; throw new Error(`Unknown key: ${String(_exhaustive)}`); } } } // --------------------------------------------------------------------- // Reactive access // --------------------------------------------------------------------- /** * Reactive store proxy. Property reads inside tracking contexts * subscribe to the specific key; reads in untracked contexts * (event handlers) return the current value without subscribing. */ get state(): FloatingRootStateView { return this._stateProxy; } /** * Current state view. Same reference as `state`. Property reads * outside tracking contexts don't subscribe — use this when the * spelling "snapshot" makes intent clearer at the call site. */ getSnapshot = (): FloatingRootStateView => this._stateProxy; /** * Returns the current value for `key`, subscribing the caller's * tracking context to updates of that specific key. The * `"referenceElement"` selector prefers `positionReference` over * the raw slot to match the React selector. */ useState(key: K): FloatingRootState[K] { if (key === "referenceElement") { return (this._positionReference() ?? this._referenceElement()) as FloatingRootState[K]; } return this._getAccessor(key)(); } /** * Returns a live `Accessor` for `key`. Equivalent to * `() => store.useState(key)` with a stable function identity. */ useStateAccessor( key: K, ): Accessor { return () => this.useState(key); } // --------------------------------------------------------------------- // Writers // --------------------------------------------------------------------- /** * Set a specific key. `createStore` handles the identity-compare * skip internally — writes equal to the current value are no-ops. */ set( key: K, value: FloatingRootState[K], ): void { const accessor = this._getAccessor(key); if ( Object.is( untrack(() => accessor()), value, ) ) return; this._writeKey(key, value); // No per-writer flush: writes batch to the next microtask. Every // production consumer reads store state through a reactive surface // (getter prop bags, positioning/render effects) that settles on // that flush, so synchronous visibility was only ever needed by // imperative test reads — those flush() explicitly before asserting. } /** * Alias for `set`. Kept for the call-site spelling that the React * source uses (`useSyncedValue`). */ sync( key: K, value: FloatingRootState[K], ): void { this.set(key, value); } /** * Merge a partial into state. Uses the draft-first setter form so * only the keys that actually changed notify subscribers. */ update(changes: Partial): void { let changed = false; for (const key in changes) { const k = key as keyof FloatingRootState; const accessor = this._getAccessor(k); if ( !Object.is( untrack(() => accessor()), changes[k], ) ) { changed = true; break; } } if (!changed) return; for (const key in changes) { const k = key as keyof FloatingRootState; this.set(k, changes[k] as FloatingRootState[typeof k]); } } // --------------------------------------------------------------------- // Open-change dispatch // --------------------------------------------------------------------- /** * Syncs the event used by hover logic to distinguish hover-open from * click-like interaction. Click events can upgrade a hover-open, but * a pending hover-open cannot overwrite a click-open. */ syncOpenEvent = (newOpen: boolean, event: Event | undefined): void => { if ( !newOpen || !untrack(() => this._open()) || (event != null && isClickLikeEvent(event)) ) { this.context.dataRef.current.openEvent = newOpen ? event : undefined; } }; /** * Runs the root-owned side effects for an open state change: syncs the * open-event bookkeeping and emits `openchange` on the events bus. */ dispatchOpenChange = ( newOpen: boolean, eventDetails: BaseUIChangeEventDetails, ): void => { this.syncOpenEvent(newOpen, eventDetails.event); const details: FloatingUIOpenChangeDetails = { open: newOpen, reason: eventDetails.reason, nativeEvent: eventDetails.event, nested: this.context.nested, triggerElement: eventDetails.trigger, }; this.context.events.emit("openchange", details); }; /** * Emits `openchange` and calls the context `onOpenChange` callback. * In `syncOnly` mode the dispatch step is skipped — the owning popup * store is responsible for emitting the event itself. */ setOpen = ( newOpen: boolean, eventDetails: BaseUIChangeEventDetails, ): void => { if (this.syncOnly) { this.context.onOpenChange?.(newOpen, eventDetails); return; } this.dispatchOpenChange(newOpen, eventDetails); this.context.onOpenChange?.(newOpen, eventDetails); }; }