/** * `MenuStore` + `MenuHandle` — thin Solid 2 port of Base UI React's * `MenuStore` / `MenuHandle` (`packages/react/src/menu/store/MenuStore.ts` + * `MenuHandle.ts`), mirroring the popover pattern already shipped * (`src/popover/store/popover-store.ts`). * * The React source threads a full store + selector pair through every * sub-part. The Solid `` uses its local signal-accessor context * (same shape as ``); this store only carries the minimal * surface the imperative handle + multi-trigger registry need: * * - A `PopupTriggerMap` so `` can register and * `MenuHandle.open(id)` can resolve the correct trigger. * - A `setOpen` slot that `` binds during setup via * {@link attachSetOpen}. * - An `open` mirror so `handle.isOpen` returns synchronously. */ import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { PopupTriggerMap } from "../../internals/floating-ui/popup-trigger-map"; import type { MenuRootChangeEventDetails, MenuRootChangeEventReason, } from "../root/menu-root-context"; /** * Writer signature for `setOpen`. Matches the Solid root's internal * `setOpen(next, details)` callback. */ export type MenuStoreSetOpen = ( next: boolean, details: MenuRootChangeEventDetails, ) => void; /** * Minimal store shape consumed by the handle + imperative callers. */ export class MenuStore<_Payload = unknown> { /** * Registry of triggers keyed by id. `` adds * itself here on mount, and `MenuHandle.open(id)` looks it up. */ public readonly context: { triggerElements: PopupTriggerMap }; /** * Internal setOpen slot. `` binds this during setup via * {@link attachSetOpen}; clears on teardown. */ private _setOpen: MenuStoreSetOpen | null = null; /** * Last-seen reactive open flag. Used by `MenuHandle.isOpen` so the * caller can read synchronously without needing a subscription hook. */ private _open = false; constructor() { this.context = { triggerElements: new PopupTriggerMap(), }; } /** * Bind the root's `setOpen` writer. * * @internal */ attachSetOpen(fn: MenuStoreSetOpen | null): void { this._setOpen = fn; } /** * Mirror the root's current open flag. Called by ``'s effect * so `handle.isOpen` returns the latest value synchronously. * * @internal */ setOpenMirror(next: boolean): void { this._open = next; } /** * Imperative open-state writer. No-op when no root is bound. */ setOpen(next: boolean, details: MenuRootChangeEventDetails): void { this._setOpen?.(next, details); } /** * Synchronous snapshot of the current open state. */ get isOpen(): boolean { return this._open; } } /** * `MenuHandle` — imperative surface + detached-trigger association. * Matches React's `MenuHandle` 1:1 on the public methods. */ export class MenuHandle { /** @internal */ public readonly store: MenuStore; constructor() { this.store = new MenuStore(); } /** * Opens the menu and associates it with the trigger with the given id. * Throws if no trigger with that id is registered. */ open(triggerId: string): void { const triggerElement = this.store.context.triggerElements.getById( triggerId, ) as HTMLElement | undefined; if (!triggerElement) { throw new Error( `Base UI: MenuHandle.open: No trigger found with id "${triggerId}".`, ); } this.store.setOpen( true, createChangeEventDetails( REASONS.imperativeAction as MenuRootChangeEventReason, undefined, triggerElement, ) as MenuRootChangeEventDetails, ); } /** Closes the menu. */ close(): void { this.store.setOpen( false, createChangeEventDetails( REASONS.imperativeAction as MenuRootChangeEventReason, undefined, undefined, ) as MenuRootChangeEventDetails, ); } /** Whether the menu is currently open. Synchronous. */ get isOpen(): boolean { return this.store.isOpen; } } /** * Factory for `MenuHandle` — matches React's `createMenuHandle()`. */ export function createMenuHandle(): MenuHandle { return new MenuHandle(); }