feat: support `defaultValue` on `<select>` (#18591)

Closes #18447

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
pull/18721/head
Ion 1 month ago committed by GitHub
parent b433f3cd87
commit c575b07bca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: support `defaultValue` on `<select>`

@ -251,6 +251,19 @@ You can give the `<select>` a default value by adding a `selected` attribute to
</select>
```
Since 5.57.0, if a `<select>` 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
<form>
<select bind:value defaultValue="b">
<option>a</option>
<option>b</option>
<option>c</option>
</select>
<input type="reset" value="Reset">
</form>
```
## `<audio>`
`<audio>` elements have their own set of bindings — five two-way ones...

@ -1352,6 +1352,9 @@ export interface HTMLSelectAttributes extends HTMLAttributes<HTMLSelectElement>
required?: boolean | undefined | null;
size?: number | undefined | null;
value?: any;
// needs both casing variants because language tools does lowercase names of non-shorthand attributes
defaultValue?: any;
defaultvalue?: any;
'on:change'?: ChangeEventHandler<HTMLSelectElement> | undefined | null;
onchange?: ChangeEventHandler<HTMLSelectElement> | undefined | null;

@ -229,6 +229,12 @@ export function RegularElement(node, context) {
continue;
}
// `<select defaultValue>` needs the options to exist before it can mark one
// as selected, so it is handled after the children, alongside `value`
if (node.name === 'select' && get_attribute_name(node, attribute) === 'defaultValue') {
continue;
}
const name = get_attribute_name(node, attribute);
if (
@ -511,6 +517,26 @@ export function RegularElement(node, context) {
}
}
// deferred from the attribute loop above, so that the options it selects from
// have been created and had their values assigned
if (!has_spread && name === 'select') {
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (get_attribute_name(node, attribute) === 'defaultValue') {
const { value, has_state } = build_attribute_value(attribute.value, context, (v, m) =>
context.state.memoizer.add(v, m)
);
const update = b.stmt(b.call('$.set_default_select_value', node_id, value));
(has_state ? context.state.update : context.state.init).push(update);
if (!bindings.has('value')) {
context.state.init.push(b.stmt(b.call('$.init_select', node_id)));
}
break;
}
}
}
context.state.template.pop_element();
}

