question-bank-framework 0.4.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>
@@ -7,9 +7,12 @@ import { useBank } from '../../core/config.tsx';
7
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,13 +22,12 @@ 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';
25
+ import { formatNumericAnswer, NumericalResponse, optionLetter, PairingResponse } from './question-response';
22
26
  import { setQuestionStatus, getQuestionStatuses } from '../lib/question-status-storage.ts';
23
27
  import { questionStatusLabels } from '../lib/question-filter.ts';
24
28
  import { getQuiz, saveQuizLocally, saveQuizProgress } from '../lib/quiz-storage.ts';
25
29
  import { cn } from '../lib/utils.ts';
26
30
 
27
- const optionLetter = (index: number): string => String.fromCharCode(65 + index);
28
-
29
31
  const questionStatusStyles: Record<QuestionStatus, string> = {
30
32
  unanswered: 'border-gray-300 bg-gray-50 text-gray-700',
31
33
  revisit: 'border-amber-300 bg-amber-50 text-amber-900',
@@ -111,7 +113,7 @@ export function QuizPage() {
111
113
  );
112
114
  };
113
115
 
114
- const setChoiceAnswer = (questionId: string, value: string[]) => {
116
+ const setGradedAnswer = (questionId: string, value: QuizAnswer) => {
115
117
  setChecked((current) => (current[questionId] ? { ...current, [questionId]: false } : current));
116
118
  setSession((current) =>
117
119
  current ? { ...current, completed: false, answers: { ...current.answers, [questionId]: value } } : current,
@@ -122,9 +124,9 @@ export function QuizPage() {
122
124
  if (!activeQuestion) return;
123
125
  const current = toSelectedIds(activeQuestion, session?.answers[questionId]);
124
126
  if (single) {
125
- setChoiceAnswer(questionId, current[0] === optionId ? [] : [optionId]);
127
+ setGradedAnswer(questionId, current[0] === optionId ? [] : [optionId]);
126
128
  } else {
127
- setChoiceAnswer(
129
+ setGradedAnswer(
128
130
  questionId,
129
131
  current.includes(optionId) ? current.filter((id) => id !== optionId) : [...current, optionId],
130
132
  );
@@ -184,17 +186,19 @@ export function QuizPage() {
184
186
  const suggestedAnswer = bank.answers[activeQuestion.id];
185
187
  const isSuggestedAnswerShown = shownAnswerId === activeQuestion.id;
186
188
  const choice = isChoiceQuestion(activeQuestion);
189
+ const gradeable = isGradeableQuestion(activeQuestion);
187
190
  const kind = questionKind(activeQuestion);
188
191
  const selectedIds = toSelectedIds(activeQuestion, answer);
189
192
  const hasSelection = isAnswerProvided(activeQuestion, answer);
190
193
  const isChecked = checked[activeQuestion.id] === true;
191
194
  const correctness = isChecked ? isAnswerCorrect(activeQuestion, answer) : null;
195
+ const grade = isChecked ? gradeAnswer(activeQuestion, answer) : null;
192
196
  const correctIds = getCorrectIds(activeQuestion);
193
197
  const activeStatus: QuestionStatus = statusMap[activeQuestion.id] ?? 'unanswered';
194
198
  const score = scoreAnswers(quizQuestions, session.answers);
195
199
 
196
200
  return (
197
- <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">
198
202
  <Link to="/quizzes" className="text-sm font-semibold text-gray-600 hover:text-gray-900">
199
203
  Back to My quizzes
200
204
  </Link>
@@ -227,7 +231,7 @@ export function QuizPage() {
227
231
  </form>
228
232
  ) : (
229
233
  <div className="flex min-w-0 items-center gap-2">
230
- <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>
231
235
  <Button type="button" variant="ghost" size="icon-xs" aria-label="Edit quiz title" title="Edit quiz title" onClick={startEditingTitle}>
232
236
  <Pencil aria-hidden="true" />
233
237
  </Button>
@@ -289,13 +293,24 @@ export function QuizPage() {
289
293
  <ol className="mt-3 grid gap-1.5">
290
294
  {quizQuestions.map((question, index) => {
291
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';
292
307
  return (
293
308
  <li key={question.id}>
294
309
  <button
295
310
  type="button"
296
311
  onClick={() => goToQuestion(index)}
297
312
  className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-gray-100"
298
- 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()}`}
299
314
  >
300
315
  <span
301
316
  aria-hidden="true"
@@ -309,9 +324,7 @@ export function QuizPage() {
309
324
  {result === true ? <Check className="size-3" /> : result === false ? <X className="size-3" /> : index + 1}
310
325
  </span>
311
326
  <span className="min-w-0 flex-1 truncate text-gray-900">{question.title}</span>
312
- <span className="shrink-0 text-xs text-gray-500">
313
- {result === null ? 'Written' : result ? 'Correct' : 'Incorrect'}
314
- </span>
327
+ <span className="shrink-0 text-xs text-gray-500">{resultLabel}</span>
315
328
  </button>
316
329
  </li>
317
330
  );
@@ -320,19 +333,19 @@ export function QuizPage() {
320
333
  </section>
321
334
  )}
322
335
 
323
- <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">
324
337
  <div className="flex flex-wrap items-center gap-2">
325
- <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>
326
339
  <Badge variant="outline">{questionKindLabels[kind]}</Badge>
327
340
  <Badge variant="outline" className={questionStatusStyles[activeStatus]}>
328
341
  {questionStatusLabels[activeStatus]}
329
342
  </Badge>
330
343
  </div>
331
- <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>
332
345
  {activeQuestion.tags.length > 0 && (
333
346
  <div className="mt-2 flex flex-wrap gap-1">
334
347
  {activeQuestion.tags.map((tag) => (
335
- <Badge key={tag} variant="secondary">
348
+ <Badge key={tag} variant="secondary" className="max-w-full shrink whitespace-normal break-words">
336
349
  {tag}
337
350
  </Badge>
338
351
  ))}
@@ -401,6 +414,39 @@ export function QuizPage() {
401
414
  })}
402
415
  </div>
403
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>
404
450
  {isChecked && correctness !== null && (
405
451
  <div
406
452
  role="status"
@@ -411,12 +457,16 @@ export function QuizPage() {
411
457
  >
412
458
  {correctness
413
459
  ? 'Correct — nice work.'
414
- : `Not quite. Correct answer${correctIds.length > 1 ? 's' : ''}: ${correctIds
415
- .map((id) => {
416
- const index = (activeQuestion.options ?? []).findIndex((o) => o.id === id);
417
- return index >= 0 ? optionLetter(index) : id;
418
- })
419
- .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(', ')}`}
420
470
  </div>
421
471
  )}
422
472
 
@@ -436,24 +486,11 @@ export function QuizPage() {
436
486
  )}
437
487
  </div>
438
488
  </div>
439
- ) : (
440
- <div className="mt-6">
441
- <label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
442
- Your answer
443
- </label>
444
- <textarea
445
- id="quiz-answer"
446
- value={typeof answer === 'string' ? answer : ''}
447
- onChange={(event) => updateWrittenAnswer(event.target.value)}
448
- placeholder="Write your working or notes here..."
449
- 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"
450
- />
451
- </div>
452
489
  )}
453
490
 
454
- {suggestedAnswer && (choice ? isSuggestedAnswerShown : true) && (
491
+ {suggestedAnswer && (gradeable ? isSuggestedAnswerShown : true) && (
455
492
  <div className="mt-5">
456
- {!choice && (
493
+ {!gradeable && (
457
494
  <Button
458
495
  variant="outline"
459
496
  aria-expanded={isSuggestedAnswerShown}
@@ -466,7 +503,7 @@ export function QuizPage() {
466
503
  {isSuggestedAnswerShown && (
467
504
  <section id="suggested-answer" className="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
468
505
  <h3 className="m-0 text-base font-semibold text-gray-900">
469
- {choice ? 'Explanation' : 'Suggested answer'}
506
+ {gradeable ? 'Explanation' : 'Suggested answer'}
470
507
  </h3>
471
508
  <MarkdownContent content={suggestedAnswer} className="mt-3 text-sm leading-relaxed text-gray-800" />
472
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.4.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",