feat(ui): show short V2 form choices inline

Render native V2 string option lists with up to four answers as visible radio cards, including each option description, so question-style forms retain the scannable V1 interaction. Lists with five or more answers continue to use a compact select control.

Apply the same selected-card treatment to multiselect answers, preserve custom-value entry, validate required inline strings, and keep grouped controls accessibly labelled. Option styling lives in a focused component stylesheet and uses existing square-corner design tokens.

Validated with UI typecheck, 531 UI tests, a production desktop build, git diff checks, and visual inspection in the deployed WebView.
This commit is contained in:
Pascal André 2026-08-18 11:04:34 +02:00
parent 929cc6e3f6
commit 18919e12d2
No known key found for this signature in database
5 changed files with 168 additions and 52 deletions

View file

@ -5,6 +5,7 @@ import {
getFormFieldDefaultValue,
getFormStringInputType,
normalizeFormStringValue,
shouldRenderFormOptionsInline,
} from "./form-request.tsx"
import { isFormFieldVisible, isHttpFormUrl } from "../lib/form-schema.ts"
@ -41,4 +42,10 @@ describe("form request protocol mapping", () => {
assert.equal(isHttpFormUrl("javascript:alert(1)"), false)
assert.equal(isHttpFormUrl("file:///tmp/form"), false)
})
it("uses visible choices for short option lists and menus for long lists", () => {
assert.equal(shouldRenderFormOptionsInline([{ value: "one" }, { value: "two" }]), true)
assert.equal(shouldRenderFormOptionsInline(Array.from({ length: 5 })), false)
assert.equal(shouldRenderFormOptionsInline([]), false)
})
})

View file

