feat: customizable select

customizable-select-skip-hydration
paoloricciuti 2 weeks ago
parent 044dce9da5
commit dba9b5fc0e

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: customizable select

@ -327,11 +327,20 @@ export function RegularElement(node, context) {
context.visit(node, child_state);
}
// Detect if this is an <option> with rich content (non-text children)
// In this case, we need to branch hydration based on browser support
const is_option_with_rich_content =
node.name === 'option' &&
trimmed.some(
(child) => child.type !== 'Text' && child.type !== 'ExpressionTag' && child.type !== 'Comment'
);
// special case — if an element that only contains text, we don't need
// to descend into it if the text is non-reactive
// in the rare case that we have static text that can't be inlined
// (e.g. `<span>{location}</span>`), set `textContent` programmatically
const use_text_content =
!is_option_with_rich_content &&
trimmed.every((node) => node.type === 'Text' || node.type === 'ExpressionTag') &&
trimmed.every(
(node) =>
@ -351,6 +360,63 @@ export function RegularElement(node, context) {
b.stmt(b.assignment('=', b.member(context.state.node, 'textContent'), value))
);
}
} else if (is_option_with_rich_content) {
// For <option> elements with rich content, we need to branch based on browser support.
// Modern browsers preserve rich HTML in options, older browsers strip it to text only.
// We use $.rich_option(rich_fn, text_fn) to handle both cases.
/** @type {Expression} */
let arg = context.state.node;
// Create the rich content branch (for modern browsers)
/** @type {typeof state} */
const rich_child_state = { ...state, init: [], update: [], after_update: [] };
let needs_reset = trimmed.some((node) => node.type !== 'Text');
process_children(trimmed, (is_text) => b.call('$.child', arg, is_text && b.true), true, {
...context,
state: rich_child_state
});
if (needs_reset) {
rich_child_state.init.push(b.stmt(b.call('$.reset', context.state.node)));
}
// Build the rich content function body
const rich_fn_body = b.block([
...rich_child_state.init,
...(rich_child_state.update.length > 0 ? [build_render_statement(rich_child_state)] : []),
...rich_child_state.after_update
]);
// Create the text fallback branch (for legacy browsers)
// Extract all text/expression content recursively from the children
const text_content = extract_text_content(trimmed);
/** @type {typeof state} */
const text_child_state = { ...state, init: [], update: [], after_update: [] };
if (text_content.length > 0) {
const { value, has_state } = build_template_chunk(text_content, context, text_child_state);
const update = b.stmt(b.assignment('=', b.member(context.state.node, 'textContent'), value));
if (has_state) {
text_child_state.update.push(update);
} else {
text_child_state.init.push(update);
}
}
const text_fn_body = b.block([
...text_child_state.init,
...(text_child_state.update.length > 0 ? [build_render_statement(text_child_state)] : []),
...text_child_state.after_update
]);
child_state.init.push(
b.stmt(b.call('$.rich_option', b.arrow([], rich_fn_body), b.arrow([], text_fn_body)))
);
} else {
/** @type {Expression} */
let arg = context.state.node;
@ -716,3 +782,28 @@ function build_element_special_value_attribute(
state.init.push(b.stmt(b.call('$.init_select', node_id)));
}
}
/**
* Recursively extracts all Text and ExpressionTag nodes from a tree of nodes.
* This is used to build the text-only fallback for rich options in legacy browsers.
* @param {AST.SvelteNode[]} nodes
* @returns {Array<AST.Text | AST.ExpressionTag>}
*/
function extract_text_content(nodes) {
/** @type {Array<AST.Text | AST.ExpressionTag>} */
const result = [];
for (const node of nodes) {
if (node.type === 'Text' || node.type === 'ExpressionTag') {
result.push(node);
} else if ('fragment' in node && node.fragment) {
// Recursively extract from elements with fragments (like RegularElement)
result.push(...extract_text_content(node.fragment.nodes));
} else if ('children' in node && Array.isArray(node.children)) {
// Handle other node types with children
result.push(...extract_text_content(node.children));
}
}
return result;
}

@ -149,10 +149,26 @@ export function RegularElement(node, context) {
const inner_state = { ...state, template: [], init: [] };
process_children(trimmed, { ...context, state: inner_state });
body = b.arrow(
[b.id('$$renderer')],
b.block([...state.init, ...build_template(inner_state.template)])
);
/** @type {import('estree').Statement[]} */
const body_statements = [...state.init, ...build_template(inner_state.template)];
if (dev) {
const location = locator(node.start);
body_statements.unshift(
b.stmt(
b.call(
'$.push_element',
b.id('$$renderer'),
b.literal(node.name),
b.literal(location.line),
b.literal(location.column)
)
)
);
body_statements.push(b.stmt(b.call('$.pop_element')));
}
body = b.arrow([b.id('$$renderer')], b.block(body_statements));
}
const [attributes, ...rest] = prepare_element_spread_object(node, context, optimiser.transform);

