feat(19-03): create OptionGroup and FreeTextInput components

- OptionGroup renders radio or checkbox based on multiSelect prop
- "Other" field auto-selects when user types into it
- FreeTextInput renders Input or Textarea based on multiline prop
This commit is contained in:
Lukas May
2026-02-04 21:51:40 +01:00
parent 004140ea83
commit f73b85062d
2 changed files with 194 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
interface FreeTextInputProps {
questionId: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}
export function FreeTextInput({
questionId,
value,
onChange,
multiline = false,
placeholder,
}: FreeTextInputProps) {
if (multiline) {
return (
<Textarea
id={questionId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? "Type your answer..."}
className="min-h-[80px]"
/>
);
}
return (
<Input
id={questionId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? "Type your answer..."}
/>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import { Input } from "@/components/ui/input";
interface OptionGroupProps {
questionId: string;
options: Array<{ label: string; description?: string }>;
multiSelect: boolean;
value: string;
onChange: (value: string) => void;
allowOther?: boolean;
}
export function OptionGroup({
questionId,
options,
multiSelect,
value,
onChange,
allowOther = true,
}: OptionGroupProps) {
const [otherText, setOtherText] = useState("");
const selectedLabels = value ? value.split(",").map((v) => v.trim()) : [];
function isSelected(label: string): boolean {
return selectedLabels.includes(label);
}
function isOtherSelected(): boolean {
// "Other" is selected if value contains something not in the options list
const optionLabels = options.map((o) => o.label);
return selectedLabels.some((s) => !optionLabels.includes(s) && s !== "");
}
function handleOptionChange(label: string, checked: boolean) {
if (multiSelect) {
let updated: string[];
if (checked) {
updated = [...selectedLabels.filter((s) => s !== ""), label];
} else {
updated = selectedLabels.filter((s) => s !== label);
}
// Preserve "Other" text if selected
if (isOtherSelected() && otherText) {
updated = updated.filter(
(s) => options.some((o) => o.label === s) || s === otherText
);
}
onChange(updated.join(","));
} else {
// Radio: single selection
onChange(label);
setOtherText("");
}
}
function handleOtherToggle(checked: boolean) {
if (multiSelect) {
if (checked && otherText) {
const existing = selectedLabels.filter(
(s) => options.some((o) => o.label === s)
);
onChange([...existing, otherText].join(","));
} else if (!checked) {
const existing = selectedLabels.filter((s) =>
options.some((o) => o.label === s)
);
onChange(existing.join(","));
setOtherText("");
}
} else {
if (checked && otherText) {
onChange(otherText);
} else {
onChange("");
setOtherText("");
}
}
}
function handleOtherTextChange(text: string) {
setOtherText(text);
if (text) {
if (multiSelect) {
const existing = selectedLabels.filter((s) =>
options.some((o) => o.label === s)
);
onChange([...existing, text].join(","));
} else {
onChange(text);
}
} else {
if (multiSelect) {
const existing = selectedLabels.filter((s) =>
options.some((o) => o.label === s)
);
onChange(existing.join(","));
} else {
onChange("");
}
}
}
const inputType = multiSelect ? "checkbox" : "radio";
return (
<div className="space-y-2">
{options.map((option) => (
<label
key={option.label}
className="flex items-start gap-3 cursor-pointer rounded-md border border-transparent px-3 py-2 hover:bg-muted/50"
>
<input
type={inputType}
name={questionId}
checked={isSelected(option.label)}
onChange={(e) =>
handleOptionChange(option.label, e.target.checked)
}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">
{option.description}
</span>
)}
</div>
</label>
))}
{allowOther && (
<label className="flex items-start gap-3 cursor-pointer rounded-md border border-transparent px-3 py-2 hover:bg-muted/50">
<input
type={inputType}
name={questionId}
checked={isOtherSelected()}
onChange={(e) => handleOtherToggle(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Other:</span>
<Input
value={otherText}
onChange={(e) => handleOtherTextChange(e.target.value)}
placeholder="Type your answer..."
className="h-8 w-48 text-sm"
/>
</div>
</label>
)}
</div>
);
}