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