diff --git a/.changeset/tidy-zebras-begin.md b/.changeset/tidy-zebras-begin.md
new file mode 100644
index 0000000000..cefbf2acfd
--- /dev/null
+++ b/.changeset/tidy-zebras-begin.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: support `defaultValue/defaultChecked` for inputs
diff --git a/documentation/docs/03-template-syntax/11-bind.md b/documentation/docs/03-template-syntax/11-bind.md
index 7dd03a6b04..975135f824 100644
--- a/documentation/docs/03-template-syntax/11-bind.md
+++ b/documentation/docs/03-template-syntax/11-bind.md
@@ -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 ` ` 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
+
+
+
+```
+
+> [!NOTE]
+> Use reset buttons sparingly, and ensure that users won't accidentally click them while trying to submit the form.
+
## ` `
Checkbox and radio inputs can be bound with `bind:checked`:
@@ -64,16 +80,29 @@ Checkbox and radio inputs can be bound with `bind:checked`:
```
+If an ` ` 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
+
+
+
+```
+
## ` `
Inputs that work together can use `bind:group`.
```svelte
@@ -146,6 +175,16 @@ When the value of an `` matches its text content, the attribute can be o
```
+You can give the `` a default value by adding a `selected` attribute to the`` (or options, in the case of ``) that should be initially selected. If the `` 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
+
+ a
+ b
+ c
+
+```
+
## ``
`` elements have their own set of bindings — five two-way ones...
diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts
index 035fa49c31..8800b65172 100644
--- a/packages/svelte/elements.d.ts
+++ b/packages/svelte/elements.d.ts
@@ -1103,6 +1103,11 @@ export interface HTMLInputAttributes extends HTMLAttributes {
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 | undefined | null;
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
index 85df92e8bf..56f7b6d6f0 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js
@@ -172,20 +172,28 @@ export function RegularElement(node, context) {
}
}
- if (
- node.name === 'input' &&
- (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)
- ))
- ) {
- context.state.init.push(b.stmt(b.call('$.remove_input_defaults', context.state.node)));
+ 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 (
+ !has_default_value_attribute &&
+ (has_spread ||
+ bindings.has('value') ||
+ bindings.has('checked') ||
+ bindings.has('group') ||
+ (!bindings.has('group') && has_value_attribute))
+ ) {
+ context.state.init.push(b.stmt(b.call('$.remove_input_defaults', context.state.node)));
+ }
}
if (node.name === 'textarea') {
@@ -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 {
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
index 2ab5d9b9fd..434447727b 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js
@@ -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') {
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index ced4c4cf25..b4dd92a9ab 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -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
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js
index 1cbe2a731f..810dcb0862 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js
@@ -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(() => {
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js
index 4d3dbff812..32cd160de2 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js
@@ -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);
}
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/shared.js b/packages/svelte/src/internal/client/dom/elements/bindings/shared.js
index 832b7f45e5..aa083776a5 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/shared.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/shared.js
@@ -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();
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index 6fae2893e6..d514dd3de1 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -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';
diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js
index 919660fd6a..75171c1786 100644
--- a/packages/svelte/src/utils.js
+++ b/packages/svelte/src/utils.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
diff --git a/packages/svelte/tests/runtime-runes/samples/form-default-value/_config.js b/packages/svelte/tests/runtime-runes/samples/form-default-value/_config.js
new file mode 100644
index 0000000000..7c31b99825
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/form-default-value/_config.js
@@ -0,0 +1,209 @@
+import { test } from '../../test';
+import { flushSync } from 'svelte';
+
+export default test({
+ async test({ assert, target }) {
+ /**
+ * @param {NodeListOf} 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} */
+ 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} */
+ 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} */
+ 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} */
+ 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} */
+ 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} */
+ 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} */
+ 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} */
+ 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} */
+ 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());
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/form-default-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/form-default-value/main.svelte
new file mode 100644
index 0000000000..d2b864e7ec
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/form-default-value/main.svelte
@@ -0,0 +1,156 @@
+
+
+
+
+
+ Bound values:
+ {value1} {value3} {value6} {value8}
+ {value9} {value12} {value14} {value16}
+ {checked2} {checked4}
+ {checked6} {checked8}
+ {checked10}
+ {selected1}
+ {selected2}
+ {selected3}
+ {selected4}
+ {selected5}
+ {selected6}
+