feat: customizable select (#17429)

* feat: customizable select

* fix: treat inner of options as separate templates

* fix: add hydration comment

* fix: clear with textContent

* fix: mark rich content option as dynamic and allow optgroup

* fix: allow select with rich content and rename

* chore: revert tests

* fix: recursively check rich options

* chore: change name of template

* fix: mark boundary as dynamic

* chore: add snapshot tests

* chore: include comprehensive hydration test

* fix: consider `selectedelement` as ancestor of `option` elements

* fix: make `:has` work with `selectedcontent`

* chore: failing test for css

* fix: also return parent for adjacent_only

* fix: move hydration marker at the end

* chore: apply suggestions from code review

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* fix: treat text elements in select and optgroup as rich

* fix hydration

* colocate code

* drop the element, it's cleaner

* rename module

* update test

* Update .changeset/chubby-dingos-laugh.md

* fix a11y warning that occurs when button only contains selectedcontent

* remove unused parameter

* last part is redundant

* simplify

* unnecessary, the constraints overlap

* this is taken care of during analysis

* simplify

* tweak

* tweak

* tweak

* fix/tweak

* tweak

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/17480/head
Paolo Ricciuti 4 months ago committed by GitHub
parent 3046004ec3
commit 645eb12258
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: customizable `<select>` elements

@ -770,7 +770,39 @@ function get_ancestor_elements(node, adjacent_only, seen = new Set()) {
}
if (parent.type === 'RegularElement' || parent.type === 'SvelteElement') {
// Special handling for <option> inside <select>: elements inside <option> should
// also be considered descendants of <selectedcontent>, which clones the selected option's content
if (parent.type === 'RegularElement' && parent.name === 'option') {
const is_direct_child = ancestors.length === 0;
const select_element = path.findLast(
(element, j) => element.type === 'RegularElement' && element.name === 'select' && j < i
);
if (select_element && (!adjacent_only || is_direct_child)) {
/** @type {Compiler.AST.RegularElement | null} */
let selectedcontent_element = null;
walk(select_element, null, {
RegularElement(child, context) {
if (child.name === 'selectedcontent') {
selectedcontent_element = child;
context.stop();
return;
}
context.next();
}
});
if (adjacent_only && is_direct_child && selectedcontent_element) {
return [selectedcontent_element, parent];
} else if (selectedcontent_element) {
ancestors.push(selectedcontent_element);
}
}
}
ancestors.push(parent);
if (adjacent_only) {
break;
}
@ -817,6 +849,34 @@ function get_descendant_elements(node, adjacent_only, seen = new Set()) {
walk_children(node.type === 'RenderTag' ? node : node.fragment);
// Special handling for <selectedcontent>: it clones the content of the selected <option>,
// so descendants of <option> elements in the same <select> should also be considered descendants
if (node.type === 'RegularElement' && node.name === 'selectedcontent') {
const path = node.metadata.path;
const select_element = path.findLast(
(/** @type {Compiler.AST.SvelteNode} */ element) =>
element.type === 'RegularElement' && element.name === 'select'
);
if (select_element) {
walk(
select_element,
{ inside_option: false },
{
_(child, context) {
if (child.type === 'RegularElement' && child.name === 'option') {
context.next({ inside_option: true });
} else if (context.state.inside_option) {
walk_children(child);
} else {
context.next();
}
}
}
);
}
}
return descendants;
}

@ -7,7 +7,11 @@ import {
} from '../../../../html-tree-validation.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { create_attribute, is_custom_element_node } from '../../nodes.js';
import {
create_attribute,
is_custom_element_node,
is_customizable_select_element
} from '../../nodes.js';
import { regex_starts_with_newline } from '../../patterns.js';
import { check_element } from './shared/a11y/index.js';
import { validate_element } from './shared/element.js';
@ -74,6 +78,15 @@ export function RegularElement(node, context) {
node.metadata.synthetic_value_node = child;
}
// Special case: <select>, <option> or <optgroup> with rich content needs special hydration handling
// We mark the subtree as dynamic so parent elements properly include the child init code
if (is_customizable_select_element(node)) {
// Mark the element's own fragment as dynamic so it's not treated as static
node.fragment.metadata.dynamic = true;
// Also mark ancestor fragments so parents properly include the child init code
mark_subtree_dynamic(context.path);
}
const binding = context.state.scope.get(node.name);
if (
binding !== null &&

@ -831,6 +831,10 @@ function has_content(element) {
return true;
}
if (node.name === 'selectedcontent') {
return true;
}
if (!has_content(node)) {
continue;
}

@ -11,7 +11,12 @@ import {
import { is_ignored } from '../../../../state.js';
import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { create_attribute, ExpressionMetadata, is_custom_element_node } from '../../../nodes.js';
import {
create_attribute,
ExpressionMetadata,
is_custom_element_node,
is_customizable_select_element
} from '../../../nodes.js';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_getter } from '../utils.js';
import {
@ -24,6 +29,9 @@ import {
import { process_children, is_static_element } from './shared/fragment.js';
import { build_render_statement, build_template_chunk, Memoizer } from './shared/utils.js';
import { visit_event_attribute } from './shared/events.js';
import { Template } from '../transform-template/template.js';
import { transform_template } from '../transform-template/index.js';
import { TEMPLATE_FRAGMENT } from '../../../../../constants.js';
/**
* @param {AST.RegularElement} node
@ -351,6 +359,57 @@ export function RegularElement(node, context) {
b.stmt(b.assignment('=', b.member(context.state.node, 'textContent'), value))
);
}
} else if (is_customizable_select_element(node)) {
// For <option>, <optgroup>, or <select> 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 create a separate template for the rich content and append it to the element.
const element_node = context.state.node;
// Add a hydration marker inside the option element so $.child() has an anchor to find
context.state.template.push_comment();
// Create a separate template for the rich content
const template_name = context.state.scope.root.unique(`${node.name}_content`);
const fragment_id = b.id(context.state.scope.generate('fragment'));
const anchor_id = b.id(context.state.scope.generate('anchor'));
// Create state with a new template for the rich content
/** @type {typeof state} */
const select_state = {
...state,
init: [],
update: [],
after_update: [],
template: new Template()
};
process_children(
trimmed,
(is_text) => b.call('$.first_child', fragment_id, is_text && b.true),
false,
{
...context,
state: select_state
}
);
// Transform the template to $.from_html(...) and hoist it
const template = transform_template(select_state, metadata.namespace, TEMPLATE_FRAGMENT);
context.state.hoisted.push(b.var(template_name, template));
// Build the rich content function body
// The anchor is the child of the element (a hydration marker during hydration)
const body = b.block([
b.var(anchor_id, b.call('$.child', element_node)),
b.var(fragment_id, b.call(template_name)),
...select_state.init,
...(select_state.update.length > 0 ? [build_render_statement(select_state)] : []),
...select_state.after_update,
b.stmt(b.call('$.append', anchor_id, fragment_id))
]);
child_state.init.push(b.stmt(b.call('$.customizable_select', element_node, b.arrow([], body))));
} else {
/** @type {Expression} */
let arg = context.state.node;

@ -15,6 +15,7 @@ import {
PromiseOptimiser,
create_async_block
} from './shared/utils.js';
import { is_customizable_select_element } from '../../../nodes.js';
/**
* @param {AST.RegularElement} node
@ -124,6 +125,10 @@ export function RegularElement(node, context) {
const [attributes, ...rest] = prepare_element_spread_object(node, context, optimiser.transform);
if (is_customizable_select_element(node)) {
rest.push(b.true);
}
const statement = b.stmt(b.call('$$renderer.select', attributes, fn, ...rest));
if (optimiser.expressions.length > 0) {
@ -149,14 +154,34 @@ 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);
if (is_customizable_select_element(node)) {
rest.push(b.true);
}
const statement = b.stmt(b.call('$$renderer.option', attributes, body, ...rest));
if (optimiser.expressions.length > 0) {
@ -192,7 +217,14 @@ export function RegularElement(node, context) {
)
);
} else {
// For optgroup or select with rich content, add hydration marker at the start
process_children(trimmed, { ...context, state });
if (
(node.name === 'optgroup' || node.name === 'select') &&
is_customizable_select_element(node)
) {
state.template.push(b.literal('<!>'));
}
}
if (!node_is_void) {

@ -148,3 +148,98 @@ export function get_name(node) {
return null;
}
/**
* Checks if an <option>, <optgroup>, or <select> element has rich content that requires special hydration handling.
* Rich content is anything beyond simple text, expressions, and comments for <option>,
* anything beyond <option> children for <optgroup>,
* or anything beyond <option>, <optgroup>, and empty text for <select>.
* Control flow blocks are recursively checked - they only count as rich content if they contain rich content.
* @param {AST.RegularElement} node
* @returns {boolean}
*/
export function is_customizable_select_element(node) {
if (node.name === 'select' || node.name === 'optgroup' || node.name === 'option') {
for (const child of find_descendants(node.fragment)) {
if (child.type === 'RegularElement') {
if (node.name === 'select' && child.name !== 'option' && child.name !== 'optgroup') {
return true;
}
if (node.name === 'optgroup' && child.name !== 'option') {
return true;
}
if (node.name === 'option') {
return true;
}
}
// Text nodes directly in <select> or <optgroup> are rich content
else if (child.type === 'Text') {
if (node.name === 'select' || node.name === 'optgroup') {
return true;
}
}
// Any non-RegularElement, non-Text node is rich content
else {
return true;
}
}
}
return false;
}
/**
* @param {AST.Fragment | null} fragment
* @returns {Iterable<AST.SvelteNode>}
*/
function* find_descendants(fragment) {
if (fragment === null) return;
for (const node of fragment.nodes) {
switch (node.type) {
case 'SnippetBlock':
case 'DebugTag':
case 'ConstTag':
case 'Comment':
case 'ExpressionTag':
break;
case 'Text':
if (node.data.trim() !== '') {
yield node;
}
break;
case 'IfBlock':
yield* find_descendants(node.consequent);
yield* find_descendants(node.alternate);
break;
case 'EachBlock':
yield* find_descendants(node.body);
yield* find_descendants(node.fallback ?? null);
break;
case 'KeyBlock':
yield* find_descendants(node.fragment);
break;
case 'AwaitBlock':
yield* find_descendants(node.pending);
yield* find_descendants(node.then);
yield* find_descendants(node.catch);
break;
case 'SvelteBoundary':
yield* find_descendants(node.fragment);
break;
default:
yield node;
}
}
}

@ -80,9 +80,9 @@ export function closing_tag_omitted(current, next) {
*/
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 or optgroup 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'] },
@ -92,8 +92,6 @@ const disallowed_children = {
h4: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
h5: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
h6: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
select: { only: ['option', 'optgroup', '#text', 'hr', 'script', 'template'] },
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incaption

@ -0,0 +1,51 @@
import { hydrating, reset, set_hydrate_node, set_hydrating } from '../hydration.js';
import { create_comment } from '../operations.js';
/** @type {boolean | null} */
let supported = null;
/**
* 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}
*/
function is_supported() {
if (supported === null) {
var select = document.createElement('select');
select.innerHTML = '<option><span>t</span></option>';
supported = /** @type {Element} */ (select.firstChild)?.firstChild?.nodeType === 1;
}
return supported;
}
/**
* Handles rich HTML content inside `<option>`, `<optgroup>`, or `<select>` elements with browser-specific branching.
* Modern browsers preserve HTML inside options, while older browsers strip it to text only.
*
* @param {HTMLOptionElement | HTMLOptGroupElement | HTMLSelectElement} element The element to process
* @param {() => void} rich_fn Function to process rich HTML content (modern browsers)
*/
export function customizable_select(element, rich_fn) {
var was_hydrating = hydrating;
if (!is_supported()) {
set_hydrating(false);
element.textContent = '';
element.append(create_comment(''));
}
try {
rich_fn();
} finally {
if (was_hydrating) {
if (hydrating) {
reset(element);
} else {
set_hydrating(true);
set_hydrate_node(element);
}
}
}
}

@ -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 { customizable_select } from './dom/elements/customizable-select.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';

@ -220,9 +220,10 @@ export class Renderer {
* @param {Record<string, boolean> | undefined} [classes]
* @param {Record<string, string> | undefined} [styles]
* @param {number | undefined} [flags]
* @param {boolean | undefined} [is_rich]
* @returns {void}
*/
select(attrs, fn, css_hash, classes, styles, flags) {
select(attrs, fn, css_hash, classes, styles, flags, is_rich) {
const { value, ...select_attrs } = attrs;
this.push(`<select${attributes(select_attrs, css_hash, classes, styles, flags)}>`);
@ -230,7 +231,7 @@ export class Renderer {
renderer.local.select_value = value;
fn(renderer);
});
this.push('</select>');
this.push(`${is_rich ? '<!>' : ''}</select>`);
}
/**
@ -240,8 +241,9 @@ export class Renderer {
* @param {Record<string, boolean> | undefined} [classes]
* @param {Record<string, string> | undefined} [styles]
* @param {number | undefined} [flags]
* @param {boolean | undefined} [is_rich]
*/
option(attrs, body, css_hash, classes, styles, flags) {
option(attrs, body, css_hash, classes, styles, flags, is_rich) {
this.#out.push(`<option${attributes(attrs, css_hash, classes, styles, flags)}`);
/**
@ -258,7 +260,7 @@ export class Renderer {
renderer.#out.push(' selected');
}
renderer.#out.push(`>${body}</option>`);
renderer.#out.push(`>${body}${is_rich ? '<!>' : ''}</option>`);
// super edge case, but may as well handle it
if (head) {

@ -0,0 +1,44 @@
select.svelte-xyz,
.svelte-xyz::picker(select) {
appearance: base-select;
}
selectedcontent.svelte-xyz b:where(.svelte-xyz){
color: red;
}
e.svelte-xyz{
selectedcontent:where(.svelte-xyz) &{
color: green;
}
}
select.svelte-xyz > button:where(.svelte-xyz) > selectedcontent:where(.svelte-xyz) > b:where(.svelte-xyz) {
color: blue;
}
select.svelte-xyz > button:where(.svelte-xyz) > selectedcontent:where(.svelte-xyz) i:where(.svelte-xyz) {
color: blue;
}
selectedcontent.svelte-xyz:has(b:where(.svelte-xyz)){
background-color: rebeccapurple;
}
selectedcontent.svelte-xyz:has(i:where(.svelte-xyz)){
background-color: rebeccapurple;
}
option.svelte-xyz > b:where(.svelte-xyz){
color: orange;
}
option.svelte-xyz b:where(.svelte-xyz){
color: #ff3e00;
}
option.svelte-xyz > b:where(.svelte-xyz) > i:where(.svelte-xyz){
text-decoration: underline;
}
option.svelte-xyz i:where(.svelte-xyz){
text-decoration: dashed;
}

@ -0,0 +1,53 @@
<select>
<button aria-label="Selected value">
<selectedcontent></selectedcontent>
</button>
<option>plain text</option>
<option><b>rich <i>italic</i></b><e>content</e></option>
</select>
<style>
select,
::picker(select) {
appearance: base-select;
}
selectedcontent b{
color: red;
}
e{
selectedcontent &{
color: green;
}
}
select > button > selectedcontent > b {
color: blue;
}
select > button > selectedcontent i {
color: blue;
}
selectedcontent:has(b){
background-color: rebeccapurple;
}
selectedcontent:has(i){
background-color: rebeccapurple;
}
option > b{
color: orange;
}
option b{
color: #ff3e00;
}
option > b > i{
text-decoration: underline;
}
option i{
text-decoration: dashed;
}
</style>

@ -0,0 +1,31 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
// This test verifies that hydration works correctly for
// optgroup elements with rich HTML content (non-option elements inside optgroup)
snapshot(target) {
const select = target.querySelector('select');
return {
select
};
},
async test(assert, target) {
const optgroup = target.querySelector('optgroup');
const options = target.querySelectorAll('option');
const button = target.querySelector('button');
// Check options content - the span inside optgroup gets stripped but text remains
assert.equal(options[0]?.textContent, 'hello hello');
assert.equal(options[1]?.textContent, 'Plain option');
// Update via button click
flushSync(() => {
button?.click();
});
assert.equal(options[0]?.textContent, 'changed changed');
}
});

@ -0,0 +1 @@
<select><optgroup label="Fruits">hello <option value="a"><span>hello</span> hello</option><option value="b">Plain option</option></optgroup><optgroup label="Static Group"><option value="c">Another option</option></optgroup></select> <button></button>

@ -0,0 +1,16 @@
<script>
let label = $state('hello');
</script>
<select>
<optgroup label="Fruits">
<span class="group-header">{label}</span>
<option value="a"><span>{label}</span> {label}</option>
<option value="b">Plain option</option>
</optgroup>
<optgroup label="Static Group">
<option value="c">Another option</option>
</optgroup>
</select>
<button onclick={() => label = "changed"}></button>

@ -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,39 @@
import { test } from '../../test';
export default test({
// This test verifies that completely static select with rich option content
// hydrates correctly and the content is preserved
snapshot(target) {
const select = target.querySelector('select');
const options = target.querySelectorAll('option');
return {
select,
option1: options[0],
option2: options[1],
option3: options[2]
};
},
async test(assert, target) {
const options = target.querySelectorAll('option');
// Verify the rich content is present in the options
assert.equal(options[0]?.textContent, 'Bold Option');
assert.equal(options[1]?.textContent, 'Italic Option');
assert.equal(options[2]?.textContent, 'Plain Option');
// Check that the rich elements are actually there (on supporting browsers)
const strong = options[0]?.querySelector('strong');
const em = options[1]?.querySelector('em');
// These may or may not exist depending on browser support
// but the text content should always be correct
if (strong) {
assert.equal(strong.textContent, 'Bold');
}
if (em) {
assert.equal(em.textContent, 'Italic');
}
}
});

@ -0,0 +1 @@
<select><option value="a"><strong>Bold</strong> Option</option><option value="b"><em>Italic</em> Option</option><option value="c">Plain Option</option></select>

@ -0,0 +1,5 @@
<select>
<option value="a"><strong>Bold</strong> Option</option>
<option value="b"><em>Italic</em> Option</option>
<option value="c">Plain Option</option>
</select>

@ -0,0 +1,3 @@
import { test } from '../../test';
export default test({});

@ -0,0 +1,173 @@
<script>
let items = [1, 2, 3];
let show = true;
let html = '<option>From HTML</option>';
import Option from './Option.svelte';
</script>
<!-- select with rich option (has span inside) - SHOULD use customizable_select_element -->
<select>
<option><span>Rich</span></option>
</select>
<!-- select with each containing plain options - should NOT use customizable_select_element -->
<select>
{#each items as item}
<option>{item}</option>
{/each}
</select>
<!-- select with if containing plain options - should NOT use customizable_select_element -->
<select>
{#if show}
<option>Visible</option>
{/if}
</select>
<!-- select with key containing plain options - should NOT use customizable_select_element -->
<select>
{#key items}
<option>Keyed</option>
{/key}
</select>
<!-- select with snippet defined at top level and rendered - should NOT use customizable_select_element -->
{#snippet opt()}
<option>Snippet</option>
{/snippet}
<select>
{@render opt()}
</select>
<!-- select with const inside each (should be ignored) - should NOT use customizable_select_element -->
<select>
{#each items as item}
{@const x = item * 2}
<option>{x}</option>
{/each}
</select>
<!-- optgroup with rich option - SHOULD use customizable_select_element -->
<select>
<optgroup label="Group">
<option><strong>Bold</strong></option>
</optgroup>
</select>
<!-- optgroup with each containing plain options - should NOT use customizable_select_element -->
<select>
<optgroup label="Group">
{#each items as item}
<option>{item}</option>
{/each}
</optgroup>
</select>
<!-- option with rich content (span) - SHOULD use customizable_select_element -->
<select>
<option value="a"><em>Italic</em> text</option>
</select>
<!-- nested: select > each > option with rich content - SHOULD use customizable_select_element on option -->
<select>
{#each items as item}
<option><span>{item}</span></option>
{/each}
</select>
<!-- nested: select > if > each > plain options - should NOT use customizable_select_element -->
<select>
{#if show}
{#each items as item}
<option>{item}</option>
{/each}
{/if}
</select>
<!-- select with svelte:boundary containing plain options - should NOT use customizable_select_element -->
<select>
<svelte:boundary>
<option>Boundary</option>
</svelte:boundary>
</select>
<!-- select with svelte:boundary containing rich options - SHOULD use customizable_select_element on option -->
<select>
<svelte:boundary>
<option><span>Rich in boundary</span></option>
</svelte:boundary>
</select>
<!-- select with Component - SHOULD be treated as rich content -->
<select>
<Option />
</select>
<!-- select with @render snippet - SHOULD be treated as rich content -->
{#snippet option_snippet()}
<option>Rendered</option>
{/snippet}
<select>
{@render option_snippet()}
</select>
<!-- select with @html - SHOULD be treated as rich content -->
<select>
{@html html}
</select>
<!-- optgroup with Component - SHOULD be treated as rich content -->
<select>
<optgroup label="Group">
<Option />
</optgroup>
</select>
<!-- optgroup with @render - SHOULD be treated as rich content -->
{#snippet option_snippet2()}
<option>Rendered in group</option>
{/snippet}
<select>
<optgroup label="Group">
{@render option_snippet2()}
</optgroup>
</select>
<!-- option with @html inside - SHOULD use customizable_select_element -->
<select>
<option>{@html '<strong>Bold HTML</strong>'}</option>
</select>
<!-- each block inside select with Component - SHOULD be treated as rich -->
<select>
{#each items as item}
<Option />
{/each}
</select>
<!-- if block inside select with @render - SHOULD be treated as rich -->
{#snippet conditional_option()}
<option>Conditional</option>
{/snippet}
<select>
{#if show}
{@render conditional_option()}
{/if}
</select>
<!-- select with button/selectedcontent and static options - SHOULD use customizable_select_element -->
<select>
<button><selectedcontent></selectedcontent></button>
<option>cool</option>
<option>cooler</option>
<option>coolerone</option>
</select>
<!-- select with button/selectedcontent and dynamic options - SHOULD use customizable_select_element -->
<select>
<button><selectedcontent></selectedcontent></button>
{#each items as item}
<option>{item}</option>
{/each}
</select>

@ -0,0 +1,31 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
// Test that optgroup with rich HTML content (non-option elements) and dynamic expressions works correctly
export default test({
mode: ['client', 'hydrate'],
test({ assert, target }) {
const select = /** @type {HTMLSelectElement} */ (target.querySelector('select'));
const optgroups = target.querySelectorAll('optgroup');
const options = target.querySelectorAll('option');
const button = /** @type {HTMLButtonElement} */ (target.querySelector('button'));
assert.ok(select);
assert.equal(optgroups.length, 2);
assert.equal(options.length, 4);
// Check initial option content (rich content inside optgroup)
assert.equal(options[0]?.textContent, 'apple apple');
assert.equal(options[1]?.textContent, 'banana');
assert.equal(options[2]?.textContent, 'carrot carrot');
assert.equal(options[3]?.textContent, 'Plain celery');
// Click button to change dynamic content
button.click();
flushSync();
// Check updated option content
assert.equal(options[0]?.textContent, 'orange orange');
assert.equal(options[2]?.textContent, 'broccoli broccoli');
}
});

@ -0,0 +1,19 @@
<script>
let fruit = $state('apple');
let vegetable = $state('carrot');
</script>
<select>
<optgroup label="Fruits">
<span class="fruits-header">{fruit}</span>
<option value="a"><span>{fruit}</span> {fruit}</option>
<option value="b">banana</option>
</optgroup>
<optgroup label="Vegetables">
<em class="veggies-header">{vegetable}</em>
<option value="c"><em>{vegetable}</em> {vegetable}</option>
<option value="d">Plain celery</option>
</optgroup>
</select>
<button onclick={() => { fruit = 'orange'; vegetable = 'broccoli'; }}>Change</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>

@ -0,0 +1,5 @@
<script>
let { text } = $props();
</script>
<span>{text}</span>

@ -0,0 +1,26 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
// Test that components can be used inside <option> elements.
// This tests two scenarios:
// 1. A component that wraps the entire <option> element
// 2. A component used as content inside an <option> element
//
// In jsdom (which doesn't support rich options), the HTML content is stripped,
// so we only verify the component doesn't crash and values work correctly.
export default test({
test({ assert, target }) {
const option1 = target.querySelector('option');
const button = target.querySelector('button');
assert.ok(option1);
assert.equal(option1?.textContent, 'bb');
flushSync(() => {
button?.click();
});
assert.equal(option1?.textContent, 'aa');
}
});

@ -0,0 +1,15 @@
<script>
import Content from './Content.svelte';
let content = $state('b');
</script>
<!-- Test 2: Option with component as content -->
<select>
<option value="x"><span>{content}</span><Content text={content} /></option>
</select>
<button onclick={() => content = content === 'a' ? 'b' : 'a'}>
Toggle Content
</button>

@ -0,0 +1,11 @@
import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<option>Component Option</option>`);
export default function Option($$anchor) {
var option = root();
$.append($$anchor, option);
}

@ -0,0 +1,412 @@
import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
import Option from './Option.svelte';
const opt = ($$anchor) => {
var option = root_1();
$.append($$anchor, option);
};
const option_snippet = ($$anchor) => {
var option_1 = root_2();
$.append($$anchor, option_1);
};
const option_snippet2 = ($$anchor) => {
var option_2 = root_3();
$.append($$anchor, option_2);
};
const conditional_option = ($$anchor) => {
var option_3 = root_4();
$.append($$anchor, option_3);
};
var root_1 = $.from_html(`<option>Snippet</option>`);
var root_2 = $.from_html(`<option>Rendered</option>`);
var root_3 = $.from_html(`<option>Rendered in group</option>`);
var root_4 = $.from_html(`<option>Conditional</option>`);
var option_content = $.from_html(`<span>Rich</span>`, 1);
var root_5 = $.from_html(`<option> </option>`);
var root_6 = $.from_html(`<option>Visible</option>`);
var root_7 = $.from_html(`<option>Keyed</option>`);
var select_content = $.from_html(`<!>`, 1);
var root_8 = $.from_html(`<option> </option>`);
var option_content_1 = $.from_html(`<strong>Bold</strong>`, 1);
var root_9 = $.from_html(`<option> </option>`);
var option_content_2 = $.from_html(`<em>Italic</em> text`, 1);
var option_content_3 = $.from_html(`<span> </span>`, 1);
var root_10 = $.from_html(`<option><!></option>`);
var root_12 = $.from_html(`<option> </option>`);
var root_13 = $.from_html(`<option>Boundary</option>`);
var option_content_4 = $.from_html(`<span>Rich in boundary</span>`, 1);
var root_14 = $.from_html(`<option><!></option>`);
var select_content_1 = $.from_html(`<!>`, 1);
var select_content_2 = $.from_html(`<!>`, 1);
var select_content_3 = $.from_html(`<!>`, 1);
var optgroup_content = $.from_html(`<!>`, 1);
var optgroup_content_1 = $.from_html(`<!>`, 1);
var option_content_5 = $.from_html(`<!>`, 1);
var select_content_4 = $.from_html(`<!>`, 1);
var select_content_5 = $.from_html(`<!>`, 1);
var root = $.from_html(`<select><option><!></option></select> <select></select> <select><!></select> <select><!></select> <select><!></select> <select></select> <select><optgroup label="Group"><option><!></option></optgroup></select> <select><optgroup label="Group"></optgroup></select> <select><option><!></option></select> <select></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><optgroup label="Group"><!></optgroup></select> <select><optgroup label="Group"><!></optgroup></select> <select><option><!></option></select> <select><!></select> <select><!></select>`, 1);
export default function Select_with_rich_content($$anchor) {
let items = [1, 2, 3];
let show = true;
let html = '<option>From HTML</option>';
var fragment = root();
var select = $.first_child(fragment);
var option_4 = $.child(select);
$.customizable_select(option_4, () => {
var anchor = $.child(option_4);
var fragment_1 = option_content();
$.append(anchor, fragment_1);
});
$.reset(select);
var select_1 = $.sibling(select, 2);
$.each(select_1, 5, () => items, $.index, ($$anchor, item) => {
var option_5 = root_5();
var text = $.child(option_5, true);
$.reset(option_5);
var option_5_value = {};
$.template_effect(() => {
$.set_text(text, $.get(item));
if (option_5_value !== (option_5_value = $.get(item))) {
option_5.__value = $.get(item);
}
});
$.append($$anchor, option_5);
});
$.reset(select_1);
var select_2 = $.sibling(select_1, 2);
var node = $.child(select_2);
{
var consequent = ($$anchor) => {
var option_6 = root_6();
$.append($$anchor, option_6);
};
$.if(node, ($$render) => {
if (show) $$render(consequent);
});
}
$.reset(select_2);
var select_3 = $.sibling(select_2, 2);
var node_1 = $.child(select_3);
$.key(node_1, () => items, ($$anchor) => {
var option_7 = root_7();
$.append($$anchor, option_7);
});
$.reset(select_3);
var select_4 = $.sibling(select_3, 2);
$.customizable_select(select_4, () => {
var anchor_1 = $.child(select_4);
var fragment_2 = select_content();
var node_2 = $.first_child(fragment_2);
opt(node_2);
$.append(anchor_1, fragment_2);
});
var select_5 = $.sibling(select_4, 2);
$.each(select_5, 5, () => items, $.index, ($$anchor, item) => {
const x = $.derived_safe_equal(() => $.get(item) * 2);
var option_8 = root_8();
var text_1 = $.child(option_8, true);
$.reset(option_8);
var option_8_value = {};
$.template_effect(() => {
$.set_text(text_1, $.get(x));
if (option_8_value !== (option_8_value = $.get(x))) {
option_8.__value = $.get(x);
}
});
$.append($$anchor, option_8);
});
$.reset(select_5);
var select_6 = $.sibling(select_5, 2);
var optgroup = $.child(select_6);
var option_9 = $.child(optgroup);
$.customizable_select(option_9, () => {
var anchor_2 = $.child(option_9);
var fragment_3 = option_content_1();
$.append(anchor_2, fragment_3);
});
$.reset(optgroup);
$.reset(select_6);
var select_7 = $.sibling(select_6, 2);
var optgroup_1 = $.child(select_7);
$.each(optgroup_1, 5, () => items, $.index, ($$anchor, item) => {
var option_10 = root_9();
var text_2 = $.child(option_10, true);
$.reset(option_10);
var option_10_value = {};
$.template_effect(() => {
$.set_text(text_2, $.get(item));
if (option_10_value !== (option_10_value = $.get(item))) {
option_10.__value = $.get(item);
}
});
$.append($$anchor, option_10);
});
$.reset(optgroup_1);
$.reset(select_7);
var select_8 = $.sibling(select_7, 2);
var option_11 = $.child(select_8);
$.customizable_select(option_11, () => {
var anchor_3 = $.child(option_11);
var fragment_4 = option_content_2();
$.next();
$.append(anchor_3, fragment_4);
});
option_11.value = option_11.__value = 'a';
$.reset(select_8);
var select_9 = $.sibling(select_8, 2);
$.each(select_9, 5, () => items, $.index, ($$anchor, item) => {
var option_12 = root_10();
$.customizable_select(option_12, () => {
var anchor_4 = $.child(option_12);
var fragment_5 = option_content_3();
var span = $.first_child(fragment_5);
var text_3 = $.child(span, true);
$.reset(span);
$.template_effect(() => $.set_text(text_3, $.get(item)));
$.append(anchor_4, fragment_5);
});
$.append($$anchor, option_12);
});
$.reset(select_9);
var select_10 = $.sibling(select_9, 2);
var node_3 = $.child(select_10);
{
var consequent_1 = ($$anchor) => {
var fragment_6 = $.comment();
var node_4 = $.first_child(fragment_6);
$.each(node_4, 1, () => items, $.index, ($$anchor, item) => {
var option_13 = root_12();
var text_4 = $.child(option_13, true);
$.reset(option_13);
var option_13_value = {};
$.template_effect(() => {
$.set_text(text_4, $.get(item));
if (option_13_value !== (option_13_value = $.get(item))) {
option_13.__value = $.get(item);
}
});
$.append($$anchor, option_13);
});
$.append($$anchor, fragment_6);
};
$.if(node_3, ($$render) => {
if (show) $$render(consequent_1);
});
}
$.reset(select_10);
var select_11 = $.sibling(select_10, 2);
var node_5 = $.child(select_11);
$.boundary(node_5, {}, ($$anchor) => {
var option_14 = root_13();
$.append($$anchor, option_14);
});
$.reset(select_11);
var select_12 = $.sibling(select_11, 2);
var node_6 = $.child(select_12);
$.boundary(node_6, {}, ($$anchor) => {
var option_15 = root_14();
$.customizable_select(option_15, () => {
var anchor_5 = $.child(option_15);
var fragment_7 = option_content_4();
$.append(anchor_5, fragment_7);
});
$.append($$anchor, option_15);
});
$.reset(select_12);
var select_13 = $.sibling(select_12, 2);
$.customizable_select(select_13, () => {
var anchor_6 = $.child(select_13);
var fragment_8 = select_content_1();
var node_7 = $.first_child(fragment_8);
Option(node_7, {});
$.append(anchor_6, fragment_8);
});
var select_14 = $.sibling(select_13, 2);
$.customizable_select(select_14, () => {
var anchor_7 = $.child(select_14);
var fragment_9 = select_content_2();
var node_8 = $.first_child(fragment_9);
option_snippet(node_8);
$.append(anchor_7, fragment_9);
});
var select_15 = $.sibling(select_14, 2);
$.customizable_select(select_15, () => {
var anchor_8 = $.child(select_15);
var fragment_10 = select_content_3();
var node_9 = $.first_child(fragment_10);
$.html(node_9, () => html);
$.append(anchor_8, fragment_10);
});
var select_16 = $.sibling(select_15, 2);
var optgroup_2 = $.child(select_16);
$.customizable_select(optgroup_2, () => {
var anchor_9 = $.child(optgroup_2);
var fragment_11 = optgroup_content();
var node_10 = $.first_child(fragment_11);
Option(node_10, {});
$.append(anchor_9, fragment_11);
});
$.reset(select_16);
var select_17 = $.sibling(select_16, 2);
var optgroup_3 = $.child(select_17);
$.customizable_select(optgroup_3, () => {
var anchor_10 = $.child(optgroup_3);
var fragment_12 = optgroup_content_1();
var node_11 = $.first_child(fragment_12);
option_snippet2(node_11);
$.append(anchor_10, fragment_12);
});
$.reset(select_17);
var select_18 = $.sibling(select_17, 2);
var option_16 = $.child(select_18);
$.customizable_select(option_16, () => {
var anchor_11 = $.child(option_16);
var fragment_13 = option_content_5();
var node_12 = $.first_child(fragment_13);
$.html(node_12, () => '<strong>Bold HTML</strong>');
$.append(anchor_11, fragment_13);
});
$.reset(select_18);
var select_19 = $.sibling(select_18, 2);
$.customizable_select(select_19, () => {
var anchor_12 = $.child(select_19);
var fragment_14 = select_content_4();
var node_13 = $.first_child(fragment_14);
$.each(node_13, 1, () => items, $.index, ($$anchor, item) => {
Option($$anchor, {});
});
$.append(anchor_12, fragment_14);
});
var select_20 = $.sibling(select_19, 2);
$.customizable_select(select_20, () => {
var anchor_13 = $.child(select_20);
var fragment_16 = select_content_5();
var node_14 = $.first_child(fragment_16);
{
var consequent_2 = ($$anchor) => {
conditional_option($$anchor);
};
$.if(node_14, ($$render) => {
if (show) $$render(consequent_2);
});
}
$.append(anchor_13, fragment_16);
});
$.append($$anchor, fragment);
}

@ -0,0 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Option($$renderer) {
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Component Option`);
});
}

@ -0,0 +1,233 @@
import * as $ from 'svelte/internal/server';
import Option from './Option.svelte';
function opt($$renderer) {
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Snippet`);
});
}
function option_snippet($$renderer) {
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Rendered`);
});
}
function option_snippet2($$renderer) {
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Rendered in group`);
});
}
function conditional_option($$renderer) {
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Conditional`);
});
}
export default function Select_with_rich_content($$renderer) {
let items = [1, 2, 3];
let show = true;
let html = '<option>From HTML</option>';
$$renderer.push(`<select>`);
$$renderer.option(
{},
($$renderer) => {
$$renderer.push(`<span>Rich</span>`);
},
void 0,
void 0,
void 0,
void 0,
true
);
$$renderer.push(`</select> <select><!--[-->`);
const each_array = $.ensure_array_like(items);
for (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) {
let item = each_array[$$index];
$$renderer.option({}, item);
}
$$renderer.push(`<!--]--></select> <select>`);
if (show) {
$$renderer.push('<!--[-->');
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Visible`);
});
} else {
$$renderer.push('<!--[!-->');
}
$$renderer.push(`<!--]--></select> <select><!---->`);
{
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Keyed`);
});
}
$$renderer.push(`<!----></select> <select>`);
opt($$renderer);
$$renderer.push(`<!----><!></select> <select><!--[-->`);
const each_array_1 = $.ensure_array_like(items);
for (let $$index_1 = 0, $$length = each_array_1.length; $$index_1 < $$length; $$index_1++) {
let item = each_array_1[$$index_1];
const x = item * 2;
$$renderer.option({}, x);
}
$$renderer.push(`<!--]--></select> <select><optgroup label="Group">`);
$$renderer.option(
{},
($$renderer) => {
$$renderer.push(`<strong>Bold</strong>`);
},
void 0,
void 0,
void 0,
void 0,
true
);
$$renderer.push(`</optgroup></select> <select><optgroup label="Group"><!--[-->`);
const each_array_2 = $.ensure_array_like(items);
for (let $$index_2 = 0, $$length = each_array_2.length; $$index_2 < $$length; $$index_2++) {
let item = each_array_2[$$index_2];
$$renderer.option({}, item);
}
$$renderer.push(`<!--]--></optgroup></select> <select>`);
$$renderer.option(
{ value: 'a' },
($$renderer) => {
$$renderer.push(`<em>Italic</em> text`);
},
void 0,
void 0,
void 0,
void 0,
true
);
$$renderer.push(`</select> <select><!--[-->`);
const each_array_3 = $.ensure_array_like(items);
for (let $$index_3 = 0, $$length = each_array_3.length; $$index_3 < $$length; $$index_3++) {
let item = each_array_3[$$index_3];
$$renderer.option(
{},
($$renderer) => {
$$renderer.push(`<span>${$.escape(item)}</span>`);
},
void 0,
void 0,
void 0,
void 0,
true
);
}
$$renderer.push(`<!--]--></select> <select>`);
if (show) {
$$renderer.push('<!--[-->');
$$renderer.push(`<!--[-->`);
const each_array_4 = $.ensure_array_like(items);
for (let $$index_4 = 0, $$length = each_array_4.length; $$index_4 < $$length; $$index_4++) {
let item = each_array_4[$$index_4];
$$renderer.option({}, item);
}
$$renderer.push(`<!--]-->`);
} else {
$$renderer.push('<!--[!-->');
}
$$renderer.push(`<!--]--></select> <select><!--[-->`);
{
$$renderer.option({}, ($$renderer) => {
$$renderer.push(`Boundary`);
});
}
$$renderer.push(`<!--]--></select> <select><!--[-->`);
{
$$renderer.option(
{},
($$renderer) => {
$$renderer.push(`<span>Rich in boundary</span>`);
},
void 0,
void 0,
void 0,
void 0,
true
);
}
$$renderer.push(`<!--]--></select> <select>`);
Option($$renderer, {});
$$renderer.push(`<!----><!></select> <select>`);
option_snippet($$renderer);
$$renderer.push(`<!----><!></select> <select>${$.html(html)}<!></select> <select><optgroup label="Group">`);
Option($$renderer, {});
$$renderer.push(`<!----><!></optgroup></select> <select><optgroup label="Group">`);
option_snippet2($$renderer);
$$renderer.push(`<!----><!></optgroup></select> <select>`);
$$renderer.option(
{},
($$renderer) => {
$$renderer.push(`${$.html('<strong>Bold HTML</strong>')}`);
},
void 0,
void 0,
void 0,
void 0,
true
);
$$renderer.push(`</select> <select><!--[-->`);
const each_array_5 = $.ensure_array_like(items);
for (let $$index_5 = 0, $$length = each_array_5.length; $$index_5 < $$length; $$index_5++) {
let item = each_array_5[$$index_5];
Option($$renderer, {});
}
$$renderer.push(`<!--]--><!></select> <select>`);
if (show) {
$$renderer.push('<!--[-->');
conditional_option($$renderer);
} else {
$$renderer.push('<!--[!-->');
}
$$renderer.push(`<!--]--><!></select>`);
}

