Controllable signal primitive
Implements the controlled/uncontrolled state primitive with full TDD coverage (5 tests passing). Delegates to an internal signal when uncontrolled, defers to the external value accessor when controlled.
This commit is contained in:
parent
db906fd85a
commit
8f2e6042ea
36
packages/core/src/primitives/create-controllable-signal.ts
Normal file
36
packages/core/src/primitives/create-controllable-signal.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { type Accessor, createSignal } from "solid-js";
|
||||||
|
|
||||||
|
export interface CreateControllableSignalOptions<T> {
|
||||||
|
/** Returns the controlled value, or undefined if uncontrolled. */
|
||||||
|
value: Accessor<T | undefined>;
|
||||||
|
/** Default value used when uncontrolled. */
|
||||||
|
defaultValue: Accessor<T>;
|
||||||
|
/** Called whenever the value changes (both modes). */
|
||||||
|
onChange?: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles controlled vs uncontrolled state for any stateful component.
|
||||||
|
* When `value()` is not undefined, the component is controlled — the external
|
||||||
|
* value is the source of truth. Otherwise, an internal signal manages state.
|
||||||
|
*/
|
||||||
|
export function createControllableSignal<T>(
|
||||||
|
options: CreateControllableSignalOptions<T>,
|
||||||
|
): [Accessor<T>, (value: T) => void] {
|
||||||
|
const [internalValue, setInternalValue] = createSignal<T>(options.defaultValue());
|
||||||
|
|
||||||
|
const get: Accessor<T> = () => {
|
||||||
|
const controlled = options.value();
|
||||||
|
return controlled !== undefined ? controlled : internalValue();
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (value: T) => {
|
||||||
|
const isControlled = options.value() !== undefined;
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalValue(() => value);
|
||||||
|
}
|
||||||
|
options.onChange?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [get, set];
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
import { createRoot, createSignal } from "solid-js";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createControllableSignal } from "../../src/primitives/create-controllable-signal";
|
||||||
|
|
||||||
|
describe("createControllableSignal", () => {
|
||||||
|
it("uses defaultValue when value accessor returns undefined (uncontrolled)", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [get] = createControllableSignal({
|
||||||
|
value: () => undefined,
|
||||||
|
defaultValue: () => false,
|
||||||
|
});
|
||||||
|
expect(get()).toBe(false);
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses value accessor when provided (controlled)", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [get] = createControllableSignal({
|
||||||
|
value: () => true,
|
||||||
|
defaultValue: () => false,
|
||||||
|
});
|
||||||
|
expect(get()).toBe(true);
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange when setter is called in uncontrolled mode", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const [, set] = createControllableSignal({
|
||||||
|
value: () => undefined,
|
||||||
|
defaultValue: () => false,
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
set(true);
|
||||||
|
expect(onChange).toHaveBeenCalledWith(true);
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates internal signal when setter called in uncontrolled mode", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [get, set] = createControllableSignal({
|
||||||
|
value: () => undefined,
|
||||||
|
defaultValue: () => "initial",
|
||||||
|
});
|
||||||
|
set("updated");
|
||||||
|
expect(get()).toBe("updated");
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not update internal signal in controlled mode — external value is source of truth", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [externalValue] = createSignal<string>("controlled");
|
||||||
|
const [get, set] = createControllableSignal({
|
||||||
|
value: externalValue,
|
||||||
|
defaultValue: () => "default",
|
||||||
|
});
|
||||||
|
set("ignored");
|
||||||
|
// Still reflects external value since we're controlled
|
||||||
|
expect(get()).toBe("controlled");
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user