diff --git a/.changeset/red-masks-remain.md b/.changeset/red-masks-remain.md new file mode 100644 index 000000000000..f6c87e2418f4 --- /dev/null +++ b/.changeset/red-masks-remain.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +feat: add field.touched() helper to remote form fields diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index cb42dc6cc1c0..5f550abb2676 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1878,6 +1878,8 @@ type RemoteFormFieldMethods = { value(): T; /** Set the values that will be submitted */ set(input: T): T; + /** Whether the field or any nested field has been interacted with since the form was mounted */ + touched(): boolean; /** Validation issues, if any */ issues(): RemoteFormIssue[] | undefined; }; diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 7bc7259185e9..117f57527fcc 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -10,7 +10,8 @@ import { throw_on_old_property_access, deep_set, normalize_issue, - flatten_issues + flatten_issues, + build_path_string } from '../../../form-utils.js'; import { get_cache, run_remote_function } from './shared.js'; @@ -210,7 +211,6 @@ export function form(validate_or_fn, maybe_fn) { Object.defineProperty(instance, 'fields', { get() { const data = get_cache(__)?.['']; - const issues = flatten_issues(data?.issues ?? []); return create_field_proxy( {}, @@ -224,9 +224,17 @@ export function form(validate_or_fn, maybe_fn) { const input = path.length === 0 ? value : deep_set(data?.input ?? {}, path.map(String), value); - (get_cache(__)[''] ??= {}).input = input; + const cache = get_cache(__); + const entry = (cache[''] ??= {}); + entry.input = input; + + if (path.length > 0) { + const key = build_path_string(path); + (entry.touched ??= {})[key] = true; + } }, - () => issues + () => flatten_issues(data?.issues ?? []), + () => data?.touched ?? {} ); } }); diff --git a/packages/kit/src/runtime/client/remote-functions/form.svelte.js b/packages/kit/src/runtime/client/remote-functions/form.svelte.js index 02bbf9239720..873d114f3f54 100644 --- a/packages/kit/src/runtime/client/remote-functions/form.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/form.svelte.js @@ -81,7 +81,7 @@ export function form(id) { let element = null; /** @type {Record} */ - let touched = {}; + let touched = $state({}); let submitted = false; @@ -317,15 +317,15 @@ export function form(id) { // but that makes the types unnecessarily awkward const element = /** @type {HTMLInputElement} */ (e.target); - let name = element.name; - if (!name) return; + const original_name = element.name; + if (!original_name) return; - const is_array = name.endsWith('[]'); - if (is_array) name = name.slice(0, -2); + const is_array = original_name.endsWith('[]'); + const base_name = is_array ? original_name.slice(0, -2) : original_name; const is_file = element.type === 'file'; - touched[name] = true; + const sanitized_name = base_name.replace(/^[nb]:/, ''); if (is_array) { let value; @@ -337,7 +337,7 @@ export function form(id) { ); } else { const elements = /** @type {HTMLInputElement[]} */ ( - Array.from(form.querySelectorAll(`[name="${name}[]"]`)) + Array.from(form.querySelectorAll(`[name="${base_name}[]"]`)) ); if (DEV) { @@ -358,39 +358,38 @@ export function form(id) { } } - set_nested_value(input, name, value); + set_nested_value(input, base_name, value); } else if (is_file) { if (DEV && element.multiple) { throw new Error( - `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${name}" to "${name}[]"` + `Can only use the \`multiple\` attribute when \`name\` includes a \`[]\` suffix — consider changing "${sanitized_name}" to "${sanitized_name}[]"` ); } const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0]; if (file) { - set_nested_value(input, name, file); + set_nested_value(input, base_name, file); } else { // Remove the property by setting to undefined and clean up - const path_parts = name.split(/\.|\[|\]/).filter(Boolean); + const path_parts = sanitized_name.split(/\.|\[|\]/).filter(Boolean); let current = /** @type {any} */ (input); for (let i = 0; i < path_parts.length - 1; i++) { - if (current[path_parts[i]] == null) return; - current = current[path_parts[i]]; + const part = path_parts[i]; + if (current[part] == null) return; + current = current[part]; } delete current[path_parts[path_parts.length - 1]]; } } else { set_nested_value( input, - name, + base_name, element.type === 'checkbox' && !element.checked ? null : element.value ); } - name = name.replace(/^[nb]:/, ''); - - touched[name] = true; + touched[sanitized_name] = true; }); form.addEventListener('reset', async () => { @@ -399,6 +398,7 @@ export function form(id) { await tick(); input = convert_formdata(new FormData(form)); + touched = {}; }); return () => { @@ -501,7 +501,8 @@ export function form(id) { touched[key] = true; } }, - () => issues + () => issues, + () => touched ) }, result: { diff --git a/packages/kit/src/runtime/form-utils.js b/packages/kit/src/runtime/form-utils.js index e268ff80b2b8..a510badce772 100644 --- a/packages/kit/src/runtime/form-utils.js +++ b/packages/kit/src/runtime/form-utils.js @@ -207,10 +207,18 @@ export function deep_get(object, path) { * @param {() => Record} get_input - Function to get current input data * @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data * @param {() => Record} get_issues - Function to get current issues + * @param {() => Record} [get_touched] - Function to get touched fields * @param {(string | number)[]} path - Current access path * @returns {any} Proxy object with name(), value(), and issues() methods */ -export function create_field_proxy(target, get_input, set_input, get_issues, path = []) { +export function create_field_proxy( + target, + get_input, + set_input, + get_issues, + get_touched = () => ({}), + path = [] +) { const get_value = () => { return deep_get(get_input(), path); }; @@ -221,7 +229,7 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat // Handle array access like jobs[0] if (/^\d+$/.test(prop)) { - return create_field_proxy({}, get_input, set_input, get_issues, [ + return create_field_proxy({}, get_input, set_input, get_issues, get_touched, [ ...path, parseInt(prop, 10) ]); @@ -234,11 +242,18 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat set_input(path, newValue); return newValue; }; - return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]); + + return create_field_proxy(set_func, get_input, set_input, get_issues, get_touched, [ + ...path, + prop + ]); } if (prop === 'value') { - return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy(get_value, get_input, set_input, get_issues, get_touched, [ + ...path, + prop + ]); } if (prop === 'issues' || prop === 'allIssues') { @@ -258,7 +273,39 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat })); }; - return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy(issues_func, get_input, set_input, get_issues, get_touched, [ + ...path, + prop + ]); + } + + if (prop === 'touched') { + const touched_func = () => { + const touched = get_touched(); + + if (path.length === 0 || key === '') { + return Object.keys(touched).length > 0; + } + + if (touched[key]) return true; + + for (const candidate in touched) { + if (candidate === key) continue; + if (!candidate.startsWith(key)) continue; + + const next = candidate.slice(key.length, key.length + 1); + if (next === '.' || next === '[') { + return true; + } + } + + return false; + }; + + return create_field_proxy(touched_func, get_input, set_input, get_issues, get_touched, [ + ...path, + prop + ]); } if (prop === 'as') { @@ -407,11 +454,14 @@ export function create_field_proxy(target, get_input, set_input, get_issues, pat }); }; - return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']); + return create_field_proxy(as_func, get_input, set_input, get_issues, get_touched, [ + ...path, + 'as' + ]); } // Handle property access (nested fields) - return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]); + return create_field_proxy({}, get_input, set_input, get_issues, get_touched, [...path, prop]); } }); } diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/touched/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/touched/+page.svelte new file mode 100644 index 000000000000..1248c135f503 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/touched/+page.svelte @@ -0,0 +1,30 @@ + + +

Remote Form Touched Test

+ +

Name touched: {touched_form.fields.name.touched() ? 'yes' : 'no'}

+

Age touched: {touched_form.fields.age.touched() ? 'yes' : 'no'}

+ + + +
+ + + + + +
diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/touched/touched.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/touched/touched.remote.ts new file mode 100644 index 000000000000..a13a2abdeda0 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/touched/touched.remote.ts @@ -0,0 +1,10 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const touched_form = form( + v.object({ + name: v.optional(v.string()), + age: v.optional(v.number()) + }), + (data) => ({ submitted: data }) +); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 9b57a59b79d7..157533a234a6 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -2007,6 +2007,30 @@ test.describe('remote functions', () => { expect(JSON.parse(arrayValue)).toEqual([{ leaf: 'array-0-leaf' }, { leaf: 'array-1-leaf' }]); }); + test('form fields touched tracks interactions', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/touched'); + + const nameTouched = page.locator('#touched-name'); + const ageTouched = page.locator('#touched-age'); + + await expect(nameTouched).toHaveText('Name touched: no'); + await expect(ageTouched).toHaveText('Age touched: no'); + + await page.click('#set-btn'); + await expect(nameTouched).toHaveText('Name touched: yes'); + + await page.click('#reset-btn'); + await expect(nameTouched).toHaveText('Name touched: no'); + + await page.fill('#age-input', '42'); + await expect(ageTouched).toHaveText('Age touched: yes'); + + await page.click('#reset-btn'); + await expect(ageTouched).toHaveText('Age touched: no'); + }); + test('selects are not nuked when unrelated controls change', async ({ page, javaScriptEnabled diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 9fde95c8dd4c..00035a4c6bb7 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1854,6 +1854,8 @@ declare module '@sveltejs/kit' { value(): T; /** Set the values that will be submitted */ set(input: T): T; + /** Whether the field or any nested field has been interacted with since the form was mounted */ + touched(): boolean; /** Validation issues, if any */ issues(): RemoteFormIssue[] | undefined; };