@hydralms/components 0.1.3 → 0.3.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 (344) hide show
  1. package/dist/StudentProfile-BVfZMbnV.cjs +1 -0
  2. package/dist/StudentProfile-DeMxdrL3.js +3275 -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/timer-display.d.ts +1 -1
  9. package/dist/assessment-toolbar/types.d.ts +52 -4
  10. package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
  11. package/dist/common/index.d.ts +3 -1
  12. package/dist/common/pagination.d.ts +26 -0
  13. package/dist/common/stepper.d.ts +6 -0
  14. package/dist/common/types.d.ts +38 -0
  15. package/dist/components.css +1 -1
  16. package/dist/content/attachment-list.d.ts +6 -0
  17. package/dist/content/audio-player.d.ts +22 -0
  18. package/dist/content/code-block.d.ts +30 -0
  19. package/dist/content/content-block.d.ts +1 -1
  20. package/dist/content/embed-block.d.ts +28 -0
  21. package/dist/content/index.d.ts +8 -1
  22. package/dist/content/types.d.ts +63 -0
  23. package/dist/curriculum/course-card.d.ts +51 -0
  24. package/dist/curriculum/curriculum-item.d.ts +1 -1
  25. package/dist/curriculum/index.d.ts +2 -0
  26. package/dist/curriculum/types.d.ts +2 -2
  27. package/dist/index.cjs +1 -1
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.js +597 -308
  30. package/dist/license/HydraContext.d.ts +16 -0
  31. package/dist/license/ProBadge.d.ts +6 -0
  32. package/dist/license/index.d.ts +7 -0
  33. package/dist/license/tiers.d.ts +3 -0
  34. package/dist/license/useHydraLicense.d.ts +6 -0
  35. package/dist/license/validate.d.ts +13 -0
  36. package/dist/license/withProGate.d.ts +6 -0
  37. package/dist/modules/AssignmentModule/AssignmentModule.d.ts +5 -0
  38. package/dist/modules/AssignmentModule/types.d.ts +69 -0
  39. package/dist/modules/CertificateModule/CertificateModule.d.ts +5 -0
  40. package/dist/modules/CertificateModule/types.d.ts +51 -0
  41. package/dist/modules/CourseCatalogModule/CourseCatalogModule.d.ts +5 -0
  42. package/dist/modules/CourseCatalogModule/types.d.ts +43 -0
  43. package/dist/modules/CoursePlayer/CoursePlayer.d.ts +4 -1
  44. package/dist/modules/DiscussionModule/DiscussionModule.d.ts +5 -0
  45. package/dist/modules/DiscussionModule/types.d.ts +47 -0
  46. package/dist/modules/ExamModule/ExamModule.d.ts +5 -0
  47. package/dist/modules/ExamModule/types.d.ts +55 -0
  48. package/dist/modules/FlashcardLab/FlashcardLab.d.ts +4 -1
  49. package/dist/modules/FlashcardLab/types.d.ts +2 -0
  50. package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +5 -0
  51. package/dist/modules/GradeCenterModule/types.d.ts +56 -0
  52. package/dist/modules/QuizModule/QuizModule.d.ts +4 -1
  53. package/dist/modules/QuizModule/types.d.ts +10 -14
  54. package/dist/modules/StudentDashboardModule/StudentDashboardModule.d.ts +5 -0
  55. package/dist/modules/StudentDashboardModule/types.d.ts +54 -0
  56. package/dist/modules/StudentProfileModule/StudentProfileModule.d.ts +5 -0
  57. package/dist/modules/StudentProfileModule/types.d.ts +43 -0
  58. package/dist/modules/SurveyModule/SurveyModule.d.ts +5 -0
  59. package/dist/modules/SurveyModule/types.d.ts +51 -0
  60. package/dist/modules/_shared/assessment-intro.d.ts +16 -0
  61. package/dist/modules/_shared/assessment-results.d.ts +23 -0
  62. package/dist/modules/_shared/types.d.ts +10 -0
  63. package/dist/modules/_shared/use-timer.d.ts +9 -0
  64. package/dist/modules/index.d.ts +18 -0
  65. package/dist/modules.cjs +1 -0
  66. package/dist/modules.js +1834 -0
  67. package/dist/progress/achievement-badge.d.ts +6 -0
  68. package/dist/progress/activity-timeline.d.ts +6 -0
  69. package/dist/progress/index.d.ts +4 -1
  70. package/dist/progress/stat-card.d.ts +1 -1
  71. package/dist/progress/streak-badge.d.ts +6 -0
  72. package/dist/progress/types.d.ts +99 -0
  73. package/dist/provider/HydraProvider.d.ts +5 -1
  74. package/dist/questions/choice.d.ts +1 -1
  75. package/dist/questions/confidence-indicator.d.ts +37 -0
  76. package/dist/questions/essay.d.ts +2 -2
  77. package/dist/questions/fill-in-the-blank.d.ts +1 -1
  78. package/dist/questions/hotspot.d.ts +21 -0
  79. package/dist/questions/index.d.ts +11 -1
  80. package/dist/questions/inline-choice.d.ts +21 -0
  81. package/dist/questions/matching.d.ts +22 -0
  82. package/dist/questions/multiple-choice.d.ts +1 -1
  83. package/dist/questions/numeric.d.ts +11 -0
  84. package/dist/questions/ordering.d.ts +12 -0
  85. package/dist/questions/question-renderer.d.ts +1 -1
  86. package/dist/questions/scenario.d.ts +23 -0
  87. package/dist/questions/scoring.d.ts +22 -0
  88. package/dist/questions/spreadsheet.d.ts +29 -0
  89. package/dist/questions/true-false.d.ts +1 -1
  90. package/dist/questions/types.d.ts +106 -1
  91. package/dist/questions/use-drag-reorder.d.ts +17 -0
  92. package/dist/sections/AnnouncementFeed/AnnouncementFeed.d.ts +1 -1
  93. package/dist/sections/AnnouncementFeed/types.d.ts +15 -1
  94. package/dist/sections/AssessmentReview/AssessmentReview.d.ts +1 -1
  95. package/dist/sections/AssessmentReview/types.d.ts +6 -0
  96. package/dist/sections/AssignmentSubmission/AssignmentSubmission.d.ts +1 -1
  97. package/dist/sections/AssignmentSubmission/types.d.ts +6 -0
  98. package/dist/sections/CertificateViewer/CertificateViewer.d.ts +1 -1
  99. package/dist/sections/CertificateViewer/certificate-variants.d.ts +42 -0
  100. package/dist/sections/CertificateViewer/types.d.ts +13 -5
  101. package/dist/sections/CourseCatalog/CourseCatalog.d.ts +2 -0
  102. package/dist/sections/CourseCatalog/types.d.ts +80 -0
  103. package/dist/sections/CourseOutline/CourseOutline.d.ts +1 -1
  104. package/dist/sections/CourseOutline/types.d.ts +6 -0
  105. package/dist/sections/DiscussionThread/DiscussionThread.d.ts +1 -1
  106. package/dist/sections/DiscussionThread/types.d.ts +6 -0
  107. package/dist/sections/EnrollmentWizard/EnrollmentWizard.d.ts +2 -0
  108. package/dist/sections/EnrollmentWizard/types.d.ts +66 -0
  109. package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
  110. package/dist/sections/ExamSession/types.d.ts +12 -1
  111. package/dist/sections/FlashcardStudySession/FlashcardStudySession.d.ts +1 -1
  112. package/dist/sections/FlashcardStudySession/types.d.ts +6 -0
  113. package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
  114. package/dist/sections/ForumBoard/types.d.ts +78 -0
  115. package/dist/sections/GradebookTable/GradebookTable.d.ts +1 -1
  116. package/dist/sections/GradebookTable/types.d.ts +14 -0
  117. package/dist/sections/LecturePlayer/LecturePlayer.d.ts +1 -1
  118. package/dist/sections/LecturePlayer/types.d.ts +8 -0
  119. package/dist/sections/LessonPage/LessonPage.d.ts +1 -1
  120. package/dist/sections/LessonPage/types.d.ts +6 -0
  121. package/dist/sections/PracticeQuiz/PracticeQuiz.d.ts +1 -1
  122. package/dist/sections/PracticeQuiz/types.d.ts +6 -0
  123. package/dist/sections/ProgressDashboard/ProgressDashboard.d.ts +1 -1
  124. package/dist/sections/ProgressDashboard/types.d.ts +6 -0
  125. package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
  126. package/dist/sections/QuizSession/types.d.ts +12 -1
  127. package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
  128. package/dist/sections/RequirementsChecklist/types.d.ts +43 -0
  129. package/dist/sections/ResourceLibrary/ResourceLibrary.d.ts +1 -1
  130. package/dist/sections/ResourceLibrary/types.d.ts +15 -1
  131. package/dist/sections/RubricView/RubricView.d.ts +9 -0
  132. package/dist/sections/RubricView/types.d.ts +56 -0
  133. package/dist/sections/ScrollableQuiz/ScrollableQuiz.d.ts +1 -1
  134. package/dist/sections/ScrollableQuiz/types.d.ts +6 -0
  135. package/dist/sections/StudentProfile/StudentProfile.d.ts +2 -0
  136. package/dist/sections/StudentProfile/types.d.ts +98 -0
  137. package/dist/sections/SurveyForm/SurveyForm.d.ts +1 -1
  138. package/dist/sections/SurveyForm/types.d.ts +6 -0
  139. package/dist/sections/_shared/merge-answers.d.ts +9 -0
  140. package/dist/sections/_shared/section-shell.d.ts +20 -0
  141. package/dist/sections/_shared/use-assessment-session.d.ts +30 -0
  142. package/dist/sections/index.d.ts +13 -1
  143. package/dist/sections.cjs +1 -1
  144. package/dist/sections.js +282 -1786
  145. package/dist/social/post-card.d.ts +1 -1
  146. package/dist/tabs-BsfVo2Bl.cjs +173 -0
  147. package/dist/tabs-BuY1iNJE.js +22305 -0
  148. package/dist/ui/alert.d.ts +1 -1
  149. package/dist/ui/badge.d.ts +1 -1
  150. package/dist/ui/button.d.ts +1 -1
  151. package/dist/ui/drawer.d.ts +84 -0
  152. package/dist/ui/index.d.ts +5 -0
  153. package/dist/ui/progress.d.ts +1 -1
  154. package/dist/ui/rich-text-editor.d.ts +32 -0
  155. package/dist/ui/rich-text-toolbar.d.ts +8 -0
  156. package/dist/ui/toast.d.ts +43 -0
  157. package/dist/utils/array-utils.d.ts +4 -0
  158. package/dist/utils/debounce.d.ts +5 -1
  159. package/dist/utils/flatten-leaves.d.ts +6 -0
  160. package/dist/utils/format-file-size.d.ts +1 -0
  161. package/dist/utils/format-timestamp.d.ts +1 -0
  162. package/dist/utils/is-empty-html.d.ts +5 -0
  163. package/dist/utils/pick-palette-color.d.ts +19 -0
  164. package/dist/utils/shuffle.d.ts +1 -0
  165. package/dist/utils/string-utils.d.ts +12 -0
  166. package/dist/video/types.d.ts +15 -0
  167. package/dist/video/video-bookmark.d.ts +1 -1
  168. package/dist/video/video-player.d.ts +1 -1
  169. package/dist/video/video-playlist-item.d.ts +1 -1
  170. package/dist/withProGate-BWqcKdPM.js +137 -0
  171. package/dist/withProGate-DX6XqKLp.cjs +1 -0
  172. package/package.json +40 -137
  173. package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
  174. package/src/assessment-toolbar/index.ts +6 -0
  175. package/src/assessment-toolbar/question-header-bar.tsx +61 -0
  176. package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
  177. package/src/assessment-toolbar/question-navigator.tsx +13 -36
  178. package/src/assessment-toolbar/timer-display.tsx +6 -5
  179. package/src/assessment-toolbar/types.ts +54 -4
  180. package/src/assessment-toolbar/use-countdown.ts +153 -0
  181. package/src/common/empty-state.tsx +1 -0
  182. package/src/common/index.ts +5 -0
  183. package/src/common/pagination.tsx +135 -0
  184. package/src/common/search-input.tsx +8 -6
  185. package/src/common/stepper.tsx +100 -0
  186. package/src/common/types.ts +41 -0
  187. package/src/content/attachment-list.tsx +92 -0
  188. package/src/content/audio-player.tsx +196 -0
  189. package/src/content/code-block.tsx +113 -0
  190. package/src/content/content-block.tsx +68 -2
  191. package/src/content/embed-block.tsx +78 -0
  192. package/src/content/file-upload-zone.tsx +11 -6
  193. package/src/content/index.ts +9 -0
  194. package/src/content/types.ts +46 -0
  195. package/src/curriculum/course-card.tsx +199 -0
  196. package/src/curriculum/curriculum-item.tsx +9 -5
  197. package/src/curriculum/curriculum-tree.tsx +20 -13
  198. package/src/curriculum/index.ts +2 -0
  199. package/src/curriculum/types.ts +2 -2
  200. package/src/feedback/feedback-banner.tsx +12 -14
  201. package/src/flashcards/flashcard-deck.tsx +1 -9
  202. package/src/flashcards/flashcard.tsx +29 -9
  203. package/src/index.ts +3 -0
  204. package/src/license/HydraContext.tsx +62 -0
  205. package/src/license/ProBadge.tsx +43 -0
  206. package/src/license/index.ts +7 -0
  207. package/src/license/tiers.ts +24 -0
  208. package/src/license/useHydraLicense.ts +10 -0
  209. package/src/license/validate.ts +90 -0
  210. package/src/license/withProGate.tsx +21 -0
  211. package/src/modules/AssignmentModule/AssignmentModule.tsx +314 -0
  212. package/src/modules/AssignmentModule/types.ts +77 -0
  213. package/src/modules/CertificateModule/CertificateModule.tsx +173 -0
  214. package/src/modules/CertificateModule/types.ts +49 -0
  215. package/src/modules/CourseCatalogModule/CourseCatalogModule.tsx +126 -0
  216. package/src/modules/CourseCatalogModule/types.ts +47 -0
  217. package/src/modules/CoursePlayer/CoursePlayer.tsx +80 -69
  218. package/src/modules/DiscussionModule/DiscussionModule.tsx +145 -0
  219. package/src/modules/DiscussionModule/types.ts +54 -0
  220. package/src/modules/ExamModule/ExamModule.tsx +151 -0
  221. package/src/modules/ExamModule/types.ts +57 -0
  222. package/src/modules/FlashcardLab/FlashcardLab.tsx +39 -21
  223. package/src/modules/FlashcardLab/types.ts +2 -0
  224. package/src/modules/GradeCenterModule/GradeCenterModule.tsx +174 -0
  225. package/src/modules/GradeCenterModule/types.ts +65 -0
  226. package/src/modules/QuizModule/QuizModule.tsx +58 -178
  227. package/src/modules/QuizModule/types.ts +10 -15
  228. package/src/modules/StudentDashboardModule/StudentDashboardModule.tsx +117 -0
  229. package/src/modules/StudentDashboardModule/types.ts +56 -0
  230. package/src/modules/StudentProfileModule/StudentProfileModule.tsx +289 -0
  231. package/src/modules/StudentProfileModule/types.ts +45 -0
  232. package/src/modules/SurveyModule/SurveyModule.tsx +185 -0
  233. package/src/modules/SurveyModule/types.ts +53 -0
  234. package/src/modules/_shared/assessment-intro.tsx +75 -0
  235. package/src/modules/_shared/assessment-results.tsx +133 -0
  236. package/src/modules/_shared/types.ts +11 -0
  237. package/src/modules/_shared/use-timer.ts +49 -0
  238. package/src/modules/index.ts +33 -0
  239. package/src/progress/achievement-badge.tsx +52 -0
  240. package/src/progress/activity-timeline.tsx +84 -0
  241. package/src/progress/grade-indicator.tsx +9 -1
  242. package/src/progress/index.ts +7 -0
  243. package/src/progress/progress-ring.tsx +2 -1
  244. package/src/progress/stat-card.tsx +37 -18
  245. package/src/progress/streak-badge.tsx +35 -0
  246. package/src/progress/types.ts +103 -0
  247. package/src/provider/HydraProvider.tsx +15 -6
  248. package/src/questions/choice.tsx +19 -14
  249. package/src/questions/confidence-indicator.tsx +107 -0
  250. package/src/questions/essay.tsx +28 -28
  251. package/src/questions/fill-in-the-blank.tsx +20 -19
  252. package/src/questions/hotspot.tsx +154 -0
  253. package/src/questions/index.ts +18 -0
  254. package/src/questions/inline-choice.tsx +152 -0
  255. package/src/questions/matching.tsx +229 -0
  256. package/src/questions/multiple-choice.tsx +19 -14
  257. package/src/questions/numeric.tsx +106 -0
  258. package/src/questions/ordering.tsx +167 -0
  259. package/src/questions/question-renderer.tsx +24 -2
  260. package/src/questions/scenario.tsx +140 -0
  261. package/src/questions/scoring.ts +201 -0
  262. package/src/questions/spreadsheet.tsx +260 -0
  263. package/src/questions/true-false.tsx +19 -14
  264. package/src/questions/types.ts +123 -1
  265. package/src/questions/use-drag-reorder.ts +80 -0
  266. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +66 -23
  267. package/src/sections/AnnouncementFeed/types.ts +15 -1
  268. package/src/sections/AssessmentReview/AssessmentReview.tsx +50 -2
  269. package/src/sections/AssessmentReview/types.ts +6 -0
  270. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +44 -6
  271. package/src/sections/AssignmentSubmission/types.ts +6 -0
  272. package/src/sections/CertificateViewer/CertificateViewer.tsx +215 -60
  273. package/src/sections/CertificateViewer/certificate-variants.tsx +170 -0
  274. package/src/sections/CertificateViewer/types.ts +19 -5
  275. package/src/sections/CourseCatalog/CourseCatalog.tsx +220 -0
  276. package/src/sections/CourseCatalog/types.ts +76 -0
  277. package/src/sections/CourseOutline/CourseOutline.tsx +45 -14
  278. package/src/sections/CourseOutline/types.ts +6 -0
  279. package/src/sections/DiscussionThread/DiscussionThread.tsx +55 -11
  280. package/src/sections/DiscussionThread/types.ts +6 -0
  281. package/src/sections/EnrollmentWizard/EnrollmentWizard.tsx +343 -0
  282. package/src/sections/EnrollmentWizard/types.ts +65 -0
  283. package/src/sections/ExamSession/ExamSession.tsx +125 -82
  284. package/src/sections/ExamSession/types.ts +12 -1
  285. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +53 -36
  286. package/src/sections/FlashcardStudySession/types.ts +6 -0
  287. package/src/sections/ForumBoard/ForumBoard.tsx +342 -0
  288. package/src/sections/ForumBoard/types.ts +81 -0
  289. package/src/sections/GradebookTable/GradebookTable.tsx +55 -2
  290. package/src/sections/GradebookTable/types.ts +14 -0
  291. package/src/sections/LecturePlayer/LecturePlayer.tsx +63 -37
  292. package/src/sections/LecturePlayer/types.ts +8 -0
  293. package/src/sections/LessonPage/LessonPage.tsx +40 -13
  294. package/src/sections/LessonPage/types.ts +6 -0
  295. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +119 -98
  296. package/src/sections/PracticeQuiz/types.ts +6 -0
  297. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +121 -67
  298. package/src/sections/ProgressDashboard/types.ts +6 -0
  299. package/src/sections/QuizSession/QuizSession.tsx +115 -67
  300. package/src/sections/QuizSession/types.ts +12 -1
  301. package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +147 -0
  302. package/src/sections/RequirementsChecklist/types.ts +44 -0
  303. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +68 -17
  304. package/src/sections/ResourceLibrary/types.ts +15 -1
  305. package/src/sections/RubricView/RubricView.tsx +174 -0
  306. package/src/sections/RubricView/types.ts +58 -0
  307. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +58 -23
  308. package/src/sections/ScrollableQuiz/types.ts +6 -0
  309. package/src/sections/StudentProfile/StudentProfile.tsx +279 -0
  310. package/src/sections/StudentProfile/types.ts +99 -0
  311. package/src/sections/SurveyForm/SurveyForm.tsx +40 -10
  312. package/src/sections/SurveyForm/types.ts +6 -0
  313. package/src/sections/_shared/merge-answers.ts +22 -0
  314. package/src/sections/_shared/section-shell.tsx +64 -0
  315. package/src/sections/_shared/use-assessment-session.ts +125 -0
  316. package/src/sections/index.ts +42 -1
  317. package/src/social/post-card.tsx +8 -19
  318. package/src/social/user-avatar.tsx +10 -5
  319. package/src/styles/globals.css +52 -41
  320. package/src/ui/badge.tsx +8 -0
  321. package/src/ui/drawer.tsx +600 -0
  322. package/src/ui/index.ts +21 -0
  323. package/src/ui/progress.tsx +4 -0
  324. package/src/ui/rich-text-editor.tsx +119 -0
  325. package/src/ui/rich-text-toolbar.tsx +157 -0
  326. package/src/ui/toast.tsx +170 -0
  327. package/src/utils/array-utils.ts +17 -0
  328. package/src/utils/debounce.ts +8 -2
  329. package/src/utils/flatten-leaves.ts +17 -0
  330. package/src/utils/format-file-size.ts +5 -0
  331. package/src/utils/format-timestamp.ts +13 -0
  332. package/src/utils/is-empty-html.ts +7 -0
  333. package/src/utils/pick-palette-color.ts +33 -0
  334. package/src/utils/shuffle.ts +8 -0
  335. package/src/utils/string-utils.ts +30 -0
  336. package/src/video/types.ts +16 -0
  337. package/src/video/video-bookmark.tsx +4 -3
  338. package/src/video/video-chapter-list.tsx +9 -4
  339. package/src/video/video-player.tsx +24 -5
  340. package/src/video/video-playlist-item.tsx +8 -3
  341. package/src/video/video-thumbnail-card.tsx +4 -0
  342. package/src/video/video-transcript.tsx +8 -5
  343. package/dist/table-BrS5cDQu.js +0 -2510
  344. package/dist/table-D6AkBBEo.cjs +0 -1
