feat: add interactive matching question component with responsive connection lines and styles

This commit is contained in:
LaurensBartelds committed 2026-04-21 23:04:39 +02:00
1 parent eb013d4978
commit 5c7b0bdb45
2 files changed
+124 -6

No files matched your search

+114 -5
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import Tex from '../Tex'
import type { Question } from '../../types'
@@ -10,17 +10,102 @@ interface Props {
graded?: boolean
}
const PASTEL_COLORS = [
'oklch(0.78 0.12 330)', // pink
'oklch(0.78 0.12 200)', // sky blue
'oklch(0.80 0.13 140)', // mint green
'oklch(0.78 0.12 80)', // peach / orange
'oklch(0.78 0.12 270)', // lavender
'oklch(0.82 0.10 60)', // soft yellow
'oklch(0.78 0.12 170)', // teal
'oklch(0.78 0.10 20)', // salmon
'oklch(0.78 0.12 300)', // orchid
'oklch(0.80 0.10 110)', // lime
]
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 gridRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const leftRefs = useRef<Record<string, HTMLButtonElement | null>>({})
const rightRefs = useRef<Record<string, HTMLButtonElement | null>>({})
const reverseMap = useMemo(() => {
const m: Record<string, string> = {}
Object.entries(current).forEach(([l, r]) => { m[r] = l })
return m
}, [current])
/* Build a stable color map: each pair index gets a pastel */
const pairColorMap = useMemo(() => {
const map: Record<string, string> = {}
let colorIdx = 0
const leftItems = q.left ?? []
leftItems.forEach((item) => {
if (current[item.id]) {
map[item.id] = PASTEL_COLORS[colorIdx % PASTEL_COLORS.length]
colorIdx++
}
})
return map
}, [current, q.left])
/* Stringify the current pairings so we can use it as a stable dependency */
const currentKey = JSON.stringify(current)
const drawLines = useCallback(() => {
const grid = gridRef.current
const svg = svgRef.current
if (!grid || !svg) return
const gridRect = grid.getBoundingClientRect()
/* Clear existing paths */
while (svg.firstChild) svg.removeChild(svg.firstChild)
Object.entries(current).forEach(([leftId, rightId]) => {
const leftEl = leftRefs.current[leftId]
const rightEl = rightRefs.current[rightId]
if (!leftEl || !rightEl) return
const lr = leftEl.getBoundingClientRect()
const rr = rightEl.getBoundingClientRect()
const x1 = lr.right - gridRect.left
const y1 = lr.top + lr.height / 2 - gridRect.top
const x2 = rr.left - gridRect.left
const y2 = rr.top + rr.height / 2 - gridRect.top
const dx = (x2 - x1) * 0.45
const color = pairColorMap[leftId] ?? PASTEL_COLORS[0]
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`)
path.setAttribute('stroke', color)
path.setAttribute('stroke-width', '2.5')
path.setAttribute('fill', 'none')
path.setAttribute('stroke-linecap', 'round')
path.setAttribute('opacity', '0.85')
svg.appendChild(path)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentKey, pairColorMap])
useEffect(() => {
/* Draw lines after render */
requestAnimationFrame(drawLines)
window.addEventListener('resize', drawLines)
window.addEventListener('scroll', drawLines, true)
return () => {
window.removeEventListener('resize', drawLines)
window.removeEventListener('scroll', drawLines, true)
}
}, [drawLines])
const handleRight = (rightId: string) => {
if (locked) return
if (activeLeft) {
@@ -48,7 +133,10 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
}
return (
<div className="match-grid">
<div className="match-grid" ref={gridRef}>
{/* SVG overlay for connection lines */}
<svg className="match-lines-svg" ref={svgRef} />
<div className="match-col">
<div className="match-col-label">Items</div>
{(q.left ?? []).map((item, i) => {
@@ -62,14 +150,27 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
const pairedRightIdx = current[item.id]
? (q.right ?? []).findIndex((x) => x.id === current[item.id])
: -1
const pairColor = pairColorMap[item.id]
return (
<button key={item.id} className={cls} onClick={() => handleLeft(item.id)} disabled={locked}>
<button
key={item.id}
className={cls}
onClick={() => handleLeft(item.id)}
disabled={locked}
ref={(el) => { leftRefs.current[item.id] = el }}
style={pairColor && !graded ? { borderColor: pairColor, borderRightWidth: 3 } : undefined}
>
<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>
<span
className="match-pair-badge"
style={pairColor ? { background: pairColor, color: '#fff' } : undefined}
>
{String.fromCharCode(65 + pairedRightIdx)}
</span>
)}
</button>
)
@@ -86,19 +187,27 @@ export default function Match({ q, value, onChange, locked, graded }: Props) {
const pairedLeftIdx = reverseMap[item.id]
? (q.left ?? []).findIndex((x) => x.id === reverseMap[item.id])
: -1
const pairColor = reverseMap[item.id] ? pairColorMap[reverseMap[item.id]] : undefined
return (
<button
key={item.id}
className={cls}
onClick={() => handleRight(item.id)}
disabled={locked || (!activeLeft && !reverseMap[item.id])}
ref={(el) => { rightRefs.current[item.id] = el }}
style={pairColor && !graded ? { borderColor: pairColor, borderLeftWidth: 3 } : undefined}
>
<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>
<span
className="match-pair-badge"
style={pairColor ? { background: pairColor, color: '#fff' } : undefined}
>
{pairedLeftIdx + 1}
</span>
)}
</button>
)