dalila 1.5.13 → 1.7.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.
Files changed (137) hide show
  1. package/README.md +47 -0
  2. package/dist/componentes/ui/accordion/index.d.ts +2 -0
  3. package/dist/componentes/ui/accordion/index.js +114 -0
  4. package/dist/componentes/ui/calendar/index.d.ts +2 -0
  5. package/dist/componentes/ui/calendar/index.js +132 -0
  6. package/dist/componentes/ui/combobox/index.d.ts +2 -0
  7. package/dist/componentes/ui/combobox/index.js +161 -0
  8. package/dist/componentes/ui/dialog/index.d.ts +10 -0
  9. package/dist/componentes/ui/dialog/index.js +54 -0
  10. package/dist/componentes/ui/drawer/index.d.ts +2 -0
  11. package/dist/componentes/ui/drawer/index.js +41 -0
  12. package/dist/componentes/ui/dropdown/index.d.ts +2 -0
  13. package/dist/componentes/ui/dropdown/index.js +48 -0
  14. package/dist/componentes/ui/dropzone/index.d.ts +2 -0
  15. package/dist/componentes/ui/dropzone/index.js +92 -0
  16. package/dist/componentes/ui/env.d.ts +1 -0
  17. package/dist/componentes/ui/env.js +2 -0
  18. package/dist/componentes/ui/index.d.ts +13 -0
  19. package/dist/componentes/ui/index.js +12 -0
  20. package/dist/componentes/ui/popover/index.d.ts +2 -0
  21. package/dist/componentes/ui/popover/index.js +156 -0
  22. package/dist/componentes/ui/runtime.d.ts +20 -0
  23. package/dist/componentes/ui/runtime.js +421 -0
  24. package/dist/componentes/ui/tabs/index.d.ts +3 -0
  25. package/dist/componentes/ui/tabs/index.js +101 -0
  26. package/dist/componentes/ui/toast/index.d.ts +3 -0
  27. package/dist/componentes/ui/toast/index.js +115 -0
  28. package/dist/componentes/ui/ui-types.d.ts +175 -0
  29. package/dist/componentes/ui/ui-types.js +1 -0
  30. package/dist/componentes/ui/validate.d.ts +7 -0
  31. package/dist/componentes/ui/validate.js +71 -0
  32. package/dist/components/ui/accordion/index.d.ts +2 -0
  33. package/dist/components/ui/accordion/index.js +114 -0
  34. package/dist/components/ui/calendar/index.d.ts +2 -0
  35. package/dist/components/ui/calendar/index.js +132 -0
  36. package/dist/components/ui/combobox/index.d.ts +2 -0
  37. package/dist/components/ui/combobox/index.js +161 -0
  38. package/dist/components/ui/dialog/index.d.ts +10 -0
  39. package/dist/components/ui/dialog/index.js +54 -0
  40. package/dist/components/ui/drawer/index.d.ts +2 -0
  41. package/dist/components/ui/drawer/index.js +41 -0
  42. package/dist/components/ui/dropdown/index.d.ts +2 -0
  43. package/dist/components/ui/dropdown/index.js +48 -0
  44. package/dist/components/ui/dropzone/index.d.ts +2 -0
  45. package/dist/components/ui/dropzone/index.js +92 -0
  46. package/dist/components/ui/env.d.ts +1 -0
  47. package/dist/components/ui/env.js +2 -0
  48. package/dist/components/ui/index.d.ts +13 -0
  49. package/dist/components/ui/index.js +12 -0
  50. package/dist/components/ui/popover/index.d.ts +2 -0
  51. package/dist/components/ui/popover/index.js +156 -0
  52. package/dist/components/ui/runtime.d.ts +20 -0
  53. package/dist/components/ui/runtime.js +421 -0
  54. package/dist/components/ui/tabs/index.d.ts +3 -0
  55. package/dist/components/ui/tabs/index.js +101 -0
  56. package/dist/components/ui/toast/index.d.ts +3 -0
  57. package/dist/components/ui/toast/index.js +115 -0
  58. package/dist/components/ui/ui-types.d.ts +175 -0
  59. package/dist/components/ui/ui-types.js +1 -0
  60. package/dist/components/ui/validate.d.ts +7 -0
  61. package/dist/components/ui/validate.js +71 -0
  62. package/dist/form/form-types.d.ts +181 -0
  63. package/dist/form/form-types.js +4 -0
  64. package/dist/form/form.d.ts +71 -0
  65. package/dist/form/form.js +1073 -0
  66. package/dist/form/index.d.ts +2 -0
  67. package/dist/form/index.js +2 -0
  68. package/dist/index.d.ts +1 -0
  69. package/dist/index.js +1 -0
  70. package/dist/runtime/bind.js +567 -9
  71. package/dist/ui/accordion.d.ts +2 -0
  72. package/dist/ui/accordion.js +114 -0
  73. package/dist/ui/calendar.d.ts +2 -0
  74. package/dist/ui/calendar.js +132 -0
  75. package/dist/ui/combobox.d.ts +2 -0
  76. package/dist/ui/combobox.js +161 -0
  77. package/dist/ui/dialog.d.ts +10 -0
  78. package/dist/ui/dialog.js +54 -0
  79. package/dist/ui/drawer.d.ts +2 -0
  80. package/dist/ui/drawer.js +41 -0
  81. package/dist/ui/dropdown.d.ts +2 -0
  82. package/dist/ui/dropdown.js +48 -0
  83. package/dist/ui/dropzone.d.ts +2 -0
  84. package/dist/ui/dropzone.js +92 -0
  85. package/dist/ui/env.d.ts +1 -0
  86. package/dist/ui/env.js +2 -0
  87. package/dist/ui/index.d.ts +13 -0
  88. package/dist/ui/index.js +12 -0
  89. package/dist/ui/popover.d.ts +2 -0
  90. package/dist/ui/popover.js +156 -0
  91. package/dist/ui/runtime.d.ts +20 -0
  92. package/dist/ui/runtime.js +421 -0
  93. package/dist/ui/tabs.d.ts +3 -0
  94. package/dist/ui/tabs.js +101 -0
  95. package/dist/ui/toast.d.ts +3 -0
  96. package/dist/ui/toast.js +115 -0
  97. package/dist/ui/ui-types.d.ts +175 -0
  98. package/dist/ui/ui-types.js +1 -0
  99. package/dist/ui/validate.d.ts +7 -0
  100. package/dist/ui/validate.js +71 -0
  101. package/package.json +60 -2
  102. package/src/components/ui/accordion/accordion.css +90 -0
  103. package/src/components/ui/alert/alert.css +78 -0
  104. package/src/components/ui/avatar/avatar.css +45 -0
  105. package/src/components/ui/badge/badge.css +71 -0
  106. package/src/components/ui/breadcrumb/breadcrumb.css +41 -0
  107. package/src/components/ui/button/button.css +135 -0
  108. package/src/components/ui/calendar/calendar.css +96 -0
  109. package/src/components/ui/card/card.css +93 -0
  110. package/src/components/ui/checkbox/checkbox.css +57 -0
  111. package/src/components/ui/chip/chip.css +62 -0
  112. package/src/components/ui/collapsible/collapsible.css +61 -0
  113. package/src/components/ui/combobox/combobox.css +85 -0
  114. package/src/components/ui/dalila/dalila.css +42 -0
  115. package/src/components/ui/dalila-core/dalila-core.css +14 -0
  116. package/src/components/ui/dialog/dialog.css +125 -0
  117. package/src/components/ui/drawer/drawer.css +122 -0
  118. package/src/components/ui/dropdown/dropdown.css +87 -0
  119. package/src/components/ui/dropzone/dropzone.css +47 -0
  120. package/src/components/ui/empty-state/empty-state.css +33 -0
  121. package/src/components/ui/form/form.css +44 -0
  122. package/src/components/ui/input/input.css +106 -0
  123. package/src/components/ui/layout/layout.css +62 -0
  124. package/src/components/ui/pagination/pagination.css +55 -0
  125. package/src/components/ui/popover/popover.css +55 -0
  126. package/src/components/ui/radio/radio.css +56 -0
  127. package/src/components/ui/separator/separator.css +38 -0
  128. package/src/components/ui/skeleton/skeleton.css +57 -0
  129. package/src/components/ui/slider/slider.css +60 -0
  130. package/src/components/ui/spinner/spinner.css +38 -0
  131. package/src/components/ui/table/table.css +54 -0
  132. package/src/components/ui/tabs/tabs.css +74 -0
  133. package/src/components/ui/toast/toast.css +100 -0
  134. package/src/components/ui/toggle/toggle.css +90 -0
  135. package/src/components/ui/tokens/tokens.css +161 -0
  136. package/src/components/ui/tooltip/tooltip.css +53 -0
  137. package/src/components/ui/typography/typography.css +81 -0