@@ -0,0 +1,167 @@
1
+ import { useState, useMemo, memo } 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 = memo(function 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 [announcement, setAnnouncement] = useState("");
33
+
34
+ const [items, setItems] = useState<AnswerOption[]>(() => {
35
+ if (sessionAnswers && sessionAnswers.length > 0) {
36
+ // Rebuild order from sessionAnswers position indices
37
+ const posMap = new Map<string, number>();
38
+ for (const sa of sessionAnswers) {
39
+ posMap.set(sa.answerUid, parseInt(sa.content || "0", 10));
40
+ }
41
+ return [...correctOrder].sort(
42
+ (a, b) => (posMap.get(a.uid) ?? 0) - (posMap.get(b.uid) ?? 0),
43
+ );
44
+ }
45
+ // Shuffle for initial display so correct order isn't given away
46
+ return [...correctOrder].sort(() => Math.random() - 0.5);
47
+ });
48
+
49
+ const emitAnswer = (orderedItems: AnswerOption[]) => {
50
+ onAnswer?.(
51
+ orderedItems.map((item, index) => ({
52
+ uid: item.uid,
53
+ content: String(index),
54
+ })),
55
+ );
56
+ };
57
+
58
+ const handleReorder = (next: AnswerOption[]) => {
59
+ setItems(next);
60
+ emitAnswer(next);
61
+ };
62
+
63
+ const moveItem = (fromIndex: number, toIndex: number) => {
64
+ if (readOnly || disabled) return;
65
+ if (toIndex < 0 || toIndex >= items.length) return;
66
+ const next = [...items];
67
+ const [moved] = next.splice(fromIndex, 1);
68
+ next.splice(toIndex, 0, moved);
69
+ setItems(next);
70
+ emitAnswer(next);
71
+ setAnnouncement(
72
+ `${moved.content.replace(/<[^>]+>/g, "")} moved to position ${toIndex + 1} of ${items.length}`,
73
+ );
74
+ };
75
+
76
+ const { getDragProps, dragIndex, dragOverIndex } = useDragReorder({
77
+ items,
78
+ onReorder: handleReorder,
79
+ disabled: readOnly || disabled,
80
+ });
81
+
82
+ const getItemClasses = (item: AnswerOption, index: number) => {
83
+ if (!showCorrectAnswers) return "";
84
+ const correctIndex = correctOrder.findIndex((a) => a.uid === item.uid);
85
+ return correctIndex === index
86
+ ? "bg-success/10 border-success/30"
87
+ : "bg-destructive/10 border-destructive/30";
88
+ };
89
+
90
+ return (
91
+ <div className="flex flex-col gap-4">
92
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
93
+ <span className="sr-only" aria-live="assertive" role="status">
94
+ {announcement}
95
+ </span>
96
+
97
+ <div className="flex flex-col gap-1.5">
98
+ {items.map((item, index) => (
99
+ <div
100
+ key={item.uid}
101
+ {...getDragProps(index)}
102
+ className={cn(
103
+ "flex items-center gap-2 rounded-md border px-3 py-2 transition-all select-none",
104
+ dragIndex === index && "opacity-50",
105
+ dragOverIndex === index &&
106
+ dragIndex !== index &&
107
+ "border-t-2 border-t-primary",
108
+ !(readOnly || disabled) && "cursor-grab active:cursor-grabbing",
109
+ getItemClasses(item, index),
110
+ )}
111
+ >
112
+ {!(readOnly || disabled) && (
113
+ <GripVertical className="size-4 text-muted-foreground shrink-0" />
114
+ )}
115
+
116
+ <span className="text-sm font-medium text-muted-foreground w-6 shrink-0">
117
+ {index + 1}.
118
+ </span>
119
+
120
+ <span
121
+ className="flex-1 text-foreground"
122
+ dangerouslySetInnerHTML={{ __html: item.content }}
123
+ />
124
+
125
+ {!(readOnly || disabled) && (
126
+ <div className="flex flex-col shrink-0">
127
+ <button
128
+ type="button"
129
+ onClick={() => moveItem(index, index - 1)}
130
+ disabled={index === 0}
131
+ className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default"
132
+ aria-label={`Move ${item.content} up`}
133
+ >
134
+ <ChevronUp className="size-4" />
135
+ </button>
136
+ <button
137
+ type="button"
138
+ onClick={() => moveItem(index, index + 1)}
139
+ disabled={index === items.length - 1}
140
+ className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default"
141
+ aria-label={`Move ${item.content} down`}
142
+ >
143
+ <ChevronDown className="size-4" />
144
+ </button>
145
+ </div>
146
+ )}
147
+
148
+ {showCorrectAnswers && (
149
+ <span className="text-xs text-muted-foreground shrink-0">
150
+ (correct: {correctOrder.findIndex((a) => a.uid === item.uid) + 1})
151
+ </span>
152
+ )}
153
+ </div>
154
+ ))}
155
+ </div>
156
+
157
+ {showCorrectAnswers && question.explanation && (
158
+ <Alert className="mt-2">
159
+ <AlertDescription>
160
+ <strong>Explanation:</strong>{" "}
161
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
162
+ </AlertDescription>
163
+ </Alert>
164
+ )}
165
+ </div>
166
+ );
167
+ });
@@ -1,9 +1,17 @@
1
+ import { memo } from "react";
1
2
  import type { QuestionProps } from "./types";
