feat: default values for form elements (#14289)

* tests

* typings

* implement for defaultValue/defaultChecked on inputs

* docs (draft)

* selected

* fix test

* remove

* tweak

* changeset

* untrack reads, they could be inside an effect

* Apply suggestions from code review

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* handle select reset case

* handle reset case specifically: use different props/queries in that case

* enhance test

* fix

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/14553/head
Simon H 3 weeks ago committed by GitHub
parent c55af4aa83
commit a57e747cbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: support `defaultValue/defaultChecked` for inputs

@ -53,6 +53,22 @@ In the case of a numeric input (`type="number"` or `type="range"`), the value wi
If the input is empty or invalid (in the case of `type="number"`), the value is `undefined`.
If an `<input>` has a `defaultValue` and is part of a form, it will revert to that value instead of the empty string when the form is reset. Note that for the initial render the value of the binding takes precedence unless it is `null` or `undefined`.
```svelte
<script>
let value = $state('');
</script>
<form>
<input bind:value defaultValue="not the empty string">
<input type="reset" value="Reset">
</form>
```
> [!NOTE]
> Use reset buttons sparingly, and ensure that users won't accidentally click them while trying to submit the form.
## `<input bind:checked>`
Checkbox and radio inputs can be bound with `bind:checked`:
@ -64,16 +80,29 @@ Checkbox and radio inputs can be bound with `bind:checked`:
</label>
```
If an `<input>` has a `defaultChecked` attribute and is part of a form, it will revert to that value instead of `false` when the form is reset. Note that for the initial render the value of the binding takes precedence unless it is `null` or `undefined`.
```svelte
<script>
let checked = $state(true);
</script>
<form>
<input type="checkbox" bind:checked defaultChecked={true}>
<input type="reset" value="Reset">
</form>
```
## `<input bind:group>`
Inputs that work together can use `bind:group`.
```svelte
<script>
let tortilla = 'Plain';
let tortilla = $state('Plain');
/** @type {Array<string>} */
let fillings = [];
let fillings = $state([]);
</script>
<!-- grouped radio inputs are mutually exclusive -->
@ -146,6 +175,16 @@ When the value of an `<option>` matches its text content, the attribute can be o
</select>
```
You can give the `<select>` a default value by adding a `selected` attribute to the`<option>` (or options, in the case of `<select multiple>`) that should be initially selected. If the `<select>` is part of a form, it will revert to that selection when the form is reset. Note that for the initial render the value of the binding takes precedence if it's not `undefined`.
```svelte
<select bind:value={selected}>
<option value={a}>a</option>
<option value={b} selected>b</option>
<option value={c}>c</option>
</select>
```
## `<audio>`
`<audio>` elements have their own set of bindings — five two-way ones...

@ -1103,6 +1103,11 @@ export interface HTMLInputAttributes extends HTMLAttributes<HTMLInputElement> {
step?: number | string | undefined | null;
type?: HTMLInputTypeAttribute | undefined | null;
value?: any;
// needs both casing variants because language tools does lowercase names of non-shorthand attributes
defaultValue?: any;
defaultvalue?: any;
defaultChecked?: any;
defaultchecked?: any;
width?: number | string | undefined | null;
webkitdirectory?: boolean | undefined | null;
@ -1384,6 +1389,9 @@ export interface HTMLTextareaAttributes extends HTMLAttributes<HTMLTextAreaEleme
required?: boolean | undefined | null;
rows?: number | undefined | null;
value?: string | string[] | number | undefined | null;
// needs both casing variants because language tools does lowercase names of non-shorthand attributes
defaultValue?: string | string[] | number | undefined | null;
defaultvalue?: string | string[] | number | undefined | null;
wrap?: 'hard' | 'soft' | undefined | null;
'on:change'?: ChangeEventHandler<HTMLTextAreaElement> | undefined | null;

@ -172,21 +172,29 @@ export function RegularElement(node, context) {
}
}
if (node.name === 'input') {
const has_value_attribute = attributes.some(
(attribute) =>
attribute.type === 'Attribute' &&
(attribute.name === 'value' || attribute.name === 'checked') &&
!is_text_attribute(attribute)
);
const has_default_value_attribute = attributes.some(
(attribute) =>
attribute.type === 'Attribute' &&
(attribute.name === 'defaultValue' || attribute.name === 'defaultChecked')
);
if (
node.name === 'input' &&
!has_default_value_attribute &&
(has_spread ||
bindings.has('value') ||
bindings.has('checked') ||
bindings.has('group') ||
attributes.some(
(attribute) =>
attribute.type === 'Attribute' &&
(attribute.name === 'value' || attribute.name === 'checked') &&
!is_text_attribute(attribute)
))
(!bindings.has('group') && has_value_attribute))
) {
context.state.init.push(b.stmt(b.call('$.remove_input_defaults', context.state.node)));
}
}
if (node.name === 'textarea') {
const attribute = lookup.get('value') ?? lookup.get('checked');
@ -555,6 +563,8 @@ function build_element_attribute_update_assignment(element, node_id, attribute,
update = b.stmt(b.call('$.set_value', node_id, value));
} else if (name === 'checked') {
update = b.stmt(b.call('$.set_checked', node_id, value));
} else if (name === 'selected') {
update = b.stmt(b.call('$.set_selected', node_id, value));
} else if (is_dom_property(name)) {
update = b.stmt(b.assignment('=', b.member(node_id, name), value));
} else {

@ -82,7 +82,8 @@ export function build_element_attributes(node, context) {
) {
events_to_capture.add(attribute.name);
}
} else {
// the defaultValue/defaultChecked properties don't exist as attributes
} else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') {
if (attribute.name === 'class') {
class_index = attributes.length;
} else if (attribute.name === 'style') {

@ -84,6 +84,25 @@ export function set_checked(element, checked) {
element.checked = checked;
}
/**
* Sets the `selected` attribute on an `option` element.
* Not set through the property because that doesn't reflect to the DOM,
* which means it wouldn't be taken into account when a form is reset.
* @param {HTMLOptionElement} element
* @param {boolean} selected
*/
export function set_selected(element, selected) {
if (selected) {
// The selected option could've changed via user selection, and
// setting the value without this check would set it back.
if (!element.hasAttribute('selected')) {
element.setAttribute('selected', '');
}
} else {
element.removeAttribute('selected');
}
}
/**
* @param {Element} element
* @param {string} attribute

@ -5,7 +5,7 @@ import * as e from '../../../errors.js';
import { is } from '../../../proxy.js';
import { queue_micro_task } from '../../task.js';
import { hydrating } from '../../hydration.js';
import { is_runes } from '../../../runtime.js';
import { is_runes, untrack } from '../../../runtime.js';
/**
* @param {HTMLInputElement} input
@ -16,24 +16,36 @@ import { is_runes } from '../../../runtime.js';
export function bind_value(input, get, set = get) {
var runes = is_runes();
listen_to_event_and_reset_event(input, 'input', () => {
listen_to_event_and_reset_event(input, 'input', (is_reset) => {
if (DEV && input.type === 'checkbox') {
// TODO should this happen in prod too?
e.bind_invalid_checkbox_value();
}
/** @type {unknown} */
var value = is_numberlike_input(input) ? to_number(input.value) : input.value;
/** @type {any} */
var value = is_reset ? input.defaultValue : input.value;
value = is_numberlike_input(input) ? to_number(value) : value;
set(value);
// In runes mode, respect any validation in accessors (doesn't apply in legacy mode,
// because we use mutable state which ensures the render effect always runs)
if (runes && value !== (value = get())) {
// @ts-expect-error the value is coerced on assignment
// the value is coerced on assignment
input.value = value ?? '';
}
});
if (
// If we are hydrating and the value has since changed,
// then use the updated value from the input instead.
(hydrating && input.defaultValue !== input.value) ||
// If defaultValue is set, then value == defaultValue
// TODO Svelte 6: remove input.value check and set to empty string?
(untrack(get) == null && input.value)
) {
set(is_numberlike_input(input) ? to_number(input.value) : input.value);
}
render_effect(() => {
if (DEV && input.type === 'checkbox') {
// TODO should this happen in prod too?
@ -42,13 +54,6 @@ export function bind_value(input, get, set = get) {
var value = get();
// If we are hydrating and the value has since changed, then use the update value
// from the input instead.
if (hydrating && input.defaultValue !== input.value) {
set(is_numberlike_input(input) ? to_number(input.value) : input.value);
return;
}
if (is_numberlike_input(input) && value === to_number(input.value)) {
// handles 0 vs 00 case (see https://github.com/sveltejs/svelte/issues/9959)
return;
@ -175,13 +180,19 @@ export function bind_group(inputs, group_index, input, get, set = get) {
* @returns {void}
*/
export function bind_checked(input, get, set = get) {
listen_to_event_and_reset_event(input, 'change', () => {
var value = input.checked;
listen_to_event_and_reset_event(input, 'change', (is_reset) => {
var value = is_reset ? input.defaultChecked : input.checked;
set(value);
});
if (get() == undefined) {
set(false);
if (
// If we are hydrating and the value has since changed,
// then use the update value from the input instead.
(hydrating && input.defaultChecked !== input.checked) ||
// If defaultChecked is set, then checked == defaultChecked
untrack(get) == null
) {
set(input.checked);
}
render_effect(() => {

@ -80,15 +80,19 @@ export function init_select(select, get_value) {
export function bind_select_value(select, get, set = get) {
var mounting = true;
listen_to_event_and_reset_event(select, 'change', () => {
listen_to_event_and_reset_event(select, 'change', (is_reset) => {
var query = is_reset ? '[selected]' : ':checked';
/** @type {unknown} */
var value;
if (select.multiple) {
value = [].map.call(select.querySelectorAll(':checked'), get_option_value);
value = [].map.call(select.querySelectorAll(query), get_option_value);
} else {
/** @type {HTMLOptionElement | null} */
var selected_option = select.querySelector(':checked');
var selected_option =
select.querySelector(query) ??
// will fall back to first non-disabled option if no option is selected
select.querySelector('option:not([disabled])');
value = selected_option && get_option_value(selected_option);
}

@ -53,8 +53,8 @@ export function without_reactive_context(fn) {
* to notify all bindings when the form is reset
* @param {HTMLElement} element
* @param {string} event
* @param {() => void} handler
* @param {() => void} [on_reset]
* @param {(is_reset?: true) => void} handler
* @param {(is_reset?: true) => void} [on_reset]
*/
export function listen_to_event_and_reset_event(element, event, handler, on_reset = handler) {
element.addEventListener(event, () => without_reactive_context(handler));
@ -65,11 +65,11 @@ export function listen_to_event_and_reset_event(element, event, handler, on_rese
// @ts-expect-error
element.__on_r = () => {
prev();
on_reset();
on_reset(true);
};
} else {
// @ts-expect-error
element.__on_r = on_reset;
element.__on_r = () => on_reset(true);
}
add_form_reset_listener();

@ -34,7 +34,8 @@ export {
set_xlink_attribute,
handle_lazy_img,
set_value,
set_checked
set_checked,
set_selected
} from './dom/elements/attributes.js';
export { set_class, set_svg_class, set_mathml_class, toggle_class } from './dom/elements/class.js';
export { apply, event, delegate, replay_events } from './dom/elements/events.js';

@ -193,6 +193,8 @@ const ATTRIBUTE_ALIASES = {
nomodule: 'noModule',
playsinline: 'playsInline',
readonly: 'readOnly',
defaultvalue: 'defaultValue',
defaultchecked: 'defaultChecked',
srcobject: 'srcObject'
};
@ -214,6 +216,8 @@ const DOM_PROPERTIES = [
'value',
'inert',
'volume',
'defaultValue',
'defaultChecked',
'srcObject'
];
@ -224,7 +228,7 @@ export function is_dom_property(name) {
return DOM_PROPERTIES.includes(name);
}
const NON_STATIC_PROPERTIES = ['autofocus', 'muted'];
const NON_STATIC_PROPERTIES = ['autofocus', 'muted', 'defaultValue', 'defaultChecked'];
/**
* Returns `true` if the given attribute cannot be set through the template

@ -0,0 +1,209 @@
import { test } from '../../test';
import { flushSync } from 'svelte';
export default test({
async test({ assert, target }) {
/**
* @param {NodeListOf<any>} inputs
* @param {string} field
* @param {any | any[]} value
*/
function check_inputs(inputs, field, value) {
for (let i = 0; i < inputs.length; i++) {
assert.equal(inputs[i][field], Array.isArray(value) ? value[i] : value, `field ${i}`);
}
}
/**
* @param {any} input
* @param {string} field
* @param {any} value
*/
function set_input(input, field, value) {
input[field] = value;
input.dispatchEvent(
new Event(typeof value === 'boolean' ? 'change' : 'input', { bubbles: true })
);
}
/**
* @param {HTMLOptionElement} option
*/
function select_option(option) {
option.selected = true;
option.dispatchEvent(new Event('change', { bubbles: true }));
}
const after_reset = [];
const reset = /** @type {HTMLInputElement} */ (target.querySelector('input[type=reset]'));
const [test1, test2, test3, test4, test5] = target.querySelectorAll('div');
const [test6, test7, test8, test9] = target.querySelectorAll('select');
const [
test1_span,
test2_span,
test3_span,
test4_span,
test5_span,
test6_span,
test7_span,
test8_span,
test9_span
] = target.querySelectorAll('span');
{
/** @type {NodeListOf<HTMLInputElement | HTMLTextAreaElement>} */
const inputs = test1.querySelectorAll('input, textarea');
check_inputs(inputs, 'value', 'x');
assert.htmlEqual(test1_span.innerHTML, 'x x x x');
for (const input of inputs) {
set_input(input, 'value', 'foo');
}
flushSync();
check_inputs(inputs, 'value', 'foo');
assert.htmlEqual(test1_span.innerHTML, 'foo foo foo foo');
after_reset.push(() => {
console.log('-------------');
check_inputs(inputs, 'value', 'x');
assert.htmlEqual(test1_span.innerHTML, 'x x x x');
});
}
{
/** @type {NodeListOf<HTMLInputElement | HTMLTextAreaElement>} */
const inputs = test2.querySelectorAll('input, textarea');
check_inputs(inputs, 'value', 'y');
assert.htmlEqual(test2_span.innerHTML, 'y y y y');
for (const input of inputs) {
set_input(input, 'value', 'foo');
}
flushSync();
check_inputs(inputs, 'value', 'foo');
assert.htmlEqual(test2_span.innerHTML, 'foo foo foo foo');
after_reset.push(() => {
check_inputs(inputs, 'value', 'x');
assert.htmlEqual(test2_span.innerHTML, 'x x x x');
});
}
{
/** @type {NodeListOf<HTMLInputElement>} */
const inputs = test3.querySelectorAll('input');
check_inputs(inputs, 'checked', true);
assert.htmlEqual(test3_span.innerHTML, 'true true');
for (const input of inputs) {
set_input(input, 'checked', false);
}
flushSync();
check_inputs(inputs, 'checked', false);
assert.htmlEqual(test3_span.innerHTML, 'false false');
after_reset.push(() => {
check_inputs(inputs, 'checked', true);
assert.htmlEqual(test3_span.innerHTML, 'true true');
});
}
{
/** @type {NodeListOf<HTMLInputElement>} */
const inputs = test4.querySelectorAll('input');
check_inputs(inputs, 'checked', false);
assert.htmlEqual(test4_span.innerHTML, 'false false');
after_reset.push(() => {
check_inputs(inputs, 'checked', true);
assert.htmlEqual(test4_span.innerHTML, 'true true');
});
}
{
/** @type {NodeListOf<HTMLInputElement>} */
const inputs = test5.querySelectorAll('input');
check_inputs(inputs, 'checked', true);
assert.htmlEqual(test5_span.innerHTML, 'true');
after_reset.push(() => {
check_inputs(inputs, 'checked', false);
assert.htmlEqual(test5_span.innerHTML, 'false');
});
}
{
/** @type {NodeListOf<HTMLOptionElement>} */
const options = test6.querySelectorAll('option');
check_inputs(options, 'selected', [false, true, false]);
assert.htmlEqual(test6_span.innerHTML, 'b');
select_option(options[2]);
flushSync();
check_inputs(options, 'selected', [false, false, true]);
assert.htmlEqual(test6_span.innerHTML, 'c');
after_reset.push(() => {
check_inputs(options, 'selected', [false, true, false]);
assert.htmlEqual(test6_span.innerHTML, 'b');
});
}
{
/** @type {NodeListOf<HTMLOptionElement>} */
const options = test7.querySelectorAll('option');
check_inputs(options, 'selected', [false, true, false]);
assert.htmlEqual(test7_span.innerHTML, 'b');
select_option(options[2]);
flushSync();
check_inputs(options, 'selected', [false, false, true]);
assert.htmlEqual(test7_span.innerHTML, 'c');
after_reset.push(() => {
check_inputs(options, 'selected', [false, true, false]);
assert.htmlEqual(test7_span.innerHTML, 'b');
});
}
{
/** @type {NodeListOf<HTMLOptionElement>} */
const options = test8.querySelectorAll('option');
check_inputs(options, 'selected', [false, false, true]);
assert.htmlEqual(test8_span.innerHTML, 'c');
select_option(options[0]);
flushSync();
check_inputs(options, 'selected', [true, false, false]);
assert.htmlEqual(test8_span.innerHTML, 'a');
after_reset.push(() => {
check_inputs(options, 'selected', [false, true, false]);
assert.htmlEqual(test8_span.innerHTML, 'b');
});
}
{
/** @type {NodeListOf<HTMLOptionElement>} */
const options = test9.querySelectorAll('option');
check_inputs(options, 'selected', [false, false, true]);
assert.htmlEqual(test9_span.innerHTML, 'c');
select_option(options[0]);
flushSync();
check_inputs(options, 'selected', [true, false, false]);
assert.htmlEqual(test9_span.innerHTML, 'a');
after_reset.push(() => {
check_inputs(options, 'selected', [false, true, false]);
assert.htmlEqual(test9_span.innerHTML, 'b');
});
}
reset.click();
await Promise.resolve();
flushSync();
after_reset.forEach((fn) => fn());
}
});

@ -0,0 +1,156 @@
<script>
let value1 = $state();
let value2 = $state();
let value3 = $state();
let value4 = $state();
let value5 = $state();
let value6 = $state();
let value7 = $state();
let value8 = $state();
let value9 = $state('y');
let value10 = $state('y');
let value11 = $state('y');
let value12 = $state('y');
let value13 = $state('y');
let value14 = $state('y');
let value15 = $state('y');
let value16 = $state('y');
let checked1 = $state();
let checked2 = $state();
let checked3 = $state();
let checked4 = $state();
let checked5 = $state(false);
let checked6 = $state(false);
let checked7 = $state(false);
let checked8 = $state(false);
let checked9 = $state(true);
let checked10 = $state(true);
let selected1 = $state();
let selected2 = $state();
let selected3 = $state('c');
let selected4 = $state('c');
let selected5 = $state(['c']);
let selected6 = $state(['c']);
let defaultValue = $state('x');
let defaultChecked = $state(true);
</script>
<form>
<p>Input/Textarea value</p>
<!-- defaultValue=x, value=undefined -->
<div class="test-1">
<input {defaultValue} bind:value={value1} />
<input {defaultValue} value={value2} />
<input defaultValue="x" bind:value={value3} />
<input defaultValue="x" value={value4} />
<textarea {defaultValue} value={value5}></textarea>
<textarea {defaultValue} bind:value={value6}></textarea>
<textarea defaultValue="x" value={value7}></textarea>
<textarea defaultValue="x" bind:value={value8}></textarea>
</div>
<!-- defaultValue=x, value=y -->
<div class="test-2">
<input {defaultValue} bind:value={value9} />
<input {defaultValue} value={value10} />
<input defaultValue="x" value={value11} />
<input defaultValue="x" bind:value={value12} />
<textarea {defaultValue} value={value13}></textarea>
<textarea {defaultValue} bind:value={value14}></textarea>
<textarea defaultValue="x" value={value15}></textarea>
<textarea defaultValue="x" bind:value={value16}></textarea>
</div>
<p>Input checked</p>
<!-- defaultChecked=true, checked=undefined -->
<div class="test-3">
<input type="checkbox" {defaultChecked} checked={checked1} />
<input type="checkbox" {defaultChecked} bind:checked={checked2} />
<input type="checkbox" defaultChecked checked={checked3} />
<input type="checkbox" defaultChecked bind:checked={checked4} />
</div>
<!-- defaultChecked=true, checked=false -->
<div class="test-4">
<input type="checkbox" {defaultChecked} checked={checked5} />
<input type="checkbox" {defaultChecked} bind:checked={checked6} />
<input type="checkbox" defaultChecked checked={checked7} />
<input type="checkbox" defaultChecked bind:checked={checked8} />
</div>
<!-- defaultChecked=false, checked=true -->
<div class="test-5">
<input type="checkbox" defaultChecked={false} checked={checked9} />
<input type="checkbox" defaultChecked={false} bind:checked={checked10} />
</div>
<!-- no support for bind:group; too complex + we may deprecate it in favor of bind:checked={get,set} -->
<p>Select (single)</p>
<!-- select with static checked, value=undefined-->
<select bind:value={selected1}>
<option value="a">A</option>
<option value="b" selected>B</option>
<option value="c">C</option>
</select>
<!-- select with dynamic checked, value=undefined-->
<select bind:value={selected2}>
<option value="a">A</option>
<option value="b" selected={defaultChecked}>B</option>
<option value="c">C</option>
</select>
<!-- select with static checked, value=something else than default-->
<select bind:value={selected3}>
<option value="a">A</option>
<option value="b" selected>B</option>
<option value="c">C</option>
</select>
<!-- select with dynamic checked, value=something else than default-->
<select bind:value={selected4}>
<option value="a">A</option>
<option value="b" selected={defaultChecked}>B</option>
<option value="c">C</option>
</select>
<p>Select (multiple)</p>
<!-- There's no possibility to have the selected attribute influence a multi select initially,
because we require the value to be an array -->
<!-- select with static checked, value=something else than default-->
<select multiple bind:value={selected5}>
<option value="a">A</option>
<option value="b" selected>B</option>
<option value="c">C</option>
</select>
<!-- select with dynamic checked, value=something else than default-->
<select multiple bind:value={selected6}>
<option value="a">A</option>
<option value="b" selected={defaultChecked}>B</option>
<option value="c">C</option>
</select>
<input type="reset" value="Reset" />
</form>
<p>
Bound values:
<span class="test-1">{value1} {value3} {value6} {value8}</span>
<span class="test-2">{value9} {value12} {value14} {value16}</span>
<span class="test-3">{checked2} {checked4}</span>
<span class="test-4">{checked6} {checked8}</span>
<span class="test-5">{checked10}</span>
<span class="test-6">{selected1}</span>
<span class="test-7">{selected2}</span>
<span class="test-8">{selected3}</span>
<span class="test-9">{selected4}</span>
<span class="test-10">{selected5}</span>
<span class="test-11">{selected6}</span>
</p>
Loading…
Cancel
Save