question-bank-framework 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ // Response widgets for the auto-graded non-choice kinds: numerical,
2
+ // matching and dropdown. Framework-owned: eject with
3
+ // `qb reveal framework/components/question-response.tsx`.
4
+
5
+ import { Check, X } from 'lucide-react';
6
+ import type { BaseQuestion, QuestionOption, QuizAnswer } from '../../core/types.ts';
7
+ import { getPairingSlots, parseNumericAnswer, questionKind, toPairings } from '../../core/types.ts';
8
+ import { MarkdownContent } from './markdown-content';
9
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
10
+ import { cn } from '../lib/utils.ts';
11
+
12
+ export const optionLetter = (index: number): string => String.fromCharCode(65 + index);
13
+
14
+ /** Format the expected value of a numerical question, e.g. "71.667 (± 0.01) I/Os". */
15
+ export function formatNumericAnswer(question: BaseQuestion): string {
16
+ const numeric = question.numeric;
17
+ if (!numeric) return '';
18
+ const tolerance = numeric.tolerance ? ` (± ${numeric.tolerance})` : '';
19
+ const unit = numeric.unit ? ` ${numeric.unit}` : '';
20
+ return `${numeric.value.toLocaleString('en-US', { maximumFractionDigits: 10 })}${tolerance}${unit}`;
21
+ }
22
+
23
+ export function NumericalResponse({
24
+ question,
25
+ answer,
26
+ checked,
27
+ correct,
28
+ onChange,
29
+ }: {
30
+ question: BaseQuestion;
31
+ answer: QuizAnswer | undefined;
32
+ checked: boolean;
33
+ correct: boolean | null;
34
+ onChange: (value: string) => void;
35
+ }) {
36
+ const text = typeof answer === 'string' ? answer : '';
37
+ const invalid = text.trim() !== '' && parseNumericAnswer(text) === null;
38
+ const inputId = `quiz-numeric-${question.id}`;
39
+ return (
40
+ <div className="mt-6">
41
+ <label htmlFor={inputId} className="text-sm font-semibold text-gray-900">
42
+ Your answer
43
+ </label>
44
+ <div className="mt-2 flex items-center gap-2">
45
+ <input
46
+ id={inputId}
47
+ type="text"
48
+ inputMode="decimal"
49
+ autoComplete="off"
50
+ value={text}
51
+ onChange={(event) => onChange(event.target.value)}
52
+ placeholder="Enter a number"
53
+ aria-invalid={invalid || (checked && correct === false) || undefined}
54
+ aria-describedby={invalid ? `${inputId}-hint` : undefined}
55
+ className={cn(
56
+ 'h-10 w-48 rounded-lg border bg-white px-3 font-mono text-base tabular-nums text-gray-900 outline-none placeholder:font-sans placeholder:text-gray-400 focus-visible:ring-2 focus-visible:ring-gray-900/20',
57
+ checked && correct === true && 'border-green-600 bg-green-50',
58
+ checked && correct === false && 'border-red-600 bg-red-50',
59
+ !checked && 'border-gray-300',
60
+ )}
61
+ />
62
+ {question.numeric?.unit && <span className="text-sm text-gray-600">{question.numeric.unit}</span>}
63
+ </div>
64
+ {invalid && (
65
+ <p id={`${inputId}-hint`} className="mt-1.5 text-sm text-amber-800">
66
+ That doesn't look like a number — it will be marked incorrect.
67
+ </p>
68
+ )}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ function OptionSelect({
74
+ id,
75
+ options,
76
+ value,
77
+ lettered,
78
+ onChange,
79
+ ariaLabel,
80
+ state,
81
+ }: {
82
+ id: string;
83
+ options: readonly QuestionOption[];
84
+ value: string | undefined;
85
+ lettered: boolean;
86
+ onChange: (value: string) => void;
87
+ ariaLabel: string;
88
+ state: 'idle' | 'correct' | 'incorrect' | 'missing';
89
+ }) {
90
+ return (
91
+ <Select value={value ?? ''} onValueChange={onChange}>
92
+ <SelectTrigger
93
+ id={id}
94
+ aria-label={ariaLabel}
95
+ className={cn(
96
+ 'h-auto min-h-9 w-full min-w-48 whitespace-normal bg-white text-left sm:w-72',
97
+ state === 'correct' && 'border-green-600 bg-green-50',
98
+ (state === 'incorrect' || state === 'missing') && 'border-red-600 bg-red-50',
99
+ )}
100
+ >
101
+ <SelectValue placeholder="Choose…" />
102
+ </SelectTrigger>
103
+ <SelectContent position="popper" className="max-w-[min(36rem,calc(100vw-2rem))]">
104
+ {options.map((option, index) => (
105
+ <SelectItem key={option.id} value={option.id} className="whitespace-normal">
106
+ {lettered && <span className="font-semibold text-gray-500">{optionLetter(index)}.</span>}
107
+ <MarkdownContent inline content={option.text} />
108
+ </SelectItem>
109
+ ))}
110
+ </SelectContent>
111
+ </Select>
112
+ );
113
+ }
114
+
115
+ /** Matching rows or dropdown blanks: one select per slot, graded per slot once checked. */
116
+ export function PairingResponse({
117
+ question,
118
+ answer,
119
+ checked,
120
+ onChange,
121
+ }: {
122
+ question: BaseQuestion;
123
+ answer: QuizAnswer | undefined;
124
+ checked: boolean;
125
+ onChange: (value: Record<string, string>) => void;
126
+ }) {
127
+ const kind = questionKind(question);
128
+ const matching = kind === 'matching';
129
+ const slots = getPairingSlots(question);
130
+ const pairings = toPairings(question, answer);
131
+ const answeredCount = Object.keys(pairings).length;
132
+
133
+ return (
134
+ <div className="mt-6">
135
+ <p id={`quiz-pairing-label-${question.id}`} className="text-sm font-semibold text-gray-900">
136
+ {matching ? 'Match each item to an option' : 'Fill in each blank'}
137
+ <span className="ml-2 font-normal text-gray-500">
138
+ {answeredCount}/{slots.length} answered
139
+ </span>
140
+ </p>
141
+
142
+ {matching && (
143
+ <div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
144
+ <p className="m-0 text-xs font-semibold uppercase tracking-wide text-gray-500">Options</p>
145
+ <ol className="m-0 mt-2 grid list-none gap-x-6 gap-y-1 p-0 sm:grid-cols-2">
146
+ {(question.options ?? []).map((option, index) => (
147
+ <li key={option.id} className="flex items-start gap-2 text-sm text-gray-800">
148
+ <span className="w-5 shrink-0 font-semibold text-gray-500">{optionLetter(index)}.</span>
149
+ <MarkdownContent inline content={option.text} className="min-w-0 flex-1" />
150
+ </li>
151
+ ))}
152
+ </ol>
153
+ </div>
154
+ )}
155
+
156
+ <ol aria-labelledby={`quiz-pairing-label-${question.id}`} className="m-0 mt-3 grid list-none gap-2 p-0">
157
+ {slots.map((slot, index) => {
158
+ const chosen = pairings[slot.id];
159
+ const isCorrect = chosen === slot.correctId;
160
+ const state = !checked ? 'idle' : chosen === undefined ? 'missing' : isCorrect ? 'correct' : 'incorrect';
161
+ const correctIndex = slot.options.findIndex((o) => o.id === slot.correctId);
162
+ const correctOption = slot.options[correctIndex];
163
+ const selectId = `quiz-slot-${question.id}-${slot.id}`;
164
+ const label = slot.label?.trim() ? slot.label : `Blank ${index + 1}`;
165
+ return (
166
+ <li
167
+ key={slot.id}
168
+ className={cn(
169
+ 'flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center',
170
+ state === 'idle' && 'border-gray-200 bg-white',
171
+ state === 'correct' && 'border-green-300 bg-green-50/50',
172
+ (state === 'incorrect' || state === 'missing') && 'border-red-300 bg-red-50/50',
173
+ )}
174
+ >
175
+ <div className="flex min-w-0 flex-1 items-start gap-2">
176
+ <span className="mt-0.5 w-6 shrink-0 text-sm font-semibold tabular-nums text-gray-500">{index + 1}.</span>
177
+ <label htmlFor={selectId} className="min-w-0 flex-1 text-base leading-7 text-gray-900">
178
+ <MarkdownContent inline content={label} />
179
+ </label>
180
+ </div>
181
+ <div className="flex shrink-0 flex-col gap-1 pl-8 sm:pl-0">
182
+ <div className="flex items-center gap-2">
183
+ <OptionSelect
184
+ id={selectId}
185
+ options={slot.options}
186
+ value={chosen}
187
+ lettered={matching}
188
+ ariaLabel={`Answer for ${matching ? 'item' : 'blank'} ${index + 1}`}
189
+ state={state}
190
+ onChange={(value) => onChange({ ...pairings, [slot.id]: value })}
191
+ />
192
+ {state === 'correct' && <Check aria-label="Correct" className="size-5 shrink-0 text-green-700" />}
193
+ {(state === 'incorrect' || state === 'missing') && (
194
+ <X aria-label={state === 'missing' ? 'Not answered' : 'Incorrect'} className="size-5 shrink-0 text-red-700" />
195
+ )}
196
+ </div>
197
+ {(state === 'incorrect' || state === 'missing') && correctOption && (
198
+ <p className="m-0 flex items-baseline gap-1 text-sm text-red-900">
199
+ <span className="shrink-0">Correct:</span>
200
+ {matching && <span className="font-semibold">{optionLetter(correctIndex)}.</span>}
201
+ <MarkdownContent inline content={correctOption.text} />
202
+ </p>
203
+ )}
204
+ </div>
205
+ </li>
206
+ );
207
+ })}
208
+ </ol>
209
+ </div>
210
+ );
211
+ }
@@ -49,7 +49,7 @@ export function QuizList() {
49
49
  };
50
50
 
51
51
  return (
52
- <div className="grid gap-8">
52
+ <div className="grid grid-cols-[minmax(0,1fr)] gap-8">
53
53
  <div className="flex flex-wrap items-end justify-between gap-4">
54
54
  <div>
55
55
  <h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">My quizzes</h1>
@@ -69,7 +69,7 @@ export function QuizList() {
69
69
  Could not delete that quiz. Please try again.
70
70
  </p>
71
71
  )}
72
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
72
+ <div className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-[repeat(2,minmax(0,1fr))]">
73
73
  {quizzes.map((quiz) => {
74
74
  const questions = quiz.questionIds
75
75
  .map((id) => bank.questionById.get(id))
@@ -85,7 +85,7 @@ export function QuizList() {
85
85
  params={{ quizId: quiz.id }}
86
86
  className="block rounded-xl border border-gray-200 bg-white p-5 transition-colors hover:border-gray-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"
87
87
  >
88
- <h2 className="m-0 pr-16 text-lg font-semibold text-gray-900">{quiz.title}</h2>
88
+ <h2 className="m-0 break-words pr-16 text-lg font-semibold text-gray-900">{quiz.title}</h2>
89
89
  <p className="mt-2 text-sm text-gray-700">
90
90
  {quiz.questionIds.length} {quiz.questionIds.length === 1 ? 'question' : 'questions'}
91
91
  </p>
@@ -4,12 +4,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Link, useParams } from '@tanstack/react-router';
5
5
  import { Check, Pencil, X } from 'lucide-react';
6
6
  import { useBank } from '../../core/config.tsx';
7
- import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
7
+ import type { QuestionStatus, QuestionStatusMap, QuizAnswer, QuizSession } from '../../core/types.ts';
8
8
  import {
9
9
  getCorrectIds,
10
+ gradeAnswer,
10
11
  isAnswerCorrect,
11
12
  isAnswerProvided,
12
13
  isChoiceQuestion,
14
+ isGradeableQuestion,
15
+ isPairingQuestion,
13
16
  questionKind,
14
17
  questionKindLabels,
15
18
  scoreAnswers,
@@ -19,11 +22,23 @@ import { Badge } from './ui/badge';
19
22
  import { Button } from './ui/button';
20
23
  import { Input } from './ui/input';
21
24
  import { MarkdownContent } from './markdown-content';
22
- import { setQuestionStatus } from '../lib/question-status-storage.ts';
25
+ import { formatNumericAnswer, NumericalResponse, optionLetter, PairingResponse } from './question-response';
26
+ import { setQuestionStatus, getQuestionStatuses } from '../lib/question-status-storage.ts';
27
+ import { questionStatusLabels } from '../lib/question-filter.ts';
23
28
  import { getQuiz, saveQuizLocally, saveQuizProgress } from '../lib/quiz-storage.ts';
24
29
  import { cn } from '../lib/utils.ts';
25
30
 
26
- const optionLetter = (index: number): string => String.fromCharCode(65 + index);
31
+ const questionStatusStyles: Record<QuestionStatus, string> = {
32
+ unanswered: 'border-gray-300 bg-gray-50 text-gray-700',
33
+ revisit: 'border-amber-300 bg-amber-50 text-amber-900',
34
+ complete: 'border-green-300 bg-green-50 text-green-800',
35
+ };
36
+
37
+ const questionStatusDotStyles: Record<QuestionStatus, string> = {
38
+ unanswered: 'border-gray-300 bg-white text-gray-600 hover:border-gray-500',
39
+ revisit: 'border-amber-400 bg-amber-100 text-amber-900 hover:border-amber-600',
40
+ complete: 'border-green-500 bg-green-100 text-green-900 hover:border-green-700',
41
+ };
27
42
 
28
43
  export function QuizPage() {
29
44
  const bank = useBank();
@@ -35,6 +50,7 @@ export function QuizPage() {
35
50
  const [checked, setChecked] = useState<Record<string, boolean>>({});
36
51
  const [editingTitle, setEditingTitle] = useState(false);
37
52
  const [titleDraft, setTitleDraft] = useState('');
53
+ const [statusMap, setStatusMap] = useState<QuestionStatusMap>({});
38
54
  const titleInputRef = useRef<HTMLInputElement>(null);
39
55
  const quizQuestions = useMemo(
40
56
  () =>
@@ -53,6 +69,9 @@ export function QuizPage() {
53
69
  }).finally(() => {
54
70
  if (active) setLoading(false);
55
71
  });
72
+ getQuestionStatuses(bank.namespace).then((statuses) => {
73
+ if (active) setStatusMap(statuses);
74
+ });
56
75
  return () => {
57
76
  active = false;
58
77
  };
@@ -94,7 +113,7 @@ export function QuizPage() {
94
113
  );
95
114
  };
96
115
 
97
- const setChoiceAnswer = (questionId: string, value: string[]) => {
116
+ const setGradedAnswer = (questionId: string, value: QuizAnswer) => {
98
117
  setChecked((current) => (current[questionId] ? { ...current, [questionId]: false } : current));
99
118
  setSession((current) =>
100
119
  current ? { ...current, completed: false, answers: { ...current.answers, [questionId]: value } } : current,
@@ -105,9 +124,9 @@ export function QuizPage() {
105
124
  if (!activeQuestion) return;
106
125
  const current = toSelectedIds(activeQuestion, session?.answers[questionId]);
107
126
  if (single) {
108
- setChoiceAnswer(questionId, current[0] === optionId ? [] : [optionId]);
127
+ setGradedAnswer(questionId, current[0] === optionId ? [] : [optionId]);
109
128
  } else {
110
- setChoiceAnswer(
129
+ setGradedAnswer(
111
130
  questionId,
112
131
  current.includes(optionId) ? current.filter((id) => id !== optionId) : [...current, optionId],
113
132
  );
@@ -123,6 +142,7 @@ export function QuizPage() {
123
142
  const updateQuestionStatus = (status: QuestionStatus) => {
124
143
  if (!activeQuestion) return;
125
144
  void setQuestionStatus(bank.namespace, activeQuestion.id, status);
145
+ setStatusMap((current) => ({ ...current, [activeQuestion.id]: status }));
126
146
  setShownAnswerId(null);
127
147
  setSession((current) =>
128
148
  current
@@ -166,16 +186,19 @@ export function QuizPage() {
166
186
  const suggestedAnswer = bank.answers[activeQuestion.id];
167
187
  const isSuggestedAnswerShown = shownAnswerId === activeQuestion.id;
168
188
  const choice = isChoiceQuestion(activeQuestion);
189
+ const gradeable = isGradeableQuestion(activeQuestion);
169
190
  const kind = questionKind(activeQuestion);
170
191
  const selectedIds = toSelectedIds(activeQuestion, answer);
171
192
  const hasSelection = isAnswerProvided(activeQuestion, answer);
172
193
  const isChecked = checked[activeQuestion.id] === true;
173
194
  const correctness = isChecked ? isAnswerCorrect(activeQuestion, answer) : null;
195
+ const grade = isChecked ? gradeAnswer(activeQuestion, answer) : null;
174
196
  const correctIds = getCorrectIds(activeQuestion);
197
+ const activeStatus: QuestionStatus = statusMap[activeQuestion.id] ?? 'unanswered';
175
198
  const score = scoreAnswers(quizQuestions, session.answers);
176
199
 
177
200
  return (
178
- <div className="mx-auto grid max-w-3xl gap-6">
201
+ <div className="mx-auto grid w-full max-w-3xl grid-cols-[minmax(0,1fr)] gap-6">
179
202
  <Link to="/quizzes" className="text-sm font-semibold text-gray-600 hover:text-gray-900">
180
203
  Back to My quizzes
181
204
  </Link>
@@ -208,7 +231,7 @@ export function QuizPage() {
208
231
  </form>
209
232
  ) : (
210
233
  <div className="flex min-w-0 items-center gap-2">
211
- <h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">{session.title}</h1>
234
+ <h1 className="m-0 min-w-0 break-words text-3xl font-bold tracking-tight text-gray-900">{session.title}</h1>
212
235
  <Button type="button" variant="ghost" size="icon-xs" aria-label="Edit quiz title" title="Edit quiz title" onClick={startEditingTitle}>
213
236
  <Pencil aria-hidden="true" />
214
237
  </Button>
@@ -233,6 +256,35 @@ export function QuizPage() {
233
256
  )}
234
257
  </div>
235
258
 
259
+ {quizQuestions.length > 1 && (
260
+ <nav aria-label="Questions in this quiz">
261
+ <ol className="m-0 flex list-none flex-wrap gap-1.5 p-0">
262
+ {quizQuestions.map((question, index) => {
263
+ const status = statusMap[question.id] ?? 'unanswered';
264
+ const isActive = index === activeIndex;
265
+ return (
266
+ <li key={question.id}>
267
+ <button
268
+ type="button"
269
+ onClick={() => goToQuestion(index)}
270
+ aria-label={`Go to question ${index + 1}: ${question.title} — ${questionStatusLabels[status]}${isActive ? ' (current question)' : ''}`}
271
+ aria-current={isActive ? 'true' : undefined}
272
+ title={`${question.title} — ${questionStatusLabels[status]}`}
273
+ className={cn(
274
+ 'flex size-8 items-center justify-center rounded-md border text-xs font-semibold tabular-nums transition-colors',
275
+ questionStatusDotStyles[status],
276
+ isActive && 'ring-2 ring-gray-900 ring-offset-2',
277
+ )}
278
+ >
279
+ {index + 1}
280
+ </button>
281
+ </li>
282
+ );
283
+ })}
284
+ </ol>
285
+ </nav>
286
+ )}
287
+
236
288
  {session.completed && score.gradeable > 0 && (
237
289
  <section aria-label="Quiz results" className="rounded-xl border border-gray-200 bg-gray-50 p-5">
238
290
  <h2 className="m-0 text-lg font-semibold text-gray-900">
@@ -241,13 +293,24 @@ export function QuizPage() {
241
293
  <ol className="mt-3 grid gap-1.5">
242
294
  {quizQuestions.map((question, index) => {
243
295
  const result = isAnswerCorrect(question, session.answers[question.id]);
296
+ const parts = isPairingQuestion(question) ? gradeAnswer(question, session.answers[question.id]) : null;
297
+ const resultLabel =
298
+ result === null
299
+ ? isGradeableQuestion(question)
300
+ ? 'Unanswered'
301
+ : 'Written'
302
+ : result
303
+ ? 'Correct'
304
+ : parts
305
+ ? `Incorrect · ${parts.earned}/${parts.possible}`
306
+ : 'Incorrect';
244
307
  return (
245
308
  <li key={question.id}>
246
309
  <button
247
310
  type="button"
248
311
  onClick={() => goToQuestion(index)}
249
312
  className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-gray-100"
250
- aria-label={`Review question ${index + 1}: ${question.title} — ${result === null ? 'written' : result ? 'correct' : 'incorrect'}`}
313
+ aria-label={`Review question ${index + 1}: ${question.title} — ${resultLabel.toLowerCase()}`}
251
314
  >
252
315
  <span
253
316
  aria-hidden="true"
@@ -261,9 +324,7 @@ export function QuizPage() {
261
324
  {result === true ? <Check className="size-3" /> : result === false ? <X className="size-3" /> : index + 1}
262
325
  </span>
263
326
  <span className="min-w-0 flex-1 truncate text-gray-900">{question.title}</span>
264
- <span className="shrink-0 text-xs text-gray-500">
265
- {result === null ? 'Written' : result ? 'Correct' : 'Incorrect'}
266
- </span>
327
+ <span className="shrink-0 text-xs text-gray-500">{resultLabel}</span>
267
328
  </button>
268
329
  </li>
269
330
  );
@@ -272,16 +333,19 @@ export function QuizPage() {
272
333
  </section>
273
334
  )}
274
335
 
275
- <section className="rounded-xl border border-gray-200 bg-white p-5">
336
+ <section className="min-w-0 rounded-xl border border-gray-200 bg-white p-5">
276
337
  <div className="flex flex-wrap items-center gap-2">
277
- <h2 className="m-0 flex-1 text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
338
+ <h2 className="m-0 min-w-0 flex-1 break-words text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
278
339
  <Badge variant="outline">{questionKindLabels[kind]}</Badge>
340
+ <Badge variant="outline" className={questionStatusStyles[activeStatus]}>
341
+ {questionStatusLabels[activeStatus]}
342
+ </Badge>
279
343
  </div>
280
- <p className="mt-1 font-mono text-xs text-gray-500">{activeQuestion.id}</p>
344
+ <p className="mt-1 break-all font-mono text-xs text-gray-500">{activeQuestion.id}</p>
281
345
  {activeQuestion.tags.length > 0 && (
282
346
  <div className="mt-2 flex flex-wrap gap-1">
283
347
  {activeQuestion.tags.map((tag) => (
284
- <Badge key={tag} variant="secondary">
348
+ <Badge key={tag} variant="secondary" className="max-w-full shrink whitespace-normal break-words">
285
349
  {tag}
286
350
  </Badge>
287
351
  ))}
@@ -350,6 +414,39 @@ export function QuizPage() {
350
414
  })}
351
415
  </div>
352
416
 
417
+ </div>
418
+ ) : kind === 'numerical' ? (
419
+ <NumericalResponse
420
+ question={activeQuestion}
421
+ answer={answer}
422
+ checked={isChecked}
423
+ correct={correctness}
424
+ onChange={(value) => setGradedAnswer(activeQuestion.id, value)}
425
+ />
426
+ ) : isPairingQuestion(activeQuestion) ? (
427
+ <PairingResponse
428
+ question={activeQuestion}
429
+ answer={answer}
430
+ checked={isChecked}
431
+ onChange={(value) => setGradedAnswer(activeQuestion.id, value)}
432
+ />
433
+ ) : (
434
+ <div className="mt-6">
435
+ <label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
436
+ Your answer
437
+ </label>
438
+ <textarea
439
+ id="quiz-answer"
440
+ value={typeof answer === 'string' ? answer : ''}
441
+ onChange={(event) => updateWrittenAnswer(event.target.value)}
442
+ placeholder="Write your working or notes here..."
443
+ className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
444
+ />
445
+ </div>
446
+ )}
447
+
448
+ {gradeable && (
449
+ <div>
353
450
  {isChecked && correctness !== null && (
354
451
  <div
355
452
  role="status"
@@ -360,12 +457,16 @@ export function QuizPage() {
360
457
  >
361
458
  {correctness
362
459
  ? 'Correct — nice work.'
363
- : `Not quite. Correct answer${correctIds.length > 1 ? 's' : ''}: ${correctIds
364
- .map((id) => {
365
- const index = (activeQuestion.options ?? []).findIndex((o) => o.id === id);
366
- return index >= 0 ? optionLetter(index) : id;
367
- })
368
- .join(', ')}`}
460
+ : kind === 'numerical'
461
+ ? `Not quite. Expected ${formatNumericAnswer(activeQuestion)}.`
462
+ : isPairingQuestion(activeQuestion) && grade
463
+ ? `Not quite — ${grade.earned} of ${grade.possible} correct. Corrections are shown on each ${kind === 'matching' ? 'item' : 'blank'}.`
464
+ : `Not quite. Correct answer${correctIds.length > 1 ? 's' : ''}: ${correctIds
465
+ .map((id) => {
466
+ const index = (activeQuestion.options ?? []).findIndex((o) => o.id === id);
467
+ return index >= 0 ? optionLetter(index) : id;
468
+ })
469
+ .join(', ')}`}
369
470
  </div>
370
471
  )}
371
472
 
@@ -385,24 +486,11 @@ export function QuizPage() {
385
486
  )}
386
487
  </div>
387
488
  </div>
388
- ) : (
389
- <div className="mt-6">
390
- <label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
391
- Your answer
392
- </label>
393
- <textarea
394
- id="quiz-answer"
395
- value={typeof answer === 'string' ? answer : ''}
396
- onChange={(event) => updateWrittenAnswer(event.target.value)}
397
- placeholder="Write your working or notes here..."
398
- className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
399
- />
400
- </div>
401
489
  )}
402
490
 
403
- {suggestedAnswer && (choice ? isSuggestedAnswerShown : true) && (
491
+ {suggestedAnswer && (gradeable ? isSuggestedAnswerShown : true) && (
404
492
  <div className="mt-5">
405
- {!choice && (
493
+ {!gradeable && (
406
494
  <Button
407
495
  variant="outline"
408
496
  aria-expanded={isSuggestedAnswerShown}
@@ -415,7 +503,7 @@ export function QuizPage() {
415
503
  {isSuggestedAnswerShown && (
416
504
  <section id="suggested-answer" className="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
417
505
  <h3 className="m-0 text-base font-semibold text-gray-900">
418
- {choice ? 'Explanation' : 'Suggested answer'}
506
+ {gradeable ? 'Explanation' : 'Suggested answer'}
419
507
  </h3>
420
508
  <MarkdownContent content={suggestedAnswer} className="mt-3 text-sm leading-relaxed text-gray-800" />
421
509
  </section>
@@ -40,7 +40,7 @@ export const saveProgress = mutation({
40
40
  args: {
41
41
  quizId: v.id('quizzes'),
42
42
  title: v.string(),
43
- answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
43
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
44
44
  currentQuestion: v.number(),
45
45
  completed: v.boolean(),
46
46
  },
@@ -53,7 +53,7 @@ export const setQuestions = mutation({
53
53
  args: {
54
54
  quizId: v.id('quizzes'),
55
55
  questionIds: v.array(v.string()),
56
- answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
56
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
57
57
  currentQuestion: v.number(),
58
58
  },
59
59
  handler: async (ctx, { quizId, questionIds, answers, currentQuestion }) => {
@@ -5,7 +5,7 @@ export default defineSchema({
5
5
  quizzes: defineTable({
6
6
  title: v.string(),
7
7
  questionIds: v.array(v.string()),
8
- answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
8
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
9
9
  currentQuestion: v.number(),
10
10
  completed: v.boolean(),
11
11
  createdAt: v.number(),
@@ -7,6 +7,7 @@ export { QuizList } from './components/quiz-list.tsx';
7
7
  export { AppShell } from './components/app-shell.tsx';
8
8
  export { FilterBuilder } from './components/filter-builder.tsx';
9
9
  export { MarkdownContent } from './components/markdown-content.tsx';
10
+ export { NumericalResponse, PairingResponse, formatNumericAnswer } from './components/question-response.tsx';
10
11
 
11
12
  export {
12
13
  createFilterFields,
@@ -358,7 +358,7 @@ export function matchesFilters(
358
358
  : active.some((rule) => matchesRule(rule, fields, fieldDefs, question, status));
359
359
  }
360
360
 
361
- /** Free-text search across id/title/prompt/tags/options plus `searchable` custom fields. */
361
+ /** Free-text search across id/title/prompt/tags/options/items/blanks plus `searchable` custom fields. */
362
362
  export function matchesSearch(
363
363
  question: BaseQuestion,
364
364
  query: string,
@@ -372,6 +372,8 @@ export function matchesSearch(
372
372
  question.prompt,
373
373
  question.tags.join(' '),
374
374
  (question.options ?? []).map((o) => o.text).join(' '),
375
+ (question.items ?? []).map((item) => item.text).join(' '),
376
+ (question.blanks ?? []).map((blank) => [blank.label ?? '', ...blank.options.map((o) => o.text)].join(' ')).join(' '),
375
377
  ];
376
378
  for (const def of fieldDefs) {
377
379
  if (!def.searchable) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "question-bank-framework",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Framework-style question bank: TanStack Start + TanStack Table grid, quizzes, and filter DSL. Bring your own bank.config.ts + content/. Eject any framework/ file with `qb reveal` (Vike-style).",
5
5
  "type": "module",
6
6
  "license": "MIT",