@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.
package/index.js CHANGED
@@ -1,163 +1,350 @@
1
- import { signal, effect, computed, batch } from "@preact/signals-core";
1
+ import {
2
+ signal,
3
+ computed,
4
+ effect,
5
+ batch,
6
+ untracked,
7
+ getCurrentInstance,
8
+ reactive,
9
+ toRawValue,
10
+ snapshot,
11
+ } from "@opentf/web";
12
+
13
+ export { signal, computed, batch, effect, untracked, reactive, getCurrentInstance };
14
+
15
+ import { clone, isEql } from "@opentf/std";
2
16
 
3
17
  /**
4
- * Helper to set a nested property in an object.
18
+ * Path-based reactive forms engine for OTF Web.
19
+ *
20
+ * The form's values/errors/touched/changed trees are `reactive()` stores
21
+ * (@opentf/web), so `form.values.user.name` and `form.errors.email` are read
22
+ * directly in JSX with fine-grained reactivity — no provider object, no bespoke
23
+ * proxy. The engine layers field registration, validation modes, and submit
24
+ * orchestration on top.
5
25
  */
6
- const setPath = (obj, path, value) => {
7
- const parts = path.split('.');
8
- let current = obj;
9
- for (let i = 0; i < parts.length - 1; i++) {
10
- const part = parts[i];
11
- const next = parts[i + 1];
12
- if (!current[part]) {
13
- current[part] = !isNaN(next) ? [] : {};
26
+
27
+ const INST_FORMS_REGISTRY = new WeakMap();
28
+
29
+ export function createForm(options = {}) {
30
+ // Scope the form to the mounting component so re-entrant `createForm` calls
31
+ // (e.g. the component body running again) return the same instance. Outside a
32
+ // component (module scope, tests) there is no instance — just build one.
33
+ const inst = getCurrentInstance();
34
+ if (!inst) return _createForm(options);
35
+
36
+ let forms = INST_FORMS_REGISTRY.get(inst);
37
+ if (!forms) INST_FORMS_REGISTRY.set(inst, (forms = new Map()));
38
+
39
+ const key = options.key || JSON.stringify({ initialValues: options.initialValues });
40
+ if (forms.has(key)) {
41
+ const form = forms.get(key);
42
+ form._updateConfig?.(options.mode, options.reValidateMode);
43
+ return form;
44
+ }
45
+ const form = _createForm(options);
46
+ forms.set(key, form);
47
+ return form;
48
+ }
49
+
50
+ function _createForm(options = {}) {
51
+ let initialValues = clone(options.initialValues || {});
52
+
53
+ const valuesStore = reactive(clone(initialValues));
54
+ const errorsStore = reactive({});
55
+ // touched mirrors the value tree with `false` leaves, so a field reads `false`
56
+ // (not undefined) before it is ever blurred. `changed` is filled by the
57
+ // creation effect below (top-level booleans vs. initial values).
58
+ const touchedStore = reactive(mirrorLeaves(initialValues, false));
59
+ const changedStore = reactive({});
60
+
61
+ const modeSig = signal(options.mode);
62
+ const reValidateModeSig = signal(options.reValidateMode);
63
+ const isSubmittingSig = signal(false);
64
+ const isValidatingSig = signal(false);
65
+ const isSubmittedSig = signal(false);
66
+ const submitCountSig = signal(0);
67
+
68
+ const normMode = (m) =>
69
+ ((m && typeof m === "object" && "value" in m ? m.value : m) || "onBlur");
70
+ const normReValidate = (m) =>
71
+ ((m && typeof m === "object" && "value" in m ? m.value : m) || "onChange");
72
+
73
+ const activeValidator = options.validator || options.validate;
74
+
75
+ // --- path helpers: walk the reactive store so reads subscribe & writes notify
76
+ function readPath(store, path) {
77
+ if (!path) return store;
78
+ let node = store;
79
+ for (const p of path.split(".")) {
80
+ if (node == null) return undefined;
81
+ node = node[p];
14
82
  }
15
- current = current[part];
83
+ return node;
16
84
  }
17
- current[parts[parts.length - 1]] = value;
18
- };
19
85
 
20
- /**
21
- * Creates a reactive form state manager.
22
- */
23
- export function createForm({ initialValues = {}, validate, schema, validator } = {}) {
24
- const fields = {}; // Stores field signals by path
25
- const touched = {}; // Stores touched signals by path
26
- const fieldPaths = signal([]); // Track list of paths for computed to depend on
27
-
28
- function ensureField(path, initVal) {
29
- if (!fields[path]) {
30
- fields[path] = signal(initVal === undefined ? "" : initVal);
31
- touched[path] = signal(false);
86
+ function writePath(store, path, value) {
87
+ const parts = path.split(".");
88
+ let node = store;
89
+ for (let i = 0; i < parts.length - 1; i++) {
90
+ const p = parts[i];
91
+ let next = node[p];
92
+ if (next == null || typeof next !== "object") {
93
+ node[p] = {};
94
+ next = node[p];
95
+ }
96
+ node = next;
32
97
  }
33
- if (!fieldPaths.value.includes(path)) {
34
- fieldPaths.value = [...fieldPaths.value, path];
98
+ node[parts[parts.length - 1]] = value;
99
+ }
100
+
101
+ function deletePath(store, path) {
102
+ const parts = path.split(".");
103
+ let node = store;
104
+ for (let i = 0; i < parts.length - 1; i++) {
105
+ node = node[parts[i]];
106
+ if (node == null || typeof node !== "object") return;
35
107
  }
36
- if (initVal !== undefined) {
37
- fields[path].value = initVal;
108
+ delete node[parts[parts.length - 1]];
109
+ }
110
+
111
+ // Reconcile a reactive store in place to match a plain object, touching only
112
+ // the leaves that actually differ (fine-grained — no wholesale replace).
113
+ function syncStore(store, next) {
114
+ next = next || {};
115
+ const raw = toRawValue(store);
116
+ for (const k of Object.keys(raw)) {
117
+ if (!(k in next)) delete store[k];
118
+ }
119
+ for (const k of Object.keys(next)) {
120
+ const nv = next[k];
121
+ const cur = raw[k];
122
+ if (nv && typeof nv === "object" && cur && typeof cur === "object") {
123
+ syncStore(store[k], nv);
124
+ } else if (!isEql(cur, nv)) {
125
+ store[k] = nv;
126
+ }
38
127
  }
39
- return fields[path];
40
128
  }
41
129
 
42
- const walk = (obj, path = "") => {
43
- if (obj && typeof obj === "object" && obj !== null) {
44
- Object.keys(obj).forEach(key => {
45
- const fullPath = path ? `${path}.${key}` : key;
46
- walk(obj[key], fullPath);
47
- });
48
- } else if (path) {
49
- ensureField(path, obj);
130
+ // --- validation
131
+ const normalizeErrors = (r) => (r && r.errors !== undefined ? r.errors : r) || {};
132
+
133
+ function applyErrors(errorsObj, fieldPath) {
134
+ if (fieldPath) {
135
+ const fieldErr = readRaw(errorsObj, fieldPath);
136
+ if (fieldErr) writePath(errorsStore, fieldPath, fieldErr);
137
+ else deletePath(errorsStore, fieldPath);
138
+ } else {
139
+ syncStore(errorsStore, errorsObj);
50
140
  }
51
- };
141
+ }
142
+
143
+ let validationToken = 0;
144
+ function runValidation(fieldPath) {
145
+ if (!activeValidator) return;
146
+ const token = ++validationToken;
147
+ const result = activeValidator(snapshot(valuesStore));
148
+ if (result instanceof Promise) {
149
+ isValidatingSig.value = true;
150
+ return result
151
+ .then((r) => {
152
+ if (token === validationToken) applyErrors(normalizeErrors(r), fieldPath);
153
+ })
154
+ .finally(() => {
155
+ if (token === validationToken) isValidatingSig.value = false;
156
+ });
157
+ }
158
+ applyErrors(normalizeErrors(result), fieldPath);
159
+ }
52
160
 
53
- // Initial population
54
- batch(() => walk(initialValues));
161
+ // --- changed tracking: per top-level key, deep-compared to initial values.
162
+ function recomputeChanged() {
163
+ const v = toRawValue(valuesStore);
164
+ const keys = new Set([...Object.keys(v), ...Object.keys(initialValues)]);
165
+ for (const k of keys) {
166
+ const ch = !isEql(v[k], initialValues[k]);
167
+ if (toRawValue(changedStore)[k] !== ch) changedStore[k] = ch;
168
+ }
169
+ }
55
170
 
56
- // Computed state object
57
- const values = computed(() => {
58
- const res = {};
59
- fieldPaths.value.forEach(path => {
60
- setPath(res, path, fields[path].value);
171
+ // One effect observes every value change and (a) recomputes `changed`, and
172
+ // (b) in onChange mode, revalidates — covering inputs, direct assignment, and
173
+ // array mutation alike. Writes are untracked so it never depends on its own
174
+ // output. Validates once on creation, satisfying onChange's "validate on init".
175
+ effect(() => {
176
+ deepTrack(valuesStore);
177
+ const mode = normMode(modeSig.value);
178
+ untracked(() => {
179
+ recomputeChanged();
180
+ if (mode === "onChange") runValidation();
61
181
  });
62
- return res;
63
182
  });
64
183
 
65
- const errors = signal({});
66
-
67
- // Reactive validation
68
- effect(() => {
69
- const currentValues = values.value;
70
- if (validator) {
71
- const result = validator(currentValues, schema);
72
- errors.value = result.errors || {};
73
- } else if (validate) {
74
- errors.value = validate(currentValues) || {};
184
+ // --- field registration
185
+ function updateValue(path, val) {
186
+ writePath(valuesStore, path, val);
187
+ const mode = normMode(modeSig.peek());
188
+ if (mode === "onChange") return; // the effect above revalidates
189
+ if (hasTruthy(toRawValue(errorsStore)) && normReValidate(reValidateModeSig.peek()) === "onChange") {
190
+ runValidation(path);
75
191
  }
192
+ }
193
+
194
+ const register = (path) => ({
195
+ name: path,
196
+ value: readPath(valuesStore, path),
197
+ checked: readPath(valuesStore, path),
198
+ error: computed(() => readPath(errorsStore, path)),
199
+ isTouched: computed(() => readPath(touchedStore, path) ?? false),
200
+ oninput: (e) => {
201
+ const t = e.target;
202
+ updateValue(path, t.type === "checkbox" ? t.checked : t.value);
203
+ },
204
+ onblur: () => {
205
+ writePath(touchedStore, path, true);
206
+ const mode = normMode(modeSig.peek());
207
+ if (mode === "onBlur") runValidation(path);
208
+ else if (
209
+ hasTruthy(toRawValue(errorsStore)) &&
210
+ normReValidate(reValidateModeSig.peek()) === "onBlur"
211
+ ) {
212
+ runValidation(path);
213
+ }
214
+ },
76
215
  });
77
216
 
78
- function register(name) {
79
- const s = ensureField(name);
80
- return {
81
- name,
82
- value: s,
83
- oninput: (e) => {
84
- s.value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
85
- },
86
- onblur: () => {
87
- if (touched[name]) touched[name].value = true;
217
+ // --- submit / reset
218
+ const handleSubmit = (fn) => async (e) => {
219
+ if (e && e.preventDefault) e.preventDefault();
220
+ syncStore(touchedStore, mirrorLeaves(snapshot(valuesStore), true));
221
+ isSubmittingSig.value = true;
222
+ submitCountSig.value++;
223
+ await runValidation();
224
+ if (!hasTruthy(toRawValue(errorsStore))) {
225
+ try {
226
+ await fn(snapshot(valuesStore));
227
+ isSubmittedSig.value = true;
228
+ } finally {
229
+ isSubmittingSig.value = false;
88
230
  }
89
- };
90
- }
231
+ } else {
232
+ isSubmittingSig.value = false;
233
+ }
234
+ };
235
+
236
+ const reset = (newValues) => {
237
+ batch(() => {
238
+ if (newValues !== undefined) initialValues = clone(newValues);
239
+ syncStore(valuesStore, clone(initialValues));
240
+ syncStore(errorsStore, {});
241
+ syncStore(touchedStore, mirrorLeaves(initialValues, false));
242
+ syncStore(changedStore, {});
243
+ isSubmittedSig.value = false;
244
+ });
245
+ };
91
246
 
92
- function handleSubmit(onSubmit) {
93
- return (e) => {
94
- if (e && e.preventDefault) e.preventDefault();
95
-
96
- // Mark all fields as touched on submit
247
+ // --- derived flags
248
+ const isValidSig = computed(() => !hasTruthy(errorsStore));
249
+ const isTouchedSig = computed(() => hasTruthy(touchedStore));
250
+ const isChangedSig = computed(() => hasTruthy(changedStore));
251
+
252
+ const form = {
253
+ register,
254
+ handleSubmit,
255
+ reset,
256
+ _updateConfig(newMode, newReValidateMode) {
97
257
  batch(() => {
98
- fieldPaths.value.forEach(path => {
99
- if (touched[path]) touched[path].value = true;
100
- });
258
+ modeSig.value = newMode;
259
+ reValidateModeSig.value = newReValidateMode;
101
260
  });
261
+ },
262
+ _notifySignals() {}, // legacy no-op: reactive stores notify on write
263
+ get values() { return valuesStore; },
264
+ get errors() { return errorsStore; },
265
+ get touched() { return touchedStore; },
266
+ get changed() { return changedStore; },
267
+ _signals: {
268
+ values: storeSignal(valuesStore),
269
+ errors: storeSignal(errorsStore),
270
+ touched: storeSignal(touchedStore),
271
+ changed: storeSignal(changedStore),
272
+ },
273
+ };
102
274
 
103
- const currentValues = values.value;
104
- let currentErrors = {};
105
- if (validator) {
106
- currentErrors = validator(currentValues, schema).errors || {};
107
- } else if (validate) {
108
- currentErrors = validate(currentValues) || {};
109
- }
110
-
111
- errors.value = currentErrors;
112
-
113
- if (Object.keys(currentErrors).length === 0) {
114
- onSubmit(currentValues);
115
- }
116
- };
117
- }
275
+ Object.defineProperties(form, {
276
+ isValid: { get: () => isValidSig.value, enumerable: true },
277
+ isChanged: { get: () => isChangedSig.value, enumerable: true },
278
+ isTouched: { get: () => isTouchedSig.value, enumerable: true },
279
+ isSubmitting: { get: () => isSubmittingSig.value, enumerable: true },
280
+ isValidating: { get: () => isValidatingSig.value, enumerable: true },
281
+ isSubmitted: { get: () => isSubmittedSig.value, enumerable: true },
282
+ submitCount: { get: () => submitCountSig.value, enumerable: true },
283
+ });
118
284
 
119
- function setValues(newValues) {
120
- batch(() => {
121
- const keysToUpdate = Object.keys(newValues);
122
- const remainingPaths = fieldPaths.value.filter(path => {
123
- return !keysToUpdate.some(k => path === k || path.startsWith(k + "."));
124
- });
125
- fieldPaths.value = remainingPaths;
126
- walk(newValues);
127
- });
285
+ return form;
286
+ }
287
+
288
+ // --- shared helpers (no per-form state)
289
+
290
+ /** Raw (non-reactive) deep read by dot-path. */
291
+ function readRaw(obj, path) {
292
+ let node = obj;
293
+ for (const p of path.split(".")) {
294
+ if (node == null) return undefined;
295
+ node = node[p];
128
296
  }
297
+ return node;
298
+ }
299
+
300
+ /** Whether any leaf is truthy — the basis of isValid/isTouched/isChanged. */
301
+ function hasTruthy(obj) {
302
+ if (obj == null || typeof obj !== "object") return !!obj;
303
+ return Object.values(obj).some(hasTruthy);
304
+ }
129
305
 
130
- function array(path) {
131
- return computed(() => values.value[path] || []);
306
+ /** Mirror a tree's structure, replacing every leaf with `leaf`. */
307
+ function mirrorLeaves(obj, leaf) {
308
+ if (obj == null || typeof obj !== "object") return leaf;
309
+ const res = Array.isArray(obj) ? [] : {};
310
+ for (const k of Object.keys(obj)) res[k] = mirrorLeaves(obj[k], leaf);
311
+ return res;
312
+ }
313
+
314
+ /** Subscribe the active consumer to every path in a store (deep). */
315
+ function deepTrack(store) {
316
+ const raw = toRawValue(store);
317
+ if (raw == null || typeof raw !== "object") return;
318
+ for (const k of Object.keys(store)) {
319
+ const v = store[k];
320
+ if (v && typeof v === "object") deepTrack(v);
132
321
  }
322
+ }
133
323
 
324
+ /**
325
+ * A preact-style signal facade over a reactive store for the advanced `_signals`
326
+ * API: `.value` snapshots/replaces the whole tree, `.subscribe(cb)` fires
327
+ * immediately then once per change.
328
+ */
329
+ function storeSignal(store) {
134
330
  return {
135
- register,
136
- handleSubmit,
137
- setValues,
138
- getValues: () => values.value,
139
- array,
140
- values: new Proxy({}, {
141
- get: (_, key) => values.value[key],
142
- set: (_, key, val) => {
143
- setValues({ [key]: val });
144
- return true;
145
- },
146
- ownKeys: (_) => Object.keys(values.value),
147
- getOwnPropertyDescriptor: (_, key) => {
148
- const val = values.value[key];
149
- return val !== undefined ? {
150
- enumerable: true,
151
- configurable: true,
152
- value: val,
153
- writable: true
154
- } : undefined;
155
- }
156
- }),
157
- errors,
158
- touched: new Proxy({}, {
159
- get: (_, key) => touched[key]
160
- }),
161
- fields // Also expose the raw signals map for advanced usage
331
+ get value() {
332
+ return snapshot(store);
333
+ },
334
+ set value(v) {
335
+ const next = v || {};
336
+ const raw = toRawValue(store);
337
+ for (const k of Object.keys(raw)) if (!(k in next)) delete store[k];
338
+ for (const k of Object.keys(next)) store[k] = next[k];
339
+ },
340
+ peek() {
341
+ return toRawValue(store);
342
+ },
343
+ subscribe(cb) {
344
+ return effect(() => {
345
+ deepTrack(store);
346
+ cb(snapshot(store));
347
+ });
348
+ },
162
349
  };
163
350
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opentf/web-form",
3
- "version": "0.1.0",
4
- "description": "A high-performance, path-based reactive forms library for Web App Framework and beyond.",
3
+ "version": "1.1.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": "*"
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": "0.6.0",
25
+ "@opentf/web-test": "1.1.0"
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,46 @@
1
+ import { expect, test, describe } from "bun:test";
2
+ import { render, userEvent } from "@opentf/web-test";
3
+ import ProfileForm from "./ProfileForm.jsx";
4
+
5
+ describe("Web Form Component Integration", () => {
6
+ test("updates nested values and validation state in UI", async () => {
7
+ const { getByTestId } = render(ProfileForm);
8
+ const user = userEvent.setup();
9
+
10
+ const input = getByTestId("name-input");
11
+ const error = getByTestId("name-error");
12
+ const status = getByTestId("status");
13
+ const title = getByTestId("title");
14
+
15
+ expect(title.textContent).toBe("John");
16
+ expect(status.textContent).toBe("Valid");
17
+
18
+ await user.clear(input);
19
+ await user.type(input, "Jo");
20
+ // Mode is onBlur by default, so we need to blur
21
+ await user.tab();
22
+
23
+ expect(title.textContent).toBe("Jo");
24
+ expect(error.textContent).toBe("Too short");
25
+ expect(status.textContent).toBe("Invalid");
26
+
27
+ await user.type(input, "hnny");
28
+ await user.tab();
29
+ expect(error.textContent).toBe("");
30
+ expect(status.textContent).toBe("Valid");
31
+ });
32
+
33
+ test("handles dynamic array mutations in UI", async () => {
34
+ const { getByTestId, getAllByRole } = render(ProfileForm);
35
+ const user = userEvent.setup();
36
+
37
+ const list = getByTestId("tag-list");
38
+ const addBtn = getByTestId("add-tag");
39
+
40
+ expect(list.children.length).toBe(1);
41
+
42
+ await user.click(addBtn);
43
+ expect(list.children.length).toBe(2);
44
+ expect(getByTestId("tag-1").textContent).toBe("tag-1");
45
+ });
46
+ });
@@ -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
+ }