mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-21 06:13:26 +00:00
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:
parent
929cc6e3f6
commit
18919e12d2
5 changed files with 168 additions and 52 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
64
packages/ui/src/styles/components/form-request-options.css
Normal file
64
packages/ui/src/styles/components/form-request-options.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue