diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 200d438..e6f5454 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -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); diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 541f72f..24ae685 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -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, + children: EveSurfaceComponent[], + layout: Record, + style: Record, + 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, children: EveSurfaceComponent[], diff --git a/packages/eve-browser-lowering/test/host-isolation.test.mjs b/packages/eve-browser-lowering/test/host-isolation.test.mjs index 601fd32..55ed2ca 100644 --- a/packages/eve-browser-lowering/test/host-isolation.test.mjs +++ b/packages/eve-browser-lowering/test/host-isolation.test.mjs @@ -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(); + } +});