Skip to content

Commit a57e747

Browse files
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 <[email protected]> * handle select reset case * handle reset case specifically: use different props/queries in that case * enhance test * fix --------- Co-authored-by: Rich Harris <[email protected]>
1 parent c55af4a commit a57e747

File tree

13 files changed

+509
-42
lines changed

13 files changed

+509
-42
lines changed

.changeset/tidy-zebras-begin.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: support `defaultValue/defaultChecked` for inputs

documentation/docs/03-template-syntax/11-bind.md

+41-2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ In the case of a numeric input (`type="number"` or `type="range"`), the value wi
5353

5454
If the input is empty or invalid (in the case of `type="number"`), the value is `undefined`.
5555

56+
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`.
57+
58+
```svelte
59+
<script>
60+
let value = $state('');
61+
</script>
62+
63+
<form>
64+
<input bind:value defaultValue="not the empty string">
65+
<input type="reset" value="Reset">
66+
</form>
67+
```
68+
69+
> [!NOTE]
70+
> Use reset buttons sparingly, and ensure that users won't accidentally click them while trying to submit the form.
71+
5672
## `<input bind:checked>`
5773

5874
Checkbox and radio inputs can be bound with `bind:checked`:
@@ -64,16 +80,29 @@ Checkbox and radio inputs can be bound with `bind:checked`:
6480
</label>
6581
```
6682

83+
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`.
84+
85+
```svelte
86+
<script>
87+
let checked = $state(true);
88+
</script>
89+
90+
<form>
91+
<input type="checkbox" bind:checked defaultChecked={true}>
92+
<input type="reset" value="Reset">
93+
</form>
94+
```
95+
6796
## `<input bind:group>`
6897

6998
Inputs that work together can use `bind:group`.
7099

71100
```svelte
72101
<script>
73-
let tortilla = 'Plain';
102+
let tortilla = $state('Plain');
74103
75104
/** @type {Array<string>} */
76-
let fillings = [];
105+
let fillings = $state([]);
77106
</script>
78107
79108
<!-- 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
146175
</select>
147176
```
148177

178+
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`.
179+
180+
```svelte
181+
<select bind:value={selected}>
182+
<option value={a}>a</option>
183+
<option value={b} selected>b</option>
184+
<option value={c}>c</option>
185+
</select>
186+
```
187+
149188
## `<audio>`
150189

151190
`<audio>` elements have their own set of bindings — five two-way ones...

packages/svelte/elements.d.ts

+8
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,11 @@ export interface HTMLInputAttributes extends HTMLAttributes<HTMLInputElement> {
11031103
step?: number | string | undefined | null;
11041104
type?: HTMLInputTypeAttribute | undefined | null;
11051105
value?: any;
1106+
// needs both casing variants because language tools does lowercase names of non-shorthand attributes
1107+
defaultValue?: any;
1108+
defaultvalue?: any;
1109+
defaultChecked?: any;
1110+
defaultchecked?: any;
11061111
width?: number | string | undefined | null;
11071112
webkitdirectory?: boolean | undefined | null;
11081113

@@ -1384,6 +1389,9 @@ export interface HTMLTextareaAttributes extends HTMLAttributes<HTMLTextAreaEleme
13841389
required?: boolean | undefined | null;
13851390
rows?: number | undefined | null;
13861391
value?: string | string[] | number | undefined | null;
1392+
// needs both casing variants because language tools does lowercase names of non-shorthand attributes
1393+
defaultValue?: string | string[] | number | undefined | null;
1394+
defaultvalue?: string | string[] | number | undefined | null;
13871395
wrap?: 'hard' | 'soft' | undefined | null;
13881396

13891397
'on:change'?: ChangeEventHandler<HTMLTextAreaElement> | undefined | null;

packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js

+24-14
Original file line numberDiff line numberDiff line change
@@ -172,20 +172,28 @@ export function RegularElement(node, context) {
172172
}
173173
}
174174