@ -37,6 +37,10 @@ export function normalizeFormStringValue(
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
export function shouldRenderFormOptionsInline(options: readonly unknown[] | undefined): boolean {
return Boolean(options?.length && options.length <= 4)
}
const FormRequest: Component<FormRequestProps> = (props) => {
const { t } = useI18n()
const [values, setValues] = createSignal<Record<string, FormValue | undefined>>(
@ -57,7 +61,9 @@ const FormRequest: Component<FormRequestProps> = (props) => {
if (!form.reportValidity()) return
const invalidCollection = visibleFields().find((field) => {
const value = values()[field.key]
if (field.type === "external" || field.type === "string" || field.type === "number" || field.type === "integer") return false
if (field.type === "external") return false
if (field.type === "string") return field.required && (typeof value !== "string" || value.length === 0)
if (field.type === "number" || field.type === "integer") return field.required && typeof value !== "number"
if (field.type === "boolean") return field.required && value === undefined
const count = Array.isArray(value) ? value.length : 0
return (field.required && count === 0) || count < (field.minItems ?? 0) || count > (field.maxItems ?? Infinity)
@ -101,53 +107,102 @@ const FormRequest: Component<FormRequestProps> = (props) => {
const label = () => field.title || field.key
const stringField = field as Extract<FormField, { type: "string" }>
const numberField = field as Extract<FormField, { type: "number" | "integer" }>
const stringOptions = () => stringField.options ?? []
const selectedString = () => String(values()[field.key] ?? "")
const customString = () => stringOptions().some((option) => option.value === selectedString()) ? "" : selectedString()
return (
<div class="form-request-field">
<Show when={field.type === "external"} fallback={
<Show when={
field.type === "external"
|| field.type === "multiselect"
|| (field.type === "string" && shouldRenderFormOptionsInline(stringField.options))
} fallback={
<label class="form-request-label" for={`form-${props.form.id}-${field.key}`}>
{label()}
<Show when={"required" in field && field.required}> <span class="form-request-required" aria-hidden="true">*</span></Show>
</label>
}>
<span class="form-request-label">{label()}</span>
<span class="form-request-label">
{label()}
<Show when={"required" in field && field.required}> <span class="form-request-required" aria-hidden="true">*</span></Show>
</span>
</Show>
<Show when={field.description}>
<p id={descriptionId} class="form-request-description">{field.description}</p>
</Show>
<Show when={field.type === "string"}>
<Show when={stringField.options?.length && !stringField.custom} fallback={
<Show when={shouldRenderFormOptionsInline(stringField.options)} fallback={
<>
<Show when={stringField.options?.length && !stringField.custom} fallback={
<input
id={`form-${props.form.id}-${field.key}`}
class="form-request-input"
type={getFormStringInputType(stringField.format)}
value={formatFormStringInputValue(stringField.format, String(values()[field.key] ?? ""))}
required={stringField.required}
minLength={stringField.minLength}
maxLength={stringField.maxLength}
pattern={stringField.pattern}
placeholder={stringField.placeholder}
list={stringField.options?.length ? `form-${props.form.id}-${field.key}-options` : undefined}
aria-describedby={field.description ? descriptionId : undefined}
onInput={(event) => update(field.key, normalizeFormStringValue(stringField.format, event.currentTarget.value))}
/>
}>
<select
id={`form-${props.form.id}-${field.key}`}
class="form-request-input"
required={stringField.required}
value={String(values()[field.key] ?? "")}
aria-describedby={field.description ? descriptionId : undefined}
onChange={(event) => update(field.key, event.currentTarget.value)}
>
<option value="">{t("formRequest.selectPlaceholder")}</option>
<For each={stringField.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</Show>
<Show when={stringField.custom && stringField.options?.length}>
<datalist id={`form-${props.form.id}-${field.key}-options`}>
<For each={stringField.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</datalist>
</Show>
</>
}>
<fieldset class="form-request-options" aria-describedby={field.description ? descriptionId : undefined}>
<legend class="sr-only">{label()}</legend>
<For each={stringOptions()}>{(option) => {
const checked = () => selectedString() === option.value
return (
<label class="form-request-choice" data-selected={checked() ? "true" : undefined}>
<input
class="form-request-choice-control"
type="radio"
name={`form-${props.form.id}-${field.key}`}
checked={checked()}
onChange={() => update(field.key, option.value)}
/>
<span class="form-request-choice-copy">
<span class="form-request-choice-label">{option.label}</span>
<Show when={option.description}>
<span class="form-request-choice-description">{option.description}</span>
</Show>
</span>
</label>
)
}}</For>
</fieldset>
<Show when={stringField.custom}>
<input
id={`form-${props.form.id}-${field.key}`}
class="form-request-input"
type={getFormStringInputType(stringField.format)}
value={formatFormStringInputValue(stringField.format, String(values()[field.key] ?? ""))}
required={stringField.required}
minLength={stringField.minLength}
maxLength={stringField.maxLength}
pattern={stringField.pattern}
placeholder={stringField.placeholder}
list={stringField.options?.length ? `form-${props.form.id}-${field.key}-options` : undefined}
type="text"
value={customString()}
placeholder={t("toolCall.question.custom.placeholder")}
aria-describedby={field.description ? descriptionId : undefined}
onInput={(event) => update(field.key, normalizeFormStringValue(stringField.format, event.currentTarget.value))}
onInput={(event) => update(field.key, event.currentTarget.value || undefined)}
/>
}>
<select
id={`form-${props.form.id}-${field.key}`}
class="form-request-input"
required={stringField.required}
value={String(values()[field.key] ?? "")}
aria-describedby={field.description ? descriptionId : undefined}
onChange={(event) => update(field.key, event.currentTarget.value)}
>
<option value="">{t("formRequest.selectPlaceholder")}</option>
<For each={stringField.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</Show>
<Show when={stringField.custom && stringField.options?.length}>
<datalist id={`form-${props.form.id}-${field.key}-options`}>
<For each={stringField.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</datalist>
</Show>
</Show>
</Show>
<Show when={field.type === "number" || field.type === "integer"}>
<input
@ -177,10 +232,13 @@ const FormRequest: Component<FormRequestProps> = (props) => {
<fieldset class="form-request-options" aria-describedby={field.description ? descriptionId : undefined}>
<legend class="sr-only">{label()}</legend>
<For each={field.type === "multiselect" ? field.options : []}>{(option) => (
<label class="form-request-option">
<label
class="form-request-choice"
data-selected={Array.isArray(values()[field.key]) && (values()[field.key] as string[]).includes(option.value) ? "true" : undefined}
>
<input
type="checkbox"
class="form-request-checkbox"
class="form-request-choice-control"
checked={Array.isArray(values()[field.key]) && (values()[field.key] as string[]).includes(option.value)}
onChange={(event) => {
const current = Array.isArray(values()[field.key]) ? values()[field.key] as string[] : []
@ -189,7 +247,12 @@ const FormRequest: Component<FormRequestProps> = (props) => {
: current.filter((value) => value !== option.value))
}}
/>
<span>{option.label}</span>
<span class="form-request-choice-copy">
<span class="form-request-choice-label">{option.label}</span>
<Show when={option.description}>
<span class="form-request-choice-description">{option.description}</span>
</Show>
</span>
</label>
)}</For>
</fieldset>

View file

@ -0,0 +1,64 @@
.form-request-options {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
padding: 0;
border: 0;
}
.form-request-choice {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
min-width: 0;
padding: var(--space-sm);
border: 1px solid var(--border-base);
background: var(--surface-base);
color: var(--text-primary);
cursor: pointer;
}
.form-request-choice:hover {
border-color: var(--text-muted);
}
.form-request-choice[data-selected="true"] {
border-color: var(--accent-primary);
background: color-mix(in srgb, var(--accent-primary) 10%, var(--surface-base));
}
.form-request-choice:focus-within {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.form-request-choice-control {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
margin: 0.125rem 0 0;
accent-color: var(--accent-primary);
}
.form-request-choice-copy {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.form-request-choice-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-tight);
overflow-wrap: anywhere;
}
.form-request-choice-description {
color: var(--text-secondary);
font-size: var(--font-size-xs);
line-height: var(--line-height-normal);
overflow-wrap: anywhere;
}

View file

@ -82,25 +82,6 @@
accent-color: var(--accent-primary);
}
.form-request-options {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
padding: 0;
border: 0;
}
.form-request-option {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-primary);
font-size: var(--font-size-sm);
min-width: 0;
cursor: pointer;
}
.form-request-link {
color: var(--accent-primary);
font-size: var(--font-size-sm);

View file

@ -10,6 +10,7 @@
@import "./components/remote-access.css";
@import "./components/permission-notification.css";
@import "./components/form-request.css";
@import "./components/form-request-options.css";
@import "./components/toast-history.css";
@import "./components/settings-screen.css";
@import "./components/settings-info.css";