dalila 1.5.13 → 1.6.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/README.md CHANGED
@@ -77,6 +77,10 @@ bind(document.getElementById('app')!, ctx);
77
77
  - [Query](./docs/core/query.md) — Cached queries
78
78
  - [Mutations](./docs/core/mutation.md) — Write operations
79
79
 
80
+ ### Forms
81
+
82
+ - [Forms](./docs/forms.md) — DOM-first form management with validation, field arrays, and accessibility
83
+
80
84
  ### Utilities
81
85
 
82
86
  - [Scheduler](./docs/core/scheduler.md) — Batching and coordination
@@ -189,6 +193,49 @@ const router = createRouter({
189
193
  router.start();
190
194
  ```
191
195
 
196
+ ### Forms
197
+
198
+ ```ts
199
+ import { createForm } from 'dalila';
200
+
201
+ const userForm = createForm({
202
+ defaultValues: { name: '', email: '' },
203
+ validate: (data) => {
204
+ const errors: Record<string, string> = {};
205
+ if (!data.name) errors.name = 'Name is required';
206
+ if (!data.email?.includes('@')) errors.email = 'Invalid email';
207
+ return errors;
208
+ }
209
+ });
210
+
211
+ async function handleSubmit(data, { signal }) {
212
+ await fetch('/api/users', {
213
+ method: 'POST',
214
+ body: JSON.stringify(data),
215
+ signal
216
+ });
217
+ }
218
+ ```
219
+
220
+ ```html
221
+ <form d-form="userForm" d-on-submit="handleSubmit">
222
+ <label>
223
+ Name
224
+ <input d-field="name" />
225
+ </label>
226
+ <span d-error="name"></span>
227
+
228
+ <label>
229
+ Email
230
+ <input d-field="email" type="email" />
231
+ </label>
232
+ <span d-error="email"></span>
233
+
234
+ <button type="submit">Save</button>
235
+ <span d-form-error="userForm"></span>
236
+ </form>
237
+ ```
238
+
192
239
  ## Development
193
240
 
194
241
  ```bash
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Type utilities for path-based access
3
+ */
4
+ export type FieldErrors = Record<string, string>;
5
+ export interface FormSubmitContext {
6
+ signal: AbortSignal;
7
+ }
8
+ export interface FormOptions<T> {
9
+ /**
10
+ * Default values for the form.
11
+ * Can be a static object, a function returning values, or a promise.
12
+ */
13
+ defaultValues?: Partial<T> | (() => Partial<T>) | (() => Promise<Partial<T>>);
14
+ /**
15
+ * Custom parser for FormData → T.
16
+ * If not provided, uses the built-in parser with dot/bracket notation.
17
+ */
18
+ parse?: (formEl: HTMLFormElement, fd: FormData) => T;
19
+ /**
20
+ * Client-side validation function.
21
+ * Returns { fieldErrors?, formError? } or just fieldErrors.
22
+ */
23
+ validate?: (data: T) => FieldErrors | {
24
+ fieldErrors?: FieldErrors;
25
+ formError?: string;
26
+ } | void;
27
+ /**
28
+ * When to run validation:
29
+ * - "submit" (default): only on submit
30
+ * - "blur": on field blur after first submit
31
+ * - "change": on every change after first submit
32
+ */
33
+ validateOn?: 'submit' | 'blur' | 'change';
34
+ /**
35
+ * Transform server errors into form errors.
36
+ * Useful for mapping backend error formats to field paths.
37
+ */
38
+ transformServerErrors?: (error: unknown) => {
39
+ fieldErrors?: FieldErrors;
40
+ formError?: string;
41
+ } | void;
42
+ }
43
+ export interface Form<T> {
44
+ /**
45
+ * Creates a submit handler that:
46
+ * - prevents default
47
+ * - collects FormData
48
+ * - parses to T
49
+ * - validates (if configured)
50
+ * - calls handler with data and AbortSignal
51
+ * - cancels previous submit if re-submitted
52
+ */
53
+ handleSubmit(handler: (data: T, ctx: FormSubmitContext) => Promise<unknown> | unknown): (ev: SubmitEvent) => void;
54
+ /**
55
+ * Reset form to initial/new defaults
56
+ */
57
+ reset(nextDefaults?: Partial<T>): void;
58
+ /**
59
+ * Set error for a specific field
60
+ */
61
+ setError(path: string, message: string): void;
62
+ /**
63
+ * Set form-level error
64
+ */
65
+ setFormError(message: string): void;
66
+ /**
67
+ * Clear errors (all or by prefix)
68
+ */
69
+ clearErrors(prefix?: string): void;
70
+ /**
71
+ * Get error message for a field
72
+ */
73
+ error(path: string): string | null;
74
+ /**
75
+ * Get form-level error
76
+ */
77
+ formError(): string | null;
78
+ /**
79
+ * Check if field has been touched
80
+ */
81
+ touched(path: string): boolean;
82
+ /**
83
+ * Check if field is dirty (value differs from default)
84
+ */
85
+ dirty(path: string): boolean;
86
+ /**
87
+ * Check if form is currently submitting
88
+ */
89
+ submitting(): boolean;
90
+ /**
91
+ * Get submit count
92
+ */
93
+ submitCount(): number;
94
+ /**
95
+ * Focus first error field (or specific field)
96
+ */
97
+ focus(path?: string): void;
98
+ /**
99
+ * Internal: register a field element
100
+ * @internal
101
+ */
102
+ _registerField(path: string, element: HTMLElement): () => void;
103
+ /**
104
+ * Internal: get form element
105
+ * @internal
106
+ */
107
+ _getFormElement(): HTMLFormElement | null;
108
+ /**
109
+ * Internal: set form element
110
+ * @internal
111
+ */
112
+ _setFormElement(form: HTMLFormElement): void;
113
+ /**
114
+ * Create or get a field array
115
+ */
116
+ fieldArray<TItem = unknown>(path: string): FieldArray<TItem>;
117
+ }
118
+ export interface FieldArrayItem<T = unknown> {
119
+ key: string;
120
+ value?: T;
121
+ }
122
+ export interface FieldArray<TItem = unknown> {
123
+ /**
124
+ * Get array of items with stable keys
125
+ */
126
+ fields(): FieldArrayItem<TItem>[];
127
+ /**
128
+ * Append item(s) to the end
129
+ */
130
+ append(value: TItem | TItem[]): void;
131
+ /**
132
+ * Remove item by key
133
+ */
134
+ remove(key: string): void;
135
+ /**
136
+ * Remove item by index
137
+ */
138
+ removeAt(index: number): void;
139
+ /**
140
+ * Insert item at index
141
+ */
142
+ insert(index: number, value: TItem): void;
143
+ /**
144
+ * Move item from one index to another
145
+ */
146
+ move(fromIndex: number, toIndex: number): void;
147
+ /**
148
+ * Swap two items by index
149
+ */
150
+ swap(indexA: number, indexB: number): void;
151
+ /**
152
+ * Replace entire array
153
+ */
154
+ replace(values: TItem[]): void;
155
+ /**
156
+ * Update a specific item by key
157
+ */
158
+ update(key: string, value: TItem): void;
159
+ /**
160
+ * Update a specific item by index
161
+ */
162
+ updateAt(index: number, value: TItem): void;
163
+ /**
164
+ * Clear all items
165
+ */
166
+ clear(): void;
167
+ /**
168
+ * Get current length
169
+ */
170
+ length(): number;
171
+ /**
172
+ * Internal: translate index-based path to key-based path
173
+ * @internal
174
+ */
175
+ _translatePath(path: string): string | null;
176
+ /**
177
+ * Internal: get current index for a key
178
+ * @internal
179
+ */
180
+ _getIndex(key: string): number;
181
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Type utilities for path-based access
3
+ */
4
+ export {};
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Dalila Forms - DOM-first reactive form management
3
+ *
4
+ * Design principles:
5
+ * - Values live in the DOM (uncontrolled by default)
6
+ * - Meta-state in memory (errors, touched, dirty, submitting)
7
+ * - Declarative HTML via directives
8
+ * - Scope-safe with automatic cleanup
9
+ * - Race-safe submits with AbortController
10
+ * - Field arrays with stable keys
11
+ */
12
+ import type { Form, FormOptions } from './form-types.js';
13
+ /**
14
+ * Symbol to mark handlers that have been wrapped by handleSubmit().
15
+ * Used by bindForm to avoid double-wrapping.
16
+ */
17
+ export declare const WRAPPED_HANDLER: unique symbol;
18
+ /**
19
+ * Parse FormData into a nested object structure.
20
+ *
21
+ * Supports:
22
+ * - Simple fields: "email" → { email: "..." }
23
+ * - Nested objects: "user.name" → { user: { name: "..." } }
24
+ * - Arrays: "phones[0].number" → { phones: [{ number: "..." }] }
25
+ * - Checkboxes: single = boolean, multiple = array of values
26
+ * - Select multiple: array of selected values
27
+ * - Radio: single value
28
+ * - Files: File object
29
+ *
30
+ * ## Checkbox Parsing Contract
31
+ *
32
+ * HTML FormData omits unchecked checkboxes entirely. To resolve this ambiguity,
33
+ * parseFormData() inspects the DOM to distinguish between "field missing" vs "checkbox unchecked".
34
+ *
35
+ * ### Single Checkbox (one input with unique name)
36
+ * When there is exactly ONE checkbox with a given name:
37
+ * - Checked (with or without value) → `true`
38
+ * - Unchecked → `false`
39
+ * - Value attribute is ignored (always returns boolean)
40
+ *
41
+ * Example:
42
+ * ```html
43
+ * <input type="checkbox" name="agree" />
44
+ * ```
45
+ * Result: `{ agree: false }` (unchecked) or `{ agree: true }` (checked)
46
+ *
47
+ * ### Multiple Checkboxes (same name, multiple inputs)
48
+ * When there are MULTIPLE checkboxes with the same name:
49
+ * - Result is ALWAYS an array
50
+ * - Some checked → `["value1", "value2"]`
51
+ * - None checked → `[]`
52
+ * - One checked → `["value1"]` (still an array!)
53
+ *
54
+ * Example:
55
+ * ```html
56
+ * <input type="checkbox" name="colors" value="red" checked />
57
+ * <input type="checkbox" name="colors" value="blue" />
58
+ * <input type="checkbox" name="colors" value="green" checked />
59
+ * ```
60
+ * Result: `{ colors: ["red", "green"] }`
61
+ *
62
+ * ### Edge Cases
63
+ * - Radio buttons: Unchecked radio → field absent (standard HTML behavior)
64
+ * - Select multiple: Always returns array (like multiple checkboxes)
65
+ *
66
+ * @param form - The form element to parse (used for DOM inspection)
67
+ * @param fd - FormData instance from the form
68
+ * @returns Parsed form data with nested structure
69
+ */
70
+ export declare function parseFormData<T = unknown>(form: HTMLFormElement, fd: FormData): T;
71
+ export declare function createForm<T = unknown>(options?: FormOptions<T>): Form<T>;