QuestionAnswer
question-answerStudent answer card: the right control per question type, missing options stated plainly, submit only when every blank is filled, verdict and explanation after answering.
Usage
Basic usage
Single choice: options are a RadioGroup; on submit the demo grades locally with gradeObjective and shows the verdict and explanation.
<QuestionAnswer
question={question}
value={value}
onChange={setValue}
result={result}
onSubmit={(answer) => setResult(grade(answer))}
/>Multiple choice
A CheckboxGroup returning a sorted key array; too few or too many selections are both wrong.
<QuestionAnswer question={question} value={value} onChange={setValue} onSubmit={submit} />True or false
The two options come with the type (options is null in the bank); the value is "true" / "false".
<QuestionAnswer question={question} value={value} onChange={setValue} onSubmit={submit} />Multiple blanks
One input per blank with its number; submit only when every blank is filled; a wrong answer lists the correct answer per blank.
<QuestionAnswer question={{ ...question, blankCount: 2 }} value={value} onChange={setValue} onSubmit={submit} />Formula keyboard
With blankInput="math" every blank renders the component injected via mathField; here it is a plain input.
<QuestionAnswer question={question} value={value} onChange={setValue} blankInput="math" mathField={MathField} />Review
Already answered: pass value and result together; controls lock and the button reads Submitted.
<QuestionAnswer question={question} value="B" result={{ correct: false, correctAnswer: "A", analysis }} onSubmit={submit} />Source line and header
reason is the recommendation line above the stem; header holds the number or a timer.
<QuestionAnswer question={question} reason="You missed this kind last time" header={<span>Question 3</span>} />Subjective
Read-only stem with a note that the teacher grades it; no submit button.
<QuestionAnswer question={question} value={undefined} onChange={() => {}} />Missing options
The options of a choice question were never entered: say plainly it cannot be answered yet instead of showing an empty radio group.
<QuestionAnswer question={{ ...question, options: null }} value={value} onChange={setValue} onSubmit={submit} />When to use
Practice pages, homework pages, and mistake redo: anywhere a student answers one question, and only this one implementation. It owns the answering screen itself: controls, whether the answer can be submitted, what shows after answering. Grading, resume records, recommendation reasons, and the next question belong to the page.
Every rule in this component maps to an incident that once happened silently (no page error, clean console, the student simply could not answer): a true-false question fell into the "options from options" branch and rendered a radio group with zero options; a multi-blank question got one input while grading compares blank by blank; object-shaped options were filtered to an empty array by typeof o === "string". A second hand-written copy will hit at least one of them again.
Display only: QuestionCard. Authoring: QuestionEditor.
Import
import { QuestionAnswer, canSubmit, gradeObjective, encodeBlanks } from "@hulianui/ui/math"It lives in @hulianui/ui/math rather than the root package: the stem, options, and result area are all Formula, so KaTeX comes along; consumers that never typeset math should not pay for it.
Props
| Name | Type | Default | Description |
|---|---|---|---|
| question | AnswerableQuestion | - | The question as the student sees it (below); no slot for the answer or explanation |
| value | StudentAnswer | undefined | - | Controlled answer: an array per blank for fill-in (a single blank is still a one-item array), an array of keys for multiple choice, a string for single choice / true-false ("true" | "false"). A server-recorded string is accepted when resuming |
| onChange | (next: StudentAnswer) => void | - | Any change |
| result | QuestionAnswerResult | null | null | Present = answered: controls lock, correct / incorrect plus the correct answer and explanation show, the button reads Submitted |
| onSubmit | (answer: StudentAnswer) => void | - | The submit button appears only when provided. Receives the canonical shape: always an array for blanks, an array of keys for multiple choice |
| pending | boolean | false | Submitting: the button spins and controls lock |
| disabled | boolean | false | Read-only |
| renderStem | (stem: string) => ReactNode | - | Custom stem rendering. The default is the same path as QuestionCard |
| resolveFigure | (key: string) => string | - |  in the stem to a displayable URL. Used by the default stem rendering; ignored when renderStem is given |
| blankInput | "text" | "math" | "text" | Input control for blanks |
| mathField | ComponentType<MathFieldLikeProps> | - | Required with blankInput="math"; without it the card falls back to a text input with a development warning |
| header | ReactNode | - | Right side of the top tag row (number / source / timer) |
| reason | ReactNode | - | Source line above the stem (recommendation reason / "Tier A homework from your teacher") |
| correctHint | ReactNode | - | The sentence in the result area when the answer is correct |
| className | string | - | Passed to the root Card |
AnswerableQuestion
| Name | Type | Description |
|---|---|---|
| type | QuestionType | string | One of the seven types; an unknown string is treated as subjective (read-only) with a development warning |
| stem | string | Stem (with $...$ and ) |
| options | QuestionOption[] | null | Choice options. Legacy shapes (["A. x"] / ["60°"]) are accepted through normalizeOptions |
| blankCount | number | Number of blanks. When missing or invalid, the count of ____ in the stem is used, then 1 |
| difficulty | number | 1 to 5, rendered as stars |
| topics | string[] | Knowledge point tags |
QuestionAnswerResult
| Name | Type | Description |
|---|---|---|
| correct | boolean | Verdict |
| correctAnswer | QuestionAnswerValue | The correct answer in any legal shape, rendered as text through answerText |
| analysis | string | Explanation, typeset by Formula |
Events
| Name | Params | Description |
|---|---|---|
| onChange | (next: StudentAnswer) | Answer changed. Multiple choice returns a sorted array of keys; blanks return the full per-blank array |
| onSubmit | (answer: StudentAnswer) | Submit clicked. Only clickable while canSubmit(answer) is true |
Slots
| Name | Description |
|---|---|
| header | Right side of the top tag row |
| reason | Source line above the stem, with an info icon |
| correctHint | Result body when correct |
Internationalization
All copy comes from the locale's components.questionAnswer (QuestionAnswerLocale, source of truth in question-answer.locale.ts, wired into zhCN / enUS). Type names and the true-false labels come from components.question (shared with QuestionCard / QuestionEditor).
Companion functions
All exported from @hulianui/ui/math:
canSubmit(answer): true only when every blank is filled. Reuse it for a Next button outside the card.answerKind(question):"single" | "multiple" | "judge" | "blank" | "subjective" | "unanswerable", which control this question gets.resolveBlankCount(question): how many blanks to render.gradeObjective(question, answer)(phase 1): grading for instant feedback, see Formula.encodeBlanks(blanks)/decodeBlanks(raw, count)(phase 1): convert between the per-blank array and the server record.
Pitfalls
- Flatten blanks before sending: a single blank submits
["90"]; if your backend stores a single blank as a string, callencodeBlanksfirst. The card does not flatten because it does not know your backend contract. - True-false values are `"true" | "false"`, not the labels and not A / B;
gradeObjectiveand the consumer's server both normalize to boolean. - Multiple choice returns an array (sorted). Backends that want
"A,C"join it themselves. - A present `result` locks the card: to let the student retry, set
resulttonulland clearvalue. - Missing options is a statement, not a fallback: a choice question with null / empty
optionsshows "This question cannot be answered yet" and no submit button. The server should not serve such questions; if one arrives, the student must know it is not their phone. - `blankInput="math"` needs `mathField`: without it the card falls back to a text input with a development warning.
- Do not wrap the card in another Field: the option group has its own
aria-label; an outer Field would make its label the group name and read the stem twice. - JSX attribute strings do not process `\\`: keep formulas with backslashes in TS string constants.
Related
- QuestionCard: display only; the stem is rendered by the same
QuestionStemBlock - QuestionEditor: authoring
- MathTextarea: the
MathFieldLikePropscontract lives in its docs - MathField: the ready-made
mathFieldimplementation (optional peer mathlive) andcreateCasComparator - Formula: the question-domain functions of
@hulianui/ui/math(gradeObjective/encodeBlanks/normalizeOptions)
Playground
<QuestionAnswer question={question} value={value} onChange={setValue} onSubmit={submit} />