svelte
Version:
Cybernetically enhanced web apps
257 lines (221 loc) • 7.82 kB
JavaScript
import { effect, teardown } from '../../../reactivity/effects.js';
import { listen_to_event_and_reset_event } from './shared.js';
import { is } from '../../../proxy.js';
import { is_array } from '../../../../shared/utils.js';
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. The first call selects
* them if nothing has set a value, later calls leave the current selection alone.
* @param {HTMLSelectElement} select
* @param {any} value
*/
export function set_default_select_value(select, value) {
var mounting = !('__defaultValue' in select);
// @ts-expect-error
if (!mounting && select.__defaultValue === value) return;
// @ts-expect-error
select.__defaultValue = value;
apply_default_select_value(select, !mounting || '__value' in select);
}
/**
* Marks the options matching `__defaultValue` as selected. Without `preserve`
* a newly matching option gets selected, as an inserted `<option selected>` would.
* @param {HTMLSelectElement} select
* @param {boolean} preserve
*/
function apply_default_select_value(select, preserve) {
// @ts-expect-error
var value = select.__defaultValue;
var multiple = select.multiple;
var values = multiple ? value ?? [] : null;
if (multiple && !is_array(values)) return;
var index = select.selectedIndex;
var selected = preserve && multiple ? new Set(select.selectedOptions) : null;
for (var option of select.options) {
var option_value = get_option_value(option);
set_selected(
option,
multiple ? /** @type {any[]} */ (values).includes(option_value) : is(option_value, value)
);
}
if (!preserve) return;
if (selected !== null) {
for (option of select.options) {
var was_selected = selected.has(option);
if (option.selected !== was_selected) option.selected = was_selected;
}
} else if (select.selectedIndex !== index) {
select.selectedIndex = index;
}
}
/**
* Selects the correct option(s) (depending on whether this is a multiple select)
* @template V
* @param {HTMLSelectElement} select
* @param {V} value
* @param {boolean} mounting
*/
export function select_option(select, value, mounting = false) {
if (select.multiple) {
// If value is null or undefined, keep the selection as is
if (value == undefined) {
return;
}
// If not an array, warn and keep the selection as is
if (!is_array(value)) {
return w.select_multiple_invalid_value();
}
// Otherwise, update the selection
for (var option of select.options) {
option.selected = value.includes(get_option_value(option));
}
return;
}
for (option of select.options) {
var option_value = get_option_value(option);
if (is(option_value, value)) {
option.selected = true;
return;
}
}
if (!mounting || value !== undefined) {
select.selectedIndex = -1; // no option should be selected
}
}
/**
* Sets up a mutation observer to sync the current selection
* and default to the dom when the options change, for example
* when they are inside an `#each` block. Called once per `<select>`,
* by the compiled output or by `attribute_effect` for spreads.
* @param {HTMLSelectElement} select
*/
export function init_select(select) {
var observer = new MutationObserver((entries) => {
// Mutations related to `<selectedcontent>` can never affect the option list.
// 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)) return;
if ('__defaultValue' in select) {
apply_default_select_value(select, 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
});
observer.observe(select, {
// Listen to option element changes
childList: true,
subtree: true, // because of <optgroup>
// Listen to option element value attribute changes
// (doesn't get notified of select value changes,
// because that property is not reflected as an attribute)
attributes: true,
attributeFilter: ['value']
});
teardown(() => {
observer.disconnect();
});
}
/**
* @param {HTMLSelectElement} select
* @param {() => unknown} get
* @param {(value: unknown) => void} set
* @returns {void}
*/
export function bind_select_value(select, get, set = get) {
var batches = new WeakSet();
var mounting = true;
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(query), get_option_value);
} else {
/** @type {HTMLOptionElement | null} */
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);
}
set(value);
// @ts-ignore
select.__value = value;
if (current_batch !== null) {
batches.add(current_batch);
}
});
// Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated
effect(() => {
var value = get();
if (select === document.activeElement) {
// In sync mode render effects are executed during tree traversal -> needs current_batch
// In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch
var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch);
// Don't update the <select> if it is focused. We can get here if, for example,
// an update is deferred because of async work depending on the select:
//
// <select bind:value={selected}>...</select>
// <p>{await find(selected)}</p>
if (batches.has(batch)) {
return;
}
}
select_option(select, value, mounting);
// Mounting and value undefined -> take selection from dom
if (mounting && value === undefined) {
/** @type {HTMLOptionElement | null} */
var selected_option = select.querySelector(':checked');
if (selected_option !== null) {
value = get_option_value(selected_option);
set(value);
}
}
// @ts-ignore
select.__value = value;
mounting = false;
});
}
/** @param {HTMLOptionElement} option */
function get_option_value(option) {
// __value only exists if the <option> has a value attribute
if ('__value' in option) {
return option.__value;
} else {
return option.value;
}
}
/**
* Returns `true` if the mutation stems from the browser mirroring the selected
* option's content into `<selectedcontent>`, or from us replacing the
* `<selectedcontent>` element with a clone of itself
* @param {MutationRecord} entry
*/
function is_selectedcontent_mutation(entry) {
if (/** @type {Element} */ (entry.target).closest('selectedcontent') !== null) {
return true;
}
if (entry.type === 'childList') {
var nodes = [...entry.addedNodes, ...entry.removedNodes];
return nodes.length > 0 && nodes.every((node) => node.nodeName === 'SELECTEDCONTENT');
}
return false;
}