Skip to content

Commit 4cf3279

Browse files
committed
feat: expose field touched state via proxy API
1 parent 9d8489a commit 4cf3279

File tree

5 files changed

+126
-32
lines changed

5 files changed

+126
-32
lines changed

packages/kit/src/exports/public.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1878,6 +1878,8 @@ type RemoteFormFieldMethods<T> = {
18781878
value(): T;
18791879
/** Set the values that will be submitted */
18801880
set(input: T): T;
1881+
/** Whether the field or any nested field has been interacted with since the form was mounted */
1882+
touched(): boolean;
18811883
/** Validation issues, if any */
18821884
issues(): RemoteFormIssue[] | undefined;
18831885
};

packages/kit/src/runtime/app/server/remote/form.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
throw_on_old_property_access,
1111
deep_set,
1212
normalize_issue,
13-
flatten_issues
13+
flatten_issues,
14+
build_path_string
1415
} from '../../../form-utils.js';
1516
import { get_cache, run_remote_function } from './shared.js';
1617

@@ -210,7 +211,6 @@ export function form(validate_or_fn, maybe_fn) {
210211
Object.defineProperty(instance, 'fields', {
211212
get() {
212213
const data = get_cache(__)?.[''];
213-
const issues = flatten_issues(data?.issues ?? []);
214214

215215
return create_field_proxy(
216216
{},
@@ -224,9 +224,17 @@ export function form(validate_or_fn, maybe_fn) {
224224
const input =
225225
path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value);
226226

227-
(get_cache(__)[''] ??= {}).input = input;
227+
const cache = get_cache(__);
228+
const entry = (cache[''] ??= {});
229+
entry.input = input;
230+
231+
if (path.length > 0) {
232+
const key = build_path_string(path);
233+
(entry.touched ??= {})[key] = true;
234+
}
228235
},
229-
() => issues
236+
() => flatten_issues(data?.issues ?? []),
237+
() => data?.touched ?? {}
230238
);
231239
}
232240
});

packages/kit/src/runtime/client/remote-functions/form.svelte.js

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -317,15 +317,15 @@ export function form(id) {
317317
// but that makes the types unnecessarily awkward
318318
const element = /** @type {HTMLInputElement} */ (e.target);
319319

320-
let name = element.name;
321-
if (!name) return;
320+
const original_name = element.name;
321+
if (!original_name) return;
322322

323-
const is_array = name.endsWith('[]');
324-
if (is_array) name = name.slice(0, -2);
323+
const is_array = original_name.endsWith('[]');
324+
const base_name = is_array ? original_name.slice(0, -2) : original_name;
325325

326326
const is_file = element.type === 'file';
327327

328-
touched[name] = true;
328+
const sanitized_name = base_name.replace(/^[nb]:/, '');
329329

330330
if (is_array) {
331331
let value;
@@ -337,7 +337,7 @@ export function form(id) {
337337
);
338338
} else {
339339
const elements = /** @type {HTMLInputElement[]} */ (
340-
Array.from(form.querySelectorAll(`[name="${name}[]"]`))
340+
Array.from(form.querySelectorAll(`[name="${base_name}[]"]`))
341341
);
342342

343343
if (DEV) {
@@ -358,39 +358,38 @@ export function form(id) {
358358
}
359359
}
360360

361-
set_nested_value(input, name, value);
361+
set_nested_value(input, base_name, value);
362362
} else if (is_file) {
363363
if (DEV && element.multiple) {
364364
throw new Error(
365-
`Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"`
365+
`Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${sanitized_name}" to "${sanitized_name}[]"`
366366
);
367367
}
368368

369369
const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0];
370370

371371
if (file) {
372-
set_nested_value(input, name, file);
372+
set_nested_value(input, base_name, file);
373373
} else {
374374
// Remove the property by setting to undefined and clean up
375-
const path_parts = name.split(/\.|\[|\]/).filter(Boolean);
375+
const path_parts = sanitized_name.split(/\.|\[|\]/).filter(Boolean);
376376
let current = /** @type {any} */ (input);
377377
for (let i = 0; i < path_parts.length - 1; i++) {
378-
if (current[path_parts[i]] == null) return;
379-
current = current[path_parts[i]];
378+
const part = path_parts[i];
379+
if (current[part] == null) return;
380+
current = current[part];
380381
}
381382
delete current[path_parts[path_parts.length - 1]];
382383
}
383384
} else {
384385
set_nested_value(
385386
input,
386-
name,
387+
base_name,
387388
element.type === 'checkbox' && !element.checked ? null : element.value
388389
);
389390
}
390391

391-
name = name.replace(/^[nb]:/, '');
392-
393-
touched[name] = true;
392+
touched[sanitized_name] = true;
394393
});
395394