@ -30,6 +30,7 @@ export function build_attribute_effect(
) {
/** @type {ObjectExpression['properties']} */
const values = [];
const is_select = element.type === 'RegularElement' && element.name === 'select';
const memoizer = new Memoizer();
@ -48,7 +49,11 @@ export function build_attribute_effect(
context.state.init.push(b.var(id, value));
values.push(b.init(attribute.name, b.id(id)));
} else {
values.push(b.init(attribute.name, value));
const name =
is_select && normalize_attribute(attribute.name) === 'defaultValue'
? 'defaultValue'
: attribute.name;
values.push(b.init(name, value));
}
} else {
let value = /** @type {Expression} */ (context.visit(attribute));

@ -46,7 +46,7 @@ export function RegularElement(node, context) {
node.attributes.some(
(attribute) =>
((attribute.type === 'Attribute' || attribute.type === 'BindDirective') &&
attribute.name === 'value') ||
(attribute.name === 'value' || attribute.name.toLowerCase() === 'defaultvalue')) ||
attribute.type === 'SpreadAttribute'
);
const is_option_special = name === 'option';

@ -306,12 +306,14 @@ function get_attribute_name(element, attribute) {
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/
export function build_spread_object(element, attributes, context, transform) {
const is_select = element.type === 'RegularElement' && element.name === 'select';
const object = b.object(
attributes.map((attribute) => {
if (attribute.type === 'transformed') {
return b.prop('init', b.key(attribute.name), attribute.expression);
} else if (attribute.type === 'Attribute') {
const name = get_attribute_name(element, attribute);
let name = get_attribute_name(element, attribute);
if (is_select && name === 'defaultvalue') name = 'defaultValue';
const value = build_attribute_value(
attribute.value,
context,

@ -26,7 +26,12 @@ import { set_class } from './class.js';
import { set_style } from './style.js';
import { ATTACHMENT_KEY, NAMESPACE_HTML, UNINITIALIZED } from '../../../../constants.js';
import { branch, destroy_effect, effect, managed } from '../../reactivity/effects.js';
import { init_select, select_option } from './bindings/select.js';
import {
init_select,
select_option,
set_default_select_value,
set_selected
} from './bindings/select.js';
import { flatten } from '../../reactivity/async.js';
export const CLASS = Symbol('class');
@ -122,25 +127,6 @@ 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');
}
}
/**
* Applies the default checked property without influencing the current checked property.
* @param {HTMLInputElement} element
@ -310,6 +296,7 @@ function set_attributes(
var current = prev || {};
var is_option_element = element.nodeName === OPTION_TAG;
var is_select_element = element.nodeName === SELECT_TAG;
for (var key in prev) {
// don't null our internal $$onX listeners
@ -446,6 +433,9 @@ function set_attributes(
var is_default = name === 'defaultValue' || name === 'defaultChecked';
// A select's default value is represented by selected options, not a property.
if (is_select_element && name === 'defaultValue') continue;
if (value == null && !is_custom_element && !is_default) {
attributes[key] = null;
@ -531,8 +521,16 @@ export function attribute_effect(
skip_warning
);
if (inited && is_select && 'value' in next) {
select_option(/** @type {HTMLSelectElement} */ (element), next.value);
if (inited && is_select) {
var select = /** @type {HTMLSelectElement} */ (element);
if ('defaultValue' in next) {
set_default_select_value(select, next.defaultValue, false);
}
if ('value' in next) {
select_option(select, next.value);
}
}
for (let symbol of Object.getOwnPropertySymbols(effects)) {
@ -557,7 +555,13 @@ export function attribute_effect(
var select = /** @type {HTMLSelectElement} */ (element);
effect(() => {
select_option(select, /** @type {Record<string | symbol, any>} */ (prev).value, true);
var attrs = /** @type {Record<string | symbol, any>} */ (prev);
if ('defaultValue' in attrs) {
set_default_select_value(select, attrs.defaultValue, true);
}
select_option(select, attrs.value, true);
init_select(select);
});
}

@ -6,6 +6,50 @@ import * as w from '../../../warnings.js';
import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js';
import { async_mode_flag } from '../../../../flags/index.js';
/**
* Sets the `selected` attribute on an option so form reset can restore it.
* @param {HTMLOptionElement} option
* @param {boolean} selected
*/
export function set_selected(option, selected) {
if (selected) {
if (!option.hasAttribute('selected')) option.setAttribute('selected', '');
} else {
option.removeAttribute('selected');
}
}
/**
* Sets the options a form reset should restore without changing the current selection.
* The initial call is allowed to establish the current selection when no value exists.
* @param {HTMLSelectElement} select
* @param {any} value
* @param {boolean} [mounting]
*/
export function set_default_select_value(select, value, mounting = !('__defaultValue' in select)) {
// The DOM cannot recover unmatched, object or multiple defaults from selected options.
// Keep the requested value so option mutations can reapply it; property presence also
// distinguishes the initial application from later updates when the value is undefined.
// @ts-expect-error
select.__defaultValue = value;
var values = select.multiple ? (value == null ? [] : value) : null;
if (select.multiple && !is_array(values)) return;
var selected = !mounting || '__value' in select ? new Set(select.selectedOptions) : null;
for (var option of select.options) {
var option_value = get_option_value(option);
var is_selected = select.multiple
? /** @type {any[]} */ (values).includes(option_value)
: is(option_value, value);
set_selected(option, is_selected);
}
if (selected !== null) {
for (option of select.options) option.selected = selected.has(option);
}
}
/**
* Selects the correct option(s) (depending on whether this is a multiple select)
* @template V
@ -60,9 +104,15 @@ export function init_select(select) {
// Reacting to them could revert a user-initiated selection change, because the
// records are delivered as soon as any listener returns (e.g. a delegated `input`
// handler), which can happen before the `change` handler has updated `__value`
if (entries.every(is_selectedcontent_mutation) || !('__value' in select)) return;
// @ts-ignore
select_option(select, select.__value);
if (entries.every(is_selectedcontent_mutation)) return;
if ('__defaultValue' in select) {
set_default_select_value(select, select.__defaultValue, false);
}
if ('__value' in select) {
select_option(select, select.__value);
}
// Deliberately don't update the potential binding value,
// the model should be preserved unless explicitly changed
});

@ -33,7 +33,6 @@ export {
set_xlink_attribute,
set_value,
set_checked,
set_selected,
set_default_checked,
set_default_value,
CLASS,
@ -62,7 +61,13 @@ export {
} from './dom/elements/bindings/media.js';
export { bind_online } from './dom/elements/bindings/navigator.js';
export { bind_prop } from './dom/elements/bindings/props.js';
export { bind_select_value, init_select, select_option } from './dom/elements/bindings/select.js';
export {
bind_select_value,
init_select,
select_option,
set_selected,
set_default_select_value
} from './dom/elements/bindings/select.js';
export { bind_element_size, bind_resize_observer } from './dom/elements/bindings/size.js';
export { bind_this } from './dom/elements/bindings/this.js';
export {

@ -13,7 +13,7 @@ import { attributes } from './index.js';
import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { sha256 } from './crypto.js';
import * as devalue from 'devalue';
import { has_own_property, noop } from '../shared/utils.js';
import { has_own_property, is_array, noop } from '../shared/utils.js';
import { escape_html } from '../../escaping.js';
/** @typedef {'head' | 'body'} RendererType */
@ -89,7 +89,7 @@ export class Renderer {
* State that is local to the branch it is declared in.
* It will be shallow-copied to all children.
*
* @type {{ select_value: string | undefined }}
* @type {{ select_value: any, select_default_multiple: boolean }}
*/
local;
@ -101,7 +101,9 @@ export class Renderer {
this.#parent = parent;
this.global = global;
this.local = parent ? { ...parent.local } : { select_value: undefined };
this.local = parent
? { ...parent.local }
: { select_value: undefined, select_default_multiple: false };
this.type = parent ? parent.type : 'body';
}
@ -338,11 +340,14 @@ export class Renderer {
* @returns {void}
*/
select(attrs, fn, css_hash, classes, styles, flags, is_rich) {
const { value, ...select_attrs } = attrs;
const { value, defaultValue, ...select_attrs } = attrs;
if (select_attrs.multiple === '') select_attrs.multiple = true;
this.push(`<select${attributes(select_attrs, css_hash, classes, styles, flags)}>`);
this.child((renderer) => {
renderer.local.select_value = value;
renderer.local.select_value = value === undefined ? defaultValue : value;
renderer.local.select_default_multiple =
value === undefined && Boolean(select_attrs.multiple);
fn(renderer);
});
this.push(`${is_rich ? '<!>' : ''}</select>`);
@ -370,7 +375,11 @@ export class Renderer {
value = attrs.value;
}
if (value === this.local.select_value) {
if (
this.local.select_default_multiple
? is_array(this.local.select_value) && this.local.select_value.includes(value)
: value === this.local.select_value
) {
renderer.#out.push(' selected=""');
}

@ -0,0 +1,96 @@
import { test } from '../../test';
import { flushSync } from 'svelte';
export default test({
async test({ assert, target }) {
/** @param {HTMLSelectElement} select @param {boolean[]} expected */
function check(select, expected) {
assert.deepEqual(
[...select.options].map((option) => option.selected),
expected
);
}
/** @param {HTMLSelectElement} select @param {number[]} indexes */
function select(select, indexes) {
for (let i = 0; i < select.options.length; i++) {
select.options[i].selected = indexes.includes(i);
}
select.dispatchEvent(new Event('change', { bubbles: true }));
}
const selects = target.querySelectorAll('select');
const reset = /** @type {HTMLInputElement} */ (target.querySelector('input[type=reset]'));
/** @param {string} name */
const button = (name) => /** @type {HTMLButtonElement} */ (target.querySelector(`.${name}`));
check(selects[0], [false, true, false]);
check(selects[1], [false, false, true]);
check(selects[2], [false, true, false]);
check(selects[3], [true]);
check(selects[4], [false, true]);
check(selects[5], [false, true, false]);
check(selects[6], [true, false]);
check(selects[7], [true, false, true]);
check(selects[8], [false, false, true]);
select(selects[0], [2]);
select(selects[1], [0]);
select(selects[5], [2]);
select(selects[7], [1]);
flushSync();
reset.click();
await Promise.resolve();
flushSync();
check(selects[0], [false, true, false]);
check(selects[1], [false, true, false]);
check(selects[5], [false, true, false]);
check(selects[7], [true, false, true]);
select(selects[2], [2]);
select(selects[5], [2]);
button('update').click();
flushSync();
check(selects[2], [false, false, true]);
check(selects[5], [false, false, true]);
assert.deepEqual(
[...selects[5].options].map((option) => option.defaultSelected),
[true, false, false]
);
button('add').click();
flushSync();
await Promise.resolve();
check(selects[3], [true, false]);
assert.equal(selects[3].options[1].defaultSelected, true);
reset.click();
await Promise.resolve();
flushSync();
check(selects[0], [true, false, false]);
check(selects[2], [true, false, false]);
check(selects[3], [false, true]);
check(selects[5], [true, false, false]);
select(selects[5], [2]);
select(selects[7], [1]);
button('remove').click();
button('clear').click();
flushSync();
assert.equal(
[...selects[5].options].some((option) => option.defaultSelected),
false
);
assert.equal(
[...selects[7].options].some((option) => option.defaultSelected),
false
);
reset.click();
await Promise.resolve();
flushSync();
check(selects[5], [true, false, false]);
check(selects[7], [false, false, false]);
}
});

@ -0,0 +1,79 @@
<script>
let selected1 = $state();
let selected2 = $state('c');
let selected3 = $state();
let selected4 = $state(['c']);
let defaultValue = $state('b');
let multipleDefault = $state(/** @type {string[] | undefined} */ (['a', 'c']));
let options = $state(['a']);
let props = $state({ defaultValue: 'b' });
</script>
<form>
<select {defaultValue} bind:value={selected1}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<select {defaultValue} bind:value={selected2}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<select {defaultValue}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<select defaultValue="b">
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
<select defaultvalue="b">
<option value="a">A</option>
<option value="b">B</option>
</select>
<select {...props} bind:value={selected3}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<select {...{ defaultValue: 'b' }} defaultValue="a">
<option value="a">A</option>
<option value="b">B</option>
</select>
<select multiple defaultValue={multipleDefault}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<select multiple defaultValue={multipleDefault} bind:value={selected4}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<input type="reset" value="Reset" />
<button
type="button"
class="update"
onclick={() => {
defaultValue = 'a';
props.defaultValue = 'a';
}}>Update defaults</button
>
<button type="button" class="add" onclick={() => options.push('b')}>Add option</button>
<button type="button" class="remove" onclick={() => delete props.defaultValue}>Remove default</button>
<button type="button" class="clear" onclick={() => (multipleDefault = undefined)}>Clear defaults</button>
</form>
<p>{selected1} {selected2} {selected3} {selected4.join(',')}</p>

@ -0,0 +1,25 @@
<select>
<option value="a">A</option>
<option selected="" value="b">B</option>
</select>
<select>
<option value="a">A</option>
<option selected="" value="b">B</option>
</select>
<select>
<option selected="" value="a">A</option>
<option value="b">B</option>
</select>
<select multiple="">
<option selected="" value="a">A</option>
<option value="b">B</option>
<option selected="" value="c">C</option>
</select>
<select>
<option value="a">A</option>
<option selected="" value="b">B</option>
</select>
<select>
<option selected="" value="a">A</option>
<option value="b">B</option>
</select>

@ -0,0 +1,34 @@
<script>
const props = { defaultValue: 'b' };
</script>
<select defaultValue="b">
<option value="a">A</option>
<option value="b">B</option>
</select>
<select {...props}>
<option value="a">A</option>
<option value="b">B</option>
</select>
<select defaultValue="b" value="a">
<option value="a">A</option>
<option value="b">B</option>
</select>
<select multiple="" defaultValue={['a', 'c']}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<select defaultvalue="b">
<option value="a">A</option>
<option value="b">B</option>
</select>
<select {...{ defaultValue: 'b' }} defaultValue="a">
<option value="a">A</option>
<option value="b">B</option>
</select>
Loading…
Cancel
Save