Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-masks-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

feat: add field.touched() helper to remote form fields
2 changes: 2 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,8 @@ type RemoteFormFieldMethods<T> = {
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;
};
Expand Down
16 changes: 12 additions & 4 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
{},
Expand All @@ -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 ?? {}
);
}
});
Expand Down
37 changes: 19 additions & 18 deletions packages/kit/src/runtime/client/remote-functions/form.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function form(id) {
let element = null;

/** @type {Record<string, boolean>} */
let touched = {};
let touched = $state({});

let submitted = false;

Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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 () => {
Expand All @@ -399,6 +398,7 @@ export function form(id) {
await tick();

input = convert_formdata(new FormData(form));
touched = {};
});

return () => {
Expand Down Expand Up @@ -501,7 +501,8 @@ export function form(id) {
touched[key] = true;
}
},
() => issues
() => issues,
() => touched
)
},
result: {
Expand Down
64 changes: 57 additions & 7 deletions packages/kit/src/runtime/form-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,18 @@ export function deep_get(object, path) {
* @param {() => Record<string, any>} get_input - Function to get current input data
* @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data
* @param {() => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues
* @param {() => Record<string, boolean>} [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);
};
Expand All @@ -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)
]);
Expand All @@ -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') {
Expand All @@ -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') {
Expand Down Expand Up @@ -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]);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script>
import { touched_form } from './touched.remote.js';
import { tick } from 'svelte';

const markNameTouched = async () => {
touched_form.fields.name.set('example');
await tick();
};
</script>

<h1>Remote Form Touched Test</h1>

<p id="touched-name">Name touched: {touched_form.fields.name.touched() ? 'yes' : 'no'}</p>
<p id="touched-age">Age touched: {touched_form.fields.age.touched() ? 'yes' : 'no'}</p>

<button id="set-btn" type="button" on:click={markNameTouched}> set name programmatically </button>

<form {...touched_form}>
<label>
Name
<input id="name-input" {...touched_form.fields.name.as('text')} />
</label>

<label>
Age
<input id="age-input" {...touched_form.fields.age.as('number')} />
</label>

<button id="reset-btn" type="reset">Reset</button>
</form>
Original file line number Diff line number Diff line change
@@ -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 })
);
24 changes: 24 additions & 0 deletions packages/kit/test/apps/basics/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1790,7 +1790,7 @@
await expect(page.locator('[data-scoped] input')).toHaveValue('');
});

test('form enhance(...) works', async ({ page, javaScriptEnabled }) => {

Check warning on line 1793 in packages/kit/test/apps/basics/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit (20, ubuntu-latest, chromium)

flaky test: form enhance(...) works

retries: 2
await page.goto('/remote/form');

await page.fill('[data-enhanced] input', 'hello');
Expand Down Expand Up @@ -2007,6 +2007,30 @@
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
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Loading