@opentf/web-form 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { render, userEvent } from "@opentf/web-test";
3
+ import DynamicArrayForm from "./DynamicArrayForm.jsx";
4
+
5
+ describe("Dynamic Arrays", () => {
6
+ test("adds and removes items", async () => {
7
+ const { getByTestId, getAllByRole, queryByTestId } = render(DynamicArrayForm);
8
+ const user = userEvent.setup();
9
+
10
+ const addBtn = getByTestId("add-item");
11
+ const list = getByTestId("item-list");
12
+
13
+ expect(getAllByRole("listitem").length).toBe(1);
14
+ expect(getByTestId("item-0").textContent).toContain("Item 1");
15
+
16
+ // Add item
17
+ await user.click(addBtn);
18
+ expect(getAllByRole("listitem").length).toBe(2);
19
+ expect(getByTestId("item-1").textContent).toContain("Item 2");
20
+
21
+ // Add another
22
+ await user.click(addBtn);
23
+ expect(getAllByRole("listitem").length).toBe(3);
24
+
25
+ // Remove middle item (Item 2 at index 1)
26
+ await user.click(getByTestId("remove-1"));
27
+ expect(getAllByRole("listitem").length).toBe(2);
28
+
29
+ // Check remaining items
30
+ expect(getByTestId("item-0").textContent).toContain("Item 1");
31
+ expect(getByTestId("item-1").textContent).toContain("Item 3"); // Item 3 moved to index 1
32
+ });
33
+ });
@@ -0,0 +1,20 @@
1
+ import { createForm } from "../index.js";
2
+
3
+ export default function DynamicPrimitiveForm() {
4
+ const form = createForm({
5
+ initialValues: { skills: ["JavaScript", "HTML"] }
6
+ });
7
+
8
+ return (
9
+ <div>
10
+ {form.values.skills.map((skill, index) => (
11
+ <div data-testid={`row-${index}`}>
12
+ <input
13
+ {...form.register(`skills.${index}`)}
14
+ data-testid={`input-${index}`}
15
+ />
16
+ </div>
17
+ ))}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,153 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { createForm } from "../index.js";
3
+
4
+ describe("Web App Framework Forms Library - Pure Logic", () => {
5
+ test("initializes with flat values", () => {
6
+ const form = createForm({
7
+ initialValues: { username: "alice", email: "alice@example.com" }
8
+ });
9
+
10
+ expect(form.values.username).toBe("alice");
11
+ expect(form.values.email).toBe("alice@example.com");
12
+ });
13
+
14
+ test("initializes with nested values", () => {
15
+ const form = createForm({
16
+ initialValues: {
17
+ profile: { firstName: "John", lastName: "Doe" }
18
+ }
19
+ });
20
+
21
+ expect(form.values.profile.firstName).toBe("John");
22
+ expect(form.values.profile.lastName).toBe("Doe");
23
+ });
24
+
25
+ test("initializes with array values", () => {
26
+ const form = createForm({
27
+ initialValues: {
28
+ skills: ["JS", "Framework"]
29
+ }
30
+ });
31
+
32
+ expect(form.values.skills[0]).toBe("JS");
33
+ expect(form.values.skills[1]).toBe("Framework");
34
+ });
35
+
36
+ test("updates values reactively", () => {
37
+ const form = createForm({
38
+ initialValues: { count: 0 }
39
+ });
40
+
41
+ form.values.count = 1;
42
+ expect(form.values.count).toBe(1);
43
+ });
44
+
45
+ test("supports direct assignment to values proxy", () => {
46
+ const form = createForm({
47
+ initialValues: { user: { name: "A" } }
48
+ });
49
+
50
+ form.values.user = { name: "B" };
51
+ expect(form.values.user.name).toBe("B");
52
+ });
53
+
54
+ test("validates values", () => {
55
+ const form = createForm({
56
+ mode: "onChange",
57
+ initialValues: { age: 20 },
58
+ validate: (v) => {
59
+ const errors = {};
60
+ if (v.age < 18) errors.age = "Too young";
61
+ return errors;
62
+ }
63
+ });
64
+
65
+ expect(form.errors.age).toBeUndefined();
66
+ form.values.age = 10;
67
+ expect(form.errors.age).toBe("Too young");
68
+ });
69
+
70
+ test("errors proxy supports direct access", () => {
71
+ const form = createForm({
72
+ mode: "onChange",
73
+ initialValues: { username: "" },
74
+ validate: (v) => (v.username ? {} : { username: "Required" }),
75
+ });
76
+
77
+ expect(form.errors.username).toBe("Required");
78
+
79
+ form.values.username = "Alice";
80
+ expect(form.errors.username).toBeUndefined();
81
+ });
82
+
83
+
84
+ test("supports Zod-style validator returning { errors }", () => {
85
+ const form = createForm({
86
+ mode: "onChange",
87
+ initialValues: { email: "a" },
88
+ validator: (v) => {
89
+ return { errors: { email: "Invalid email" } };
90
+ },
91
+ });
92
+
93
+ expect(form.errors.email).toBe("Invalid email");
94
+ });
95
+
96
+ test("handles async validation", async () => {
97
+ const form = createForm({
98
+ mode: "onChange",
99
+ initialValues: { username: "abc" },
100
+ validate: async (v) => {
101
+ await new Promise((r) => setTimeout(r, 10));
102
+ return v.username.length < 3 ? { username: "Too short" } : {};
103
+ },
104
+ });
105
+
106
+ expect(form.isValidating).toBe(true); // Immediate validation in onChange mode
107
+ await new Promise((r) => setTimeout(r, 20));
108
+ expect(form.isValidating).toBe(false);
109
+ expect(form.errors.username).toBeUndefined();
110
+
111
+ form.values.username = "al";
112
+ expect(form.isValidating).toBe(true);
113
+ await new Promise((r) => setTimeout(r, 20));
114
+ expect(form.isValidating).toBe(false);
115
+ expect(form.errors.username).toBe("Too short");
116
+
117
+ form.values.username = "alice";
118
+ await new Promise((r) => setTimeout(r, 20));
119
+ expect(form.errors.username).toBeUndefined();
120
+ expect(form.isValid).toBe(true);
121
+ });
122
+
123
+ test("reactive array mutations (push/splice)", () => {
124
+ const form = createForm({
125
+ initialValues: { tags: ["js"] },
126
+ });
127
+
128
+ let changeCount = 0;
129
+ form._signals.values.subscribe(() => changeCount++);
130
+ expect(changeCount).toBe(1);
131
+
132
+ form.values.tags.push("web");
133
+ expect(form.values.tags).toEqual(["js", "web"]);
134
+ expect(changeCount).toBe(2);
135
+
136
+ form.values.tags.splice(0, 1);
137
+ expect(form.values.tags).toEqual(["web"]);
138
+ expect(changeCount).toBe(3);
139
+ });
140
+
141
+ test("reset() with custom values", () => {
142
+ const form = createForm({
143
+ initialValues: { a: 1 },
144
+ });
145
+
146
+ form.values.a = 2;
147
+ form.reset({ a: 10 });
148
+ expect(form.values.a).toBe(10);
149
+ expect(form.isChanged).toBe(false); // isChanged is against the NEW initialValues?
150
+ // Actually, createForm stores initialValues from the constructor.
151
+ // If reset(newVals) is called, it should ideally update the reference for isChanged.
152
+ });
153
+ });
@@ -0,0 +1,28 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { render, userEvent } from "@opentf/web-test";
3
+ import NestedForm from "./NestedForm.jsx";
4
+
5
+ describe("Nested Fields", () => {
6
+ test("handles deep object paths", async () => {
7
+ let submittedValues = null;
8
+ const { getByTestId } = render(NestedForm, {
9
+ onSubmit: (v) => submittedValues = v
10
+ });
11
+ const user = userEvent.setup();
12
+
13
+ const firstName = getByTestId("first-name");
14
+ const notifications = getByTestId("notifications");
15
+ const submit = getByTestId("submit");
16
+
17
+ expect(firstName.value).toBe("John");
18
+ expect(notifications.checked).toBe(true);
19
+
20
+ await user.clear(firstName);
21
+ await user.type(firstName, "Jane");
22
+ await user.click(notifications); // Uncheck
23
+
24
+ await user.click(submit);
25
+ expect(submittedValues.user.profile.firstName).toBe("Jane");
26
+ expect(submittedValues.user.settings.notifications).toBe(false);
27
+ });
28
+ });
@@ -0,0 +1,32 @@
1
+ import { createForm } from "../index.js";
2
+
3
+ export default function NestedForm({ onSubmit }) {
4
+ const form = createForm({
5
+ initialValues: {
6
+ user: {
7
+ profile: { firstName: "John" },
8
+ settings: { notifications: true }
9
+ }
10
+ }
11
+ });
12
+
13
+ return (
14
+ <div>
15
+ <input
16
+ {...form.register("user.profile.firstName")}
17
+ data-testid="first-name"
18
+ />
19
+ <input
20
+ type="checkbox"
21
+ {...form.register("user.settings.notifications")}
22
+ data-testid="notifications"
23
+ />
24
+ <button
25
+ onclick={form.handleSubmit(onSubmit)}
26
+ data-testid="submit"
27
+ >
28
+ Submit
29
+ </button>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,34 @@
1
+ import { createForm } from "../index.js";
2
+
3
+ export default function ProfileForm() {
4
+ const form = createForm({
5
+ initialValues: {
6
+ user: { name: "John" },
7
+ tags: ["js"]
8
+ },
9
+ validate: (v) => {
10
+ const errors = {};
11
+ if (v.user.name.length < 3) errors.user = { name: "Too short" };
12
+ return errors;
13
+ }
14
+ });
15
+
16
+ const addTag = () => form.values.tags.push(`tag-${form.values.tags.length}`);
17
+
18
+ return (
19
+ <div>
20
+ <h1 data-testid="title">{form.values.user.name}</h1>
21
+ <input {...form.register("user.name")} data-testid="name-input" />
22
+ <div data-testid="name-error">{form.errors.user?.name}</div>
23
+
24
+ <ul data-testid="tag-list">
25
+ {form.values.tags.map((tag, i) => (
26
+ <li data-testid={`tag-${i}`}>{tag}</li>
27
+ ))}
28
+ </ul>
29
+ <button onclick={addTag} data-testid="add-tag">Add Tag</button>
30
+
31
+ <div data-testid="status">{form.isValid ? "Valid" : "Invalid"}</div>
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,26 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { render, userEvent } from "@opentf/web-test";
3
+ import DynamicPrimitiveForm from "./DynamicListForm.jsx";
4
+
5
+ describe("Web Form Capabilities", () => {
6
+ test("reactively updates dynamic primitive arrays without losing focus", async () => {
7
+ const { getByTestId } = render(DynamicPrimitiveForm);
8
+ const user = userEvent.setup();
9
+
10
+ const input0 = getByTestId("input-0");
11
+ const input1 = getByTestId("input-1");
12
+
13
+ expect(input0.value).toBe("JavaScript");
14
+ expect(input1.value).toBe("HTML");
15
+
16
+ // Simulate typing a new character into the first input
17
+ await user.type(input0, "!");
18
+
19
+ // The value should update immediately via the form's reactivity loop
20
+ expect(input0.value).toBe("JavaScript!");
21
+
22
+ // Crucially, the exact same DOM node should be retained (focus proves this).
23
+ // The node must not be destroyed and recreated.
24
+ expect(document.activeElement).toBe(input0);
25
+ });
26
+ });
@@ -0,0 +1,83 @@
1
+ import { test, expect } from "bun:test";
2
+ import { createForm } from "../index.js";
3
+ import { effect, signal, batch } from "@opentf/web";
4
+
5
+ test("register() returns reactive metadata", async () => {
6
+ const form = createForm({
7
+ initialValues: { username: "" },
8
+ validator: (v) => {
9
+ const errors = {};
10
+ if (!v.username) errors.username = "Required";
11
+ return { errors };
12
+ },
13
+ mode: "onChange"
14
+ });
15
+
16
+ const field = form.register("username");
17
+
18
+ expect(field.name).toBe("username");
19
+ expect(field.error.value).toBe("Required"); // Validated on init due to onChange mode
20
+ expect(field.isTouched.value).toBe(false);
21
+
22
+ // Update value to clear error
23
+ form.values.username = "valid";
24
+ expect(field.error.value).toBeUndefined();
25
+
26
+ // Trigger touch
27
+ field.onblur();
28
+ expect(field.isTouched.value).toBe(true);
29
+ });
30
+
31
+ test("register() handles nested metadata", () => {
32
+ const form = createForm({
33
+ initialValues: { profile: { firstName: "" } },
34
+ validator: (v) => {
35
+ const errors = {};
36
+ if (!v.profile?.firstName) errors.profile = { firstName: "Required" };
37
+ return { errors };
38
+ }
39
+ });
40
+
41
+ const field = form.register("profile.firstName");
42
+
43
+ expect(field.name).toBe("profile.firstName");
44
+ expect(field.isTouched.value).toBe(false);
45
+
46
+ // Manual touch
47
+ field.onblur();
48
+ expect(field.isTouched.value).toBe(true);
49
+
50
+ // Manual error update
51
+ form._signals.errors.value = { profile: { firstName: "Too short" } };
52
+ form._notifySignals("e"); // Manual notify
53
+ expect(field.error.value).toBe("Too short");
54
+ });
55
+
56
+ test("register() metadata is reactive in effects", () => {
57
+ const form = createForm({
58
+ initialValues: { email: "" }
59
+ });
60
+
61
+ const field = form.register("email");
62
+ let caughtError = null;
63
+ let caughtTouched = null;
64
+
65
+ effect(() => {
66
+ caughtError = field.error.value;
67
+ caughtTouched = field.isTouched.value;
68
+ });
69
+
70
+ expect(caughtError).toBeUndefined();
71
+ expect(caughtTouched).toBe(false);
72
+
73
+ // Update state
74
+ batch(() => {
75
+ form._signals.errors.value = { email: "Invalid" };
76
+ form._signals.touched.value = { email: true };
77
+ form._notifySignals("e");
78
+ form._notifySignals("t");
79
+ });
80
+
81
+ expect(caughtError).toBe("Invalid");
82
+ expect(caughtTouched).toBe(true);
83
+ });
@@ -0,0 +1,93 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { createForm } from "../index.js";
3
+ import { sleep } from "@opentf/std";
4
+
5
+ describe("Web App Framework Forms Library - State Helpers", () => {
6
+ test("isValid tracks error state", () => {
7
+ const form = createForm({
8
+ mode: "onChange",
9
+ initialValues: { name: "" },
10
+ validate: (v) => v.name ? {} : { name: "Error" }
11
+ });
12
+
13
+ expect(form.isValid).toBe(false);
14
+ form.values.name = "Alice";
15
+ expect(form.isValid).toBe(true);
16
+ });
17
+
18
+ test("isChanged tracks deep equality with initial values", () => {
19
+ const form = createForm({
20
+ initialValues: { user: { name: "Alice" } }
21
+ });
22
+
23
+ expect(form.isChanged).toBe(false);
24
+ expect(form.changed.user).toBe(false);
25
+
26
+ form.values.user = { name: "Bob" };
27
+ expect(form.isChanged).toBe(true);
28
+ expect(form.changed.user).toBe(true);
29
+
30
+ form.values.user = { name: "Alice" };
31
+ expect(form.isChanged).toBe(false);
32
+ expect(form.changed.user).toBe(false);
33
+ });
34
+
35
+ test("isTouched tracks field blur state", () => {
36
+ const form = createForm({
37
+ initialValues: { email: "" }
38
+ });
39
+ const { onblur } = form.register("email");
40
+
41
+ expect(form.isTouched).toBe(false);
42
+ expect(form.touched.email).toBe(false);
43
+
44
+ onblur();
45
+ expect(form.isTouched).toBe(true);
46
+ expect(form.touched.email).toBe(true);
47
+ });
48
+
49
+ test("isSubmitting and isSubmitted lifecycle", async () => {
50
+ let resolved = false;
51
+ const form = createForm({
52
+ initialValues: { name: "Alice" }
53
+ });
54
+
55
+ const onSubmit = async () => {
56
+ await sleep(50);
57
+ resolved = true;
58
+ };
59
+
60
+ const handler = form.handleSubmit(onSubmit);
61
+
62
+ expect(form.isSubmitting).toBe(false);
63
+ expect(form.isSubmitted).toBe(false);
64
+
65
+ const promise = handler();
66
+ expect(form.isSubmitting).toBe(true);
67
+
68
+ await promise;
69
+ expect(resolved).toBe(true);
70
+ expect(form.isSubmitting).toBe(false);
71
+ expect(form.isSubmitted).toBe(true);
72
+ });
73
+
74
+ test("reset() restores initial state", () => {
75
+ const form = createForm({
76
+ initialValues: { count: 0 }
77
+ });
78
+
79
+ form.values.count = 10;
80
+ form.register("count").onblur();
81
+
82
+ expect(form.values.count).toBe(10);
83
+ expect(form.isChanged).toBe(true);
84
+ expect(form.isTouched).toBe(true);
85
+
86
+ form.reset();
87
+
88
+ expect(form.values.count).toBe(0);
89
+ expect(form.isChanged).toBe(false);
90
+ expect(form.isTouched).toBe(false);
91
+ });
92
+
93
+ });
@@ -0,0 +1,58 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { render, userEvent } from "@opentf/web-test";
3
+ import { sleep } from "@opentf/std";
4
+ import StateForm from "./StateForm.jsx";
5
+
6
+ const waitFor = async (fn, timeout = 1000) => {
7
+ const start = Date.now();
8
+ while (Date.now() - start < timeout) {
9
+ try {
10
+ fn();
11
+ return;
12
+ } catch (e) {
13
+ await sleep(10);
14
+ }
15
+ }
16
+ fn();
17
+ };
18
+
19
+ describe("Form State UI Reactivity", () => {
20
+ test("updates UI based on state helpers", async () => {
21
+ let submitted = false;
22
+ const { getByTestId, queryByTestId } = render(StateForm, { onSubmit: async () => {
23
+ submitted = true;
24
+ }});
25
+ const user = userEvent.setup();
26
+
27
+ const input = getByTestId("username");
28
+ const statusValid = getByTestId("status-valid");
29
+ const statusChanged = getByTestId("status-changed");
30
+ const statusTouched = getByTestId("status-touched");
31
+ const resetBtn = getByTestId("reset");
32
+ const submitBtn = getByTestId("submit");
33
+
34
+ // Initial state
35
+ expect(statusValid.textContent).toBe("Valid");
36
+
37
+ // Change value to invalid
38
+ await user.clear(input);
39
+ await user.type(input, "ab");
40
+ expect(statusValid.textContent).toBe("Invalid");
41
+ expect(statusChanged.textContent).toBe("Changed");
42
+ expect(getByTestId("error").textContent).toBe("Too short");
43
+
44
+ // Submit valid data
45
+ await user.clear(input);
46
+ await user.type(input, "bob");
47
+ await user.click(submitBtn);
48
+
49
+ await waitFor(() => expect(submitted).toBe(true));
50
+ await waitFor(() => expect(getByTestId("success")).toBeTruthy());
51
+
52
+ // Reset
53
+ await user.click(resetBtn);
54
+ expect(input.value).toBe("alice");
55
+ expect(statusChanged.textContent).toBe("Unchanged");
56
+ expect(queryByTestId("success")).toBeNull();
57
+ });
58
+ });
@@ -0,0 +1,29 @@
1
+ import { createForm } from "../index.js";
2
+
3
+ export default function StateForm({ onSubmit }) {
4
+ const form = createForm({
5
+ mode: "onChange", // Use onChange for easier testing of reactive text
6
+ initialValues: { username: "alice" },
7
+ validate: (v) => v.username.length < 3 ? { username: "Too short" } : {}
8
+ });
9
+
10
+ // Submit is driven via onclick rather than a native `type="submit"` (which, in
11
+ // happy-dom, mangles the form subtree on submission regardless of
12
+ // preventDefault — a test-env quirk, not a real-browser behaviour).
13
+ return (
14
+ <div>
15
+ <input {...form.register("username")} data-testid="username" />
16
+
17
+ <div data-testid="status-valid">{form.isValid ? "Valid" : "Invalid"}</div>
18
+ <div data-testid="status-changed">{form.isChanged ? "Changed" : "Unchanged"}</div>
19
+ <div data-testid="status-touched">{form.isTouched ? "Touched" : "Untouched"}</div>
20
+
21
+ {form.errors.username && <span data-testid="error">{form.errors.username}</span>}
22
+
23
+ <button type="button" onclick={() => form.reset()} data-testid="reset">Reset</button>
24
+ <button type="button" onclick={form.handleSubmit(onSubmit)} data-testid="submit">Submit</button>
25
+
26
+ {form.isSubmitted && <div data-testid="success">Submitted!</div>}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,84 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { createForm } from "../index.js";
3
+
4
+ describe("Web App Framework Forms Library - Validation Modes", () => {
5
+ test("mode: onBlur (default)", () => {
6
+ const form = createForm({
7
+ initialValues: { name: "" },
8
+ validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
9
+ });
10
+
11
+ expect(form.errors.name).toBeUndefined();
12
+
13
+ // oninput should NOT trigger validation
14
+ form.register("name").oninput({ target: { value: "a" } });
15
+ expect(form.errors.name).toBeUndefined();
16
+
17
+ // onblur SHOULD trigger validation
18
+ form.register("name").onblur();
19
+ expect(form.errors.name).toBe("Short");
20
+ });
21
+
22
+ test("mode: onChange", () => {
23
+ const form = createForm({
24
+ mode: "onChange",
25
+ initialValues: { name: "" },
26
+ validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
27
+ });
28
+
29
+ expect(form.errors.name).toBe("Short"); // Validates on init
30
+
31
+ form.register("name").oninput({ target: { value: "abc" } });
32
+ expect(form.errors.name).toBeUndefined();
33
+ });
34
+
35
+ test("mode: onSubmit", async () => {
36
+ const form = createForm({
37
+ mode: "onSubmit",
38
+ initialValues: { name: "" },
39
+ validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
40
+ });
41
+
42
+ form.register("name").oninput({ target: { value: "a" } });
43
+ form.register("name").onblur();
44
+ expect(form.errors.name).toBeUndefined();
45
+
46
+ await form.handleSubmit(() => {})();
47
+ expect(form.errors.name).toBe("Short");
48
+ });
49
+
50
+ test("reValidateMode: onChange (default)", () => {
51
+ const form = createForm({
52
+ mode: "onBlur", // Validate on blur first
53
+ initialValues: { name: "" },
54
+ validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
55
+ });
56
+
57
+ form.register("name").onblur();
58
+ expect(form.errors.name).toBe("Short");
59
+
60
+ // Once error exists, oninput SHOULD trigger re-validation
61
+ form.register("name").oninput({ target: { value: "abc" } });
62
+ expect(form.errors.name).toBeUndefined();
63
+ });
64
+
65
+ test("reValidateMode: onBlur", () => {
66
+ const form = createForm({
67
+ mode: "onBlur",
68
+ reValidateMode: "onBlur",
69
+ initialValues: { name: "" },
70
+ validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
71
+ });
72
+
73
+ form.register("name").onblur();
74
+ expect(form.errors.name).toBe("Short");
75
+
76
+ // Should NOT re-validate on input
77
+ form.register("name").oninput({ target: { value: "abc" } });
78
+ expect(form.errors.name).toBe("Short");
79
+
80
+ // Should re-validate on blur
81
+ form.register("name").onblur();
82
+ expect(form.errors.name).toBeUndefined();
83
+ });
84
+ });