175-
if (
176-
node.name === 'input' &&
177-
(has_spread ||
178-
bindings.has('value') ||
179-
bindings.has('checked') ||
180-
bindings.has('group') ||
181-
attributes.some(
182-
(attribute) =>
183-
attribute.type === 'Attribute' &&
184-
(attribute.name === 'value' || attribute.name === 'checked') &&
185-
!is_text_attribute(attribute)
186-
))
187-
) {
188-
context.state.init.push(b.stmt(b.call('$.remove_input_defaults', context.state.node)));
175+
if (node.name === 'input') {
176+
const has_value_attribute = attributes.some(
177+
(attribute) =>
178+
attribute.type === 'Attribute' &&
179+
(attribute.name === 'value' || attribute.name === 'checked') &&
180+
!is_text_attribute(attribute)
181+
);
182+
const has_default_value_attribute = attributes.some(
183+
(attribute) =>
184+
attribute.type === 'Attribute' &&
185+
(attribute.name === 'defaultValue' || attribute.name === 'defaultChecked')
186+
);
187+
if (
188+
!has_default_value_attribute &&
189+
(has_spread ||
190+
bindings.has('value') ||
191+
bindings.has('checked') ||
192+
bindings.has('group') ||
193+
(!bindings.has('group') && has_value_attribute))
194+
) {
195+
context.state.init.push(b.stmt(b.call('$.remove_input_defaults', context.state.node)));
196+
}
189197
}
190198

191199
if (node.name === 'textarea') {
@@ -555,6 +563,8 @@ function build_element_attribute_update_assignment(element, node_id, attribute,
555563
update = b.stmt(b.call('$.set_value', node_id, value));
556564
} else if (name === 'checked') {
557565
update = b.stmt(b.call('$.set_checked', node_id, value));
566+
} else if (name === 'selected') {
567+
update = b.stmt(b.call('$.set_selected', node_id, value));
558568
} else if (is_dom_property(name)) {
559569
update = b.stmt(b.assignment('=', b.member(node_id, name), value));
560570
} else {

packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/element.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export function build_element_attributes(node, context) {
8282
) {
8383
events_to_capture.add(attribute.name);
8484
}
85-
} else {
85+
// the defaultValue/defaultChecked properties don't exist as attributes
86+
} else if (attribute.name !== 'defaultValue' && attribute.name !== 'defaultChecked') {
8687
if (attribute.name === 'class') {
8788
class_index = attributes.length;
8889
} else if (attribute.name === 'style') {

packages/svelte/src/internal/client/dom/elements/attributes.js

+19
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,25 @@ export function set_checked(element, checked) {
8484
element.checked = checked;
8585
}
8686

87+
/**
88+
* Sets the `selected` attribute on an `option` element.
89+
* Not set through the property because that doesn't reflect to the DOM,
90+
* which means it wouldn't be taken into account when a form is reset.
91+
* @param {HTMLOptionElement} element
92+
* @param {boolean} selected
93+
*/
94+
export function set_selected(element, selected) {
95+
if (selected) {
96+
// The selected option could've changed via user selection, and
97+
// setting the value without this check would set it back.
98+
if (!element.hasAttribute('selected')) {
99+
element.setAttribute('selected', '');
100+
}
101+
} else {
102+
element.removeAttribute('selected');
103+
}
104+
}
105+
87106
/**
88107
* @param {Element} element
89108
* @param {string} attribute

packages/svelte/src/internal/client/dom/elements/bindings/input.js

+27-16
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as e from '../../../errors.js';
55
import { is } from '../../../proxy.js';
66
import { queue_micro_task } from '../../task.js';
77
import { hydrating } from '../../hydration.js';
8-
import { is_runes } from '../../../runtime.js';
8+
import { is_runes, untrack } from '../../../runtime.js';
99

1010
/**
1111
* @param {HTMLInputElement} input
@@ -16,24 +16,36 @@ import { is_runes } from '../../../runtime.js';
1616
export function bind_value(input, get, set = get) {
1717
var runes = is_runes();
1818

19-
listen_to_event_and_reset_event(input, 'input', () => {
19+
listen_to_event_and_reset_event(input, 'input', (is_reset) => {
2020
if (DEV && input.type === 'checkbox') {
2121
// TODO should this happen in prod too?
2222
e.bind_invalid_checkbox_value();
2323
}
2424

25-
/** @type {unknown} */
26-
var value = is_numberlike_input(input) ? to_number(input.value) : input.value;
25+
/** @type {any} */
26+
var value = is_reset ? input.defaultValue : input.value;
27+
value = is_numberlike_input(input) ? to_number(value) : value;
2728
set(value);
2829

2930
// In runes mode, respect any validation in accessors (doesn't apply in legacy mode,
3031
// because we use mutable state which ensures the render effect always runs)
3132
if (runes && value !== (value = get())) {
32-
// @ts-expect-error the value is coerced on assignment
33+
// the value is coerced on assignment
3334
input.value = value ?? '';
3435
}
3536
});
3637

38+
if (
39+
// If we are hydrating and the value has since changed,
40+
// then use the updated value from the input instead.
41+
(hydrating && input.defaultValue !== input.value) ||
42+
// If defaultValue is set, then value == defaultValue
43+
// TODO Svelte 6: remove input.value check and set to empty string?
44+
(untrack(get) == null && input.value)
45+
) {
46+
set(is_numberlike_input(input) ? to_number(input.value) : input.value);
47+
}
48+
3749
render_effect(() => {
3850
if (DEV && input.type === 'checkbox') {
3951
// TODO should this happen in prod too?
@@ -42,13 +54,6 @@ export function bind_value(input, get, set = get) {
4254

4355
var value = get();
4456

45-
// If we are hydrating and the value has since changed, then use the update value
46-
// from the input instead.
47-
if (hydrating && input.defaultValue !== input.value) {
48-
set(is_numberlike_input(input) ? to_number(input.value) : input.value);
49-
return;
50-
}
51-
5257
if (is_numberlike_input(input) && value === to_number(input.value)) {
5358
// handles 0 vs 00 case (see https://github.com/sveltejs/svelte/issues/9959)
5459
return;
@@ -175,13 +180,19 @@ export function bind_group(inputs, group_index, input, get, set = get) {
175180
* @returns {void}
176181
*/
177182
export function bind_checked(input, get, set = get) {
178-
listen_to_event_and_reset_event(input, 'change', () => {
179-
var value = input.checked;
183+
listen_to_event_and_reset_event(input, 'change', (is_reset) => {
184+
var value = is_reset ? input.defaultChecked : input.checked;
180185
set(value);
181186
});
182187

183-
if (get() == undefined) {
184-
set(false);
188+
if (
189+
// If we are hydrating and the value has since changed,
190+
// then use the update value from the input instead.
191+
(hydrating && input.defaultChecked !== input.checked) ||
192+
// If defaultChecked is set, then checked == defaultChecked
193+
untrack(get) == null
194+
) {
195+
set(input.checked);
185196
}
186197

187198
render_effect(() => {

packages/svelte/src/internal/client/dom/elements/bindings/select.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,19 @@ export function init_select(select, get_value) {
8080
export function bind_select_value(select, get, set = get) {
8181
var mounting = true;
8282

83-
listen_to_event_and_reset_event(select, 'change', () => {
83+
listen_to_event_and_reset_event(select, 'change', (is_reset) => {
84+
var query = is_reset ? '[selected]' : ':checked';
8485
/** @type {unknown} */
8586
var value;
8687

8788
if (select.multiple) {
88-
value = [].map.call(select.querySelectorAll(':checked'), get_option_value);
89+
value = [].map.call(select.querySelectorAll(query), get_option_value);
8990
} else {
9091
/** @type {HTMLOptionElement | null} */
91-
var selected_option = select.querySelector(':checked');
92+
var selected_option =
93+
select.querySelector(query) ??
94+
// will fall back to first non-disabled option if no option is selected
95+
select.querySelector('option:not([disabled])');
9296
value = selected_option && get_option_value(selected_option);
9397
}
9498

packages/svelte/src/internal/client/dom/elements/bindings/shared.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ export function without_reactive_context(fn) {
5353
* to notify all bindings when the form is reset
5454
* @param {HTMLElement} element
5555
* @param {string} event
56-
* @param {() => void} handler
57-
* @param {() => void} [on_reset]
56+
* @param {(is_reset?: true) => void} handler
57+
* @param {(is_reset?: true) => void} [on_reset]
5858
*/
5959
export function listen_to_event_and_reset_event(element, event, handler, on_reset = handler) {
6060
element.addEventListener(event, () => without_reactive_context(handler));
@@ -65,11 +65,11 @@ export function listen_to_event_and_reset_event(element, event, handler, on_rese
6565
// @ts-expect-error
6666
element.__on_r = () => {
6767
prev();
68-
on_reset();
68+
on_reset(true);
6969
};
7070
} else {
7171
// @ts-expect-error
72-
element.__on_r = on_reset;
72+
element.__on_r = () => on_reset(true);
7373
}
7474

7575
add_form_reset_listener();

packages/svelte/src/internal/client/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export {
3434
set_xlink_attribute,
3535
handle_lazy_img,
3636
set_value,
37-
set_checked
37+
set_checked,
38+
set_selected
3839
} from './dom/elements/attributes.js';
3940
export { set_class, set_svg_class, set_mathml_class, toggle_class } from './dom/elements/class.js';
4041
export { apply, event, delegate, replay_events } from './dom/elements/events.js';

packages/svelte/src/utils.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ const ATTRIBUTE_ALIASES = {
193193
nomodule: 'noModule',
194194
playsinline: 'playsInline',
195195
readonly: 'readOnly',
196+
defaultvalue: 'defaultValue',
197+
defaultchecked: 'defaultChecked',
196198
srcobject: 'srcObject'
197199
};
198200

@@ -214,6 +216,8 @@ const DOM_PROPERTIES = [
214216
'value',
215217
'inert',
216218
'volume',
219+
'defaultValue',
220+
'defaultChecked',
217221
'srcObject'
218222
];
219223

@@ -224,7 +228,7 @@ export function is_dom_property(name) {
224228
return DOM_PROPERTIES.includes(name);
225229
}
226230

227-
const NON_STATIC_PROPERTIES = ['autofocus', 'muted'];
231+
const NON_STATIC_PROPERTIES = ['autofocus', 'muted', 'defaultValue', 'defaultChecked'];
228232

229233
/**
230234
* Returns `true` if the given attribute cannot be set through the template

0 commit comments

Comments
 (0)