@ -0,0 +1,157 @@
<script>
let items = [1, 2, 3];
let show = true;
let html = '<option>From HTML</option>';
import Option from './Option.svelte';
</script>
<!-- select with rich option (has span inside) - SHOULD use customizable_select_element -->
<select>
<option><span>Rich</span></option>
</select>
<!-- select with each containing plain options - should NOT use customizable_select_element -->
<select>
{#each items as item}
<option>{item}</option>
{/each}
</select>
<!-- select with if containing plain options - should NOT use customizable_select_element -->
<select>
{#if show}
<option>Visible</option>
{/if}
</select>
<!-- select with key containing plain options - should NOT use customizable_select_element -->
<select>
{#key items}
<option>Keyed</option>
{/key}
</select>
<!-- select with snippet defined at top level and rendered - should NOT use customizable_select_element -->
{#snippet opt()}
<option>Snippet</option>
{/snippet}
<select>
{@render opt()}
</select>
<!-- select with const inside each (should be ignored) - should NOT use customizable_select_element -->
<select>
{#each items as item}
{@const x = item * 2}
<option>{x}</option>
{/each}
</select>
<!-- optgroup with rich option - SHOULD use customizable_select_element -->
<select>
<optgroup label="Group">
<option><strong>Bold</strong></option>
</optgroup>
</select>
<!-- optgroup with each containing plain options - should NOT use customizable_select_element -->
<select>
<optgroup label="Group">
{#each items as item}
<option>{item}</option>
{/each}
</optgroup>
</select>
<!-- option with rich content (span) - SHOULD use customizable_select_element -->
<select>
<option value="a"><em>Italic</em> text</option>
</select>
<!-- nested: select > each > option with rich content - SHOULD use customizable_select_element on option -->
<select>
{#each items as item}
<option><span>{item}</span></option>
{/each}
</select>
<!-- nested: select > if > each > plain options - should NOT use customizable_select_element -->
<select>
{#if show}
{#each items as item}
<option>{item}</option>
{/each}
{/if}
</select>
<!-- select with svelte:boundary containing plain options - should NOT use customizable_select_element -->
<select>
<svelte:boundary>
<option>Boundary</option>
</svelte:boundary>
</select>
<!-- select with svelte:boundary containing rich options - SHOULD use customizable_select_element on option -->
<select>
<svelte:boundary>
<option><span>Rich in boundary</span></option>
</svelte:boundary>
</select>
<!-- select with Component - SHOULD be treated as rich content -->
<select>
<Option />
</select>
<!-- select with @render snippet - SHOULD be treated as rich content -->
{#snippet option_snippet()}
<option>Rendered</option>
{/snippet}
<select>
{@render option_snippet()}
</select>
<!-- select with @html - SHOULD be treated as rich content -->
<select>
{@html html}
</select>
<!-- optgroup with Component - SHOULD be treated as rich content -->
<select>
<optgroup label="Group">
<Option />
</optgroup>
</select>
<!-- optgroup with @render - SHOULD be treated as rich content -->
{#snippet option_snippet2()}
<option>Rendered in group</option>
{/snippet}
<select>
<optgroup label="Group">
{@render option_snippet2()}
</optgroup>
</select>
<!-- option with @html inside - SHOULD use customizable_select_element -->
<select>
<option>{@html '<strong>Bold HTML</strong>'}</option>
</select>
<!-- each block inside select with Component - SHOULD be treated as rich -->
<select>
{#each items as item}
<Option />
{/each}
</select>
<!-- if block inside select with @render - SHOULD be treated as rich -->
{#snippet conditional_option()}
<option>Conditional</option>
{/snippet}
<select>
{#if show}
{@render conditional_option()}
{/if}
</select>

@ -14,3 +14,14 @@
<button>Click me</button>
<a href="/#">Link text</a>
<a href="/#"><img src="./icon.svg" alt="Link text"></a>
<select>
<!-- valid if button contains <selectedcontent> -->
<button>
<selectedcontent></selectedcontent>
</button>
<option>one</option>
<option>two</option>
<option>three</option>
</select>

Loading…
Cancel
Save