396395
form.addEventListener('reset', async () => {
@@ -399,6 +398,7 @@ export function form(id) {
399398
await tick();
400399

401400
input = convert_formdata(new FormData(form));
401+
touched = {};
402402
});
403403

404404
return () => {
@@ -501,7 +501,8 @@ export function form(id) {
501501
touched[key] = true;
502502
}
503503
},
504-
() => issues
504+
() => issues,
505+
() => touched
505506
)
506507
},
507508
result: {

packages/kit/src/runtime/form-utils.js

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,18 @@ export function deep_get(object, path) {
207207
* @param {() => Record<string, any>} get_input - Function to get current input data
208208
* @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data
209209
* @param {() => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues
210+
* @param {() => Record<string, boolean>} [get_touched] - Function to get touched fields
210211
* @param {(string | number)[]} path - Current access path
211212
* @returns {any} Proxy object with name(), value(), and issues() methods
212213
*/
213-
export function create_field_proxy(target, get_input, set_input, get_issues, path = []) {
214+
export function create_field_proxy(
215+
target,
216+
get_input,
217+
set_input,
218+
get_issues,
219+
get_touched = () => ({}),
220+
path = []
221+
) {
214222
const get_value = () => {
215223
return deep_get(get_input(), path);
216224
};
@@ -221,10 +229,14 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
221229

222230
// Handle array access like jobs[0]
223231
if (/^\d+$/.test(prop)) {
224-
return create_field_proxy({}, get_input, set_input, get_issues, [
225-
...path,
226-
parseInt(prop, 10)
227-
]);
232+
return create_field_proxy(
233+
{},
234+
get_input,
235+
set_input,
236+
get_issues,
237+
get_touched,
238+
[...path, parseInt(prop, 10)]
239+
);
228240
}
229241

230242
const key = build_path_string(path);
@@ -234,11 +246,26 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
234246
set_input(path, newValue);
235247
return newValue;
236248
};
237-
return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]);
249+
250+
return create_field_proxy(
251+
set_func,
252+
get_input,
253+
set_input,
254+
get_issues,
255+
get_touched,
256+
[...path, prop]
257+
);
238258
}
239259

240260
if (prop === 'value') {
241-
return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]);
261+
return create_field_proxy(
262+
get_value,
263+
get_input,
264+
set_input,
265+
get_issues,
266+
get_touched,
267+
[...path, prop]
268+
);
242269
}
243270

244271
if (prop === 'issues' || prop === 'allIssues') {
@@ -258,7 +285,47 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
258285
}));
259286
};
260287

261-
return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]);
288+
return create_field_proxy(
289+
issues_func,
290+
get_input,
291+
set_input,
292+
get_issues,
293+
get_touched,
294+
[...path, prop]
295+
);
296+
}
297+
298+
if (prop === 'touched') {
299+
const touched_func = () => {
300+
const touched = get_touched();
301+
302+
if (path.length === 0 || key === '') {
303+
return Object.keys(touched).length > 0;
304+
}
305+
306+
if (touched[key]) return true;
307+
308+
for (const candidate in touched) {
309+
if (candidate === key) continue;
310+
if (!candidate.startsWith(key)) continue;
311+
312+
const next = candidate.slice(key.length, key.length + 1);
313+
if (next === '.' || next === '[') {
314+
return true;
315+
}
316+
}
317+
318+
return false;
319+
};
320+
321+
return create_field_proxy(
322+
touched_func,
323+
get_input,
324+
set_input,
325+
get_issues,
326+
get_touched,
327+
[...path, prop]
328+
);
262329
}
263330

264331
if (prop === 'as') {
@@ -407,11 +474,25 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat
407474
});
408475
};
409476

410-
return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']);
477+
return create_field_proxy(
478+
as_func,
479+
get_input,
480+
set_input,
481+
get_issues,
482+
get_touched,
483+
[...path, 'as']
484+
);
411485
}
412486

413487
// Handle property access (nested fields)
414-
return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]);
488+
return create_field_proxy(
489+
{},
490+
get_input,
491+
set_input,
492+
get_issues,
493+
get_touched,
494+
[...path, prop]
495+
);
415496
}
416497
});
417498
}

packages/kit/types/index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,8 @@ declare module '@sveltejs/kit' {
18541854
value(): T;
18551855
/** Set the values that will be submitted */
18561856
set(input: T): T;
1857+
/** Whether the field or any nested field has been interacted with since the form was mounted */
1858+
touched(): boolean;
18571859
/** Validation issues, if any */
18581860
issues(): RemoteFormIssue[] | undefined;
18591861
};
@@ -3504,4 +3506,4 @@ declare module '$app/types' {
35043506
export type Asset = ReturnType<AppTypes['Asset']>;
35053507
}
35063508

3507-
//# sourceMappingURL=index.d.ts.map
3509+
//# sourceMappingURL=index.d.ts.map

0 commit comments

Comments
 (0)