@hydralms/components 0.1.1 → 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.
- package/package.json +3 -1
- package/src/__tests__/setup.ts +1 -0
- package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
- package/src/assessment-toolbar/index.ts +10 -0
- package/src/assessment-toolbar/question-navigator.tsx +86 -0
- package/src/assessment-toolbar/timer-display.tsx +73 -0
- package/src/assessment-toolbar/types.ts +92 -0
- package/src/assets/hydra-icon.png +0 -0
- package/src/assets/hydra-icon.svg +18 -0
- package/src/assets/hydra-lms-icon.png +0 -0
- package/src/assets/hydra-lms-icon.svg +9 -0
- package/src/common/confirm-dialog.tsx +60 -0
- package/src/common/due-date-display.tsx +64 -0
- package/src/common/empty-state.tsx +24 -0
- package/src/common/index.ts +12 -0
- package/src/common/search-input.tsx +68 -0
- package/src/common/status-badge.test.tsx +43 -0
- package/src/common/status-badge.tsx +81 -0
- package/src/common/types.ts +129 -0
- package/src/content/content-block.tsx +116 -0
- package/src/content/file-upload-zone.tsx +109 -0
- package/src/content/index.ts +7 -0
- package/src/content/types.ts +76 -0
- package/src/curriculum/curriculum-item.tsx +81 -0
- package/src/curriculum/curriculum-tree.tsx +69 -0
- package/src/curriculum/index.ts +11 -0
- package/src/curriculum/learning-object-icon.tsx +44 -0
- package/src/curriculum/types.ts +83 -0
- package/src/feedback/feedback-banner.tsx +46 -0
- package/src/feedback/index.ts +8 -0
- package/src/feedback/likert-scale.tsx +58 -0
- package/src/feedback/star-rating.tsx +65 -0
- package/src/feedback/types.ts +86 -0
- package/src/flashcards/flashcard-deck.tsx +130 -0
- package/src/flashcards/flashcard.tsx +108 -0
- package/src/flashcards/index.ts +3 -0
- package/src/flashcards/types.ts +60 -0
- package/src/index.ts +38 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
- package/src/modules/CoursePlayer/types.ts +48 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
- package/src/modules/FlashcardLab/types.ts +58 -0
- package/src/modules/QuizModule/QuizModule.tsx +241 -0
- package/src/modules/QuizModule/types.ts +56 -0
- package/src/modules/index.ts +12 -0
- package/src/progress/grade-indicator.tsx +65 -0
- package/src/progress/index.ts +8 -0
- package/src/progress/progress-ring.tsx +56 -0
- package/src/progress/stat-card.tsx +42 -0
- package/src/progress/types.ts +73 -0
- package/src/provider/HydraProvider.tsx +26 -0
- package/src/provider/index.ts +2 -0
- package/src/questions/choice.tsx +90 -0
- package/src/questions/essay.tsx +59 -0
- package/src/questions/fill-in-the-blank.tsx +69 -0
- package/src/questions/index.ts +14 -0
- package/src/questions/multiple-choice.test.tsx +104 -0
- package/src/questions/multiple-choice.tsx +97 -0
- package/src/questions/question-renderer.tsx +37 -0
- package/src/questions/true-false.test.tsx +89 -0
- package/src/questions/true-false.tsx +90 -0
- package/src/questions/types.ts +53 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
- package/src/sections/AnnouncementFeed/types.ts +50 -0
- package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
- package/src/sections/AssessmentReview/types.ts +61 -0
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
- package/src/sections/AssignmentSubmission/types.ts +60 -0
- package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
- package/src/sections/CertificateViewer/types.ts +45 -0
- package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
- package/src/sections/CourseOutline/types.ts +53 -0
- package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
- package/src/sections/DiscussionThread/types.ts +77 -0
- package/src/sections/ExamSession/ExamSession.tsx +182 -0
- package/src/sections/ExamSession/types.ts +64 -0
- package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
- package/src/sections/FlashcardStudySession/types.ts +42 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
- package/src/sections/GradebookTable/types.ts +75 -0
- package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
- package/src/sections/LecturePlayer/types.ts +48 -0
- package/src/sections/LessonPage/LessonPage.tsx +91 -0
- package/src/sections/LessonPage/types.ts +41 -0
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
- package/src/sections/PracticeQuiz/types.ts +44 -0
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
- package/src/sections/ProgressDashboard/types.ts +74 -0
- package/src/sections/QuizSession/QuizSession.tsx +113 -0
- package/src/sections/QuizSession/types.ts +47 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
- package/src/sections/ResourceLibrary/types.ts +57 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
- package/src/sections/ScrollableQuiz/types.ts +40 -0
- package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
- package/src/sections/SurveyForm/types.ts +69 -0
- package/src/sections/index.ts +90 -0
- package/src/social/index.ts +3 -0
- package/src/social/post-card.tsx +91 -0
- package/src/social/types.ts +57 -0
- package/src/social/user-avatar.tsx +76 -0
- package/src/styles/globals.css +125 -0
- package/src/ui/alert-dialog.tsx +343 -0
- package/src/ui/alert.tsx +65 -0
- package/src/ui/avatar.tsx +52 -0
- package/src/ui/badge.tsx +53 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/index.ts +44 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/slot.tsx +48 -0
- package/src/ui/table.tsx +108 -0
- package/src/ui/tabs.tsx +147 -0
- package/src/ui/textarea.tsx +20 -0
- package/src/ui/tooltip.tsx +177 -0
- package/src/utils/debounce.test.ts +59 -0
- package/src/utils/debounce.ts +10 -0
- package/src/utils/format-duration.test.ts +55 -0
- package/src/utils/format-duration.ts +30 -0
- package/src/video/index.ts +17 -0
- package/src/video/types.ts +216 -0
- package/src/video/video-bookmark.tsx +76 -0
- package/src/video/video-chapter-list.tsx +93 -0
- package/src/video/video-player.tsx +103 -0
- package/src/video/video-playlist-item.tsx +90 -0
- package/src/video/video-thumbnail-card.tsx +74 -0
- package/src/video/video-transcript.tsx +102 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ProgressRing } from "./progress-ring";
|
|
2
|
+
import type { GradeIndicatorProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
const SIZES = { small: 48, medium: 64, large: 96 };
|
|
6
|
+
const FONT_SIZES = { small: "0.75rem", medium: "0.875rem", large: "1rem" };
|
|
7
|
+
const TRACK_HEIGHTS = { small: "4px", medium: "6px", large: "8px" };
|
|
8
|
+
|
|
9
|
+
const COLOR_MAP: Record<string, string> = {
|
|
10
|
+
primary: "var(--primary)",
|
|
11
|
+
success: "var(--success)",
|
|
12
|
+
warning: "var(--warning)",
|
|
13
|
+
destructive: "var(--destructive)",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function getColor(percentage: number, threshold?: number): string {
|
|
17
|
+
if (threshold == null) return "primary";
|
|
18
|
+
if (percentage >= threshold) return "success";
|
|
19
|
+
if (percentage >= threshold * 0.7) return "warning";
|
|
20
|
+
return "destructive";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function GradeIndicator({
|
|
24
|
+
percentage,
|
|
25
|
+
letterGrade,
|
|
26
|
+
variant = "circular",
|
|
27
|
+
size = "medium",
|
|
28
|
+
passingThreshold,
|
|
29
|
+
showLabel = true,
|
|
30
|
+
className,
|
|
31
|
+
style,
|
|
32
|
+
}: GradeIndicatorProps) {
|
|
33
|
+
const colorKey = getColor(percentage, passingThreshold);
|
|
34
|
+
const color = COLOR_MAP[colorKey] ?? "var(--primary)";
|
|
35
|
+
|
|
36
|
+
if (variant === "linear") {
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn("flex flex-row items-center gap-1", className)} style={style}>
|
|
39
|
+
<div className="flex-1 bg-muted rounded-full overflow-hidden" style={{ height: TRACK_HEIGHTS[size] }}>
|
|
40
|
+
<div
|
|
41
|
+
className="h-full rounded-full transition-[width] duration-300 ease-in-out"
|
|
42
|
+
style={{ width: `${percentage}%`, background: color }}
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
{showLabel && (
|
|
46
|
+
<span className="font-semibold min-w-9" style={{ fontSize: FONT_SIZES[size] }}>
|
|
47
|
+
{letterGrade ?? `${Math.round(percentage)}%`}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={cn("inline-flex flex-col items-center", className)} style={style}>
|
|
56
|
+
<ProgressRing
|
|
57
|
+
value={percentage}
|
|
58
|
+
size={SIZES[size]}
|
|
59
|
+
strokeWidth={size === "small" ? 4 : 6}
|
|
60
|
+
color={color}
|
|
61
|
+
label={letterGrade ?? `${Math.round(percentage)}%`}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ProgressRingProps } from "./types";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
export function ProgressRing({
|
|
5
|
+
value,
|
|
6
|
+
size = 120,
|
|
7
|
+
strokeWidth = 8,
|
|
8
|
+
label,
|
|
9
|
+
color,
|
|
10
|
+
className,
|
|
11
|
+
style,
|
|
12
|
+
}: ProgressRingProps) {
|
|
13
|
+
const radius = (size - strokeWidth) / 2;
|
|
14
|
+
const circumference = 2 * Math.PI * radius;
|
|
15
|
+
const offset =
|
|
16
|
+
circumference - (Math.min(Math.max(value, 0), 100) / 100) * circumference;
|
|
17
|
+
const center = size / 2;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={cn("relative inline-flex", className)}
|
|
22
|
+
style={{ width: `${size}px`, height: `${size}px`, ...style }}
|
|
23
|
+
>
|
|
24
|
+
<svg width={size} height={size}>
|
|
25
|
+
<circle
|
|
26
|
+
cx={center}
|
|
27
|
+
cy={center}
|
|
28
|
+
r={radius}
|
|
29
|
+
fill="none"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
strokeWidth={strokeWidth}
|
|
32
|
+
opacity={0.15}
|
|
33
|
+
/>
|
|
34
|
+
<circle
|
|
35
|
+
cx={center}
|
|
36
|
+
cy={center}
|
|
37
|
+
r={radius}
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke={color ?? "currentColor"}
|
|
40
|
+
strokeWidth={strokeWidth}
|
|
41
|
+
strokeDasharray={circumference}
|
|
42
|
+
strokeDashoffset={offset}
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
transform={`rotate(-90 ${center} ${center})`}
|
|
45
|
+
style={{ transition: "stroke-dashoffset 0.4s ease" }}
|
|
46
|
+
/>
|
|
47
|
+
</svg>
|
|
48
|
+
<span
|
|
49
|
+
className="absolute inset-0 flex items-center justify-center font-bold text-foreground"
|
|
50
|
+
style={{ fontSize: `${size * 0.2}px` }}
|
|
51
|
+
>
|
|
52
|
+
{label ?? `${Math.round(value)}%`}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
|
2
|
+
import type { StatCardProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
const TREND_COLORS = {
|
|
6
|
+
up: "var(--success)",
|
|
7
|
+
down: "var(--destructive)",
|
|
8
|
+
flat: "var(--muted-foreground)",
|
|
9
|
+
};
|
|
10
|
+
const TREND_ICONS = { up: TrendingUp, down: TrendingDown, flat: Minus };
|
|
11
|
+
|
|
12
|
+
export function StatCard({
|
|
13
|
+
icon,
|
|
14
|
+
label,
|
|
15
|
+
value,
|
|
16
|
+
subtitle,
|
|
17
|
+
trend,
|
|
18
|
+
className,
|
|
19
|
+
style,
|
|
20
|
+
}: StatCardProps) {
|
|
21
|
+
const TrendIcon = trend ? TREND_ICONS[trend.direction] : null;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className={cn("rounded-md border border-border p-4", className)} style={style}>
|
|
25
|
+
{icon && <div className="mb-1 text-primary [&>svg]:size-6">{icon}</div>}
|
|
26
|
+
<span className="text-xs text-muted-foreground">{label}</span>
|
|
27
|
+
<div className="flex flex-row items-baseline gap-1">
|
|
28
|
+
<span className="text-2xl font-bold">{value}</span>
|
|
29
|
+
{trend && TrendIcon && (
|
|
30
|
+
<span className="flex flex-row items-center gap-px" style={{ color: TREND_COLORS[trend.direction] }}>
|
|
31
|
+
<TrendIcon size={14} />
|
|
32
|
+
<span className="text-xs font-semibold">
|
|
33
|
+
{trend.value > 0 ? "+" : ""}
|
|
34
|
+
{trend.value}%
|
|
35
|
+
</span>
|
|
36
|
+
</span>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
{subtitle && <span className="text-xs text-muted-foreground">{subtitle}</span>}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProgressRing renders a circular SVG progress indicator with a centered label.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* <ProgressRing value={75} size={120} />
|
|
8
|
+
*/
|
|
9
|
+
export interface ProgressRingProps {
|
|
10
|
+
/** Progress value from 0 to 100 */
|
|
11
|
+
value: number;
|
|
12
|
+
/** Diameter of the ring in pixels */
|
|
13
|
+
size?: number;
|
|
14
|
+
/** Stroke width in pixels */
|
|
15
|
+
strokeWidth?: number;
|
|
16
|
+
/** Override text in the center of the ring */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Ring color — defaults to the theme primary */
|
|
19
|
+
color?: string;
|
|
20
|
+
/** CSS class name for the root element */
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Inline styles for the root element */
|
|
23
|
+
style?: React.CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* GradeIndicator displays a grade as a circular or linear percentage badge
|
|
28
|
+
* with pass/fail coloring based on a configurable threshold.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* <GradeIndicator percentage={85} letterGrade="B+" passingThreshold={70} />
|
|
32
|
+
*/
|
|
33
|
+
export interface GradeIndicatorProps {
|
|
34
|
+
/** Grade percentage from 0 to 100 */
|
|
35
|
+
percentage: number;
|
|
36
|
+
/** Optional letter grade label */
|
|
37
|
+
letterGrade?: string;
|
|
38
|
+
/** Display variant */
|
|
39
|
+
variant?: "circular" | "linear";
|
|
40
|
+
/** Component size */
|
|
41
|
+
size?: "small" | "medium" | "large";
|
|
42
|
+
/** Below this percentage the indicator shows warning/error colors */
|
|
43
|
+
passingThreshold?: number;
|
|
44
|
+
/** Whether to show the percentage label */
|
|
45
|
+
showLabel?: boolean;
|
|
46
|
+
/** CSS class name for the root element */
|
|
47
|
+
className?: string;
|
|
48
|
+
/** Inline styles for the root element */
|
|
49
|
+
style?: React.CSSProperties;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* StatCard displays a single key-value statistic with an icon and optional trend indicator.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* <StatCard label="Lessons Completed" value={12} />
|
|
57
|
+
*/
|
|
58
|
+
export interface StatCardProps {
|
|
59
|
+
/** Optional icon displayed at the top */
|
|
60
|
+
icon?: ReactNode;
|
|
61
|
+
/** Stat label */
|
|
62
|
+
label: string;
|
|
63
|
+
/** Stat value */
|
|
64
|
+
value: string | number;
|
|
65
|
+
/** Secondary text below the value */
|
|
66
|
+
subtitle?: string;
|
|
67
|
+
/** Optional trend data */
|
|
68
|
+
trend?: { value: number; direction: "up" | "down" | "flat" };
|
|
69
|
+
/** CSS class name for the root element */
|
|
70
|
+
className?: string;
|
|
71
|
+
/** Inline styles for the root element */
|
|
72
|
+
style?: React.CSSProperties;
|
|
73
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import "../styles/globals.css";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface HydraProviderProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** Controls color mode. Defaults to `"dark"`. Set to `"light"` to use the light theme. */
|
|
7
|
+
colorMode?: "light" | "dark";
|
|
8
|
+
className?: string;
|
|
9
|
+
style?: React.CSSProperties;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function HydraProvider({
|
|
13
|
+
children,
|
|
14
|
+
colorMode = "dark",
|
|
15
|
+
className,
|
|
16
|
+
style,
|
|
17
|
+
}: HydraProviderProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={`hydra-root${colorMode === "dark" ? " dark" : ""}${className ? ` ${className}` : ""}`}
|
|
21
|
+
style={style}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -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
|
+
});
|