@opentf/web-form 0.1.0-alpha.1 → 1.0.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opentf/web-form",
3
- "version": "0.1.0-alpha.1",
4
- "description": "A high-performance, path-based reactive forms library for Web App Framework and beyond.",
3
+ "version": "1.0.0",
4
+ "description": "A high-performance, path-based reactive forms engine for the native-first OTF Web.",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
7
7
  "type": "module",
@@ -9,12 +9,19 @@
9
9
  ".": "./index.js"
10
10
  },
11
11
  "dependencies": {
12
- "@preact/signals-core": "^1.14.1"
12
+ "@opentf/std": "^1.0.0-beta.2"
13
13
  },
14
14
  "peerDependencies": {
15
- "zod": "^4.3.6"
15
+ "@opentf/web": "workspace:*"
16
16
  },
17
17
  "publishConfig": {
18
18
  "access": "public"
19
+ },
20
+ "scripts": {
21
+ "test": "bun test --env=happy-dom"
22
+ },
23
+ "devDependencies": {
24
+ "@opentf/web": "workspace:*",
25
+ "@opentf/web-test": "workspace:*"
19
26
  }
20
- }
27
+ }
@@ -0,0 +1,37 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { render, userEvent } from "@opentf/web-test";
3
+ import BasicForm from "./BasicForm.jsx";
4
+
5
+ describe("Basic Form", () => {
6
+ test("initializes and handles submission", async () => {
7
+ let submittedValues = null;
8
+ const { getByTestId, queryByTestId } = render(BasicForm, {
9
+ onSubmit: (v) => submittedValues = v
10
+ });
11
+ const user = userEvent.setup();
12
+
13
+ const username = getByTestId("username");
14
+ const email = getByTestId("email");
15
+ const submit = getByTestId("submit");
16
+
17
+ expect(username.value).toBe("alice");
18
+ expect(email.value).toBe("");
19
+
20
+ // Try to submit with invalid email
21
+ await user.click(submit);
22
+ expect(submittedValues).toBeNull();
23
+ expect(getByTestId("email-error").textContent).toBe("Invalid email");
24
+
25
+ // Correct the email
26
+ await user.type(email, "alice@example.com");
27
+ expect(queryByTestId("email-error")).toBeNull();
28
+
29
+ // Change username
30
+ await user.clear(username);
31
+ await user.type(username, "bob");
32
+
33
+ // Submit
34
+ await user.click(submit);
35
+ expect(submittedValues).toEqual({ username: "bob", email: "alice@example.com" });
36
+ });
37
+ });
@@ -0,0 +1,29 @@
1
+ import { createForm } from "../index.js";
2
+
3
+ export default function BasicForm({ onSubmit }) {
4
+ const form = createForm({
5
+ initialValues: { username: "alice", email: "" },
6
+ validate: (values) => {
7
+ const errs = {};
8
+ if (!values.username) errs.username = "Username required";
9
+ if (!values.email.includes("@")) errs.email = "Invalid email";
10
+ return errs;
11
+ }
12
+ });
13
+
14
+ return (
15
+ <form onsubmit={form.handleSubmit(onSubmit)}>
16
+ <div>
17
+ <label for="username">Username</label>
18
+ <input {...form.register("username")} id="username" data-testid="username" />
19
+ {form.errors.username && <span data-testid="username-error">{form.errors.username}</span>}
20
+ </div>
21
+ <div>
22
+ <label for="email">Email</label>
23
+ <input {...form.register("email")} id="email" data-testid="email" />
24
+ {form.errors.email && <span data-testid="email-error">{form.errors.email}</span>}
25
+ </div>
26
+ <button type="submit" data-testid="submit">Submit</button>
27
+ </form>
28
+ );
29
+ }
@@ -0,0 +1,79 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { render, userEvent } from "@opentf/web-test";
3
+ import { createForm } from "../index.js";
4
+
5
+ export function ProfileForm() {
6
+ const form = createForm({
7
+ initialValues: {
8
+ user: { name: "John" },
9
+ tags: ["js"]
10
+ },
11
+ validate: (v) => {
12
+ const errors = {};
13
+ if (v.user.name.length < 3) errors.user = { name: "Too short" };
14
+ return errors;
15
+ }
16
+ });
17
+
18
+ const addTag = () => form.values.tags.push(`tag-${form.values.tags.length}`);
19
+
20
+ return (
21
+ <div>
22
+ <h1 data-testid="title">{form.values.user.name}</h1>
23
+ <input {...form.register("user.name")} data-testid="name-input" />
24
+ <div data-testid="name-error">{form.errors.user?.name}</div>
25
+
26
+ <ul data-testid="tag-list">
27
+ {form.values.tags.map((tag, i) => (
28
+ <li data-testid={`tag-${i}`}>{tag}</li>
29
+ ))}
30
+ </ul>
31
+ <button onclick={addTag} data-testid="add-tag">Add Tag</button>
32
+
33
+ <div data-testid="status">{form.isValid ? "Valid" : "Invalid"}</div>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ describe("Web Form Component Integration", () => {
39
+ test("updates nested values and validation state in UI", async () => {
40
+ const { getByTestId } = render(ProfileForm);
41
+ const user = userEvent.setup();
42
+
43
+ const input = getByTestId("name-input");
44
+ const error = getByTestId("name-error");
45
+ const status = getByTestId("status");
46
+ const title = getByTestId("title");
47
+
48
+ expect(title.textContent).toBe("John");
49
+ expect(status.textContent).toBe("Valid");
50
+
51
+ await user.clear(input);
52
+ await user.type(input, "Jo");
53
+ // Mode is onBlur by default, so we need to blur
54
+ await user.tab();
55
+
56
+ expect(title.textContent).toBe("Jo");
57
+ expect(error.textContent).toBe("Too short");
58
+ expect(status.textContent).toBe("Invalid");
59
+
60
+ await user.type(input, "hnny");
61
+ await user.tab();
62
+ expect(error.textContent).toBe("");
63
+ expect(status.textContent).toBe("Valid");
64
+ });
65
+
66
+ test("handles dynamic array mutations in UI", async () => {
67
+ const { getByTestId, getAllByRole } = render(ProfileForm);
68
+ const user = userEvent.setup();
69
+
70
+ const list = getByTestId("tag-list");
71
+ const addBtn = getByTestId("add-tag");
72
+
73
+ expect(list.children.length).toBe(1);
74
+
75
+ await user.click(addBtn);
76
+ expect(list.children.length).toBe(2);
77
+ expect(getByTestId("tag-1").textContent).toBe("tag-1");
78
+ });
79
+ });
@@ -0,0 +1,32 @@
1
+ import { createForm } from "../index.js";
2
+
3
+ export default function DynamicArrayForm() {
4
+ const form = createForm({
5
+ initialValues: { items: ["Item 1"] }
6
+ });
7
+
8
+ const addItem = () => {
9
+ const items = form.values.items;
10
+ form.values.items = [...items, `Item ${items.length + 1}`];
11
+ };
12
+
13
+ const removeItem = (index) => {
14
+ const newItems = [...form.values.items];
15
+ newItems.splice(index, 1);
16
+ form.values.items = newItems;
17
+ };
18
+
19
+ return (
20
+ <div>
21
+ <button onclick={addItem} data-testid="add-item">Add Item</button>
22
+ <ul data-testid="item-list">
23
+ {form.values.items.map((item, index) => (
24
+ <li data-testid={`item-${index}`}>
25
+ {item}
26
+ <button onclick={() => removeItem(index)} data-testid={`remove-${index}`}>Remove</button>
27
+ </li>
28
+ ))}
29
+ </ul>
30
+ </div>
31
+ );
32
+ }
@@ -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,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
+ });