@hydralms/components 0.1.3 → 0.2.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.
Files changed (199) hide show
  1. package/dist/ForumBoard-CHXU3mjC.js +2207 -0
  2. package/dist/ForumBoard-d1w5-r6n.cjs +1 -0
  3. package/dist/assessment-toolbar/assessment-toolbar.d.ts +1 -1
  4. package/dist/assessment-toolbar/index.d.ts +5 -1
  5. package/dist/assessment-toolbar/question-header-bar.d.ts +2 -0
  6. package/dist/assessment-toolbar/question-materials-drawer.d.ts +2 -0
  7. package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
  8. package/dist/assessment-toolbar/types.d.ts +52 -4
  9. package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
  10. package/dist/common/index.d.ts +2 -1
  11. package/dist/common/stepper.d.ts +6 -0
  12. package/dist/common/types.d.ts +37 -0
  13. package/dist/components.css +1 -1
  14. package/dist/content/attachment-list.d.ts +6 -0
  15. package/dist/content/content-block.d.ts +1 -1
  16. package/dist/content/index.d.ts +2 -1
  17. package/dist/content/types.d.ts +39 -0
  18. package/dist/curriculum/curriculum-item.d.ts +1 -1
  19. package/dist/index.cjs +1 -1
  20. package/dist/index.js +551 -312
  21. package/dist/modules/AssignmentModule/AssignmentModule.d.ts +8 -0
  22. package/dist/modules/AssignmentModule/types.d.ts +65 -0
  23. package/dist/modules/CertificateModule/CertificateModule.d.ts +9 -0
  24. package/dist/modules/CertificateModule/types.d.ts +49 -0
  25. package/dist/modules/DiscussionModule/DiscussionModule.d.ts +8 -0
  26. package/dist/modules/DiscussionModule/types.d.ts +47 -0
  27. package/dist/modules/ExamModule/ExamModule.d.ts +8 -0
  28. package/dist/modules/ExamModule/types.d.ts +64 -0
  29. package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +9 -0
  30. package/dist/modules/GradeCenterModule/types.d.ts +54 -0
  31. package/dist/modules/QuizModule/QuizModule.d.ts +1 -1
  32. package/dist/modules/QuizModule/types.d.ts +6 -1
  33. package/dist/modules/SurveyModule/SurveyModule.d.ts +7 -0
  34. package/dist/modules/SurveyModule/types.d.ts +49 -0
  35. package/dist/modules/index.d.ts +12 -0
  36. package/dist/modules.cjs +1 -0
  37. package/dist/modules.js +1422 -0
  38. package/dist/progress/achievement-badge.d.ts +6 -0
  39. package/dist/progress/activity-timeline.d.ts +6 -0
  40. package/dist/progress/index.d.ts +4 -1
  41. package/dist/progress/stat-card.d.ts +1 -1
  42. package/dist/progress/streak-badge.d.ts +6 -0
  43. package/dist/progress/types.d.ts +97 -0
  44. package/dist/questions/essay.d.ts +1 -1
  45. package/dist/questions/hotspot.d.ts +21 -0
  46. package/dist/questions/index.d.ts +9 -1
  47. package/dist/questions/inline-choice.d.ts +21 -0
  48. package/dist/questions/matching.d.ts +22 -0
  49. package/dist/questions/numeric.d.ts +11 -0
  50. package/dist/questions/ordering.d.ts +12 -0
  51. package/dist/questions/scenario.d.ts +23 -0
  52. package/dist/questions/scoring.d.ts +22 -0
  53. package/dist/questions/spreadsheet.d.ts +29 -0
  54. package/dist/questions/types.d.ts +106 -1
  55. package/dist/questions/use-drag-reorder.d.ts +17 -0
  56. package/dist/sections/CertificateViewer/types.d.ts +7 -5
  57. package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
  58. package/dist/sections/ExamSession/types.d.ts +6 -1
  59. package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
  60. package/dist/sections/ForumBoard/types.d.ts +64 -0
  61. package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
  62. package/dist/sections/QuizSession/types.d.ts +6 -1
  63. package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
  64. package/dist/sections/RequirementsChecklist/types.d.ts +37 -0
  65. package/dist/sections/RubricView/RubricView.d.ts +9 -0
  66. package/dist/sections/RubricView/types.d.ts +50 -0
  67. package/dist/sections/index.d.ts +7 -1
  68. package/dist/sections.cjs +1 -1
  69. package/dist/sections.js +250 -1715
  70. package/dist/social/post-card.d.ts +1 -1
  71. package/dist/tabs-DRM2Iq_J.cjs +172 -0
  72. package/dist/tabs-Wf3h_Cx3.js +21580 -0
  73. package/dist/ui/alert.d.ts +1 -1
  74. package/dist/ui/badge.d.ts +1 -1
  75. package/dist/ui/button.d.ts +1 -1
  76. package/dist/ui/drawer.d.ts +84 -0
  77. package/dist/ui/index.d.ts +3 -0
  78. package/dist/ui/progress.d.ts +1 -1
  79. package/dist/ui/rich-text-editor.d.ts +30 -0
  80. package/dist/ui/rich-text-toolbar.d.ts +8 -0
  81. package/dist/utils/array-utils.d.ts +4 -0
  82. package/dist/utils/flatten-leaves.d.ts +6 -0
  83. package/dist/utils/format-file-size.d.ts +1 -0
  84. package/dist/utils/format-timestamp.d.ts +1 -0
  85. package/dist/utils/is-empty-html.d.ts +5 -0
  86. package/dist/utils/shuffle.d.ts +1 -0
  87. package/dist/utils/string-utils.d.ts +12 -0
  88. package/dist/video/video-bookmark.d.ts +1 -1
  89. package/dist/video/video-playlist-item.d.ts +1 -1
  90. package/package.json +92 -3
  91. package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
  92. package/src/assessment-toolbar/index.ts +6 -0
  93. package/src/assessment-toolbar/question-header-bar.tsx +61 -0
  94. package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
  95. package/src/assessment-toolbar/question-navigator.tsx +3 -31
  96. package/src/assessment-toolbar/timer-display.tsx +2 -2
  97. package/src/assessment-toolbar/types.ts +54 -4
  98. package/src/assessment-toolbar/use-countdown.ts +153 -0
  99. package/src/common/index.ts +3 -0
  100. package/src/common/search-input.tsx +7 -6
  101. package/src/common/stepper.tsx +100 -0
  102. package/src/common/types.ts +39 -0
  103. package/src/content/attachment-list.tsx +90 -0
  104. package/src/content/content-block.tsx +4 -2
  105. package/src/content/file-upload-zone.tsx +1 -6
  106. package/src/content/index.ts +3 -0
  107. package/src/content/types.ts +41 -0
  108. package/src/curriculum/curriculum-item.tsx +7 -3
  109. package/src/feedback/feedback-banner.tsx +12 -14
  110. package/src/flashcards/flashcard-deck.tsx +1 -9
  111. package/src/flashcards/flashcard.tsx +1 -1
  112. package/src/modules/AssignmentModule/AssignmentModule.tsx +305 -0
  113. package/src/modules/AssignmentModule/types.ts +73 -0
  114. package/src/modules/CertificateModule/CertificateModule.tsx +161 -0
  115. package/src/modules/CertificateModule/types.ts +47 -0
  116. package/src/modules/CoursePlayer/CoursePlayer.tsx +44 -48
  117. package/src/modules/DiscussionModule/DiscussionModule.tsx +110 -0
  118. package/src/modules/DiscussionModule/types.ts +54 -0
  119. package/src/modules/ExamModule/ExamModule.tsx +285 -0
  120. package/src/modules/ExamModule/types.ts +66 -0
  121. package/src/modules/FlashcardLab/FlashcardLab.tsx +29 -16
  122. package/src/modules/GradeCenterModule/GradeCenterModule.tsx +169 -0
  123. package/src/modules/GradeCenterModule/types.ts +63 -0
  124. package/src/modules/QuizModule/QuizModule.tsx +88 -88
  125. package/src/modules/QuizModule/types.ts +6 -1
  126. package/src/modules/SurveyModule/SurveyModule.tsx +180 -0
  127. package/src/modules/SurveyModule/types.ts +51 -0
  128. package/src/modules/index.ts +24 -0
  129. package/src/progress/achievement-badge.tsx +52 -0
  130. package/src/progress/activity-timeline.tsx +84 -0
  131. package/src/progress/index.ts +7 -0
  132. package/src/progress/stat-card.tsx +30 -18
  133. package/src/progress/streak-badge.tsx +35 -0
  134. package/src/progress/types.ts +101 -0
  135. package/src/questions/choice.tsx +7 -9
  136. package/src/questions/essay.tsx +23 -25
  137. package/src/questions/fill-in-the-blank.tsx +13 -16
  138. package/src/questions/hotspot.tsx +154 -0
  139. package/src/questions/index.ts +16 -0
  140. package/src/questions/inline-choice.tsx +151 -0
  141. package/src/questions/matching.tsx +228 -0
  142. package/src/questions/multiple-choice.tsx +7 -9
  143. package/src/questions/numeric.tsx +102 -0
  144. package/src/questions/ordering.tsx +159 -0
  145. package/src/questions/question-renderer.tsx +21 -0
  146. package/src/questions/scenario.tsx +140 -0
  147. package/src/questions/scoring.ts +201 -0
  148. package/src/questions/spreadsheet.tsx +259 -0
  149. package/src/questions/true-false.tsx +7 -9
  150. package/src/questions/types.ts +123 -1
  151. package/src/questions/use-drag-reorder.ts +80 -0
  152. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +2 -15
  153. package/src/sections/AssessmentReview/AssessmentReview.tsx +13 -2
  154. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +7 -5
  155. package/src/sections/CertificateViewer/CertificateViewer.tsx +409 -56
  156. package/src/sections/CertificateViewer/types.ts +13 -5
  157. package/src/sections/CourseOutline/CourseOutline.tsx +4 -14
  158. package/src/sections/DiscussionThread/DiscussionThread.tsx +13 -10
  159. package/src/sections/ExamSession/ExamSession.tsx +44 -7
  160. package/src/sections/ExamSession/types.ts +6 -1
  161. package/src/sections/ForumBoard/ForumBoard.tsx +284 -0
  162. package/src/sections/ForumBoard/types.ts +67 -0
  163. package/src/sections/GradebookTable/GradebookTable.tsx +1 -1
  164. package/src/sections/LecturePlayer/LecturePlayer.tsx +1 -1
  165. package/src/sections/LessonPage/LessonPage.tsx +5 -9
  166. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +15 -26
  167. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +65 -65
  168. package/src/sections/QuizSession/QuizSession.tsx +67 -8
  169. package/src/sections/QuizSession/types.ts +6 -1
  170. package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +107 -0
  171. package/src/sections/RequirementsChecklist/types.ts +38 -0
  172. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +4 -9
  173. package/src/sections/RubricView/RubricView.tsx +138 -0
  174. package/src/sections/RubricView/types.ts +52 -0
  175. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +23 -9
  176. package/src/sections/SurveyForm/SurveyForm.tsx +8 -5
  177. package/src/sections/index.ts +20 -1
  178. package/src/social/post-card.tsx +8 -19
  179. package/src/social/user-avatar.tsx +1 -0
  180. package/src/styles/globals.css +13 -0
  181. package/src/ui/drawer.tsx +600 -0
  182. package/src/ui/index.ts +19 -0
  183. package/src/ui/rich-text-editor.tsx +109 -0
  184. package/src/ui/rich-text-toolbar.tsx +156 -0
  185. package/src/utils/array-utils.ts +17 -0
  186. package/src/utils/flatten-leaves.ts +17 -0
  187. package/src/utils/format-file-size.ts +5 -0
  188. package/src/utils/format-timestamp.ts +13 -0
  189. package/src/utils/is-empty-html.ts +7 -0
  190. package/src/utils/shuffle.ts +8 -0
  191. package/src/utils/string-utils.ts +30 -0
  192. package/src/video/video-bookmark.tsx +4 -3
  193. package/src/video/video-chapter-list.tsx +9 -4
  194. package/src/video/video-player.tsx +11 -4
  195. package/src/video/video-playlist-item.tsx +8 -3
  196. package/src/video/video-thumbnail-card.tsx +4 -0
  197. package/src/video/video-transcript.tsx +8 -5
  198. package/dist/table-BrS5cDQu.js +0 -2510
  199. package/dist/table-D6AkBBEo.cjs +0 -1
