Disclosure state primitive

Implements controlled/uncontrolled open-close state for disclosure components (Dialog, Popover, Tooltip, Collapsible, etc.) wrapping createControllableSignal with open/close/toggle convenience methods.
This commit is contained in:
Mats Bosson 2026-03-29 02:41:54 +07:00
parent 512eba474a
commit ed13193430
2 changed files with 113 additions and 0 deletions

View File

@ -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<boolean>;
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<boolean>({
value: () => options.open,
defaultValue: () => options.defaultOpen ?? false,
onChange: options.onOpenChange,
});
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen(!isOpen()),
};
}

View File

@ -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();
});
});
});