/**
* `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();
}