@@ -0,0 +1,151 @@
1
+ import { useState, useMemo } from "react";
2
+ import { Alert, AlertDescription } from "../ui/alert";
3
+ import { cn } from "../lib/utils";
4
+ import type { QuestionProps, InlineBlank } from "./types";
5
+
6
+ type ContentPart =
7
+ | { type: "text"; html: string }
8
+ | { type: "blank"; uid: string };
9
+
10
+ function parseInlineContent(html: string): ContentPart[] {
11
+ const parts: ContentPart[] = [];
12
+ const regex = /\{\{blank:([\w-]+)\}\}/g;
13
+ let lastIndex = 0;
14
+ let match;
15
+ while ((match = regex.exec(html)) !== null) {
16
+ if (match.index > lastIndex) {
17
+ parts.push({ type: "text", html: html.slice(lastIndex, match.index) });
18
+ }
19
+ parts.push({ type: "blank", uid: match[1] });
20
+ lastIndex = regex.lastIndex;
21
+ }
22
+ if (lastIndex < html.length) {
23
+ parts.push({ type: "text", html: html.slice(lastIndex) });
24
+ }
25
+ return parts;
26
+ }
27
+
28
+ /**
29
+ * InlineChoice renders a cloze-style passage with embedded dropdown blanks.
30
+ *
31
+ * Content should contain `{{blank:uid}}` markers that correspond to entries in `question.inlineBlanks`.
32
+ *
33
+ * @example
34
+ * <InlineChoice
35
+ * question={{
36
+ * uid: "q1",
37
+ * type: "inline_choice",
38
+ * content: "The capital of France is {{blank:b1}}.",
39
+ * inlineBlanks: [{ uid: "b1", sequence: 0, options: [
40
+ * { uid: "o1", content: "Paris", isCorrect: true },
41
+ * { uid: "o2", content: "London" },
42
+ * ]}],
43
+ * }}
44
+ * onAnswer={(answers) => handleAnswer(answers)}
45
+ * />
46
+ */
47
+ export const InlineChoice = ({
48
+ question,
49
+ sessionAnswers,
50
+ onAnswer,
51
+ readOnly = false,
52
+ showCorrectAnswers = false,
53
+ disabled = false,
54
+ }: QuestionProps) => {
55
+ const blanksMap = useMemo(() => {
56
+ const map = new Map<string, InlineBlank>();
57
+ for (const blank of question.inlineBlanks ?? []) {
58
+ map.set(blank.uid, blank);
59
+ }
60
+ return map;
61
+ }, [question.inlineBlanks]);
62
+
63
+ const [selections, setSelections] = useState<Map<string, string>>(() => {
64
+ const map = new Map<string, string>();
65
+ for (const sa of sessionAnswers ?? []) {
66
+ if (sa.content) map.set(sa.answerUid, sa.content);
67
+ }
68
+ return map;
69
+ });
70
+
71
+ const parts = useMemo(
72
+ () => parseInlineContent(question.content),
73
+ [question.content],
74
+ );
75
+
76
+ const handleSelect = (blankUid: string, optionUid: string) => {
77
+ if (readOnly || disabled) return;
78
+
79
+ const next = new Map(selections);
80
+ next.set(blankUid, optionUid);
81
+ setSelections(next);
82
+
83
+ const answers = [...next.entries()].map(([uid, content]) => ({
84
+ uid,
85
+ content,
86
+ }));
87
+ onAnswer?.(answers);
88
+ };
89
+
90
+ const getSelectClasses = (blankUid: string) => {
91
+ if (!showCorrectAnswers) return "";
92
+ const blank = blanksMap.get(blankUid);
93
+ const selected = selections.get(blankUid);
94
+ if (!blank || !selected) return "";
95
+ const correctOption = blank.options.find((o) => o.isCorrect);
96
+ if (!correctOption) return "";
97
+ return selected === correctOption.uid
98
+ ? "border-success/50 bg-success/5 text-success"
99
+ : "border-destructive/50 bg-destructive/5 text-destructive";
100
+ };
101
+
102
+ return (
103
+ <div className="flex flex-col gap-4">
104
+ <div className="leading-relaxed text-foreground">
105
+ {parts.map((part, i) => {
106
+ if (part.type === "text") {
107
+ return (
108
+ <span
109
+ key={i}
110
+ dangerouslySetInnerHTML={{ __html: part.html }}
111
+ />
112
+ );
113
+ }
114
+
115
+ const blank = blanksMap.get(part.uid);
116
+ if (!blank) return null;
117
+
118
+ return (
119
+ <select
120
+ key={part.uid}
121
+ value={selections.get(part.uid) || ""}
122
+ onChange={(e) => handleSelect(part.uid, e.target.value)}
123
+ disabled={readOnly || disabled}
124
+ className={cn(
125
+ "inline-block mx-1 px-2 py-0.5 rounded border border-border bg-background text-foreground text-sm align-baseline",
126
+ "disabled:opacity-60 disabled:cursor-default",
127
+ getSelectClasses(part.uid),
128
+ )}
129
+ >
130
+ <option value="">Select...</option>
131
+ {blank.options.map((opt) => (
132
+ <option key={opt.uid} value={opt.uid}>
133
+ {opt.content}
134
+ </option>
135
+ ))}
136
+ </select>
137
+ );
138
+ })}
139
+ </div>
140
+
141
+ {showCorrectAnswers && question.explanation && (
142
+ <Alert className="mt-2">
143
+ <AlertDescription>
144
+ <strong>Explanation:</strong>{" "}
145
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
146
+ </AlertDescription>
147
+ </Alert>
148
+ )}
149
+ </div>
150
+ );
151
+ };
@@ -0,0 +1,228 @@
1
+ import { useState, useMemo, useCallback } from "react";
2
+ import { Alert, AlertDescription } from "../ui/alert";
3
+ import { cn } from "../lib/utils";
4
+ import type { QuestionProps } from "./types";
5
+
6
+ /**
7
+ * Matching renders a two-column matching question with dropdown selects and optional drag-and-drop.
8
+ *
9
+ * Each item (left column) must be matched to its correct target (right column).
10
+ * The dropdown is the primary accessible interaction; drag-and-drop is progressive enhancement.
11
+ *
12
+ * @example
13
+ * <Matching
14
+ * question={{
15
+ * uid: "q1",
16
+ * type: "matching",
17
+ * content: "Match each country to its capital.",
18
+ * matchingPairs: [
19
+ * { uid: "p1", item: "France", target: "Paris", sequence: 0 },
20
+ * { uid: "p2", item: "Germany", target: "Berlin", sequence: 1 },
21
+ * ],
22
+ * }}
23
+ * onAnswer={(answers) => handleAnswer(answers)}
24
+ * />
25
+ */
26
+ export const Matching = ({
27
+ question,
28
+ sessionAnswers,
29
+ onAnswer,
30
+ readOnly = false,
31
+ showCorrectAnswers = false,
32
+ disabled = false,
33
+ }: QuestionProps) => {
34
+ const sortedPairs = useMemo(
35
+ () =>
36
+ [...(question.matchingPairs ?? [])].sort(
37
+ (a, b) => a.sequence - b.sequence,
38
+ ),
39
+ [question.matchingPairs],
40
+ );
41
+
42
+ // Shuffled targets for display
43
+ const shuffledTargets = useMemo(
44
+ () => [...sortedPairs].sort(() => Math.random() - 0.5),
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ [sortedPairs.map((p) => p.uid).join()],
47
+ );
48
+
49
+ // Map: item pair uid → selected target pair uid
50
+ const [matches, setMatches] = useState<Map<string, string>>(() => {
51
+ const map = new Map<string, string>();
52
+ for (const sa of sessionAnswers ?? []) {
53
+ if (sa.content) map.set(sa.answerUid, sa.content);
54
+ }
55
+ return map;
56
+ });
57
+
58
+ const [dragItemUid, setDragItemUid] = useState<string | null>(null);
59
+ const [dragOverTargetUid, setDragOverTargetUid] = useState<string | null>(
60
+ null,
61
+ );
62
+
63
+ const emitAnswer = useCallback(
64
+ (next: Map<string, string>) => {
65
+ onAnswer?.(
66
+ [...next.entries()].map(([uid, content]) => ({ uid, content })),
67
+ );
68
+ },
69
+ [onAnswer],
70
+ );
71
+
72
+ const handleSelect = (itemUid: string, targetUid: string) => {
73
+ if (readOnly || disabled) return;
74
+ const next = new Map(matches);
75
+ if (targetUid) {
76
+ next.set(itemUid, targetUid);
77
+ } else {
78
+ next.delete(itemUid);
79
+ }
80
+ setMatches(next);
81
+ emitAnswer(next);
82
+ };
83
+
84
+ // Drag-and-drop handlers (progressive enhancement)
85
+ const handleDragStartItem = (pairUid: string) => (e: React.DragEvent) => {
86
+ if (readOnly || disabled) {
87
+ e.preventDefault();
88
+ return;
89
+ }
90
+ setDragItemUid(pairUid);
91
+ e.dataTransfer.effectAllowed = "link";
92
+ e.dataTransfer.setData("text/plain", pairUid);
93
+ };
94
+
95
+ const handleDragOverTarget = (targetUid: string) => (e: React.DragEvent) => {
96
+ e.preventDefault();
97
+ e.dataTransfer.dropEffect = "link";
98
+ setDragOverTargetUid(targetUid);
99
+ };
100
+
101
+ const handleDropTarget = (targetUid: string) => (e: React.DragEvent) => {
102
+ e.preventDefault();
103
+ if (dragItemUid) {
104
+ handleSelect(dragItemUid, targetUid);
105
+ }
106
+ setDragItemUid(null);
107
+ setDragOverTargetUid(null);
108
+ };
109
+
110
+ const handleDragEnd = () => {
111
+ setDragItemUid(null);
112
+ setDragOverTargetUid(null);
113
+ };
114
+
115
+ const getMatchClasses = (pairUid: string) => {
116
+ if (!showCorrectAnswers) return "";
117
+ const selected = matches.get(pairUid);
118
+ if (!selected) return "";
119
+ return selected === pairUid
120
+ ? "bg-success/10 border-success/30"
121
+ : "bg-destructive/10 border-destructive/30";
122
+ };
123
+
124
+ // Find which item is matched to a given target
125
+ const getTargetMatchedBy = (targetUid: string): string | undefined => {
126
+ for (const [itemUid, matchedTarget] of matches) {
127
+ if (matchedTarget === targetUid) return itemUid;
128
+ }
129
+ return undefined;
130
+ };
131
+
132
+ return (
133
+ <div className="flex flex-col gap-4">
134
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
135
+
136
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
137
+ {/* Left column: items */}
138
+ <div className="flex flex-col gap-2">
139
+ <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">
140
+ Items
141
+ </div>
142
+ {sortedPairs.map((pair) => (
143
+ <div
144
+ key={pair.uid}
145
+ draggable={!(readOnly || disabled)}
146
+ onDragStart={handleDragStartItem(pair.uid)}
147
+ onDragEnd={handleDragEnd}
148
+ className={cn(
149
+ "rounded-md border px-3 py-2 transition-colors",
150
+ dragItemUid === pair.uid && "opacity-50",
151
+ !(readOnly || disabled) && "cursor-grab active:cursor-grabbing",
152
+ getMatchClasses(pair.uid),
153
+ )}
154
+ >
155
+ <div className="flex items-center gap-2">
156
+ <span
157
+ className="flex-1 text-foreground"
158
+ dangerouslySetInnerHTML={{ __html: pair.item }}
159
+ />
160
+ <select
161
+ value={matches.get(pair.uid) || ""}
162
+ onChange={(e) => handleSelect(pair.uid, e.target.value)}
163
+ disabled={readOnly || disabled}
164
+ className={cn(
165
+ "px-2 py-1 rounded border border-border bg-background text-foreground text-sm",
166
+ "disabled:opacity-60 disabled:cursor-default",
167
+ )}
168
+ >
169
+ <option value="">—</option>
170
+ {shuffledTargets.map((target) => (
171
+ <option key={target.uid} value={target.uid}>
172
+ {target.target.replace(/<[^>]+>/g, "")}
173
+ </option>
174
+ ))}
175
+ </select>
176
+ </div>
177
+ </div>
178
+ ))}
179
+ </div>
180
+
181
+ {/* Right column: targets (drop zones) */}
182
+ <div className="flex flex-col gap-2">
183
+ <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">
184
+ Targets
185
+ </div>
186
+ {shuffledTargets.map((pair) => {
187
+ const matchedBy = getTargetMatchedBy(pair.uid);
188
+ const matchedPair = matchedBy
189
+ ? sortedPairs.find((p) => p.uid === matchedBy)
190
+ : null;
191
+
192
+ return (
193
+ <div
194
+ key={pair.uid}
195
+ onDragOver={handleDragOverTarget(pair.uid)}
196
+ onDrop={handleDropTarget(pair.uid)}
197
+ className={cn(
198
+ "rounded-md border border-dashed px-3 py-2 transition-colors min-h-10",
199
+ dragOverTargetUid === pair.uid && "ring-2 ring-primary",
200
+ matchedBy && "border-solid",
201
+ )}
202
+ >
203
+ <span
204
+ className="text-foreground"
205
+ dangerouslySetInnerHTML={{ __html: pair.target }}
206
+ />
207
+ {matchedPair && (
208
+ <div className="mt-1 text-xs text-muted-foreground">
209
+ ← matched
210
+ </div>
211
+ )}
212
+ </div>
213
+ );
214
+ })}
215
+ </div>
216
+ </div>
217
+
218
+ {showCorrectAnswers && question.explanation && (
219
+ <Alert className="mt-2">
220
+ <AlertDescription>
221
+ <strong>Explanation:</strong>{" "}
222
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
223
+ </AlertDescription>
224
+ </Alert>
225
+ )}
226
+ </div>
227
+ );
228
+ };
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useMemo } from "react";
2
2
  import type { QuestionProps } from "./types";
