feat: add Essay, Match, and OrderQuestion renderers

This commit is contained in:
LaurensBartelds committed 2026-04-21 21:43:30 +02:00
1 parent 82ba53da0e
commit e48e37ad87
3 files changed
+231

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
import { useState, useMemo } from 'react'
import Tex from '../Tex'
import type { Question } from '../../types'
interface Props {
q: Question
value: Record<string, string> | null | undefined
onChange: (val: Record<string, string>) => void
locked?: boolean
graded?: boolean
}
export default function Match({ q, value, onChange, locked, graded }: Props) {
const current = value ?? {}
const [activeLeft, setActiveLeft] = useState<string | null>(null)
const answer = (q.answer as Record<string, string>) ?? {}
const reverseMap = useMemo(() => {
const m: Record<string, string> = {}
Object.entries(current).forEach(([l, r]) => { m[r] = l })
return m
}, [current])
const handleRight = (rightId: string) => {
if (locked) return
if (activeLeft) {
const next = { ...current }
Object.keys(next).forEach((k) => { if (k === activeLeft || next[k] === rightId) delete next[k] })
next[activeLeft] = rightId
onChange(next)
setActiveLeft(null)
} else if (reverseMap[rightId]) {
const next = { ...current }
delete next[reverseMap[rightId]]
onChange(next)
}
}
const handleLeft = (leftId: string) => {
if (locked) return
if (current[leftId]) {
const next = { ...current }
delete next[leftId]
onChange(next)
return
}
setActiveLeft(activeLeft === leftId ? null : leftId)
}
return (
<div className="match-grid">
<div className="match-col">
<div className="match-col-label">Items</div>
{(q.left ?? []).map((item, i) => {
let cls = 'match-item'
if (activeLeft === item.id) cls += ' active'
else if (current[item.id]) cls += ' paired'
if (graded) {
if (answer[item.id] && current[item.id] === answer[item.id]) cls += ' correct'
else if (current[item.id]) cls += ' wrong'
}
const pairedRightIdx = current[item.id]
? (q.right ?? []).findIndex((x) => x.id === current[item.id])
: -1
return (
<button key={item.id} className={cls} onClick={() => handleLeft(item.id)} disabled={locked}>
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span className="mono faint" style={{ fontSize: 11 }}>{i + 1}</span>
<Tex>{item.text}</Tex>
</span>
{pairedRightIdx >= 0 && (
<span className="match-pair-badge">{String.fromCharCode(65 + pairedRightIdx)}</span>
)}
</button>
)
})}
</div>
<div className="match-col">
<div className="match-col-label">Matches</div>
{(q.right ?? []).map((item, i) => {
let cls = 'match-item'
if (reverseMap[item.id]) cls += ' paired'
if (graded && reverseMap[item.id]) {
cls += answer[reverseMap[item.id]] === item.id ? ' correct' : ' wrong'
}
const pairedLeftIdx = reverseMap[item.id]
? (q.left ?? []).findIndex((x) => x.id === reverseMap[item.id])
: -1
return (
<button
key={item.id}
className={cls}
onClick={() => handleRight(item.id)}
disabled={locked || (!activeLeft && !reverseMap[item.id])}
>
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span className="mono faint" style={{ fontSize: 11 }}>{String.fromCharCode(65 + i)}</span>
<Tex>{item.text}</Tex>
</span>
{pairedLeftIdx >= 0 && (
<span className="match-pair-badge">{pairedLeftIdx + 1}</span>
)}
</button>
)
})}
</div>
</div>
)
}