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:
39
packages/web/src/components/FreeTextInput.tsx
Normal file
39
packages/web/src/components/FreeTextInput.tsx
Normal 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..."}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
packages/web/src/components/OptionGroup.tsx
Normal file
155
packages/web/src/components/OptionGroup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user