@@ -0,0 +1,1073 @@
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 { signal } from '../core/signal.js';
13
+ import { getCurrentScope } from '../core/scope.js';
14
+ // Wrapped Handler Symbol
15
+ /**
16
+ * Symbol to mark handlers that have been wrapped by handleSubmit().
17
+ * Used by bindForm to avoid double-wrapping.
18
+ */
19
+ export const WRAPPED_HANDLER = Symbol('dalila.wrappedHandler');
20
+ // FormData Parser
21
+ /**
22
+ * Parse FormData into a nested object structure.
23
+ *
24
+ * Supports:
25
+ * - Simple fields: "email" → { email: "..." }
26
+ * - Nested objects: "user.name" → { user: { name: "..." } }
27
+ * - Arrays: "phones[0].number" → { phones: [{ number: "..." }] }
28
+ * - Checkboxes: single = boolean, multiple = array of values
29
+ * - Select multiple: array of selected values
30
+ * - Radio: single value
31
+ * - Files: File object
32
+ *
33
+ * ## Checkbox Parsing Contract
34
+ *
35
+ * HTML FormData omits unchecked checkboxes entirely. To resolve this ambiguity,
36
+ * parseFormData() inspects the DOM to distinguish between "field missing" vs "checkbox unchecked".
37
+ *
38
+ * ### Single Checkbox (one input with unique name)
39
+ * When there is exactly ONE checkbox with a given name:
40
+ * - Checked (with or without value) → `true`
41
+ * - Unchecked → `false`
42
+ * - Value attribute is ignored (always returns boolean)
43
+ *
44
+ * Example:
45
+ * ```html
46
+ * <input type="checkbox" name="agree" />
47
+ * ```
48
+ * Result: `{ agree: false }` (unchecked) or `{ agree: true }` (checked)
49
+ *
50
+ * ### Multiple Checkboxes (same name, multiple inputs)
51
+ * When there are MULTIPLE checkboxes with the same name:
52
+ * - Result is ALWAYS an array
53
+ * - Some checked → `["value1", "value2"]`
54
+ * - None checked → `[]`
55
+ * - One checked → `["value1"]` (still an array!)
56
+ *
57
+ * Example:
58
+ * ```html
59
+ * <input type="checkbox" name="colors" value="red" checked />
60
+ * <input type="checkbox" name="colors" value="blue" />
61
+ * <input type="checkbox" name="colors" value="green" checked />
62
+ * ```
63
+ * Result: `{ colors: ["red", "green"] }`
64
+ *
65
+ * ### Edge Cases
66
+ * - Radio buttons: Unchecked radio → field absent (standard HTML behavior)
67
+ * - Select multiple: Always returns array (like multiple checkboxes)
68
+ *
69
+ * @param form - The form element to parse (used for DOM inspection)
70
+ * @param fd - FormData instance from the form
71
+ * @returns Parsed form data with nested structure
72
+ */
73
+ export function parseFormData(form, fd) {
74
+ const result = {};
75
+ // Step 1: Identify all enabled checkboxes in the form (to handle unchecked ones)
76
+ // Disabled checkboxes are omitted by native FormData, so we must exclude them too
77
+ const allCheckboxes = form.querySelectorAll('input[type="checkbox"]:not(:disabled)');
78
+ const checkboxesByName = new Map();
79
+ for (const checkbox of Array.from(allCheckboxes)) {
80
+ const input = checkbox;
81
+ const name = input.name;
82
+ if (!name)
83
+ continue; // Skip unnamed checkboxes
84
+ if (!checkboxesByName.has(name)) {
85
+ checkboxesByName.set(name, []);
86
+ }
87
+ checkboxesByName.get(name).push(input);
88
+ }
89
+ // Step 2: Identify enabled select[multiple] fields
90
+ // Disabled selects are omitted by native FormData, so we must exclude them too
91
+ const allSelectMultiple = form.querySelectorAll('select[multiple]:not(:disabled)');
92
+ const selectMultipleNames = new Set();
93
+ for (const select of Array.from(allSelectMultiple)) {
94
+ const name = select.name;
95
+ if (name)
96
+ selectMultipleNames.add(name);
97
+ }
98
+ // Step 3: Process all FormData entries (use getAll to handle multi-value fields)
99
+ const processedNames = new Set();
100
+ // Get unique field names from FormData
101
+ const fieldNames = new Set();
102
+ for (const [name] of fd.entries()) {
103
+ fieldNames.add(name);
104
+ }
105
+ for (const name of fieldNames) {
106
+ // Skip checkboxes (handled separately in Step 4)
107
+ if (checkboxesByName.has(name)) {
108
+ continue;
109
+ }
110
+ processedNames.add(name);
111
+ const element = form.elements.namedItem(name);
112
+ // Handle select[multiple] - always use getAll
113
+ if (selectMultipleNames.has(name)) {
114
+ const values = fd.getAll(name);
115
+ setNestedValue(result, name, values);
116
+ continue;
117
+ }
118
+ // Get all values for this name
119
+ const allValues = fd.getAll(name);
120
+ // If multiple values exist (e.g., repeated inputs), use array
121
+ if (allValues.length > 1) {
122
+ setNestedValue(result, name, allValues);
123
+ continue;
124
+ }
125
+ // Single value processing
126
+ const value = allValues[0];
127
+ let finalValue = value;
128
+ if (element && 'type' in element) {
129
+ const input = element;
130
+ if (input.type === 'file') {
131
+ finalValue = value; // File object
132
+ }
133
+ else if (input.type === 'number') {
134
+ const num = parseFloat(value);
135
+ finalValue = isNaN(num) ? value : num;
136
+ }
137
+ else if (input.type === 'radio') {
138
+ finalValue = value;
139
+ }
140
+ }
141
+ setNestedValue(result, name, finalValue);
142
+ }
143
+ // Step 4: Handle all checkbox fields (including unchecked ones)
144
+ for (const [name, checkboxes] of checkboxesByName) {
145
+ const isSingleCheckbox = checkboxes.length === 1;
146
+ const checkedValues = fd.getAll(name);
147
+ if (isSingleCheckbox) {
148
+ // Single checkbox → boolean
149
+ // checked → true, unchecked → false
150
+ setNestedValue(result, name, checkedValues.length > 0);
151
+ }
152
+ else {
153
+ // Multiple checkboxes → always array
154
+ // checked → array of values, none checked → []
155
+ setNestedValue(result, name, checkedValues);
156
+ }
157
+ processedNames.add(name);
158
+ }
159
+ // Step 5: Handle select[multiple] with no selection (not in FormData)
160
+ for (const name of selectMultipleNames) {
161
+ if (!processedNames.has(name)) {
162
+ // No selection → empty array
163
+ setNestedValue(result, name, []);
164
+ processedNames.add(name);
165
+ }
166
+ }
167
+ return result;
168
+ }
169
+ /**
170
+ * Set a value in a nested object using dot/bracket notation.
171
+ * Examples:
172
+ * - "email" → obj.email
173
+ * - "user.name" → obj.user.name
174
+ * - "phones[0].number" → obj.phones[0].number
175
+ */
176
+ function setNestedValue(obj, path, value) {
177
+ const parts = parsePath(path);
178
+ let current = obj;
179
+ for (let i = 0; i < parts.length - 1; i++) {
180
+ const part = parts[i];
181
+ const next = parts[i + 1];
182
+ if (!(part in current)) {
183
+ // Create object or array based on next part
184
+ current[part] = typeof next === 'number' ? [] : {};
185
+ }
186
+ current = current[part];
187
+ }
188
+ const lastPart = parts[parts.length - 1];
189
+ current[lastPart] = value;
190
+ }
191
+ /**
192
+ * Parse a path string into an array of keys/indices.
193
+ * Examples:
194
+ * - "email" → ["email"]
195
+ * - "user.name" → ["user", "name"]
196
+ * - "phones[0].number" → ["phones", 0, "number"]
197
+ */
198
+ function parsePath(path) {
199
+ const parts = [];
200
+ let current = '';
201
+ let i = 0;
202
+ while (i < path.length) {
203
+ const char = path[i];
204
+ if (char === '.') {
205
+ if (current) {
206
+ parts.push(current);
207
+ current = '';
208
+ }
209
+ i++;
210
+ }
211
+ else if (char === '[') {
212
+ if (current) {
213
+ parts.push(current);
214
+ current = '';
215
+ }
216
+ // Find closing bracket
217
+ const closeIndex = path.indexOf(']', i);
218
+ if (closeIndex === -1) {
219
+ throw new Error(`Invalid path: missing closing bracket in "${path}"`);
220
+ }
221
+ const index = path.slice(i + 1, closeIndex);
222
+ const parsed = parseInt(index, 10);
223
+ // Numeric index → array access; non-numeric → object key
224
+ parts.push(isNaN(parsed) ? index : parsed);
225
+ i = closeIndex + 1;
226
+ // Skip dot after bracket if present
227
+ if (path[i] === '.')
228
+ i++;
229
+ }
230
+ else {
231
+ current += char;
232
+ i++;
233
+ }
234
+ }
235
+ if (current) {
236
+ parts.push(current);
237
+ }
238
+ return parts;
239
+ }
240
+ /**
241
+ * Get a nested value from an object using a path.
242
+ */
243
+ function getNestedValue(obj, path) {
244
+ if (!obj)
245
+ return undefined;
246
+ const parts = parsePath(path);
247
+ let current = obj;
248
+ for (const part of parts) {
249
+ if (current == null)
250
+ return undefined;
251
+ current = current[part];
252
+ }
253
+ return current;
254
+ }
255
+ /**
256
+ * Escape a string for safe use inside a CSS attribute selector.
257
+ * Uses CSS.escape when available, falls back to escaping all non-word chars.
258
+ */
259
+ function cssEscape(value) {
260
+ if (typeof CSS !== 'undefined' && CSS.escape) {
261
+ return CSS.escape(value);
262
+ }
263
+ return value.replace(/([^\w-])/g, '\\$1');
264
+ }
265
+ // Form Implementation
266
+ export function createForm(options = {}) {
267
+ const scope = getCurrentScope();
268
+ // State
269
+ const errors = signal({});
270
+ const formErrorSignal = signal(null);
271
+ const touchedSet = signal(new Set());
272
+ const dirtySet = signal(new Set());
273
+ const submittingSignal = signal(false);
274
+ const submitCountSignal = signal(0);
275
+ // Registry
276
+ const fieldRegistry = new Map();
277
+ const fieldArrayRegistry = new Map();
278
+ // Form element reference
279
+ let formElement = null;
280
+ // Submit abort controller
281
+ let submitController = null;
282
+ // Default values
283
+ let defaultValues = {};
284
+ let defaultsInitialized = false;
285
+ // Initialize defaults
286
+ (async () => {
287
+ try {
288
+ const dv = options.defaultValues;
289
+ if (dv) {
290
+ if (typeof dv === 'function') {
291
+ const result = dv();
292
+ defaultValues = result instanceof Promise ? await result : result;
293
+ }
294
+ else {
295
+ defaultValues = dv;
296
+ }
297
+ }
298
+ // Hydrate any field arrays created before async defaults resolved
299
+ for (const [path, array] of fieldArrayRegistry) {
300
+ if (array.length() === 0) {
301
+ const initialValue = getNestedValue(defaultValues, path);
302
+ if (Array.isArray(initialValue)) {
303
+ array.replace(initialValue);
304
+ }
305
+ }
306
+ }
307
+ }
308
+ catch (err) {
309
+ if (typeof console !== 'undefined') {
310
+ console.error('[Dalila] Failed to initialize form defaultValues:', err);
311
+ }
312
+ }
313
+ finally {
314
+ defaultsInitialized = true;
315
+ }
316
+ })();
317
+ // Validation mode
318
+ const validateOn = options.validateOn ?? 'submit';
319
+ let hasSubmitted = false;
320
+ // Error Management
321
+ function setError(path, message) {
322
+ errors.update((prev) => ({ ...prev, [path]: message }));
323
+ }
324
+ function setFormError(message) {
325
+ formErrorSignal.set(message);
326
+ }
327
+ function clearErrors(prefix) {
328
+ if (!prefix) {
329
+ errors.set({});
330
+ formErrorSignal.set(null);
331
+ return;
332
+ }
333
+ errors.update((prev) => {
334
+ const next = {};
335
+ for (const [key, value] of Object.entries(prev)) {
336
+ if (!key.startsWith(prefix)) {
337
+ next[key] = value;
338
+ }
339
+ }
340
+ return next;
341
+ });
342
+ }
343
+ function error(path) {
344
+ return errors()[path] ?? null;
345
+ }
346
+ function formError() {
347
+ return formErrorSignal();
348
+ }
349
+ // Touched / Dirty Management
350
+ function touched(path) {
351
+ return touchedSet().has(path);
352
+ }
353
+ function markTouched(path) {
354
+ touchedSet.update((prev) => {
355
+ const next = new Set(prev);
356
+ next.add(path);
357
+ return next;
358
+ });
359
+ }
360
+ function dirty(path) {
361
+ return dirtySet().has(path);
362
+ }
363
+ function markDirty(path, isDirty) {
364
+ dirtySet.update((prev) => {
365
+ const next = new Set(prev);
366
+ if (isDirty) {
367
+ next.add(path);
368
+ }
369
+ else {
370
+ next.delete(path);
371
+ }
372
+ return next;
373
+ });
374
+ }
375
+ // Validation
376
+ function validate(data) {
377
+ if (!options.validate)
378
+ return true;
379
+ clearErrors();
380
+ const result = options.validate(data);
381
+ if (!result)
382
+ return true;
383
+ // Check if result is object with fieldErrors/formError
384
+ if (typeof result === 'object' && result !== null) {
385
+ if ('fieldErrors' in result || 'formError' in result) {
386
+ const typedResult = result;
387
+ if (typedResult.fieldErrors) {
388
+ errors.set(typedResult.fieldErrors);
389
+ }
390
+ if (typedResult.formError) {
391
+ formErrorSignal.set(typedResult.formError);
392
+ }
393
+ // Check if fieldErrors is empty (not just truthy)
394
+ // Validator can return { fieldErrors: {}, formError: null } which is valid
395
+ const hasFieldErrors = typedResult.fieldErrors && Object.keys(typedResult.fieldErrors).length > 0;
396
+ const hasFormError = typedResult.formError && typedResult.formError.length > 0;
397
+ return !hasFieldErrors && !hasFormError;
398
+ }
399
+ else {
400
+ // Assume it's FieldErrors
401
+ const fieldErrors = result;
402
+ errors.set(fieldErrors);
403
+ return Object.keys(fieldErrors).length === 0;
404
+ }
405
+ }
406
+ return true;
407
+ }
408
+ // Field Registry
409
+ function _registerField(path, element) {
410
+ fieldRegistry.set(path, element);
411
+ // Helper to get current path from DOM (handles array reordering)
412
+ // After d-array reorders items, data-field-path is updated but handlers
413
+ // were registered with the old path. This helper reads the current path.
414
+ const getCurrentPath = () => {
415
+ return element.getAttribute('data-field-path') || element.getAttribute('name') || path;
416
+ };
417
+ // Setup blur handler for touched + validation
418
+ const handleBlur = () => {
419
+ // Use dynamic path lookup instead of captured path
420
+ const currentPath = getCurrentPath();
421
+ markTouched(currentPath);
422
+ if (hasSubmitted && validateOn === 'blur' && formElement) {
423
+ const fd = new FormData(formElement);
424
+ const parser = options.parse ?? parseFormData;
425
+ const data = parser(formElement, fd);
426
+ validate(data);
427
+ }
428
+ };
429
+ element.addEventListener('blur', handleBlur);
430
+ // Setup change handler for dirty + validation
431
+ const handleChange = () => {
432
+ if (!defaultsInitialized)
433
+ return;
434
+ // Use dynamic path lookup instead of captured path
435
+ const currentPath = getCurrentPath();
436
+ const input = element;
437
+ const defaultValue = getNestedValue(defaultValues, currentPath);
438
+ let currentValue;
439
+ // Normalize currentValue to match defaultValue type/shape
440
+ // This prevents checkbox groups, select multiple, and number inputs
441
+ // from staying permanently dirty when user restores default state
442
+ if (input.type === 'checkbox') {
443
+ if (Array.isArray(defaultValue)) {
444
+ // Multiple checkboxes: collect all checked values with same name
445
+ const checkboxes = formElement?.querySelectorAll(`input[type="checkbox"][name="${cssEscape(currentPath)}"]`);
446
+ currentValue = Array.from(checkboxes || [])
447
+ .filter(cb => cb.checked)
448
+ .map(cb => cb.value);
449
+ }
450
+ else {
451
+ // Single checkbox: boolean
452
+ currentValue = input.checked;
453
+ }
454
+ }
455
+ else if (input.type === 'file') {
456
+ currentValue = input.files?.[0];
457
+ }
458
+ else if (input.type === 'number') {
459
+ // Parse to number to match default type
460
+ const num = parseFloat(input.value);
461
+ currentValue = isNaN(num) ? input.value : num;
462
+ }
463
+ else if (element.tagName === 'SELECT') {
464
+ // Select multiple: collect selected values as array
465
+ const select = element;
466
+ if (select.multiple) {
467
+ currentValue = Array.from(select.selectedOptions).map(opt => opt.value);
468
+ }
469
+ else {
470
+ currentValue = select.value;
471
+ }
472
+ }
473
+ else {
474
+ currentValue = input.value;
475
+ }
476
+ // Deep comparison for arrays
477
+ let isDirty;
478
+ if (Array.isArray(currentValue) && Array.isArray(defaultValue)) {
479
+ isDirty = currentValue.length !== defaultValue.length ||
480
+ currentValue.some((v, i) => v !== defaultValue[i]);
481
+ }
482
+ else {
483
+ isDirty = currentValue !== defaultValue;
484
+ }
485
+ markDirty(currentPath, isDirty);
486
+ if (hasSubmitted && validateOn === 'change' && formElement) {
487
+ const fd = new FormData(formElement);
488
+ const parser = options.parse ?? parseFormData;
489
+ const data = parser(formElement, fd);
490
+ validate(data);
491
+ }
492
+ };
493
+ element.addEventListener('change', handleChange);
494
+ element.addEventListener('input', handleChange);
495
+ // Cleanup - use initial path since that's what was registered
496
+ const cleanup = () => {
497
+ fieldRegistry.delete(path);
498
+ element.removeEventListener('blur', handleBlur);
499
+ element.removeEventListener('change', handleChange);
500
+ element.removeEventListener('input', handleChange);
501
+ };
502
+ if (scope) {
503
+ scope.onCleanup(cleanup);
504
+ }
505
+ return cleanup;
506
+ }
507
+ // Submit Handler
508
+ function handleSubmit(handler) {
509
+ const wrappedHandler = async (ev) => {
510
+ ev.preventDefault();
511
+ const form = ev.target;
512
+ formElement = form;
513
+ // Cancel previous submit
514
+ submitController?.abort();
515
+ submitController = new AbortController();
516
+ const signal = submitController.signal;
517
+ const localController = submitController;
518
+ submittingSignal.set(true);
519
+ submitCountSignal.update((n) => n + 1);
520
+ hasSubmitted = true;
521
+ clearErrors();
522
+ try {
523
+ // Parse form data
524
+ const fd = new FormData(form);
525
+ const parser = options.parse ?? parseFormData;
526
+ const data = parser(form, fd);
527
+ // Validate
528
+ const isValid = validate(data);
529
+ if (!isValid) {
530
+ focus(); // Focus first error
531
+ return;
532
+ }
533
+ // Call handler
534
+ await handler(data, { signal });
535
+ // If aborted, don't do anything
536
+ if (signal.aborted)
537
+ return;
538
+ // Success: clear form state
539
+ touchedSet.set(new Set());
540
+ dirtySet.set(new Set());
541
+ }
542
+ catch (err) {
543
+ // If aborted, don't do anything
544
+ if (signal.aborted)
545
+ return;
546
+ // Transform server errors
547
+ if (options.transformServerErrors) {
548
+ const transformed = options.transformServerErrors(err);
549
+ if (transformed) {
550
+ if (transformed.fieldErrors) {
551
+ errors.set(transformed.fieldErrors);
552
+ }
553
+ if (transformed.formError) {
554
+ formErrorSignal.set(transformed.formError);
555
+ }
556
+ }
557
+ }
558
+ else {
559
+ // Default: set generic form error
560
+ formErrorSignal.set(err instanceof Error ? err.message : 'An error occurred');
561
+ }
562
+ focus(); // Focus first error
563
+ }
564
+ finally {
565
+ // Only update submitting if this is still the latest submit
566
+ if (submitController === localController) {
567
+ submittingSignal.set(false);
568
+ }
569
+ }
570
+ };
571
+ // Mark as wrapped to prevent double-wrapping in bindForm
572
+ wrappedHandler[WRAPPED_HANDLER] = true;
573
+ return wrappedHandler;
574
+ }
575
+ // Focus Management
576
+ // Find element by current DOM path instead of stale registry
577
+ // After d-array reorders, data-field-path is updated but registry has old keys
578
+ function findFieldElement(path) {
579
+ if (!formElement)
580
+ return null;
581
+ const escapePath = cssEscape(path);
582
+ // Prioritize [d-field] to avoid matching hidden inputs
583
+ // Try data-field-path first (set by d-array), then name attribute
584
+ return formElement.querySelector(`[d-field][data-field-path="${escapePath}"], [d-field][name="${escapePath}"]`);
585
+ }
586
+ function focus(path) {
587
+ if (path) {
588
+ const el = findFieldElement(path);
589
+ if (el && 'focus' in el) {
590
+ el.focus();
591
+ }
592
+ return;
593
+ }
594
+ // Focus first error
595
+ const errorEntries = Object.entries(errors());
596
+ if (errorEntries.length === 0)
597
+ return;
598
+ const [firstErrorPath] = errorEntries[0];
599
+ const el = findFieldElement(firstErrorPath);
600
+ if (el && 'focus' in el) {
601
+ el.focus();
602
+ }
603
+ }
604
+ // Reset
605
+ function reset(nextDefaults) {
606
+ if (nextDefaults !== undefined) {
607
+ defaultValues = nextDefaults;
608
+ }
609
+ // Reset form element
610
+ if (formElement) {
611
+ formElement.reset();
612
+ // Find fields by DOM query instead of stale registry
613
+ // After d-array reorders, data-field-path reflects current indices
614
+ const allFields = formElement.querySelectorAll('[d-field]');
615
+ for (const el of allFields) {
616
+ // Get current path from DOM (handles reordered arrays)
617
+ const fieldPath = el.getAttribute('data-field-path') || el.getAttribute('name');
618
+ if (!fieldPath)
619
+ continue;
620
+ const defaultValue = getNestedValue(defaultValues, fieldPath);
621
+ const input = el;
622
+ if (defaultValue !== undefined) {
623
+ if (input.type === 'checkbox') {
624
+ // Respect checkbox contract (single = boolean, multiple = array)
625
+ if (Array.isArray(defaultValue)) {
626
+ // Multiple checkboxes: check if value is in array
627
+ input.checked = defaultValue.includes(input.value);
628
+ }
629
+ else {
630
+ // Single checkbox: use boolean value
631
+ input.checked = !!defaultValue;
632
+ }
633
+ }
634
+ else if (input.type === 'radio') {
635
+ input.checked = input.value === String(defaultValue);
636
+ }
637
+ else if (input.tagName === 'SELECT') {
638
+ const select = el;
639
+ if (select.multiple && Array.isArray(defaultValue)) {
640
+ // Handle select multiple
641
+ for (const option of Array.from(select.options)) {
642
+ option.selected = defaultValue.includes(option.value);
643
+ }
644
+ }
645
+ else {
646
+ select.value = String(defaultValue);
647
+ }
648
+ }
649
+ else {
650
+ input.value = String(defaultValue);
651
+ }
652
+ }
653
+ }
654
+ }
655
+ // Reset state
656
+ clearErrors();
657
+ touchedSet.set(new Set());
658
+ dirtySet.set(new Set());
659
+ submitCountSignal.set(0);
660
+ hasSubmitted = false;
661
+ // Reset field arrays to default values
662
+ for (const [path, array] of fieldArrayRegistry) {
663
+ const defaultValue = getNestedValue(defaultValues, path);
664
+ if (Array.isArray(defaultValue)) {
665
+ array.replace(defaultValue);
666
+ }
667
+ else {
668
+ array.clear();
669
+ }
670
+ }
671
+ // Abort any in-flight submit
672
+ submitController?.abort();
673
+ submitController = null;
674
+ submittingSignal.set(false);
675
+ }
676
+ // Field Arrays
677
+ function fieldArray(path) {
678
+ // Return existing if already created
679
+ if (fieldArrayRegistry.has(path)) {
680
+ return fieldArrayRegistry.get(path);
681
+ }
682
+ // Create new field array with meta-state remapping support
683
+ const array = createFieldArray(path, {
684
+ form: formElement,
685
+ scope,
686
+ // Pass meta-state signals for remapping on reorder
687
+ errors,
688
+ touchedSet,
689
+ dirtySet,
690
+ });
691
+ // Initialize from defaultValues if available
692
+ // When d-array is first rendered, it should start with values from defaultValues
693
+ if (defaultsInitialized) {
694
+ const initialValue = getNestedValue(defaultValues, path);
695
+ if (Array.isArray(initialValue)) {
696
+ array.replace(initialValue);
697
+ }
698
+ }
699
+ fieldArrayRegistry.set(path, array);
700
+ return array;
701
+ }
702
+ // Getters
703
+ function submitting() {
704
+ return submittingSignal();
705
+ }
706
+ function submitCount() {
707
+ return submitCountSignal();
708
+ }
709
+ function _getFormElement() {
710
+ return formElement;
711
+ }
712
+ function _setFormElement(form) {
713
+ formElement = form;
714
+ }
715
+ // Cleanup on scope disposal
716
+ if (scope) {
717
+ scope.onCleanup(() => {
718
+ submitController?.abort();
719
+ fieldRegistry.clear();
720
+ fieldArrayRegistry.clear();
721
+ });
722
+ }
723
+ return {
724
+ handleSubmit,
725
+ reset,
726
+ setError,
727
+ setFormError,
728
+ clearErrors,
729
+ error,
730
+ formError,
731
+ touched,
732
+ dirty,
733
+ submitting,
734
+ submitCount,
735
+ focus,
736
+ _registerField,
737
+ _getFormElement,
738
+ _setFormElement,
739
+ fieldArray,
740
+ };
741
+ }
742
+ function createFieldArray(basePath, options) {
743
+ const keys = signal([]);
744
+ const values = signal(new Map());
745
+ let keyCounter = 0;
746
+ function generateKey() {
747
+ return `${basePath}_${keyCounter++}`;
748
+ }
749
+ // Helper to remap meta-state paths when array order changes
750
+ function remapMetaState(oldIndices, newIndices) {
751
+ if (!options.errors && !options.touchedSet && !options.dirtySet)
752
+ return;
753
+ // Build index mapping: oldIndex -> newIndex
754
+ const indexMap = new Map();
755
+ for (let i = 0; i < oldIndices.length; i++) {
756
+ indexMap.set(oldIndices[i], newIndices[i]);
757
+ }
758
+ // Remap errors
759
+ if (options.errors) {
760
+ options.errors.update((prev) => {
761
+ const next = {};
762
+ for (const [path, message] of Object.entries(prev)) {
763
+ const newPath = remapPath(path, indexMap);
764
+ next[newPath] = message;
765
+ }
766
+ return next;
767
+ });
768
+ }
769
+ // Remap touched
770
+ if (options.touchedSet) {
771
+ options.touchedSet.update((prev) => {
772
+ const next = new Set();
773
+ for (const path of prev) {
774
+ next.add(remapPath(path, indexMap));
775
+ }
776
+ return next;
777
+ });
778
+ }
779
+ // Remap dirty
780
+ if (options.dirtySet) {
781
+ options.dirtySet.update((prev) => {
782
+ const next = new Set();
783
+ for (const path of prev) {
784
+ next.add(remapPath(path, indexMap));
785
+ }
786
+ return next;
787
+ });
788
+ }
789
+ }
790
+ function remapPath(path, indexMap) {
791
+ // Match paths like "basePath[index].field" or "basePath[index]"
792
+ const regex = new RegExp(`^${escapeRegExp(basePath)}\\[(\\d+)\\](.*)$`);
793
+ const match = path.match(regex);
794
+ if (!match)
795
+ return path;
796
+ const oldIndex = parseInt(match[1], 10);
797
+ const rest = match[2];
798
+ const newIndex = indexMap.get(oldIndex);
799
+ if (newIndex === undefined)
800
+ return path;
801
+ return `${basePath}[${newIndex}]${rest}`;
802
+ }
803
+ function escapeRegExp(string) {
804
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
805
+ }
806
+ // Accessors
807
+ function fields() {
808
+ const currentKeys = keys();
809
+ const currentValues = values();
810
+ return currentKeys.map((key) => ({
811
+ key,
812
+ value: currentValues.get(key),
813
+ }));
814
+ }
815
+ function length() {
816
+ return keys().length;
817
+ }
818
+ function _getIndex(key) {
819
+ return keys().indexOf(key);
820
+ }
821
+ function _translatePath(path) {
822
+ // Translate index-based path to key-based path
823
+ // Example: "phones[0].number" → "phones:key123.number"
824
+ const match = path.match(/^([^\[]+)\[(\d+)\](.*)$/);
825
+ if (!match)
826
+ return null;
827
+ const [, arrayPath, indexStr, rest] = match;
828
+ if (arrayPath !== basePath)
829
+ return null;
830
+ const index = parseInt(indexStr, 10);
831
+ const currentKeys = keys();
832
+ const key = currentKeys[index];
833
+ if (!key)
834
+ return null;
835
+ return `${arrayPath}:${key}${rest}`;
836
+ }
837
+ // Mutations
838
+ function append(value) {
839
+ const items = Array.isArray(value) ? value : [value];
840
+ const newKeys = items.map(() => generateKey());
841
+ keys.update((prev) => [...prev, ...newKeys]);
842
+ values.update((prev) => {
843
+ const next = new Map(prev);
844
+ newKeys.forEach((key, i) => next.set(key, items[i]));
845
+ return next;
846
+ });
847
+ }
848
+ function remove(key) {
849
+ const removeIndex = _getIndex(key);
850
+ const currentLength = keys().length;
851
+ keys.update((prev) => prev.filter((k) => k !== key));
852
+ values.update((prev) => {
853
+ const next = new Map(prev);
854
+ next.delete(key);
855
+ return next;
856
+ });
857
+ // Clear meta-state for the removed index, then remap remaining indices
858
+ // to keep errors/touched/dirty aligned with current rows.
859
+ if (removeIndex >= 0) {
860
+ const prefix = `${basePath}[${removeIndex}]`;
861
+ // Clear errors for removed item
862
+ if (options.errors) {
863
+ options.errors.update((prev) => {
864
+ const next = {};
865
+ for (const [path, message] of Object.entries(prev)) {
866
+ if (!path.startsWith(prefix)) {
867
+ next[path] = message;
868
+ }
869
+ }
870
+ return next;
871
+ });
872
+ }
873
+ // Clear touched for removed item
874
+ if (options.touchedSet) {
875
+ options.touchedSet.update((prev) => {
876
+ const next = new Set();
877
+ for (const path of prev) {
878
+ if (!path.startsWith(prefix)) {
879
+ next.add(path);
880
+ }
881
+ }
882
+ return next;
883
+ });
884
+ }
885
+ // Clear dirty for removed item
886
+ if (options.dirtySet) {
887
+ options.dirtySet.update((prev) => {
888
+ const next = new Set();
889
+ for (const path of prev) {
890
+ if (!path.startsWith(prefix)) {
891
+ next.add(path);
892
+ }
893
+ }
894
+ return next;
895
+ });
896
+ }
897
+ // Remap indices after removed item (shift down)
898
+ const oldIndices = [];
899
+ const newIndices = [];
900
+ for (let i = removeIndex + 1; i < currentLength; i++) {
901
+ oldIndices.push(i);
902
+ newIndices.push(i - 1);
903
+ }
904
+ if (oldIndices.length > 0) {
905
+ remapMetaState(oldIndices, newIndices);
906
+ }
907
+ }
908
+ }
909
+ function removeAt(index) {
910
+ if (index < 0 || index >= keys().length)
911
+ return;
912
+ const key = keys()[index];
913
+ if (key)
914
+ remove(key);
915
+ }
916
+ function insert(index, value) {
917
+ const len = keys().length;
918
+ if (index < 0 || index > len)
919
+ return;
920
+ const key = generateKey();
921
+ const currentLength = len;
922
+ // Remap meta-state so indices at and after insert point shift up
923
+ const oldIndices = [];
924
+ const newIndices = [];
925
+ for (let i = index; i < currentLength; i++) {
926
+ oldIndices.push(i);
927
+ newIndices.push(i + 1);
928
+ }
929
+ if (oldIndices.length > 0) {
930
+ remapMetaState(oldIndices, newIndices);
931
+ }
932
+ keys.update((prev) => {
933
+ const next = [...prev];
934
+ next.splice(index, 0, key);
935
+ return next;
936
+ });
937
+ values.update((prev) => {
938
+ const next = new Map(prev);
939
+ next.set(key, value);
940
+ return next;
941
+ });
942
+ }
943
+ function move(fromIndex, toIndex) {
944
+ const len = keys().length;
945
+ if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len)
946
+ return;
947
+ if (fromIndex === toIndex)
948
+ return;
949
+ // Build index remapping for meta-state
950
+ const oldIndices = [];
951
+ const newIndices = [];
952
+ if (fromIndex < toIndex) {
953
+ // Moving forward: items between shift down
954
+ oldIndices.push(fromIndex);
955
+ newIndices.push(toIndex);
956
+ for (let i = fromIndex + 1; i <= toIndex; i++) {
957
+ oldIndices.push(i);
958
+ newIndices.push(i - 1);
959
+ }
960
+ }
961
+ else {
962
+ // Moving backward: items between shift up
963
+ oldIndices.push(fromIndex);
964
+ newIndices.push(toIndex);
965
+ for (let i = toIndex; i < fromIndex; i++) {
966
+ oldIndices.push(i);
967
+ newIndices.push(i + 1);
968
+ }
969
+ }
970
+ remapMetaState(oldIndices, newIndices);
971
+ keys.update((prev) => {
972
+ const next = [...prev];
973
+ const [item] = next.splice(fromIndex, 1);
974
+ next.splice(toIndex, 0, item);
975
+ return next;
976
+ });
977
+ }
978
+ function swap(indexA, indexB) {
979
+ const len = keys().length;
980
+ if (indexA < 0 || indexA >= len || indexB < 0 || indexB >= len)
981
+ return;
982
+ if (indexA === indexB)
983
+ return;
984
+ // Remap meta-state for swap
985
+ remapMetaState([indexA, indexB], [indexB, indexA]);
986
+ keys.update((prev) => {
987
+ const next = [...prev];
988
+ [next[indexA], next[indexB]] = [next[indexB], next[indexA]];
989
+ return next;
990
+ });
991
+ }
992
+ function replace(newValues) {
993
+ const newKeys = newValues.map(() => generateKey());
994
+ // Clear all meta-state for this array (full replacement)
995
+ if (options.errors) {
996
+ options.errors.update((prev) => {
997
+ const next = {};
998
+ for (const [path, message] of Object.entries(prev)) {
999
+ if (!path.startsWith(`${basePath}[`)) {
1000
+ next[path] = message;
1001
+ }
1002
+ }
1003
+ return next;
1004
+ });
1005
+ }
1006
+ if (options.touchedSet) {
1007
+ options.touchedSet.update((prev) => {
1008
+ const next = new Set();
1009
+ for (const path of prev) {
1010
+ if (!path.startsWith(`${basePath}[`)) {
1011
+ next.add(path);
1012
+ }
1013
+ }
1014
+ return next;
1015
+ });
1016
+ }
1017
+ if (options.dirtySet) {
1018
+ options.dirtySet.update((prev) => {
1019
+ const next = new Set();
1020
+ for (const path of prev) {
1021
+ if (!path.startsWith(`${basePath}[`)) {
1022
+ next.add(path);
1023
+ }
1024
+ }
1025
+ return next;
1026
+ });
1027
+ }
1028
+ keys.set(newKeys);
1029
+ values.set(new Map(newKeys.map((key, i) => [key, newValues[i]])));
1030
+ }
1031
+ function update(key, value) {
1032
+ values.update((prev) => {
1033
+ const next = new Map(prev);
1034
+ next.set(key, value);
1035
+ return next;
1036
+ });
1037
+ }
1038
+ function updateAt(index, value) {
1039
+ if (index < 0 || index >= keys().length)
1040
+ return;
1041
+ const key = keys()[index];
1042
+ if (key)
1043
+ update(key, value);
1044
+ }
1045
+ function clear() {
1046
+ // Clear meta-state to prevent stale errors/touched/dirty
1047
+ // on new items appended after clear(). Delegate to replace([])
1048
+ // which already handles full meta-state cleanup.
1049
+ replace([]);
1050
+ }
1051
+ // Cleanup
1052
+ if (options.scope) {
1053
+ options.scope.onCleanup(() => {
1054
+ clear();
1055
+ });
1056
+ }
1057
+ return {
1058
+ fields,
1059
+ append,
1060
+ remove,
1061
+ removeAt,
1062
+ insert,
1063
+ move,
1064
+ swap,
1065
+ replace,
1066
+ update,
1067
+ updateAt,
1068
+ clear,
1069
+ length,
1070
+ _getIndex,
1071
+ _translatePath,
1072
+ };
1073
+ }