Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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/tidy-zebras-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: support `defaultValue/defaultChecked` for inputs
40 changes: 38 additions & 2 deletions documentation/docs/03-template-syntax/11-bind.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ 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`.

You can give the input a default value by setting the `defaultValue` property. This way, when the input is part of a form and its `form.reset()` method is invoked, it will revert to that value instead of the empty string. Note that for the initial render the value of the binding takes precedence if it's not `null` or `undefined`.

```svelte
<script>
let value = $state('');
</script>

<form>
<input bind:value defaultValue="x">
<input type="reset" value="Reset">
</form>
```

## `<input bind:checked>`

Checkbox and radio inputs can be bound with `bind:checked`:
Expand All @@ -64,16 +77,29 @@ Checkbox and radio inputs can be bound with `bind:checked`:
</label>
```

You can give the input a default value by setting the `defaultChecked` property. This way, when the input is part of a form and its `form.reset()` method is invoked, it will revert to that value instead of `false`. Note that for the initial render the value of the binding takes precedence if it's not `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 -->
Expand Down Expand Up @@ -146,6 +172,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 setting the `selected` property on the elements that should be selected initially. This way, when the select is part of a form and its `form.reset()` method is invoked, it will revert to that value instead of the empty string (for single-value selects) or the empty array (for multi-value selects). 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...
Expand Down
8 changes: 8 additions & 0 deletions packages/svelte/elements.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { is_mathml, is_svg, is_void } from '../../../../utils.js';
import { cannot_be_set_statically, is_mathml, is_svg, is_void } from '../../../../utils.js';
import {
is_tag_valid_with_ancestor,
is_tag_valid_with_parent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
15 changes: 15 additions & 0 deletions packages/svelte/src/internal/client/dom/elements/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ 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) {
element.setAttribute('selected', '');
} else {
element.removeAttribute('selected');
}
}

/**
* @param {Element} element
* @param {string} attribute
Expand Down
32 changes: 21 additions & 11 deletions packages/svelte/src/internal/client/dom/elements/bindings/input.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { DEV } from 'esm-env';
import { render_effect, teardown } from '../../../reactivity/effects.js';
import { listen_to_event_and_reset_event, without_reactive_context } from './shared.js';
import { listen_to_event_and_reset_event } from './shared.js';
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
Expand Down Expand Up @@ -34,6 +34,17 @@ export function bind_value(input, get, set = get) {
}
});

if (
// If we are hydrating and the value has since changed,
// then use the update 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?
Expand All @@ -42,13 +53,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;
Expand Down Expand Up @@ -180,8 +184,14 @@ export function bind_checked(input, get, set = get) {
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(() => {
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,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';
Expand Down
6 changes: 5 additions & 1 deletion packages/svelte/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ const ATTRIBUTE_ALIASES = {
nomodule: 'noModule',
playsinline: 'playsInline',
readonly: 'readOnly',
defaultvalue: 'defaultValue',
defaultchecked: 'defaultChecked',
srcobject: 'srcObject'
};

Expand All @@ -214,6 +216,8 @@ const DOM_PROPERTIES = [
'value',
'inert',
'volume',
'defaultValue',
'defaultChecked',
'srcObject'
];

Expand All @@ -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
Expand Down
Loading