pull/18591/merge
Ion 1 day ago committed by GitHub
commit b932e6bdf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -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;

@ -231,6 +231,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' && attribute.name === 'defaultValue') {
continue;
}
const name = get_attribute_name(node, attribute);
if (
@ -513,6 +519,23 @@ 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 (attribute.name === '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);
break;
}
}
}
context.state.template.pop_element();
}

@ -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 === 'defaultValue')) ||
attribute.type === 'SpreadAttribute'
);
const is_option_special = name === 'option';

@ -1,7 +1,7 @@
/** @import { Blocker, Effect } from '#client' */
import { DEV } from 'esm-env';
import { hydrating, set_hydrating } from '../hydration.js';
import { get_descriptors, get_prototype_of } from '../../../shared/utils.js';
import { get_descriptors, get_prototype_of, is_array } from '../../../shared/utils.js';
import { create_event, delegate, delegated, event, event_symbol } from './events.js';
import { add_form_reset_listener, autofocus } from './misc.js';
import * as w from '../../warnings.js';
@ -26,7 +26,8 @@ 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 { get_option_value, init_select, select_option } from './bindings/select.js';
import { is } from '../../proxy.js';
import { flatten } from '../../reactivity/async.js';
export const CLASS = Symbol('class');
@ -163,6 +164,51 @@ export function set_default_value(element, value) {
element.value = existing_value;
}
/**
* `<select>` has no `defaultValue` property. The default selection is instead
* expressed through the `selected` attribute of the matching `<option>`, which is
* what the form reset algorithm restores, so mark that option and unmark the rest.
* The current selection is preserved, since `selected` is the default state only.
* @param {HTMLSelectElement} select
* @param {any} value
*/
export function set_default_select_value(select, value) {
// `__value` is set by `<select value>` / `bind:value`. If it is absent, nothing has
// asked for a particular option yet, so the default selection should take effect —
// which is what the browser does with a `selected` attribute in the markup.
var has_explicit_value = '__value' in select;
var selected_index = select.selectedIndex;
if (select.multiple) {
// a `multiple` select takes a list of values, so an option is part of the
// default selection when it is a member of that list — mirroring how
// `select_option` applies the current value
if (!is_array(value)) return;
var selected = new Set([...select.selectedOptions]);
for (var multi_option of select.options) {
set_selected(multi_option, value.includes(get_option_value(multi_option)));
}
if (has_explicit_value) {
for (var option_to_restore of select.options) {
option_to_restore.selected = selected.has(option_to_restore);
}
}
return;
}
for (var option of select.options) {
set_selected(option, is(get_option_value(option), value));
}
if (has_explicit_value) {
select.selectedIndex = selected_index;
}
}
/**
* @param {Element} element
* @param {string} attribute
@ -559,7 +605,16 @@ 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);
// `defaultValue` is meaningless as a property here, so `set_attributes` cannot
// apply it. Mark the default option once the options exist, as we do for the
// non-spread case.
if ('defaultValue' in attrs) {
set_default_select_value(select, attrs.defaultValue);
}
select_option(select, attrs.value, true);
init_select(select);
});
}

@ -156,7 +156,7 @@ export function bind_select_value(select, get, set = get) {
}
/** @param {HTMLOptionElement} option */
function get_option_value(option) {
export function get_option_value(option) {
// __value only exists if the <option> has a value attribute
if ('__value' in option) {
return option.__value;

@ -36,6 +36,7 @@ export {
set_selected,
set_default_checked,
set_default_value,
set_default_select_value,
CLASS,
STYLE
} from './dom/elements/attributes.js';

@ -332,11 +332,16 @@ export class Renderer {
* @returns {void}
*/
select(attrs, fn, css_hash, classes, styles, flags, is_rich) {
const { value, ...select_attrs } = attrs;
// the compiler lowercases attribute names written in the template, but a spread
// passes the keys through as the user wrote them, so accept both spellings
const { value, defaultValue, defaultvalue, ...select_attrs } = attrs;
const default_value = defaultValue === undefined ? defaultvalue : defaultValue;
this.push(`<select${attributes(select_attrs, css_hash, classes, styles, flags)}>`);
this.child((renderer) => {
renderer.local.select_value = value;
// `<select>` has no defaultValue attribute — it only says which option is
// selected by default, so it applies when there is no `value` to override it
renderer.local.select_value = value === undefined ? default_value : value;
fn(renderer);
});
this.push(`${is_rich ? '<!>' : ''}</select>`);

@ -0,0 +1,55 @@
import { test } from '../../test';
import { flushSync } from 'svelte';
export default test({
async test({ assert, target }) {
/**
* @param {NodeListOf<any>} options
* @param {any[]} selected
*/
function check_options(options, selected) {
for (let i = 0; i < options.length; i++) {
assert.equal(options[i].selected, selected[i], `option ${i}`);
}
}
/**
* @param {HTMLSelectElement} select
* @param {number[]} indexes
*/
function select_options(select, indexes) {
const options = select.querySelectorAll('option');
for (let i = 0; i < options.length; i++) {
options[i].selected = indexes.includes(i);
}
select.dispatchEvent(new Event('change', { bubbles: true }));
}
const reset = /** @type {HTMLInputElement} */ (target.querySelector('input[type=reset]'));
const [test1, test2] = target.querySelectorAll('select');
const span = /** @type {HTMLSpanElement} */ (target.querySelector('span'));
// every option named by defaultValue is selected, not just none of them
check_options(test1.querySelectorAll('option'), [true, false, true]);
// an explicit value still wins over defaultValue
check_options(test2.querySelectorAll('option'), [false, false, true]);
assert.htmlEqual(span.innerHTML, 'c');
// change both selections, then reset the form
select_options(test1, [1]);
select_options(test2, [0, 1]);
flushSync();
assert.htmlEqual(span.innerHTML, 'a,b');
reset.click();
await Promise.resolve();
flushSync();
// reset restores the default selection in both cases
check_options(test1.querySelectorAll('option'), [true, false, true]);
check_options(test2.querySelectorAll('option'), [true, false, true]);
assert.htmlEqual(span.innerHTML, 'a,c');
}
});

@ -0,0 +1,27 @@
<script>
let selected = $state(['c']);
let defaultValue = $state(['a', 'c']);
</script>
<form>
<!-- defaultValue=[a, c], no value: the default selection takes effect -->
<select multiple {defaultValue}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<!-- defaultValue=[a, c], value=[c]: the explicit value wins -->
<select multiple {defaultValue} bind:value={selected}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<input type="reset" value="Reset" />
</form>
<p>
<span class="test-1">{selected.join(',')}</span>
</p>

@ -0,0 +1,56 @@
import { test } from '../../test';
import { flushSync } from 'svelte';
export default test({
async test({ assert, target }) {
/**
* @param {NodeListOf<any>} options
* @param {any[]} selected
*/
function check_options(options, selected) {
for (let i = 0; i < options.length; i++) {
assert.equal(options[i].selected, selected[i], `option ${i}`);
}
}
/**
* @param {HTMLOptionElement} option
*/
function select_option(option) {
option.selected = true;
option.dispatchEvent(new Event('change', { bubbles: true }));
}
const reset = /** @type {HTMLInputElement} */ (target.querySelector('input[type=reset]'));
const [test1, test2] = target.querySelectorAll('select');
const [test1_span] = target.querySelectorAll('span');
// a spread carrying defaultValue selects the matching option
{
const options = test1.querySelectorAll('option');
check_options(options, [false, true, false]);
assert.htmlEqual(test1_span.innerHTML, 'b');
}
// value in the same spread still wins over defaultValue
{
const options = test2.querySelectorAll('option');
check_options(options, [false, false, true]);
}
// changing the selection and resetting goes back to defaultValue
select_option(test1.querySelectorAll('option')[2]);
select_option(test2.querySelectorAll('option')[0]);
flushSync();
assert.htmlEqual(test1_span.innerHTML, 'c');
reset.click();
await Promise.resolve();
flushSync();
check_options(test1.querySelectorAll('option'), [false, true, false]);
check_options(test2.querySelectorAll('option'), [false, true, false]);
assert.htmlEqual(test1_span.innerHTML, 'b');
}
});

@ -0,0 +1,26 @@
<script>
let props1 = $state({ defaultValue: 'b' });
let props2 = $state({ defaultValue: 'b', value: 'c' });
let selected = $state();
</script>
<form>
<!-- spread carrying defaultValue, no value -->
<select {...props1} bind:value={selected}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<!-- spread carrying both, value wins -->
<select {...props2}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<input type="reset" value="Reset" />
</form>
<p><span class="test-1">{selected}</span></p>

@ -0,0 +1,70 @@
import { test } from '../../test';
import { flushSync } from 'svelte';
export default test({
async test({ assert, target }) {
/**
* @param {NodeListOf<any>} options
* @param {any[]} selected
*/
function check_options(options, selected) {
for (let i = 0; i < options.length; i++) {
assert.equal(options[i].selected, selected[i], `option ${i}`);
}
}
/**
* @param {HTMLOptionElement} option
*/
function select_option(option) {
option.selected = true;
option.dispatchEvent(new Event('change', { bubbles: true }));
}
const reset = /** @type {HTMLInputElement} */ (target.querySelector('input[type=reset]'));
const [test1, test2, test3] = target.querySelectorAll('select');
const [test1_span, test2_span, test3_span] = target.querySelectorAll('span');
// defaultValue selects the matching option when no value is given
{
const options = test1.querySelectorAll('option');
check_options(options, [false, true, false]);
assert.htmlEqual(test1_span.innerHTML, 'b');
}
// an explicit value wins over defaultValue, but reset goes back to defaultValue
{
const options = test2.querySelectorAll('option');
check_options(options, [false, false, true]);
assert.htmlEqual(test2_span.innerHTML, 'c');
}
// static defaultValue behaves the same as the dynamic one
{
const options = test3.querySelectorAll('option');
check_options(options, [false, true, false]);
assert.htmlEqual(test3_span.innerHTML, 'b');
}
// change the selection, then reset the form
select_option(test1.querySelectorAll('option')[2]);
select_option(test2.querySelectorAll('option')[0]);
select_option(test3.querySelectorAll('option')[2]);
flushSync();
assert.htmlEqual(test1_span.innerHTML, 'c');
assert.htmlEqual(test2_span.innerHTML, 'a');
assert.htmlEqual(test3_span.innerHTML, 'c');
reset.click();
await Promise.resolve();
flushSync();
check_options(test1.querySelectorAll('option'), [false, true, false]);
check_options(test2.querySelectorAll('option'), [false, true, false]);
check_options(test3.querySelectorAll('option'), [false, true, false]);
assert.htmlEqual(test1_span.innerHTML, 'b');
assert.htmlEqual(test2_span.innerHTML, 'b');
assert.htmlEqual(test3_span.innerHTML, 'b');
}
});

@ -0,0 +1,38 @@
<script>
let selected1 = $state();
let selected2 = $state('c');
let selected3 = $state();
let defaultValue = $state('b');
</script>
<form>
<!-- defaultValue=b, value=undefined -->
<select {defaultValue} bind:value={selected1}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<!-- defaultValue=b, value=c -->
<select {defaultValue} bind:value={selected2}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<!-- static defaultValue -->
<select defaultValue="b" bind:value={selected3}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
<input type="reset" value="Reset" />
</form>
<p>
<span class="test-1">{selected1}</span>
<span class="test-2">{selected2}</span>
<span class="test-3">{selected3}</span>
</p>

@ -0,0 +1,12 @@
<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>

@ -0,0 +1,18 @@
<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>
Loading…
Cancel
Save