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.
- package/README.md +47 -0
- package/dist/componentes/ui/accordion/index.d.ts +2 -0
- package/dist/componentes/ui/accordion/index.js +114 -0
- package/dist/componentes/ui/calendar/index.d.ts +2 -0
- package/dist/componentes/ui/calendar/index.js +132 -0
- package/dist/componentes/ui/combobox/index.d.ts +2 -0
- package/dist/componentes/ui/combobox/index.js +161 -0
- package/dist/componentes/ui/dialog/index.d.ts +10 -0
- package/dist/componentes/ui/dialog/index.js +54 -0
- package/dist/componentes/ui/drawer/index.d.ts +2 -0
- package/dist/componentes/ui/drawer/index.js +41 -0
- package/dist/componentes/ui/dropdown/index.d.ts +2 -0
- package/dist/componentes/ui/dropdown/index.js +48 -0
- package/dist/componentes/ui/dropzone/index.d.ts +2 -0
- package/dist/componentes/ui/dropzone/index.js +92 -0
- package/dist/componentes/ui/env.d.ts +1 -0
- package/dist/componentes/ui/env.js +2 -0
- package/dist/componentes/ui/index.d.ts +13 -0
- package/dist/componentes/ui/index.js +12 -0
- package/dist/componentes/ui/popover/index.d.ts +2 -0
- package/dist/componentes/ui/popover/index.js +156 -0
- package/dist/componentes/ui/runtime.d.ts +20 -0
- package/dist/componentes/ui/runtime.js +421 -0
- package/dist/componentes/ui/tabs/index.d.ts +3 -0
- package/dist/componentes/ui/tabs/index.js +101 -0
- package/dist/componentes/ui/toast/index.d.ts +3 -0
- package/dist/componentes/ui/toast/index.js +115 -0
- package/dist/componentes/ui/ui-types.d.ts +175 -0
- package/dist/componentes/ui/ui-types.js +1 -0
- package/dist/componentes/ui/validate.d.ts +7 -0
- package/dist/componentes/ui/validate.js +71 -0
- package/dist/components/ui/accordion/index.d.ts +2 -0
- package/dist/components/ui/accordion/index.js +114 -0
- package/dist/components/ui/calendar/index.d.ts +2 -0
- package/dist/components/ui/calendar/index.js +132 -0
- package/dist/components/ui/combobox/index.d.ts +2 -0
- package/dist/components/ui/combobox/index.js +161 -0
- package/dist/components/ui/dialog/index.d.ts +10 -0
- package/dist/components/ui/dialog/index.js +54 -0
- package/dist/components/ui/drawer/index.d.ts +2 -0
- package/dist/components/ui/drawer/index.js +41 -0
- package/dist/components/ui/dropdown/index.d.ts +2 -0
- package/dist/components/ui/dropdown/index.js +48 -0
- package/dist/components/ui/dropzone/index.d.ts +2 -0
- package/dist/components/ui/dropzone/index.js +92 -0
- package/dist/components/ui/env.d.ts +1 -0
- package/dist/components/ui/env.js +2 -0
- package/dist/components/ui/index.d.ts +13 -0
- package/dist/components/ui/index.js +12 -0
- package/dist/components/ui/popover/index.d.ts +2 -0
- package/dist/components/ui/popover/index.js +156 -0
- package/dist/components/ui/runtime.d.ts +20 -0
- package/dist/components/ui/runtime.js +421 -0
- package/dist/components/ui/tabs/index.d.ts +3 -0
- package/dist/components/ui/tabs/index.js +101 -0
- package/dist/components/ui/toast/index.d.ts +3 -0
- package/dist/components/ui/toast/index.js +115 -0
- package/dist/components/ui/ui-types.d.ts +175 -0
- package/dist/components/ui/ui-types.js +1 -0
- package/dist/components/ui/validate.d.ts +7 -0
- package/dist/components/ui/validate.js +71 -0
- package/dist/form/form-types.d.ts +181 -0
- package/dist/form/form-types.js +4 -0
- package/dist/form/form.d.ts +71 -0
- package/dist/form/form.js +1073 -0
- package/dist/form/index.d.ts +2 -0
- package/dist/form/index.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runtime/bind.js +567 -9
- package/dist/ui/accordion.d.ts +2 -0
- package/dist/ui/accordion.js +114 -0
- package/dist/ui/calendar.d.ts +2 -0
- package/dist/ui/calendar.js +132 -0
- package/dist/ui/combobox.d.ts +2 -0
- package/dist/ui/combobox.js +161 -0
- package/dist/ui/dialog.d.ts +10 -0
- package/dist/ui/dialog.js +54 -0
- package/dist/ui/drawer.d.ts +2 -0
- package/dist/ui/drawer.js +41 -0
- package/dist/ui/dropdown.d.ts +2 -0
- package/dist/ui/dropdown.js +48 -0
- package/dist/ui/dropzone.d.ts +2 -0
- package/dist/ui/dropzone.js +92 -0
- package/dist/ui/env.d.ts +1 -0
- package/dist/ui/env.js +2 -0
- package/dist/ui/index.d.ts +13 -0
- package/dist/ui/index.js +12 -0
- package/dist/ui/popover.d.ts +2 -0
- package/dist/ui/popover.js +156 -0
- package/dist/ui/runtime.d.ts +20 -0
- package/dist/ui/runtime.js +421 -0
- package/dist/ui/tabs.d.ts +3 -0
- package/dist/ui/tabs.js +101 -0
- package/dist/ui/toast.d.ts +3 -0
- package/dist/ui/toast.js +115 -0
- package/dist/ui/ui-types.d.ts +175 -0
- package/dist/ui/ui-types.js +1 -0
- package/dist/ui/validate.d.ts +7 -0
- package/dist/ui/validate.js +71 -0
- package/package.json +60 -2
- package/src/components/ui/accordion/accordion.css +90 -0
- package/src/components/ui/alert/alert.css +78 -0
- package/src/components/ui/avatar/avatar.css +45 -0
- package/src/components/ui/badge/badge.css +71 -0
- package/src/components/ui/breadcrumb/breadcrumb.css +41 -0
- package/src/components/ui/button/button.css +135 -0
- package/src/components/ui/calendar/calendar.css +96 -0
- package/src/components/ui/card/card.css +93 -0
- package/src/components/ui/checkbox/checkbox.css +57 -0
- package/src/components/ui/chip/chip.css +62 -0
- package/src/components/ui/collapsible/collapsible.css +61 -0
- package/src/components/ui/combobox/combobox.css +85 -0
- package/src/components/ui/dalila/dalila.css +42 -0
- package/src/components/ui/dalila-core/dalila-core.css +14 -0
- package/src/components/ui/dialog/dialog.css +125 -0
- package/src/components/ui/drawer/drawer.css +122 -0
- package/src/components/ui/dropdown/dropdown.css +87 -0
- package/src/components/ui/dropzone/dropzone.css +47 -0
- package/src/components/ui/empty-state/empty-state.css +33 -0
- package/src/components/ui/form/form.css +44 -0
- package/src/components/ui/input/input.css +106 -0
- package/src/components/ui/layout/layout.css +62 -0
- package/src/components/ui/pagination/pagination.css +55 -0
- package/src/components/ui/popover/popover.css +55 -0
- package/src/components/ui/radio/radio.css +56 -0
- package/src/components/ui/separator/separator.css +38 -0
- package/src/components/ui/skeleton/skeleton.css +57 -0
- package/src/components/ui/slider/slider.css +60 -0
- package/src/components/ui/spinner/spinner.css +38 -0
- package/src/components/ui/table/table.css +54 -0
- package/src/components/ui/tabs/tabs.css +74 -0
- package/src/components/ui/toast/toast.css +100 -0
- package/src/components/ui/toggle/toggle.css +90 -0
- package/src/components/ui/tokens/tokens.css +161 -0
- package/src/components/ui/tooltip/tooltip.css +53 -0
- 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
|
+
}
|