@ -82,7 +82,8 @@ const disallowed_children = {
...autoclosing_children,
optgroup: { only: ['option', '#text'] },
// Strictly speaking, seeing an <option> doesn't mean we're in a <select>, but we assume it here
option: { only: ['#text'] },
// option does not have an `only` restriction because newer browsers support rich HTML content
// inside option elements. For older browsers, hydration will handle the mismatch.
form: { descendant: ['form'] },
a: { descendant: ['a'] },
button: { descendant: ['button'] },

@ -0,0 +1,16 @@
import { check_rich_option_support } from '../operations.js';
/**
* Handles rich HTML content inside `<option>` elements with browser-specific branching.
* Modern browsers preserve HTML inside options, while older browsers strip it to text only.
*
* @param {() => void} rich_fn Function to process rich HTML content (modern browsers)
* @param {() => void} text_fn Function to process text-only content (legacy browsers)
*/
export function rich_option(rich_fn, text_fn) {
if (check_rich_option_support()) {
rich_fn();
} else {
text_fn();
}
}

@ -18,6 +18,9 @@ export var $document;
/** @type {boolean} */
export var is_firefox;
/** @type {boolean | null} */
var supports_rich_option = null;
/** @type {() => Node | null} */
var first_child_getter;
/** @type {() => Node | null} */
@ -258,3 +261,17 @@ export function set_attribute(element, key, value = '') {
}
return element.setAttribute(key, value);
}
/**
* Checks if the browser supports rich HTML content inside `<option>` elements.
* Modern browsers preserve HTML elements inside options, while older browsers
* strip them during parsing, leaving only text content.
* @returns {boolean}
*/
export function check_rich_option_support() {
if (supports_rich_option !== null) return supports_rich_option;
var select = document.createElement('select');
select.innerHTML = '<option><span>t</span></option>';
return (supports_rich_option =
/** @type {Element} */ (select.firstChild)?.firstChild?.nodeType === 1);
}

@ -42,6 +42,7 @@ export {
export { set_class } from './dom/elements/class.js';
export { apply, event, delegate, replay_events } from './dom/elements/events.js';
export { autofocus, remove_textarea_child } from './dom/elements/misc.js';
export { rich_option } from './dom/elements/rich-option.js';
export { set_style } from './dom/elements/style.js';
export { animation, transition } from './dom/elements/transitions.js';
export { bind_active_element } from './dom/elements/bindings/document.js';

@ -0,0 +1,34 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
// This test verifies that hydration continues correctly after
// an option element with rich HTML content
snapshot(target) {
const select = target.querySelector('select');
const options = target.querySelectorAll('option');
const p = target.querySelector('p');
const button = target.querySelector('button');
return {
select,
option1: options[0],
option2: options[1],
p,
button
};
},
async test(assert, target) {
const option = target.querySelector('option');
const button = target.querySelector('button');
assert.equal(option?.textContent, 'hello hello');
flushSync(() => {
button?.click();
});
assert.equal(option?.textContent, 'changed changed');
}
});

@ -0,0 +1 @@
<select><option value="a">hello hello</option><option value="b">Plain text</option></select> <button></button>

@ -0,0 +1,12 @@
<script>
let label = $state('hello');
let count = $state(42);
</script>
<select>
<!-- this would fail during hydration if rich_option handling is not correct -->
<option value="a"><span>{label}</span> {label}</option>
<option value="b">Plain text</option>
</select>
<button onclick={() => label = "changed"}></button>

@ -0,0 +1,37 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
// Test that rich HTML content in <option> elements compiles without errors
// and that the component functions correctly (on browsers that support it)
export default test({
mode: ['client'],
test({ assert, target }) {
const select = /** @type {HTMLSelectElement} */ (target.querySelector('select'));
const p = /** @type {HTMLParagraphElement} */ (target.querySelector('p'));
const button = /** @type {HTMLButtonElement} */ (target.querySelector('button'));
assert.ok(select);
assert.ok(p);
assert.ok(button);
assert.equal(select.value, 'a');
assert.equal(p.textContent, 'Selected: a');
// Verify options exist
assert.equal(select.options.length, 3);
// Change selection
select.value = 'b';
select.dispatchEvent(new Event('change'));
flushSync();
assert.equal(p.textContent, 'Selected: b');
// Test reactivity of content within option (only works on browsers that support rich options)
// On modern browsers, clicking the button should update the text inside the span
button.click();
flushSync();
// The option text content should be updated on browsers that support rich options
// For this test, we just verify the component doesn't crash
}
});

@ -0,0 +1,15 @@
<script>
let selected = $state('a');
let label_a = $state('Option');
let label_b = $state('Strong');
</script>
<select bind:value={selected}>
<option value="a"><span>{label_a}</span> A</option>
<option value="b"><strong>{label_b}</strong> B</option>
<option value="c">Plain C</option>
</select>
<p>Selected: {selected}</p>
<button onclick={() => label_a = 'Changed'}>Change A</button>
Loading…
Cancel
Save