@hydralms/components 0.1.0 → 0.1.2

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 (159) hide show
  1. package/dist/components.css +1 -1
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +442 -110
  4. package/dist/modules/CoursePlayer/CoursePlayer.d.ts +2 -0
  5. package/dist/modules/CoursePlayer/types.d.ts +59 -0
  6. package/dist/modules/FlashcardLab/FlashcardLab.d.ts +2 -0
  7. package/dist/modules/FlashcardLab/types.d.ts +55 -0
  8. package/dist/modules/QuizModule/QuizModule.d.ts +2 -0
  9. package/dist/modules/QuizModule/types.d.ts +54 -0
  10. package/dist/modules/index.d.ts +6 -0
  11. package/dist/provider/HydraProvider.d.ts +1 -1
  12. package/dist/sections.cjs +1 -1
  13. package/dist/sections.js +261 -291
  14. package/dist/table-BrS5cDQu.js +2510 -0
  15. package/dist/table-D6AkBBEo.cjs +1 -0
  16. package/dist/ui/alert-dialog.d.ts +14 -8
  17. package/dist/ui/button.d.ts +1 -1
  18. package/dist/ui/tabs.d.ts +15 -5
  19. package/dist/ui/tooltip.d.ts +12 -5
  20. package/dist/video/index.d.ts +6 -1
  21. package/dist/video/types.d.ts +167 -0
  22. package/dist/video/video-bookmark.d.ts +2 -0
  23. package/dist/video/video-chapter-list.d.ts +2 -0
  24. package/dist/video/video-playlist-item.d.ts +2 -0
  25. package/dist/video/video-thumbnail-card.d.ts +2 -0
  26. package/dist/video/video-transcript.d.ts +2 -0
  27. package/package.json +135 -24
  28. package/src/__tests__/setup.ts +1 -0
  29. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  30. package/src/assessment-toolbar/index.ts +10 -0
  31. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  32. package/src/assessment-toolbar/timer-display.tsx +73 -0
  33. package/src/assessment-toolbar/types.ts +92 -0
  34. package/src/assets/hydra-icon.png +0 -0
  35. package/src/assets/hydra-icon.svg +18 -0
  36. package/src/assets/hydra-lms-icon.png +0 -0
  37. package/src/assets/hydra-lms-icon.svg +9 -0
  38. package/src/common/confirm-dialog.tsx +60 -0
  39. package/src/common/due-date-display.tsx +64 -0
  40. package/src/common/empty-state.tsx +24 -0
  41. package/src/common/index.ts +12 -0
  42. package/src/common/search-input.tsx +68 -0
  43. package/src/common/status-badge.test.tsx +43 -0
  44. package/src/common/status-badge.tsx +81 -0
  45. package/src/common/types.ts +129 -0
  46. package/src/content/content-block.tsx +116 -0
  47. package/src/content/file-upload-zone.tsx +109 -0
  48. package/src/content/index.ts +7 -0
  49. package/src/content/types.ts +76 -0
  50. package/src/curriculum/curriculum-item.tsx +81 -0
  51. package/src/curriculum/curriculum-tree.tsx +69 -0
  52. package/src/curriculum/index.ts +11 -0
  53. package/src/curriculum/learning-object-icon.tsx +44 -0
  54. package/src/curriculum/types.ts +83 -0
  55. package/src/feedback/feedback-banner.tsx +46 -0
  56. package/src/feedback/index.ts +8 -0
  57. package/src/feedback/likert-scale.tsx +58 -0
  58. package/src/feedback/star-rating.tsx +65 -0
  59. package/src/feedback/types.ts +86 -0
  60. package/src/flashcards/flashcard-deck.tsx +130 -0
  61. package/src/flashcards/flashcard.tsx +108 -0
  62. package/src/flashcards/index.ts +3 -0
  63. package/src/flashcards/types.ts +60 -0
  64. package/src/index.ts +38 -0
  65. package/src/lib/utils.ts +6 -0
  66. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  67. package/src/modules/CoursePlayer/types.ts +48 -0
  68. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  69. package/src/modules/FlashcardLab/types.ts +58 -0
  70. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  71. package/src/modules/QuizModule/types.ts +56 -0
  72. package/src/modules/index.ts +12 -0
  73. package/src/progress/grade-indicator.tsx +65 -0
  74. package/src/progress/index.ts +8 -0
  75. package/src/progress/progress-ring.tsx +56 -0
  76. package/src/progress/stat-card.tsx +42 -0
  77. package/src/progress/types.ts +73 -0
  78. package/src/provider/HydraProvider.tsx +26 -0
  79. package/src/provider/index.ts +2 -0
  80. package/src/questions/choice.tsx +90 -0
  81. package/src/questions/essay.tsx +59 -0
  82. package/src/questions/fill-in-the-blank.tsx +69 -0
  83. package/src/questions/index.ts +14 -0
  84. package/src/questions/multiple-choice.test.tsx +104 -0
  85. package/src/questions/multiple-choice.tsx +97 -0
  86. package/src/questions/question-renderer.tsx +37 -0
  87. package/src/questions/true-false.test.tsx +89 -0
  88. package/src/questions/true-false.tsx +90 -0
  89. package/src/questions/types.ts +53 -0
  90. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  91. package/src/sections/AnnouncementFeed/types.ts +50 -0
  92. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  93. package/src/sections/AssessmentReview/types.ts +61 -0
  94. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  95. package/src/sections/AssignmentSubmission/types.ts +60 -0
  96. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  97. package/src/sections/CertificateViewer/types.ts +45 -0
  98. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  99. package/src/sections/CourseOutline/types.ts +53 -0
  100. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  101. package/src/sections/DiscussionThread/types.ts +77 -0
  102. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  103. package/src/sections/ExamSession/types.ts +64 -0
  104. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  105. package/src/sections/FlashcardStudySession/types.ts +42 -0
  106. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  107. package/src/sections/GradebookTable/types.ts +75 -0
  108. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  109. package/src/sections/LecturePlayer/types.ts +48 -0
  110. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  111. package/src/sections/LessonPage/types.ts +41 -0
  112. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  113. package/src/sections/PracticeQuiz/types.ts +44 -0
  114. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  115. package/src/sections/ProgressDashboard/types.ts +74 -0
  116. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  117. package/src/sections/QuizSession/types.ts +47 -0
  118. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  119. package/src/sections/ResourceLibrary/types.ts +57 -0
  120. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  121. package/src/sections/ScrollableQuiz/types.ts +40 -0
  122. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  123. package/src/sections/SurveyForm/types.ts +69 -0
  124. package/src/sections/index.ts +90 -0
  125. package/src/social/index.ts +3 -0
  126. package/src/social/post-card.tsx +91 -0
  127. package/src/social/types.ts +57 -0
  128. package/src/social/user-avatar.tsx +76 -0
  129. package/src/styles/globals.css +125 -0
  130. package/src/ui/alert-dialog.tsx +343 -0
  131. package/src/ui/alert.tsx +65 -0
  132. package/src/ui/avatar.tsx +52 -0
  133. package/src/ui/badge.tsx +53 -0
  134. package/src/ui/button.tsx +62 -0
  135. package/src/ui/card.tsx +92 -0
  136. package/src/ui/index.ts +44 -0
  137. package/src/ui/input.tsx +21 -0
  138. package/src/ui/progress.tsx +73 -0
  139. package/src/ui/separator.tsx +29 -0
  140. package/src/ui/skeleton.tsx +15 -0
  141. package/src/ui/slot.tsx +48 -0
  142. package/src/ui/table.tsx +108 -0
  143. package/src/ui/tabs.tsx +147 -0
  144. package/src/ui/textarea.tsx +20 -0
  145. package/src/ui/tooltip.tsx +177 -0
  146. package/src/utils/debounce.test.ts +59 -0
  147. package/src/utils/debounce.ts +10 -0
  148. package/src/utils/format-duration.test.ts +55 -0
  149. package/src/utils/format-duration.ts +30 -0
  150. package/src/video/index.ts +17 -0
  151. package/src/video/types.ts +216 -0
  152. package/src/video/video-bookmark.tsx +76 -0
  153. package/src/video/video-chapter-list.tsx +93 -0
  154. package/src/video/video-player.tsx +103 -0
  155. package/src/video/video-playlist-item.tsx +90 -0
  156. package/src/video/video-thumbnail-card.tsx +74 -0
  157. package/src/video/video-transcript.tsx +102 -0
  158. package/dist/table-CW4_BYny.js +0 -9869
  159. package/dist/table-DSBBqb9X.cjs +0 -56