3
3
  import { Alert, AlertDescription } from "../ui/alert";
4
4
  import { cn } from "../lib/utils";
@@ -20,10 +20,13 @@ export const MultipleChoice = ({
20
20
  showCorrectAnswers = false,
21
21
  disabled = false,
22
22
  }: QuestionProps) => {
23
- const [selectedAnswers, setSelectedAnswers] = useState<string[]>([]);
23
+ const [selectedAnswers, setSelectedAnswers] = useState<string[]>(
24
+ () => sessionAnswers?.map((sa) => sa.answerUid) || [],
25
+ );
24
26
 
25
- const sortedAnswers = [...(question.answers || [])].sort(
26
- (a, b) => a.sequence - b.sequence,
27
+ const sortedAnswers = useMemo(
28
+ () => [...(question.answers || [])].sort((a, b) => a.sequence - b.sequence),
29
+ [question.answers],
27
30
  );
28
31
 
29
32
  const handleChange = (uid: string) => {
@@ -39,11 +42,6 @@ export const MultipleChoice = ({
39
42
  });
40
43
  };
41
44
 
42
- useEffect(() => {
43
- const current = sessionAnswers?.map((sa) => sa.answerUid) || [];
44
- setSelectedAnswers(current);
45
- }, [sessionAnswers]);
46
-
47
45
  const getAnswerClasses = (answerUid: string) => {
48
46
  if (!showCorrectAnswers) return "";
49
47
 
@@ -0,0 +1,102 @@
1
+ import { useState, useMemo, useRef } from "react";
2
+ import { debounce } from "../utils/debounce";
3
+ import { Input } from "../ui/input";
4
+ import { Alert, AlertDescription } from "../ui/alert";
5
+ import { cn } from "../lib/utils";
6
+ import type { QuestionProps } from "./types";
7
+
8
+ /**
9
+ * Numeric renders a number input question with optional tolerance and unit display.
10
+ *
11
+ * @example
12
+ * <Numeric
13
+ * question={{ uid: "q1", type: "numeric", content: "What is pi to 2 decimal places?", numericAnswer: 3.14, numericTolerance: 0.01 }}
14
+ * onAnswer={(answers) => handleAnswer(answers)}
15
+ * />
16
+ */
17
+ export const Numeric = ({
18
+ question,
19
+ sessionAnswers,
20
+ onAnswer,
21
+ readOnly = false,
22
+ showCorrectAnswers = false,
23
+ disabled = false,
24
+ }: QuestionProps) => {
25
+ const [value, setValue] = useState(() => sessionAnswers?.[0]?.content || "");
26
+
27
+ const onAnswerRef = useRef(onAnswer);
28
+ onAnswerRef.current = onAnswer;
29
+ const questionUidRef = useRef(question.uid);
30
+ questionUidRef.current = question.uid;
31
+
32
+ const debouncedAnswer = useMemo(
33
+ () =>
34
+ debounce((content: string) => {
35
+ onAnswerRef.current?.([{ uid: questionUidRef.current, content }]);
36
+ }, 300),
37
+ [],
38
+ );
39
+
40
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
41
+ const newValue = e.target.value;
42
+ setValue(newValue);
43
+ debouncedAnswer(newValue);
44
+ };
45
+
46
+ const isCorrect = (() => {
47
+ if (!showCorrectAnswers || question.numericAnswer === undefined) return null;
48
+ if (value === "") return false;
49
+ const userNum = parseFloat(value);
50
+ if (isNaN(userNum)) return false;
51
+ const tolerance = question.numericTolerance ?? 0;
52
+ return Math.abs(userNum - question.numericAnswer) <= tolerance;
53
+ })();
54
+
55
+ const inputClasses = (() => {
56
+ if (!showCorrectAnswers || isCorrect === null) return "";
57
+ return isCorrect
58
+ ? "border-success/50 bg-success/5"
59
+ : "border-destructive/50 bg-destructive/5";
60
+ })();
61
+
62
+ return (
63
+ <div className="flex flex-col gap-2">
64
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
65
+
66
+ <div className="flex items-center gap-2">
67
+ <Input
68
+ type="number"
69
+ value={value}
70
+ onChange={handleChange}
71
+ placeholder="Enter a number..."
72
+ disabled={readOnly || disabled}
73
+ className={cn("max-w-48", inputClasses)}
74
+ step="any"
75
+ />
76
+ {question.numericUnit && (
77
+ <span className="text-muted-foreground text-sm">
78
+ {question.numericUnit}
79
+ </span>
80
+ )}
81
+ </div>
82
+
83
+ {showCorrectAnswers && question.numericAnswer !== undefined && (
84
+ <p className="text-sm text-muted-foreground">
85
+ Correct answer: {question.numericAnswer}
86
+ {(question.numericTolerance ?? 0) > 0 && (
87
+ <span> (± {question.numericTolerance})</span>
88
+ )}
89
+ </p>
90
+ )}
91
+
92
+ {showCorrectAnswers && question.explanation && (
93
+ <Alert className="mt-1">
94
+ <AlertDescription>
95
+ <strong>Explanation:</strong>{" "}
96
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
97
+ </AlertDescription>
98
+ </Alert>
99
+ )}
100
+ </div>
101
+ );
102
+ };
@@ -0,0 +1,159 @@
1
+ import { useState, useMemo } from "react";
2
+ import { ChevronUp, ChevronDown, GripVertical } from "lucide-react";
3
+ import { Alert, AlertDescription } from "../ui/alert";
4
+ import { cn } from "../lib/utils";
5
+ import { useDragReorder } from "./use-drag-reorder";
6
+ import type { QuestionProps, AnswerOption } from "./types";
7
+
8
+ /**
9
+ * Ordering renders a drag-and-drop reorderable list of items.
10
+ * The correct order is determined by each answer's `sequence` field.
11
+ *
12
+ * @example
13
+ * <Ordering
14
+ * question={question}
15
+ * onAnswer={(answers) => handleAnswer(answers)}
16
+ * />
17
+ */
18
+ export const Ordering = ({
19
+ question,
20
+ sessionAnswers,
21
+ onAnswer,
22
+ readOnly = false,
23
+ showCorrectAnswers = false,
24
+ disabled = false,
25
+ }: QuestionProps) => {
26
+ const correctOrder = useMemo(
27
+ () =>
28
+ [...(question.answers || [])].sort((a, b) => a.sequence - b.sequence),
29
+ [question.answers],
30
+ );
31
+
32
+ const [items, setItems] = useState<AnswerOption[]>(() => {
33
+ if (sessionAnswers && sessionAnswers.length > 0) {
34
+ // Rebuild order from sessionAnswers position indices
35
+ const posMap = new Map<string, number>();
36
+ for (const sa of sessionAnswers) {
37
+ posMap.set(sa.answerUid, parseInt(sa.content || "0", 10));
38
+ }
39
+ return [...correctOrder].sort(
40
+ (a, b) => (posMap.get(a.uid) ?? 0) - (posMap.get(b.uid) ?? 0),
41
+ );
42
+ }
43
+ // Shuffle for initial display so correct order isn't given away
44
+ return [...correctOrder].sort(() => Math.random() - 0.5);
45
+ });
46
+
47
+ const emitAnswer = (orderedItems: AnswerOption[]) => {
48
+ onAnswer?.(
49
+ orderedItems.map((item, index) => ({
50
+ uid: item.uid,
51
+ content: String(index),
52
+ })),
53
+ );
54
+ };
55
+
56
+ const handleReorder = (next: AnswerOption[]) => {
57
+ setItems(next);
58
+ emitAnswer(next);
59
+ };
60
+
61
+ const moveItem = (fromIndex: number, toIndex: number) => {
62
+ if (readOnly || disabled) return;
63
+ if (toIndex < 0 || toIndex >= items.length) return;
64
+ const next = [...items];
65
+ const [moved] = next.splice(fromIndex, 1);
66
+ next.splice(toIndex, 0, moved);
67
+ setItems(next);
68
+ emitAnswer(next);
69
+ };
70
+
71
+ const { getDragProps, dragIndex, dragOverIndex } = useDragReorder({
72
+ items,
73
+ onReorder: handleReorder,
74
+ disabled: readOnly || disabled,
75
+ });
76
+
77
+ const getItemClasses = (item: AnswerOption, index: number) => {
78
+ if (!showCorrectAnswers) return "";
79
+ const correctIndex = correctOrder.findIndex((a) => a.uid === item.uid);
80
+ return correctIndex === index
81
+ ? "bg-success/10 border-success/30"
82
+ : "bg-destructive/10 border-destructive/30";
83
+ };
84
+
85
+ return (
86
+ <div className="flex flex-col gap-4">
87
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
88
+
89
+ <div className="flex flex-col gap-1.5">
90
+ {items.map((item, index) => (
91
+ <div
92
+ key={item.uid}
93
+ {...getDragProps(index)}
94
+ className={cn(
95
+ "flex items-center gap-2 rounded-md border px-3 py-2 transition-all select-none",
96
+ dragIndex === index && "opacity-50",
97
+ dragOverIndex === index &&
98
+ dragIndex !== index &&
99
+ "border-t-2 border-t-primary",
100
+ !(readOnly || disabled) && "cursor-grab active:cursor-grabbing",
101
+ getItemClasses(item, index),
102
+ )}
103
+ >
104
+ {!(readOnly || disabled) && (
105
+ <GripVertical className="size-4 text-muted-foreground shrink-0" />
106
+ )}
107
+
108
+ <span className="text-sm font-medium text-muted-foreground w-6 shrink-0">
109
+ {index + 1}.
110
+ </span>
111
+
112
+ <span
113
+ className="flex-1 text-foreground"
114
+ dangerouslySetInnerHTML={{ __html: item.content }}
115
+ />
116
+
117
+ {!(readOnly || disabled) && (
118
+ <div className="flex flex-col shrink-0">
119
+ <button
120
+ type="button"
121
+ onClick={() => moveItem(index, index - 1)}
122
+ disabled={index === 0}
123
+ className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default"
124
+ aria-label={`Move ${item.content} up`}
125
+ >
126
+ <ChevronUp className="size-4" />
127
+ </button>
128
+ <button
129
+ type="button"
130
+ onClick={() => moveItem(index, index + 1)}
131
+ disabled={index === items.length - 1}
132
+ className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default"
133
+ aria-label={`Move ${item.content} down`}
134
+ >
135
+ <ChevronDown className="size-4" />
136
+ </button>
137
+ </div>
138
+ )}
139
+
140
+ {showCorrectAnswers && (
141
+ <span className="text-xs text-muted-foreground shrink-0">
142
+ (correct: {correctOrder.findIndex((a) => a.uid === item.uid) + 1})
143
+ </span>
144
+ )}
145
+ </div>
146
+ ))}
147
+ </div>
148
+
149
+ {showCorrectAnswers && question.explanation && (
150
+ <Alert className="mt-2">
151
+ <AlertDescription>
152
+ <strong>Explanation:</strong>{" "}
153
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
154
+ </AlertDescription>
155
+ </Alert>
156
+ )}
157
+ </div>
158
+ );
159
+ };