diff --git a/packages/core/src/primitives/create-disclosure-state.ts b/packages/core/src/primitives/create-disclosure-state.ts new file mode 100644 index 0000000..ae251e0 --- /dev/null +++ b/packages/core/src/primitives/create-disclosure-state.ts @@ -0,0 +1,37 @@ +import type { Accessor } from "solid-js"; +import { createControllableSignal } from "./create-controllable-signal"; + +export interface CreateDisclosureStateOptions { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export interface DisclosureState { + isOpen: Accessor; + open: () => void; + close: () => void; + toggle: () => void; +} + +/** + * Shared open/close state for all disclosure components (Dialog, Popover, + * Tooltip, Collapsible, etc.). Wraps createControllableSignal with + * convenience open/close/toggle methods. + */ +export function createDisclosureState( + options: CreateDisclosureStateOptions, +): DisclosureState { + const [isOpen, setIsOpen] = createControllableSignal({ + value: () => options.open, + defaultValue: () => options.defaultOpen ?? false, + onChange: options.onOpenChange, + }); + + return { + isOpen, + open: () => setIsOpen(true), + close: () => setIsOpen(false), + toggle: () => setIsOpen(!isOpen()), + }; +} diff --git a/packages/core/tests/primitives/create-disclosure-state.test.ts b/packages/core/tests/primitives/create-disclosure-state.test.ts new file mode 100644 index 0000000..820b023 --- /dev/null +++ b/packages/core/tests/primitives/create-disclosure-state.test.ts @@ -0,0 +1,76 @@ +import { createRoot } from "solid-js"; +import { describe, expect, it, vi } from "vitest"; +import { createDisclosureState } from "../../src/primitives/create-disclosure-state"; + +describe("createDisclosureState", () => { + it("starts closed by default", () => { + createRoot((dispose) => { + const state = createDisclosureState({}); + expect(state.isOpen()).toBe(false); + dispose(); + }); + }); + + it("respects defaultOpen", () => { + createRoot((dispose) => { + const state = createDisclosureState({ defaultOpen: true }); + expect(state.isOpen()).toBe(true); + dispose(); + }); + }); + + it("open() sets state to true", () => { + createRoot((dispose) => { + const state = createDisclosureState({}); + state.open(); + expect(state.isOpen()).toBe(true); + dispose(); + }); + }); + + it("close() sets state to false", () => { + createRoot((dispose) => { + const state = createDisclosureState({ defaultOpen: true }); + state.close(); + expect(state.isOpen()).toBe(false); + dispose(); + }); + }); + + it("toggle() flips state", () => { + createRoot((dispose) => { + const state = createDisclosureState({}); + state.toggle(); + expect(state.isOpen()).toBe(true); + state.toggle(); + expect(state.isOpen()).toBe(false); + dispose(); + }); + }); + + it("calls onOpenChange when state changes", () => { + createRoot((dispose) => { + const onChange = vi.fn(); + const state = createDisclosureState({ onOpenChange: onChange }); + state.open(); + expect(onChange).toHaveBeenCalledWith(true); + state.close(); + expect(onChange).toHaveBeenCalledWith(false); + dispose(); + }); + }); + + it("respects controlled open prop", () => { + createRoot((dispose) => { + const state = createDisclosureState({ open: true }); + expect(state.isOpen()).toBe(true); + // Calling close() fires onChange but does not change internal state + const onChange = vi.fn(); + const controlled = createDisclosureState({ open: true, onOpenChange: onChange }); + controlled.close(); + expect(controlled.isOpen()).toBe(true); // still controlled + expect(onChange).toHaveBeenCalledWith(false); + dispose(); + }); + }); +});