@@ -0,0 +1,90 @@
1
+ import { useState, useEffect } from "react";
2
+ import type { QuestionProps } from "./types";
3
+ import { Alert, AlertDescription } from "../ui/alert";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * Choice renders a single-answer question using radio buttons.
8
+ *
9
+ * @example
10
+ * <Choice
11
+ * question={question}
12
+ * onAnswer={(answers) => handleAnswer(answers)}
13
+ * />
14
+ */
15
+ export const Choice = ({
16
+ question,
17
+ sessionAnswers,
18
+ onAnswer,
19
+ readOnly = false,
20
+ showCorrectAnswers = false,
21
+ disabled = false,
22
+ }: QuestionProps) => {
23
+ const [selectedAnswer, setSelectedAnswer] = useState<string>("");
24
+
25
+ const sortedAnswers = [...(question.answers || [])].sort(
26
+ (a, b) => a.sequence - b.sequence,
27
+ );
28
+
29
+ const handleChange = (uid: string) => {
30
+ if (readOnly || disabled) return;
31
+
32
+ setSelectedAnswer(uid);
33
+ onAnswer?.([{ uid }]);
34
+ };
35
+
36
+ useEffect(() => {
37
+ const current = sessionAnswers?.[0]?.answerUid || "";
38
+ setSelectedAnswer(current);
39
+ }, [sessionAnswers]);
40
+
41
+ const getAnswerClasses = (answerUid: string) => {
42
+ if (!showCorrectAnswers) return "px-2";
43
+
44
+ const answer = question.answers?.find((a) => a.uid === answerUid);
45
+ if (answer?.isCorrect) {
46
+ return "bg-success/10 border border-success/30 px-2";
47
+ }
48
+ if (selectedAnswer === answerUid && !answer?.isCorrect) {
49
+ return "bg-destructive/10 border border-destructive/30 px-2";
50
+ }
51
+ return "px-2";
52
+ };
53
+
54
+ return (
55
+ <div className="flex flex-col gap-4">
56
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
57
+
58
+ <div className="flex flex-col gap-2">
59
+ {sortedAnswers.map((answer) => (
60
+ <div
61
+ key={answer.uid}
62
+ className={cn("rounded-md transition-colors", getAnswerClasses(answer.uid))}
63
+ >
64
+ <label className="flex items-center gap-2 cursor-pointer py-1 has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
65
+ <input
66
+ type="radio"
67
+ name={question.uid}
68
+ value={answer.uid}
69
+ checked={selectedAnswer === answer.uid}
70
+ onChange={() => handleChange(answer.uid)}
71
+ disabled={readOnly || disabled}
72
+ className="accent-primary m-0 shrink-0"
73
+ />
74
+ <span dangerouslySetInnerHTML={{ __html: answer.content }} />
75
+ </label>
76
+ </div>
77
+ ))}
78
+ </div>
79
+
80
+ {showCorrectAnswers && question.explanation && (
81
+ <Alert className="mt-2">
82
+ <AlertDescription>
83
+ <strong>Explanation:</strong>{" "}
84
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
85
+ </AlertDescription>
86
+ </Alert>
87
+ )}
88
+ </div>
89
+ );
90
+ };
@@ -0,0 +1,59 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { debounce } from "../utils/debounce";
3
+ import { Textarea } from "../ui/textarea";
4
+ import type { QuestionProps } from "./types";
5
+
6
+ /**
7
+ * Essay renders a long-form text input question with a multiline textarea.
8
+ *
9
+ * @example
10
+ * <Essay
11
+ * question={question}
12
+ * onAnswer={(answers) => handleAnswer(answers)}
13
+ * />
14
+ */
15
+ export const Essay = ({
16
+ question,
17
+ sessionAnswers,
18
+ onAnswer,
19
+ readOnly = false,
20
+ disabled = false,
21
+ }: QuestionProps) => {
22
+ const [value, setValue] = useState("");
23
+
24
+ const debouncedAnswer = useCallback(
25
+ debounce((content: string) => {
26
+ onAnswer?.([
27
+ {
28
+ uid: question.answers?.[0]?.uid || question.uid,
29
+ content,
30
+ },
31
+ ]);
32
+ }, 500),
33
+ [onAnswer, question.answers, question.uid],
34
+ );
35
+
36
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
37
+ const newValue = e.target.value;
38
+ setValue(newValue);
39
+ debouncedAnswer(newValue);
40
+ };
41
+
42
+ useEffect(() => {
43
+ setValue(sessionAnswers?.[0]?.content || "");
44
+ }, [sessionAnswers]);
45
+
46
+ return (
47
+ <div className="flex flex-col gap-2">
48
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
49
+
50
+ <Textarea
51
+ className="min-h-30 resize-y"
52
+ value={value}
53
+ onChange={handleChange}
54
+ placeholder="Write your response here..."
55
+ disabled={readOnly || disabled}
56
+ />
57
+ </div>
58
+ );
59
+ };
@@ -0,0 +1,69 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { debounce } from "../utils/debounce";
3
+ import { Input } from "../ui/input";
4
+ import { Alert, AlertDescription } from "../ui/alert";
5
+ import type { QuestionProps } from "./types";
6
+
7
+ /**
8
+ * FillInTheBlank renders a short text input question with debounced answer submission.
9
+ *
10
+ * @example
11
+ * <FillInTheBlank
12
+ * question={question}
13
+ * onAnswer={(answers) => handleAnswer(answers)}
14
+ * />
15
+ */
16
+ export const FillInTheBlank = ({
17
+ question,
18
+ sessionAnswers,
19
+ onAnswer,
20
+ readOnly = false,
21
+ showCorrectAnswers = false,
22
+ disabled = false,
23
+ }: QuestionProps) => {
24
+ const [value, setValue] = useState("");
25
+
26
+ const debouncedAnswer = useCallback(
27
+ debounce((content: string) => {
28
+ onAnswer?.([
29
+ {
30
+ uid: question.answers?.[0]?.uid || "",
31
+ content,
32
+ },
33
+ ]);
34
+ }, 300),
35
+ [onAnswer, question.answers],
36
+ );
37
+
38
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
39
+ const newValue = e.target.value;
40
+ setValue(newValue);
41
+ debouncedAnswer(newValue);
42
+ };
43
+
44
+ useEffect(() => {
45
+ setValue(sessionAnswers?.[0]?.content || "");
46
+ }, [sessionAnswers]);
47
+
48
+ return (
49
+ <div className="flex flex-col gap-2">
50
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
51
+
52
+ <Input
53
+ value={value}
54
+ onChange={handleChange}
55
+ placeholder="Type your answer here..."
56
+ disabled={readOnly || disabled}
57
+ />
58
+
59
+ {showCorrectAnswers && question.explanation && (
60
+ <Alert className="mt-1">
61
+ <AlertDescription>
62
+ <strong>Explanation:</strong>{" "}
63
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
64
+ </AlertDescription>
65
+ </Alert>
66
+ )}
67
+ </div>
68
+ );
69
+ };
@@ -0,0 +1,14 @@
1
+ export { QuestionRenderer } from "./question-renderer";
2
+ export { MultipleChoice } from "./multiple-choice";
3
+ export { Choice } from "./choice";
4
+ export { TrueFalse } from "./true-false";
5
+ export { FillInTheBlank } from "./fill-in-the-blank";
6
+ export { Essay } from "./essay";
7
+
8
+ export type {
9
+ QuestionProps,
10
+ QuestionData,
11
+ QuestionTypeEnum,
12
+ AnswerOption,
13
+ SessionAnswer,
14
+ } from "./types";
@@ -0,0 +1,104 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { MultipleChoice } from "./multiple-choice";
4
+ import type { QuestionData } from "./types";
5
+
6
+ const mockQuestion: QuestionData = {
7
+ uid: "q1",
8
+ type: "multiple_choice",
9
+ content: "<p>Select all prime numbers.</p>",
10
+ explanation: "<p>2 and 7 are prime.</p>",
11
+ answers: [
12
+ { uid: "a1", content: "2", isCorrect: true, sequence: 1 },
13
+ { uid: "a2", content: "4", isCorrect: false, sequence: 2 },
14
+ { uid: "a3", content: "7", isCorrect: true, sequence: 3 },
15
+ { uid: "a4", content: "9", isCorrect: false, sequence: 4 },
16
+ ],
17
+ };
18
+
19
+ describe("MultipleChoice", () => {
20
+ it("renders question content and all choices", () => {
21
+ render(<MultipleChoice question={mockQuestion} />);
22
+ expect(screen.getByText("Select all prime numbers.")).toBeInTheDocument();
23
+ expect(screen.getByText("2")).toBeInTheDocument();
24
+ expect(screen.getByText("4")).toBeInTheDocument();
25
+ expect(screen.getByText("7")).toBeInTheDocument();
26
+ expect(screen.getByText("9")).toBeInTheDocument();
27
+ });
28
+
29
+ it("renders checkboxes", () => {
30
+ render(<MultipleChoice question={mockQuestion} />);
31
+ const checkboxes = screen.getAllByRole("checkbox");
32
+ expect(checkboxes).toHaveLength(4);
33
+ });
34
+
35
+ it("calls onAnswer when selecting an option", async () => {
36
+ const onAnswer = vi.fn();
37
+ const user = userEvent.setup();
38
+
39
+ render(<MultipleChoice question={mockQuestion} onAnswer={onAnswer} />);
40
+ await user.click(screen.getByText("2"));
41
+
42
+ expect(onAnswer).toHaveBeenCalledWith([{ uid: "a1" }]);
43
+ });
44
+
45
+ it("supports selecting multiple options", async () => {
46
+ const onAnswer = vi.fn();
47
+ const user = userEvent.setup();
48
+
49
+ render(<MultipleChoice question={mockQuestion} onAnswer={onAnswer} />);
50
+ await user.click(screen.getByText("2"));
51
+ await user.click(screen.getByText("7"));
52
+
53
+ expect(onAnswer).toHaveBeenLastCalledWith([
54
+ { uid: "a1" },
55
+ { uid: "a3" },
56
+ ]);
57
+ });
58
+
59
+ it("deselects an answer on second click", async () => {
60
+ const onAnswer = vi.fn();
61
+ const user = userEvent.setup();
62
+
63
+ render(<MultipleChoice question={mockQuestion} onAnswer={onAnswer} />);
64
+ await user.click(screen.getByText("2"));
65
+ await user.click(screen.getByText("2"));
66
+
67
+ expect(onAnswer).toHaveBeenLastCalledWith([]);
68
+ });
69
+
70
+ it("disables inputs when readOnly", () => {
71
+ render(<MultipleChoice question={mockQuestion} readOnly />);
72
+ const checkboxes = screen.getAllByRole("checkbox");
73
+ checkboxes.forEach((cb) => expect(cb).toBeDisabled());
74
+ });
75
+
76
+ it("disables inputs when disabled", () => {
77
+ render(<MultipleChoice question={mockQuestion} disabled />);
78
+ const checkboxes = screen.getAllByRole("checkbox");
79
+ checkboxes.forEach((cb) => expect(cb).toBeDisabled());
80
+ });
81
+
82
+ it("shows explanation when showCorrectAnswers is true", () => {
83
+ render(<MultipleChoice question={mockQuestion} showCorrectAnswers />);
84
+ expect(screen.getByText("Explanation:")).toBeInTheDocument();
85
+ expect(screen.getByText("2 and 7 are prime.")).toBeInTheDocument();
86
+ });
87
+
88
+ it("pre-selects answers from sessionAnswers", () => {
89
+ render(
90
+ <MultipleChoice
91
+ question={mockQuestion}
92
+ sessionAnswers={[
93
+ { uid: "sa1", answerUid: "a1" },
94
+ { uid: "sa2", answerUid: "a3" },
95
+ ]}
96
+ />,
97
+ );
98
+ const checkboxes = screen.getAllByRole("checkbox");
99
+ expect(checkboxes[0]).toBeChecked();
100
+ expect(checkboxes[1]).not.toBeChecked();
101
+ expect(checkboxes[2]).toBeChecked();
102
+ expect(checkboxes[3]).not.toBeChecked();
103
+ });
104
+ });
@@ -0,0 +1,97 @@
1
+ import { useState, useEffect } from "react";
2
+ import type { QuestionProps } from "./types";
3
+ import { Alert, AlertDescription } from "../ui/alert";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * MultipleChoice renders a question with multiple correct answers using checkboxes.
8
+ *
9
+ * @example
10
+ * <MultipleChoice
11
+ * question={question}
12
+ * onAnswer={(answers) => handleAnswer(answers)}
13
+ * />
14
+ */
15
+ export const MultipleChoice = ({
16
+ question,
17
+ sessionAnswers,
18
+ onAnswer,
19
+ readOnly = false,
20
+ showCorrectAnswers = false,
21
+ disabled = false,
22
+ }: QuestionProps) => {
23
+ const [selectedAnswers, setSelectedAnswers] = useState<string[]>([]);
24
+
25
+ const sortedAnswers = [...(question.answers || [])].sort(
26
+ (a, b) => a.sequence - b.sequence,
27
+ );
28
+
29
+ const handleChange = (uid: string) => {
30
+ if (readOnly || disabled) return;
31
+
32
+ setSelectedAnswers((prev) => {
33
+ const newSelected = prev.includes(uid)
34
+ ? prev.filter((id) => id !== uid)
35
+ : [...prev, uid];
36
+
37
+ onAnswer?.(newSelected.map((id) => ({ uid: id })));
38
+ return newSelected;
39
+ });
40
+ };
41
+
42
+ useEffect(() => {
43
+ const current = sessionAnswers?.map((sa) => sa.answerUid) || [];
44
+ setSelectedAnswers(current);
45
+ }, [sessionAnswers]);
46
+
47
+ const getAnswerClasses = (answerUid: string) => {
48
+ if (!showCorrectAnswers) return "";
49
+
50
+ const answer = question.answers?.find((a) => a.uid === answerUid);
51
+ if (answer?.isCorrect) {
52
+ return "bg-success/10 border border-success/30 px-2";
53
+ }
54
+ if (selectedAnswers.includes(answerUid) && !answer?.isCorrect) {
55
+ return "bg-destructive/10 border border-destructive/30 px-2";
56
+ }
57
+ return "";
58
+ };
59
+
60
+ return (
61
+ <div className="flex flex-col gap-4">
62
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
63
+
64
+ <div className="flex flex-col gap-2">
65
+ {sortedAnswers.map((answer) => (
66
+ <div
67
+ key={answer.uid}
68
+ className={cn("rounded-md transition-colors", getAnswerClasses(answer.uid))}
69
+ >
70
+ <label className="flex items-center gap-2 cursor-pointer py-1 has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
71
+ <input
72
+ type="checkbox"
73
+ checked={selectedAnswers.includes(answer.uid)}
74
+ onChange={() => handleChange(answer.uid)}
75
+ disabled={readOnly || disabled}
76
+ className="accent-primary m-0 shrink-0"
77
+ />
78
+ <span
79
+ className="text-sm"
80
+ dangerouslySetInnerHTML={{ __html: answer.content }}
81
+ />
82
+ </label>
83
+ </div>
84
+ ))}
85
+ </div>
86
+
87
+ {showCorrectAnswers && question.explanation && (
88
+ <Alert className="mt-2">
89
+ <AlertDescription>
90
+ <strong>Explanation:</strong>{" "}
91
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
92
+ </AlertDescription>
93
+ </Alert>
94
+ )}
95
+ </div>
96
+ );
97
+ };
@@ -0,0 +1,37 @@
1
+ import type { QuestionProps } from "./types";
2
+ import { MultipleChoice } from "./multiple-choice";
3
+ import { Choice } from "./choice";
4
+ import { TrueFalse } from "./true-false";
5
+ import { FillInTheBlank } from "./fill-in-the-blank";
6
+ import { Essay } from "./essay";
7
+
8
+ /**
9
+ * QuestionRenderer dispatches to the appropriate question component based on question type.
10
+ *
11
+ * @example
12
+ * <QuestionRenderer
13
+ * question={question}
14
+ * sessionAnswers={answers}
15
+ * onAnswer={handleAnswer}
16
+ * />
17
+ */
18
+ export const QuestionRenderer = (props: QuestionProps) => {
19
+ switch (props.question.type) {
20
+ case "multiple_choice":
21
+ return <MultipleChoice {...props} />;
22
+ case "choice":
23
+ return <Choice {...props} />;
24
+ case "true_false":
25
+ return <TrueFalse {...props} />;
26
+ case "fill_in_the_blank":
27
+ return <FillInTheBlank {...props} />;
28
+ case "essay":
29
+ return <Essay {...props} />;
30
+ default:
31
+ return (
32
+ <p className="text-muted-foreground">
33
+ Question type &ldquo;{props.question.type}&rdquo; is not supported yet.
34
+ </p>
35
+ );
36
+ }
37
+ };
@@ -0,0 +1,89 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { TrueFalse } from "./true-false";
4
+ import type { QuestionData } from "./types";
5
+
6
+ const mockQuestion: QuestionData = {
7
+ uid: "q1",
8
+ type: "true_false",
9
+ content: "<p>The sky is blue.</p>",
10
+ explanation: "<p>Due to Rayleigh scattering.</p>",
11
+ answers: [
12
+ { uid: "a1", content: "True", isCorrect: true, sequence: 1 },
13
+ { uid: "a2", content: "False", isCorrect: false, sequence: 2 },
14
+ ],
15
+ };
16
+
17
+ describe("TrueFalse", () => {
18
+ it("renders question content and both options", () => {
19
+ render(<TrueFalse question={mockQuestion} />);
20
+ expect(screen.getByText("The sky is blue.")).toBeInTheDocument();
21
+ expect(screen.getByText("True")).toBeInTheDocument();
22
+ expect(screen.getByText("False")).toBeInTheDocument();
23
+ });
24
+
25
+ it("renders radio inputs", () => {
26
+ render(<TrueFalse question={mockQuestion} />);
27
+ const radios = screen.getAllByRole("radio");
28
+ expect(radios).toHaveLength(2);
29
+ });
30
+
31
+ it("calls onAnswer when an option is selected", async () => {
32
+ const onAnswer = vi.fn();
33
+ const user = userEvent.setup();
34
+
35
+ render(<TrueFalse question={mockQuestion} onAnswer={onAnswer} />);
36
+ await user.click(screen.getByText("True"));
37
+
38
+ expect(onAnswer).toHaveBeenCalledWith([{ uid: "a1" }]);
39
+ });
40
+
41
+ it("disables inputs when readOnly is true", () => {
42
+ render(<TrueFalse question={mockQuestion} readOnly />);
43
+ const radios = screen.getAllByRole("radio");
44
+ radios.forEach((radio) => expect(radio).toBeDisabled());
45
+ });
46
+
47
+ it("disables inputs when disabled is true", () => {
48
+ render(<TrueFalse question={mockQuestion} disabled />);
49
+ const radios = screen.getAllByRole("radio");
50
+ radios.forEach((radio) => expect(radio).toBeDisabled());
51
+ });
52
+
53
+ it("shows explanation when showCorrectAnswers is true", () => {
54
+ render(<TrueFalse question={mockQuestion} showCorrectAnswers />);
55
+ expect(screen.getByText("Explanation:")).toBeInTheDocument();
56
+ expect(screen.getByText("Due to Rayleigh scattering.")).toBeInTheDocument();
57
+ });
58
+
59
+ it("does not show explanation when showCorrectAnswers is false", () => {
60
+ render(<TrueFalse question={mockQuestion} showCorrectAnswers={false} />);
61
+ expect(screen.queryByText("Explanation:")).not.toBeInTheDocument();
62
+ });
63
+
64
+ it("pre-selects answer from sessionAnswers", () => {
65
+ render(
66
+ <TrueFalse
67
+ question={mockQuestion}
68
+ sessionAnswers={[{ uid: "sa1", answerUid: "a1" }]}
69
+ />,
70
+ );
71
+ const radios = screen.getAllByRole("radio");
72
+ expect(radios[0]).toBeChecked();
73
+ });
74
+
75
+ it("sorts answers by sequence", () => {
76
+ const reversed: QuestionData = {
77
+ ...mockQuestion,
78
+ answers: [
79
+ { uid: "a2", content: "False", isCorrect: false, sequence: 2 },
80
+ { uid: "a1", content: "True", isCorrect: true, sequence: 1 },
81
+ ],
82
+ };
83
+
84
+ render(<TrueFalse question={reversed} />);
85
+ const radios = screen.getAllByRole("radio");
86
+ const labels = radios.map((r) => r.closest("label")?.textContent);
87
+ expect(labels).toEqual(["True", "False"]);
88
+ });
89
+ });
@@ -0,0 +1,90 @@
1
+ import { useState, useEffect } from "react";
2
+ import type { QuestionProps } from "./types";
3
+ import { Alert, AlertDescription } from "../ui/alert";
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * TrueFalse renders a true/false question with two radio button options.
8
+ *
9
+ * @example
10
+ * <TrueFalse
11
+ * question={question}
12
+ * onAnswer={(answers) => handleAnswer(answers)}
13
+ * />
14
+ */
15
+ export const TrueFalse = ({
16
+ question,
17
+ sessionAnswers,
18
+ onAnswer,
19
+ readOnly = false,
20
+ showCorrectAnswers = false,
21
+ disabled = false,
22
+ }: QuestionProps) => {
23
+ const [selectedAnswer, setSelectedAnswer] = useState<string>("");
24
+
25
+ const sortedAnswers = [...(question.answers || [])].sort(
26
+ (a, b) => a.sequence - b.sequence,
27
+ );
28
+
29
+ const handleChange = (uid: string) => {
30
+ if (readOnly || disabled) return;
31
+
32
+ setSelectedAnswer(uid);
33
+ onAnswer?.([{ uid }]);
34
+ };
35
+
36
+ useEffect(() => {
37
+ const current = sessionAnswers?.[0]?.answerUid || "";
38
+ setSelectedAnswer(current);
39
+ }, [sessionAnswers]);
40
+
41
+ const getAnswerClasses = (answerUid: string) => {
42
+ if (!showCorrectAnswers) return "px-2";
43
+
44
+ const answer = question.answers?.find((a) => a.uid === answerUid);
45
+ if (answer?.isCorrect) {
46
+ return "bg-success/10 border border-success/30 px-2";
47
+ }
48
+ if (selectedAnswer === answerUid && !answer?.isCorrect) {
49
+ return "bg-destructive/10 border border-destructive/30 px-2";
50
+ }
51
+ return "px-2";
52
+ };
53
+
54
+ return (
55
+ <div className="flex flex-col gap-4">
56
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
57
+
58
+ <div className="flex flex-col gap-2">
59
+ {sortedAnswers.map((answer) => (
60
+ <div
61
+ key={answer.uid}
62
+ className={cn("rounded-md transition-colors", getAnswerClasses(answer.uid))}
63
+ >
64
+ <label className="flex items-center gap-2 cursor-pointer py-1 has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
65
+ <input
66
+ type="radio"
67
+ name={question.uid}
68
+ value={answer.uid}
69
+ checked={selectedAnswer === answer.uid}
70
+ onChange={() => handleChange(answer.uid)}
71
+ disabled={readOnly || disabled}
72
+ className="accent-primary m-0 shrink-0"
73
+ />
74
+ <span dangerouslySetInnerHTML={{ __html: answer.content }} />
75
+ </label>
76
+ </div>
77
+ ))}
78
+ </div>
79
+
80
+ {showCorrectAnswers && question.explanation && (
81
+ <Alert className="mt-2">
82
+ <AlertDescription>
83
+ <strong>Explanation:</strong>{" "}
84
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
85
+ </AlertDescription>
86
+ </Alert>
87
+ )}
88
+ </div>
89
+ );
90
+ };