- 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
156 lines
4.4 KiB
TypeScript
156 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|