| import { useState, useEffect, useRef, useCallback } from "react"; |
| import { Input } from "@pram/ui/components/input"; |
|
|
| import { MCQ_FORMATS } from "@/lib/question-formats"; |
| import type { Question } from "@/lib/types"; |
|
|
| const TRUE_FALSE_CHOICES = [ |
| { key: "TRUE", label: "True" }, |
| { key: "FALSE", label: "False" }, |
| { key: "NOT_GIVEN", label: "Not Given" }, |
| ]; |
|
|
| const AUTHOR_VIEW_CHOICES = [ |
| { key: "YES", label: "Yes" }, |
| { key: "NO", label: "No" }, |
| { key: "NOT_GIVEN", label: "Not Given" }, |
| ]; |
|
|
| const radioClass = |
| "flex items-center gap-3 p-3 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] cursor-pointer hover:border-[var(--matcha-300)] transition-all"; |
| const radioSelected = |
| "border-[var(--matcha-600)] bg-[#e8f5ed] ring-2 ring-[var(--matcha-600)]/30 shadow-sm"; |
| const radioDisabled = "opacity-60 cursor-not-allowed"; |
|
|
| |
| function normalizeTriStateKey( |
| value: string, |
| choices: readonly { key: string }[], |
| ): string { |
| const raw = (value ?? "").trim(); |
| if (!raw) return ""; |
| const u = raw.toUpperCase().replace(/\s+/g, "_"); |
| if (choices.some((c) => c.key === u)) return u; |
| const compact = u.replace(/_/g, ""); |
| const alias: Record<string, string> = { |
| TRUE: "TRUE", |
| FALSE: "FALSE", |
| NOTGIVEN: "NOT_GIVEN", |
| YES: "YES", |
| NO: "NO", |
| }; |
| return alias[compact] ?? u; |
| } |
|
|
| |
| function DebouncedTextInput({ |
| value, |
| onChange, |
| disabled, |
| placeholder, |
| className, |
| }: { |
| value: string; |
| onChange: (val: string) => void; |
| disabled?: boolean; |
| placeholder?: string; |
| className?: string; |
| }) { |
| const [localValue, setLocalValue] = useState(value); |
| const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); |
|
|
| |
| useEffect(() => { |
| setLocalValue(value); |
| }, [value]); |
|
|
| const handleChange = useCallback( |
| (e: React.ChangeEvent<HTMLInputElement>) => { |
| const val = e.target.value; |
| setLocalValue(val); |
| clearTimeout(debounceRef.current); |
| debounceRef.current = setTimeout(() => { |
| onChange(val); |
| }, 400); |
| }, |
| [onChange], |
| ); |
|
|
| const handleBlur = useCallback(() => { |
| clearTimeout(debounceRef.current); |
| onChange(localValue); |
| }, [localValue, onChange]); |
|
|
| return ( |
| <Input |
| aria-label="Jawaban esai" |
| value={localValue} |
| onChange={handleChange} |
| onBlur={handleBlur} |
| disabled={disabled} |
| placeholder={placeholder} |
| className={className} |
| /> |
| ); |
| } |
|
|
| export function QuestionInput({ |
| question, |
| value, |
| onChange, |
| disabled, |
| }: { |
| question: Question; |
| value: string; |
| onChange: (val: string) => void; |
| disabled?: boolean; |
| }) { |
| const format = question.format; |
| const options = question.options as Array<{ key: string; text: string }> | undefined; |
|
|
| if (format === "matching_pairs") { |
| if (!options || options.length === 0) { |
| return ( |
| <div className="text-sm text-[var(--warm-silver)] italic"> |
| Tidak ada opsi tersedia untuk soal ini. |
| </div> |
| ); |
| } |
| const parseCurrentMapping = (val: string): Map<string, string> => { |
| const map = new Map<string, string>(); |
| val.split(",").forEach((pair) => { |
| const [k, v] = pair.split(":").map((s) => s.trim()); |
| if (k && v) map.set(k, v); |
| }); |
| return map; |
| }; |
| const currentMap = value ? parseCurrentMapping(value) : new Map(); |
| const updateMapping = (key: string, val: string) => { |
| currentMap.set(key, val); |
| const serialized = Array.from(currentMap.entries()) |
| .map(([k, v]) => `${k}:${v}`) |
| .join(","); |
| onChange(serialized); |
| }; |
| return ( |
| <div className="space-y-3"> |
| {options.map((opt) => { |
| const matched = currentMap.get(opt.key) || ""; |
| return ( |
| <div key={opt.key} className="flex items-center gap-3"> |
| <span className="w-8 h-8 rounded-full text-sm font-bold flex items-center justify-center shrink-0 bg-[var(--oat-light)] text-[var(--clay-black)]"> |
| {opt.key} |
| </span> |
| <span className="flex-1 text-sm text-[var(--clay-black)]">{opt.text}</span> |
| <span className="text-[var(--warm-silver)]">→</span> |
| <input |
| type="text" |
| value={matched} |
| onChange={(e) => updateMapping(opt.key, e.target.value)} |
| disabled={disabled} |
| placeholder="Padanan..." |
| aria-label="Padanan jawaban" |
| className="w-24 px-3 py-2 text-sm rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] focus:outline-none focus:border-[var(--matcha-600)]" |
| /> |
| </div> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| if ((MCQ_FORMATS as readonly string[]).includes(format ?? "")) { |
| if (!options || options.length === 0) { |
| return ( |
| <div className="text-sm text-[var(--warm-silver)] italic"> |
| Tidak ada opsi tersedia untuk soal ini. |
| </div> |
| ); |
| } |
| return ( |
| <div className="space-y-2"> |
| {options.map((opt) => ( |
| <label |
| key={opt.key} |
| className={`${radioClass} ${value === opt.key ? radioSelected : ""} ${disabled ? radioDisabled : ""}`} |
| > |
| <input |
| type="radio" |
| name={question.id} |
| value={opt.key} |
| checked={value === opt.key} |
| onChange={() => onChange(opt.key)} |
| disabled={disabled} |
| className="hidden" |
| /> |
| <span className={`w-8 h-8 rounded-full text-sm font-bold flex items-center justify-center shrink-0 transition-colors ${value === opt.key ? "bg-[var(--matcha-600)] text-[var(--pure-white)]" : "bg-[var(--oat-light)] text-[var(--clay-black)]"}`}> |
| {opt.key} |
| </span> |
| <span className="text-sm text-[var(--clay-black)]">{opt.text}</span> |
| </label> |
| ))} |
| </div> |
| ); |
| } |
|
|
| if (format === "true_false_not_given") { |
| const normalizedValue = normalizeTriStateKey(value, TRUE_FALSE_CHOICES); |
| return ( |
| <div className="space-y-2"> |
| {TRUE_FALSE_CHOICES.map((c) => { |
| const selected = normalizedValue === c.key; |
| return ( |
| <label |
| key={c.key} |
| className={`${radioClass} ${selected ? radioSelected : ""} ${disabled ? radioDisabled : ""}`} |
| > |
| <input |
| type="radio" |
| name={question.id} |
| value={c.key} |
| checked={selected} |
| onChange={() => onChange(c.key)} |
| disabled={disabled} |
| className="sr-only" |
| /> |
| <span |
| aria-hidden |
| className={`w-5 h-5 rounded-full border-2 shrink-0 flex items-center justify-center transition-colors ${ |
| selected |
| ? "border-[var(--matcha-600)] bg-[var(--matcha-600)]" |
| : "border-[var(--oat-border)] bg-[var(--pure-white)]" |
| }`} |
| > |
| {selected ? <span className="w-2 h-2 rounded-full bg-[var(--pure-white)]" /> : null} |
| </span> |
| <span |
| className={`text-sm font-semibold ${ |
| selected ? "text-[var(--matcha-800)]" : "text-[var(--clay-black)]" |
| }`} |
| > |
| {c.label} |
| </span> |
| </label> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| if (format === "author_view") { |
| const normalizedValue = normalizeTriStateKey(value, AUTHOR_VIEW_CHOICES); |
| return ( |
| <div className="space-y-2"> |
| {AUTHOR_VIEW_CHOICES.map((c) => { |
| const selected = normalizedValue === c.key; |
| return ( |
| <label |
| key={c.key} |
| className={`${radioClass} ${selected ? radioSelected : ""} ${disabled ? radioDisabled : ""}`} |
| > |
| <input |
| type="radio" |
| name={question.id} |
| value={c.key} |
| checked={selected} |
| onChange={() => onChange(c.key)} |
| disabled={disabled} |
| className="sr-only" |
| /> |
| <span |
| aria-hidden |
| className={`w-5 h-5 rounded-full border-2 shrink-0 flex items-center justify-center transition-colors ${ |
| selected |
| ? "border-[var(--matcha-600)] bg-[var(--matcha-600)]" |
| : "border-[var(--oat-border)] bg-[var(--pure-white)]" |
| }`} |
| > |
| {selected ? <span className="w-2 h-2 rounded-full bg-[var(--pure-white)]" /> : null} |
| </span> |
| <span |
| className={`text-sm font-semibold ${selected ? "text-[var(--matcha-800)]" : "text-[var(--clay-black)]"}`} |
| > |
| {c.label} |
| </span> |
| </label> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| |
| return ( |
| <DebouncedTextInput |
| value={value} |
| onChange={onChange} |
| disabled={disabled} |
| placeholder="Ketik jawaban Anda..." |
| className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-lg)]" |
| /> |
| ); |
| } |
|
|