2
3
  import { MultipleChoice } from "./multiple-choice";
3
4
  import { Choice } from "./choice";
4
5
  import { TrueFalse } from "./true-false";
5
6
  import { FillInTheBlank } from "./fill-in-the-blank";
6
7
  import { Essay } from "./essay";
8
+ import { Numeric } from "./numeric";
9
+ import { Ordering } from "./ordering";
10
+ import { Matching } from "./matching";
11
+ import { Hotspot } from "./hotspot";
12
+ import { InlineChoice } from "./inline-choice";
13
+ import { Scenario } from "./scenario";
14
+ import { Spreadsheet } from "./spreadsheet";
7
15
 
8
16
  /**
9
17
  * QuestionRenderer dispatches to the appropriate question component based on question type.
@@ -15,7 +23,7 @@ import { Essay } from "./essay";
15
23
  * onAnswer={handleAnswer}
16
24
  * />
17
25
  */
18
- export const QuestionRenderer = (props: QuestionProps) => {
26
+ export const QuestionRenderer = memo(function QuestionRenderer(props: QuestionProps) {
19
27
  switch (props.question.type) {
20
28
  case "multiple_choice":
21
29
  return <MultipleChoice {...props} />;
@@ -27,6 +35,20 @@ export const QuestionRenderer = (props: QuestionProps) => {
27
35
  return <FillInTheBlank {...props} />;
28
36
  case "essay":
29
37
  return <Essay {...props} />;
38
+ case "numeric":
39
+ return <Numeric {...props} />;
40
+ case "ordering":
41
+ return <Ordering {...props} />;
42
+ case "matching":
43
+ return <Matching {...props} />;
44
+ case "hotspot":
45
+ return <Hotspot {...props} />;
46
+ case "inline_choice":
47
+ return <InlineChoice {...props} />;
48
+ case "scenario":
49
+ return <Scenario {...props} />;
50
+ case "spreadsheet":
51
+ return <Spreadsheet {...props} />;
30
52
  default:
31
53
  return (
32
54
  <p className="text-muted-foreground">
@@ -34,4 +56,4 @@ export const QuestionRenderer = (props: QuestionProps) => {
34
56
  </p>
35
57
  );
36
58
  }
37
- };
59
+ });
@@ -0,0 +1,140 @@
1
+ import { useMemo, useRef, memo } from "react";
2
+ import { Alert, AlertDescription } from "../ui/alert";
3
+ import { Separator } from "../ui/separator";
4
+ import { cn } from "../lib/utils";
5
+ import { QuestionRenderer } from "./question-renderer";
6
+ import type { QuestionProps, SessionAnswer } from "./types";
7
+
8
+ /**
9
+ * Scenario renders a shared context/stimulus with multiple sub-questions.
10
+ *
11
+ * Sub-questions can be any existing question type (choice, numeric, matching, etc.).
12
+ * The shared context is displayed prominently above all sub-questions.
13
+ *
14
+ * @example
15
+ * <Scenario
16
+ * question={{
17
+ * uid: "s1",
18
+ * type: "scenario",
19
+ * content: "<p>Read the following passage and answer the questions below.</p>",
20
+ * scenarioQuestions: [
21
+ * { uid: "sq1", type: "choice", content: "What is the main idea?", answers: [...] },
22
+ * { uid: "sq2", type: "true_false", content: "The author agrees.", answers: [...] },
23
+ * ],
24
+ * scenarioScoringMode: "per_question",
25
+ * }}
26
+ * onAnswer={(answers) => handleAnswer(answers)}
27
+ * />
28
+ */
29
+ export const Scenario = memo(function Scenario({
30
+ question,
31
+ sessionAnswers,
32
+ onAnswer,
33
+ readOnly = false,
34
+ showCorrectAnswers = false,
35
+ disabled = false,
36
+ }: QuestionProps) {
37
+ const subQuestions = question.scenarioQuestions ?? [];
38
+
39
+ // Track the latest answers from each sub-question in a ref so
40
+ // handleSubAnswer always merges against the freshest state.
41
+ const latestBySubQ = useRef<Map<string, { uid: string; content?: string }[]>>(
42
+ new Map(),
43
+ );
44
+
45
+ // Parse session answers into per-sub-question groups.
46
+ // SessionAnswer.answerUid format: "subQUid::originalAnswerUid"
47
+ const answersBySubQ = useMemo(() => {
48
+ const map = new Map<string, SessionAnswer[]>();
49
+ for (const sa of sessionAnswers ?? []) {
50
+ const sepIdx = sa.answerUid.indexOf("::");
51
+ if (sepIdx === -1) continue;
52
+ const subQUid = sa.answerUid.slice(0, sepIdx);
53
+ const originalAnswerUid = sa.answerUid.slice(sepIdx + 2);
54
+ const list = map.get(subQUid) ?? [];
55
+ list.push({ ...sa, uid: subQUid, answerUid: originalAnswerUid });
56
+ map.set(subQUid, list);
57
+ }
58
+
59
+ // Sync the ref with what we parsed from props
60
+ latestBySubQ.current = new Map();
61
+ for (const [subQUid, answers] of map) {
62
+ latestBySubQ.current.set(
63
+ subQUid,
64
+ answers.map((a) => ({ uid: `${subQUid}::${a.answerUid}`, content: a.content })),
65
+ );
66
+ }
67
+
68
+ return map;
69
+ }, [sessionAnswers]);
70
+
71
+ const handleSubAnswer = (
72
+ subQuestionUid: string,
73
+ rawAnswers: { uid: string; content?: string }[],
74
+ ) => {
75
+ // Prefix each answer uid with the sub-question uid
76
+ const prefixed = rawAnswers.map((a) => ({
77
+ uid: `${subQuestionUid}::${a.uid}`,
78
+ content: a.content,
79
+ }));
80
+
81
+ // Update our ref
82
+ latestBySubQ.current.set(subQuestionUid, prefixed);
83
+
84
+ // Merge all sub-question answers into a single flat array
85
+ const merged: { uid: string; content?: string }[] = [];
86
+ for (const answers of latestBySubQ.current.values()) {
87
+ merged.push(...answers);
88
+ }
89
+
90
+ onAnswer?.(merged);
91
+ };
92
+
93
+ return (
94
+ <div className="flex flex-col gap-6">
95
+ {/* Shared context / stimulus */}
96
+ <div
97
+ className={cn(
98
+ "rounded-lg border border-border bg-muted/30 px-4 py-3",
99
+ "prose prose-sm max-w-none text-foreground",
100
+ )}
101
+ >
102
+ <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
103
+ Scenario
104
+ </div>
105
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
106
+ </div>
107
+
108
+ <Separator />
109
+
110
+ {/* Sub-questions */}
111
+ <div className="flex flex-col gap-5">
112
+ {subQuestions.map((sq, idx) => (
113
+ <div key={sq.uid} className="flex flex-col gap-1">
114
+ <span className="text-xs font-medium text-muted-foreground">
115
+ Part {String.fromCharCode(65 + idx)}
116
+ </span>
117
+ <QuestionRenderer
118
+ question={sq}
119
+ sessionAnswers={answersBySubQ.get(sq.uid) ?? []}
120
+ onAnswer={(answers) => handleSubAnswer(sq.uid, answers)}
121
+ readOnly={readOnly}
122
+ showCorrectAnswers={showCorrectAnswers}
123
+ disabled={disabled}
124
+ />
125
+ </div>
126
+ ))}
127
+ </div>
128
+
129
+ {/* Scenario-level explanation */}
130
+ {showCorrectAnswers && question.explanation && (
131
+ <Alert className="mt-2">
132
+ <AlertDescription>
133
+ <strong>Scenario Explanation:</strong>{" "}
134
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
135
+ </AlertDescription>
136
+ </Alert>
137
+ )}
138
+ </div>
139
+ );
140
+ });
@@ -0,0 +1,201 @@
1
+ import type { QuestionData, SessionAnswer } from "./types";
2
+
3
+ /**
4
+ * Scores a single question against the user's answers.
5
+ *
6
+ * @returns `true` if correct, `false` if incorrect, `null` if the question is not auto-gradable.
7
+ */
8
+ export function scoreQuestion(
9
+ question: QuestionData,
10
+ userAnswers: SessionAnswer[],
11
+ ): boolean | null {
12
+ switch (question.type) {
13
+ case "choice":
14
+ case "multiple_choice":
15
+ case "true_false": {
16
+ const correctUids = new Set(
17
+ (question.answers ?? []).filter((a) => a.isCorrect).map((a) => a.uid),
18
+ );
19
+ const userUids = new Set(userAnswers.map((a) => a.answerUid));
20
+ if (correctUids.size === 0) return null;
21
+ return (
22
+ correctUids.size === userUids.size &&
23
+ [...correctUids].every((uid) => userUids.has(uid))
24
+ );
25
+ }
26
+
27
+ case "fill_in_the_blank":
28
+ case "essay":
29
+ return null;
30
+
31
+ case "ordering": {
32
+ const answers = question.answers ?? [];
33
+ if (answers.length === 0) return null;
34
+ const sorted = [...answers].sort((a, b) => a.sequence - b.sequence);
35
+ return sorted.every((answer, correctIndex) => {
36
+ const ua = userAnswers.find((a) => a.answerUid === answer.uid);
37
+ return ua?.content === String(correctIndex);
38
+ });
39
+ }
40
+
41
+ case "matching": {
42
+ const pairs = question.matchingPairs ?? [];
43
+ if (pairs.length === 0) return null;
44
+ return pairs.every((pair) => {
45
+ const ua = userAnswers.find((a) => a.answerUid === pair.uid);
46
+ return ua?.content === pair.uid;
47
+ });
48
+ }
49
+
50
+ case "numeric": {
51
+ if (question.numericAnswer === undefined) return null;
52
+ const raw = userAnswers[0]?.content;
53
+ if (raw === undefined || raw === "") return false;
54
+ const userNum = parseFloat(raw);
55
+ if (isNaN(userNum)) return false;
56
+ const tolerance = question.numericTolerance ?? 0;
57
+ return Math.abs(userNum - question.numericAnswer) <= tolerance;
58
+ }
59
+
60
+ case "hotspot": {
61
+ const regions = question.hotspotRegions ?? [];
62
+ const correctUids = new Set(
63
+ regions.filter((r) => r.isCorrect).map((r) => r.uid),
64
+ );
65
+ if (correctUids.size === 0) return null;
66
+ const userUids = new Set(userAnswers.map((a) => a.answerUid));
67
+ return (
68
+ correctUids.size === userUids.size &&
69
+ [...correctUids].every((uid) => userUids.has(uid))
70
+ );
71
+ }
72
+
73
+ case "inline_choice": {
74
+ const blanks = question.inlineBlanks ?? [];
75
+ if (blanks.length === 0) return null;
76
+ return blanks.every((blank) => {
77
+ const correctOption = blank.options.find((o) => o.isCorrect);
78
+ if (!correctOption) return true;
79
+ const ua = userAnswers.find((a) => a.answerUid === blank.uid);
80
+ return ua?.content === correctOption.uid;
81
+ });
82
+ }
83
+
84
+ case "spreadsheet": {
85
+ const rows = question.spreadsheetRows ?? [];
86
+ const cols = question.spreadsheetColumns ?? [];
87
+ const colMap = new Map(cols.map((c) => [c.uid, c]));
88
+ const editableCells = rows.flatMap((r) =>
89
+ r.cells.filter((c) => !c.locked && c.correctAnswer !== undefined),
90
+ );
91
+ if (editableCells.length === 0) return null;
92
+ return editableCells.every((cell) => {
93
+ const ua = userAnswers.find((a) => a.answerUid === cell.uid);
94
+ const userValue = ua?.content?.trim() ?? "";
95
+ if (!userValue) return false;
96
+ const col = colMap.get(cell.columnUid);
97
+ if (!col || col.type === "text") {
98
+ return userValue.toLowerCase() === cell.correctAnswer!.trim().toLowerCase();
99
+ }
100
+ const userNum = parseFloat(userValue.replace(/,/g, ""));
101
+ const correctNum = parseFloat(cell.correctAnswer!.replace(/,/g, ""));
102
+ if (isNaN(userNum) || isNaN(correctNum)) return false;
103
+ const tol = cell.tolerance ?? 0;
104
+ return Math.abs(userNum - correctNum) <= tol;
105
+ });
106
+ }
107
+
108
+ case "scenario": {
109
+ const subQuestions = question.scenarioQuestions ?? [];
110
+ if (subQuestions.length === 0) return null;
111
+
112
+ const mode = question.scenarioScoringMode ?? "per_question";
113
+
114
+ if (mode === "all_or_nothing") {
115
+ const grouped = groupScenarioAnswers(userAnswers);
116
+ const results = subQuestions.map((sq) => {
117
+ const sqAnswers = grouped.get(sq.uid) ?? [];
118
+ return scoreQuestion(sq, sqAnswers);
119
+ });
120
+ if (results.some((r) => r === null)) return null;
121
+ return results.every((r) => r === true);
122
+ }
123
+
124
+ // "per_question" mode has no single aggregate score
125
+ return null;
126
+ }
127
+
128
+ default:
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Scores an entire set of questions against user answers.
135
+ *
136
+ * @returns An object with correct count, total gradable, and percentage.
137
+ */
138
+ export function scoreAssessment(
139
+ questions: QuestionData[],
140
+ answers: SessionAnswer[],
141
+ ): { correct: number; total: number; percentage: number } {
142
+ let correct = 0;
143
+ let gradable = 0;
144
+ for (const q of questions) {
145
+ const userAnswers = answers.filter((a) => a.uid === q.uid);
146
+ if (q.type === "scenario" && q.scenarioScoringMode !== "all_or_nothing") {
147
+ const subResults = scoreScenarioSubQuestions(q, userAnswers);
148
+ for (const [, result] of subResults) {
149
+ if (result !== null) {
150
+ gradable++;
151
+ if (result) correct++;
152
+ }
153
+ }
154
+ continue;
155
+ }
156
+ const result = scoreQuestion(q, userAnswers);
157
+ if (result !== null) {
158
+ gradable++;
159
+ if (result) correct++;
160
+ }
161
+ }
162
+ const total = gradable > 0 ? gradable : questions.length;
163
+ const percentage = total > 0 ? Math.round((correct / total) * 100) : 0;
164
+ return { correct, total, percentage };
165
+ }
166
+
167
+ /** Parse scenario answer UIDs (format "subQUid::originalAnswerUid") and group by sub-question. */
168
+ function groupScenarioAnswers(
169
+ userAnswers: SessionAnswer[],
170
+ ): Map<string, SessionAnswer[]> {
171
+ const map = new Map<string, SessionAnswer[]>();
172
+ for (const ua of userAnswers) {
173
+ const sepIdx = ua.answerUid.indexOf("::");
174
+ if (sepIdx === -1) continue;
175
+ const subQUid = ua.answerUid.slice(0, sepIdx);
176
+ const originalAnswerUid = ua.answerUid.slice(sepIdx + 2);
177
+ const list = map.get(subQUid) ?? [];
178
+ list.push({ ...ua, answerUid: originalAnswerUid });
179
+ map.set(subQUid, list);
180
+ }
181
+ return map;
182
+ }
183
+
184
+ /**
185
+ * Scores individual sub-questions within a scenario.
186
+ * Returns a Map of sub-question UID to score (`true`/`false`/`null`).
187
+ */
188
+ export function scoreScenarioSubQuestions(
189
+ question: QuestionData,
190
+ userAnswers: SessionAnswer[],
191
+ ): Map<string, boolean | null> {
192
+ const results = new Map<string, boolean | null>();
193
+ const subQuestions = question.scenarioQuestions ?? [];
194
+ const grouped = groupScenarioAnswers(userAnswers);
195
+
196
+ for (const sq of subQuestions) {
197
+ const sqAnswers = grouped.get(sq.uid) ?? [];
198
+ results.set(sq.uid, scoreQuestion(sq, sqAnswers));
199
+ }
200
+ return results;
201
+ }