Lower Eve select controls natively in browsers

This commit is contained in:
Meta Vens 2026-08-18 02:08:56 +02:00
commit b68b833cd8
3 changed files with 139 additions and 2 deletions

View file

@ -683,7 +683,10 @@ function renderEveComponentProjection(node, options) {
toggle.append(el("span", "toggle-copy", stringProp(props.label, "Toggle")));
return toggle;
}
if (kind === "input.number" || kind === "input.select" || kind === "control.range" || kind === "control.input.text" || kind === "control.select") {
if (kind === "input.select" || kind === "control.select") {
return renderSelect(node, props, children, layout, style, options);
}
if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") {
const field = el("label", "field cultui-field");
assignId(field, node);
applyBoxProps(field, props);
@ -1538,6 +1541,46 @@ function wireCommand(element, node, commandId, props, options) {
}
});
}
function renderSelect(node, props, children, layout, style, options) {
const field = el("label", "field cultui-field cultui-select");
assignId(field, node);
applyBoxProps(field, props);
applyGeneratedLayout(field, layout, style);
const label = stringProp(props.label, "");
if (label)
field.append(el("span", "field-label", label));
const select = el("select", "field-control cultui-field-select");
select.setAttribute("aria-label", label || node.id || "Select");
for (const child of children) {
if (child.kind !== "control.option" && child.kind !== "input.option" && child.kind !== "option")
continue;
const optionProps = objectProps(child.props);
const option = document.createElement("option");
option.value = stringProp(optionProps.value, child.id || "");
option.textContent = stringProp(optionProps.label, stringProp(optionProps.text, option.value));
option.disabled = boolProp(optionProps.disabled) ||
(optionProps.enabled !== undefined && !boolProp(optionProps.enabled));
select.append(option);
}
select.value = stringProp(props.value, "");
select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled));
const commandId = resolveComponentCommandId(props, node);
if (commandId) {
select.dataset.commandId = commandId;
select.addEventListener("change", () => {
const intent = createEveCommandIntent(commandId, {
...props,
action: {
...objectProps(props.action),
value: select.value,
},
}, options);
void options.commandSink?.(intent, node);
});
}
field.append(select);
return field;
}
function renderSlider(props, children, options) {
const anatomy = resolveSliderAnatomy(props, children, options);
const box = objectProps(anatomy.find(part => part.kind === "control.box")?.props);

View file

@ -885,7 +885,11 @@ function renderEveComponentProjection(
return toggle;
}
if (kind === "input.number" || kind === "input.select" || kind === "control.range" || kind === "control.input.text" || kind === "control.select") {
if (kind === "input.select" || kind === "control.select") {
return renderSelect(node, props, children, layout, style, options);
}
if (kind === "input.number" || kind === "control.range" || kind === "control.input.text") {
const field = el("label", "field cultui-field");
assignId(field, node);
applyBoxProps(field, props);
@ -1816,6 +1820,54 @@ function wireCommand(
});
}
function renderSelect(
node: EveSurfaceComponent,
props: Record<string, unknown>,
children: EveSurfaceComponent[],
layout: Record<string, unknown>,
style: Record<string, unknown>,
options: EveBrowserLoweringOptions,
): HTMLElement {
const field = el("label", "field cultui-field cultui-select");
assignId(field, node);
applyBoxProps(field, props);
applyGeneratedLayout(field, layout, style);
const label = stringProp(props.label, "");
if (label) field.append(el("span", "field-label", label));
const select = el("select", "field-control cultui-field-select") as HTMLSelectElement;
select.setAttribute("aria-label", label || node.id || "Select");
for (const child of children) {
if (child.kind !== "control.option" && child.kind !== "input.option" && child.kind !== "option") continue;
const optionProps = objectProps(child.props);
const option = document.createElement("option");
option.value = stringProp(optionProps.value, child.id || "");
option.textContent = stringProp(optionProps.label, stringProp(optionProps.text, option.value));
option.disabled = boolProp(optionProps.disabled) ||
(optionProps.enabled !== undefined && !boolProp(optionProps.enabled));
select.append(option);
}
select.value = stringProp(props.value, "");
select.disabled = boolProp(props.disabled) || (props.enabled !== undefined && !boolProp(props.enabled));
const commandId = resolveComponentCommandId(props, node);
if (commandId) {
select.dataset.commandId = commandId;
select.addEventListener("change", () => {
const intent = createEveCommandIntent(commandId, {
...props,
action: {
...objectProps(props.action),
value: select.value,
},
}, options);
void options.commandSink?.(intent, node);
});
}
field.append(select);
return field;
}
function renderSlider(
props: Record<string, unknown>,
children: EveSurfaceComponent[],

View file

@ -15,6 +15,8 @@ function installDom() {
"HTMLElement",
"HTMLButtonElement",
"HTMLInputElement",
"HTMLSelectElement",
"HTMLOptionElement",
"HTMLTextAreaElement",
"HTMLImageElement",
];
@ -152,3 +154,43 @@ test("browser lowering isolates two hosts and patches only the bound component",
restoreDom();
}
});
test("browser lowering renders command-backed select options", () => {
const restoreDom = installDom();
try {
const commands = [];
const host = document.querySelector("#a");
renderEveSurface({
providerId: "hangar-provider",
surface: {
id: "hangar",
root: {
id: "hangar.verse",
kind: "control.select",
props: { label: "VERSE", value: "local", command: "hangar.select_verse" },
children: [
{ kind: "control.option", props: { label: "Local", value: "local" } },
{ kind: "control.option", props: { label: "GameCult", value: "gamecult" } },
],
},
},
}, host, {
clientId: "browser-test",
commandSink: intent => commands.push(intent),
});
const select = host.querySelector("select");
assert.deepEqual([...select.options].map(option => [option.text, option.value]), [
["Local", "local"],
["GameCult", "gamecult"],
]);
assert.equal(select.value, "local");
select.value = "gamecult";
select.dispatchEvent(new window.Event("change", { bubbles: true }));
assert.equal(commands.length, 1);
assert.equal(commands[0].command, "hangar.select_verse");
assert.equal(commands[0].payload.value, "gamecult");
} finally {
restoreDom();
}
});