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