feat(frontend): F1 widgets (select/info/freetext) + reserved controls

This commit is contained in:
2026-06-28 12:25:50 +02:00
parent e9aca179d7
commit a267754f8f
7 changed files with 76 additions and 0 deletions
@@ -0,0 +1,10 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FreetextWidget } from "./FreetextWidget";
test("submits typed text", async () => {
const onRespond = vi.fn();
render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
await userEvent.type(screen.getByRole("textbox"), "ciao");
await userEvent.click(screen.getByRole("button", { name: /invia/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "freetext", text: "ciao" });
});
+12
View File
@@ -0,0 +1,12 @@
import { useState } from "react";
import type { WidgetProps } from "./types";
export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
const [text, setText] = useState("");
return (
<div className="space-y-2">
{descriptor.title && <p className="font-medium">{descriptor.title}</p>}
<textarea className="w-full border rounded p-2" value={text} onChange={(e) => setText(e.target.value)} />
<button className="border rounded px-3 py-1" onClick={() => onRespond({ id: descriptor.id, kind: "freetext", text })}>Invia</button>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import type { WidgetProps } from "./types";
export function InfoWidget({ descriptor }: WidgetProps) {
const color = descriptor.level === "error" ? "text-red-600" : descriptor.level === "warning" ? "text-amber-600" : "text-foreground";
return <p className={`text-sm ${color}`}>{descriptor.text ?? descriptor.title}</p>;
}
@@ -0,0 +1,7 @@
const LABELS: Record<string, string> = { back: "Torna indietro", exit: "Esci", other: "Altro — specifica" };
export function ReservedControls({ reserved, onControl }: { reserved?: string[]; onControl: (c: string) => void }) {
if (!reserved?.length) return null;
return <div className="flex gap-2 pt-2">{reserved.map((c) => (
<button key={c} className="text-sm border rounded px-2 py-1" onClick={() => onControl(c)}>{LABELS[c] ?? c}</button>
))}</div>;
}
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SelectWidget } from "./SelectWidget";
test("picking an option responds with its id", async () => {
const onRespond = vi.fn();
render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }, { id: "b", label: "B", recommended: true }] }} onRespond={onRespond} />);
expect(screen.getByText(/consigliato/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /A/ }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "select", choices: ["a"] });
});
test("a reserved control responds with control, not a choice", async () => {
const onRespond = vi.fn();
render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }], reserved: ["back"] }} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("button", { name: /indietro/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" });
});
+19
View File
@@ -0,0 +1,19 @@
import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls";
export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
return (
<div className="space-y-2">
{descriptor.title && <p className="font-medium">{descriptor.title}</p>}
{descriptor.intro && <p className="text-sm text-muted-foreground">{descriptor.intro}</p>}
<div className="flex flex-col gap-2">
{descriptor.options?.map((o) => (
<button key={o.id} className="border rounded px-3 py-2 text-left hover:bg-accent"
onClick={() => onRespond({ id: descriptor.id, kind: "select", choices: [o.id] })}>
{o.label}{o.recommended && <span className="ml-2 text-xs text-green-600">(consigliato)</span>}
</button>
))}
</div>
<ReservedControls reserved={descriptor.reserved} onControl={(c) => onRespond({ id: descriptor.id, control: c })} />
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { register } from "./registry";
import { SelectWidget } from "./SelectWidget";
import { InfoWidget } from "./InfoWidget";
import { FreetextWidget } from "./FreetextWidget";
register("select", SelectWidget); register("info", InfoWidget); register("freetext", FreetextWidget);
export { resolve } from "./registry";