feat: add application productivity foundations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { computed, signal, type ReadonlySignal, type Signal } from "./signal.ts";
|
||||
|
||||
export interface FormState<T extends Record<string, unknown>> {
|
||||
values: Signal<T>;
|
||||
errors: Signal<Partial<Record<keyof T, string>>>;
|
||||
submitting: Signal<boolean>;
|
||||
dirty: ReadonlySignal<boolean>;
|
||||
field<K extends keyof T>(
|
||||
name: K,
|
||||
): {
|
||||
value: T[K];
|
||||
onInput(payload: { value?: unknown; checked?: boolean }): void;
|
||||
};
|
||||
set<K extends keyof T>(name: K, value: T[K]): void;
|
||||
reset(next?: T): void;
|
||||
validate(): Promise<boolean>;
|
||||
submit<R>(operation: (values: T) => R | Promise<R>): Promise<R>;
|
||||
}
|
||||
|
||||
export function useForm<T extends Record<string, unknown>>(
|
||||
initial: T,
|
||||
validator?: (
|
||||
values: T,
|
||||
) => Partial<Record<keyof T, string>> | Promise<Partial<Record<keyof T, string>>>,
|
||||
): FormState<T> {
|
||||
let baseline = structuredClone(initial);
|
||||
const values = signal(structuredClone(initial));
|
||||
const errors = signal<Partial<Record<keyof T, string>>>({});
|
||||
const submitting = signal(false);
|
||||
const dirty = computed(() => JSON.stringify(values.get()) !== JSON.stringify(baseline));
|
||||
const set = <K extends keyof T>(name: K, value: T[K]) => {
|
||||
values.set({ ...values.get(), [name]: value });
|
||||
if (errors.get()[name]) errors.set({ ...errors.get(), [name]: undefined });
|
||||
};
|
||||
const validate = async () => {
|
||||
errors.set((await validator?.(values.get())) ?? {});
|
||||
return Object.values(errors.get()).every((value) => !value);
|
||||
};
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
submitting,
|
||||
dirty,
|
||||
field(name) {
|
||||
return {
|
||||
value: values.get()[name],
|
||||
onInput(payload) {
|
||||
const current = values.get()[name];
|
||||
set(
|
||||
name,
|
||||
(typeof current === "boolean"
|
||||
? Boolean(payload.checked)
|
||||
: payload.value) as T[typeof name],
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
set,
|
||||
reset(next = initial) {
|
||||
baseline = structuredClone(next);
|
||||
values.set(structuredClone(next));
|
||||
errors.set({});
|
||||
},
|
||||
validate,
|
||||
async submit(operation) {
|
||||
if (!(await validate())) throw new Error("WRN-FORM-INVALID");
|
||||
submitting.set(true);
|
||||
try {
|
||||
return await operation(values.get());
|
||||
} finally {
|
||||
submitting.set(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export {
|
||||
createTimeline,
|
||||
urlSignal,
|
||||
} from "./advanced.ts";
|
||||
export { useForm } from "./form.ts";
|
||||
export type { FormState } from "./form.ts";
|
||||
export type {
|
||||
HistorySignal,
|
||||
ReactiveContext,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { useForm } from "../src/index.ts";
|
||||
|
||||
test("form state binds fields, validates, submits and resets", async () => {
|
||||
const form = useForm({ name: "", active: false }, (value) => ({
|
||||
name: value.name ? undefined : "Required",
|
||||
}));
|
||||
form.field("name").onInput({ value: "Ada" });
|
||||
form.field("active").onInput({ checked: true });
|
||||
expect(form.dirty.get()).toBe(true);
|
||||
expect(await form.submit(async (value) => value.name)).toBe("Ada");
|
||||
form.reset();
|
||||
expect(form.values.get()).toEqual({ name: "", active: false });
|
||||
});
|
||||
Reference in New Issue
Block a user