@hydralms/components 0.1.1 → 0.1.3

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 (131) hide show
  1. package/package.json +52 -1
  2. package/src/__tests__/setup.ts +1 -0
  3. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  4. package/src/assessment-toolbar/index.ts +10 -0
  5. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  6. package/src/assessment-toolbar/timer-display.tsx +73 -0
  7. package/src/assessment-toolbar/types.ts +92 -0
  8. package/src/assets/hydra-icon.png +0 -0
  9. package/src/assets/hydra-icon.svg +18 -0
  10. package/src/assets/hydra-lms-icon.png +0 -0
  11. package/src/assets/hydra-lms-icon.svg +9 -0
  12. package/src/common/confirm-dialog.tsx +60 -0
  13. package/src/common/due-date-display.tsx +64 -0
  14. package/src/common/empty-state.tsx +24 -0
  15. package/src/common/index.ts +12 -0
  16. package/src/common/search-input.tsx +68 -0
  17. package/src/common/status-badge.test.tsx +43 -0
  18. package/src/common/status-badge.tsx +81 -0
  19. package/src/common/types.ts +129 -0
  20. package/src/content/content-block.tsx +116 -0
  21. package/src/content/file-upload-zone.tsx +109 -0
  22. package/src/content/index.ts +7 -0
  23. package/src/content/types.ts +76 -0
  24. package/src/curriculum/curriculum-item.tsx +81 -0
  25. package/src/curriculum/curriculum-tree.tsx +69 -0
  26. package/src/curriculum/index.ts +11 -0
  27. package/src/curriculum/learning-object-icon.tsx +44 -0
  28. package/src/curriculum/types.ts +83 -0
  29. package/src/feedback/feedback-banner.tsx +46 -0
  30. package/src/feedback/index.ts +8 -0
  31. package/src/feedback/likert-scale.tsx +58 -0
  32. package/src/feedback/star-rating.tsx +65 -0
  33. package/src/feedback/types.ts +86 -0
  34. package/src/flashcards/flashcard-deck.tsx +130 -0
  35. package/src/flashcards/flashcard.tsx +108 -0
  36. package/src/flashcards/index.ts +3 -0
  37. package/src/flashcards/types.ts +60 -0
  38. package/src/index.ts +38 -0
  39. package/src/lib/utils.ts +6 -0
  40. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  41. package/src/modules/CoursePlayer/types.ts +48 -0
  42. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  43. package/src/modules/FlashcardLab/types.ts +58 -0
  44. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  45. package/src/modules/QuizModule/types.ts +56 -0
  46. package/src/modules/index.ts +12 -0
  47. package/src/progress/grade-indicator.tsx +65 -0
  48. package/src/progress/index.ts +8 -0
  49. package/src/progress/progress-ring.tsx +56 -0
  50. package/src/progress/stat-card.tsx +42 -0
  51. package/src/progress/types.ts +73 -0
  52. package/src/provider/HydraProvider.tsx +26 -0
  53. package/src/provider/index.ts +2 -0
  54. package/src/questions/choice.tsx +90 -0
  55. package/src/questions/essay.tsx +59 -0
  56. package/src/questions/fill-in-the-blank.tsx +69 -0
  57. package/src/questions/index.ts +14 -0
  58. package/src/questions/multiple-choice.test.tsx +104 -0
  59. package/src/questions/multiple-choice.tsx +97 -0
  60. package/src/questions/question-renderer.tsx +37 -0
  61. package/src/questions/true-false.test.tsx +89 -0
  62. package/src/questions/true-false.tsx +90 -0
  63. package/src/questions/types.ts +53 -0
  64. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  65. package/src/sections/AnnouncementFeed/types.ts +50 -0
  66. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  67. package/src/sections/AssessmentReview/types.ts +61 -0
  68. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  69. package/src/sections/AssignmentSubmission/types.ts +60 -0
  70. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  71. package/src/sections/CertificateViewer/types.ts +45 -0
  72. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  73. package/src/sections/CourseOutline/types.ts +53 -0
  74. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  75. package/src/sections/DiscussionThread/types.ts +77 -0
  76. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  77. package/src/sections/ExamSession/types.ts +64 -0
  78. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  79. package/src/sections/FlashcardStudySession/types.ts +42 -0
  80. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  81. package/src/sections/GradebookTable/types.ts +75 -0
  82. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  83. package/src/sections/LecturePlayer/types.ts +48 -0
  84. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  85. package/src/sections/LessonPage/types.ts +41 -0
  86. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  87. package/src/sections/PracticeQuiz/types.ts +44 -0
  88. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  89. package/src/sections/ProgressDashboard/types.ts +74 -0
  90. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  91. package/src/sections/QuizSession/types.ts +47 -0
  92. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  93. package/src/sections/ResourceLibrary/types.ts +57 -0
  94. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  95. package/src/sections/ScrollableQuiz/types.ts +40 -0
  96. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  97. package/src/sections/SurveyForm/types.ts +69 -0
  98. package/src/sections/index.ts +90 -0
  99. package/src/social/index.ts +3 -0
  100. package/src/social/post-card.tsx +91 -0
  101. package/src/social/types.ts +57 -0
  102. package/src/social/user-avatar.tsx +76 -0
  103. package/src/styles/globals.css +125 -0
  104. package/src/ui/alert-dialog.tsx +343 -0
  105. package/src/ui/alert.tsx +65 -0
  106. package/src/ui/avatar.tsx +52 -0
  107. package/src/ui/badge.tsx +53 -0
  108. package/src/ui/button.tsx +62 -0
  109. package/src/ui/card.tsx +92 -0
  110. package/src/ui/index.ts +44 -0
  111. package/src/ui/input.tsx +21 -0
  112. package/src/ui/progress.tsx +73 -0
  113. package/src/ui/separator.tsx +29 -0
  114. package/src/ui/skeleton.tsx +15 -0
  115. package/src/ui/slot.tsx +48 -0
  116. package/src/ui/table.tsx +108 -0
  117. package/src/ui/tabs.tsx +147 -0
  118. package/src/ui/textarea.tsx +20 -0
  119. package/src/ui/tooltip.tsx +177 -0
  120. package/src/utils/debounce.test.ts +59 -0
  121. package/src/utils/debounce.ts +10 -0
  122. package/src/utils/format-duration.test.ts +55 -0
  123. package/src/utils/format-duration.ts +30 -0
  124. package/src/video/index.ts +17 -0
  125. package/src/video/types.ts +216 -0
  126. package/src/video/video-bookmark.tsx +76 -0
  127. package/src/video/video-chapter-list.tsx +93 -0
  128. package/src/video/video-player.tsx +103 -0
  129. package/src/video/video-playlist-item.tsx +90 -0
  130. package/src/video/video-thumbnail-card.tsx +74 -0
  131. package/src/video/video-transcript.tsx +102 -0
@@ -0,0 +1,218 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Download, Grid, List as ListIcon } from "lucide-react";
3
+ import { LearningObjectIcon } from "../../curriculum";
4
+ import { SearchInput, EmptyState } from "../../common";
5
+ import { Button } from "../../ui/button";
6
+ import { Card, CardContent } from "../../ui/card";
7
+ import { Tabs, TabsList, TabsTrigger } from "../../ui/tabs";
8
+ import { Tooltip, TooltipTrigger, TooltipContent } from "../../ui/tooltip";
9
+ import type { ResourceLibraryProps, Resource } from "./types";
10
+ import { cn } from "../../lib/utils";
11
+
12
+ function formatBytes(bytes: number): string {
13
+ if (bytes < 1024) return `${bytes} B`;
14
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
15
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
16
+ }
17
+
18
+ const TYPE_TO_ICON: Record<string, string> = {
19
+ pdf: "document",
20
+ document: "document",
21
+ video: "video",
22
+ link: "link",
23
+ image: "document",
24
+ archive: "document",
25
+ other: "document",
26
+ };
27
+
28
+ export function ResourceLibrary({
29
+ resources,
30
+ categories,
31
+ onResourceClick,
32
+ onDownload,
33
+ viewMode: initialViewMode = "list",
34
+ allowViewToggle = true,
35
+ showSearch = true,
36
+ emptyMessage = "No resources found",
37
+ readOnly = false,
38
+ className,
39
+ style,
40
+ }: ResourceLibraryProps) {
41
+ const [searchQuery, setSearchQuery] = useState("");
42
+ const [activeCategoryUid, setActiveCategoryUid] = useState<string | null>(null);
43
+ const [viewMode, setViewMode] = useState(initialViewMode);
44
+
45
+ const filtered = useMemo(() => {
46
+ let result = resources;
47
+ if (activeCategoryUid) {
48
+ result = result.filter((r) => r.categoryUid === activeCategoryUid);
49
+ }
50
+ if (searchQuery.trim()) {
51
+ const q = searchQuery.toLowerCase();
52
+ result = result.filter(
53
+ (r) =>
54
+ r.name.toLowerCase().includes(q) ||
55
+ r.description?.toLowerCase().includes(q),
56
+ );
57
+ }
58
+ return result;
59
+ }, [resources, activeCategoryUid, searchQuery]);
60
+
61
+ function renderResource(resource: Resource) {
62
+ const iconType = TYPE_TO_ICON[resource.type] ?? "document";
63
+
64
+ return (
65
+ <div
66
+ key={resource.uid}
67
+ className={cn(
68
+ "flex items-center gap-3 px-3 py-2",
69
+ !readOnly && "cursor-pointer hover:bg-muted",
70
+ readOnly && "opacity-70",
71
+ )}
72
+ onClick={() => !readOnly && onResourceClick(resource)}
73
+ >
74
+ <div className="min-w-10">
75
+ <LearningObjectIcon type={iconType} size={20} />
76
+ </div>
77
+ <div className="flex-1 min-w-0">
78
+ <span className="text-sm text-foreground">{resource.name}</span>
79
+ {(resource.description || resource.fileSize != null) && (
80
+ <span className="text-sm text-muted-foreground">
81
+ {[
82
+ resource.description,
83
+ resource.fileSize != null && formatBytes(resource.fileSize),
84
+ ]
85
+ .filter(Boolean)
86
+ .join(" \u00b7 ")}
87
+ </span>
88
+ )}
89
+ </div>
90
+ {onDownload && (
91
+ <Tooltip>
92
+ <TooltipTrigger>
93
+ <Button
94
+ variant="ghost"
95
+ size="icon-xs"
96
+ aria-label="Download"
97
+ onClick={(e) => {
98
+ e.stopPropagation();
99
+ onDownload(resource);
100
+ }}
101
+ >
102
+ <Download size={16} />
103
+ </Button>
104
+ </TooltipTrigger>
105
+ <TooltipContent>Download</TooltipContent>
106
+ </Tooltip>
107
+ )}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <div className={className} style={style}>
114
+ {/* Toolbar */}
115
+ <div className="flex gap-2 items-center mb-2">
116
+ {showSearch && (
117
+ <div className="flex-1 max-w-80">
118
+ <SearchInput
119
+ value={searchQuery}
120
+ onChange={setSearchQuery}
121
+ placeholder="Search resources..."
122
+ size="small"
123
+ />
124
+ </div>
125
+ )}
126
+ {allowViewToggle && (
127
+ <div className="flex gap-0.5">
128
+ <Tooltip>
129
+ <TooltipTrigger>
130
+ <Button
131
+ variant="ghost"
132
+ size="icon-xs"
133
+ aria-label="List view"
134
+ className={cn(viewMode === "list" && "text-primary")}
135
+ onClick={() => setViewMode("list")}
136
+ >
137
+ <ListIcon size={18} />
138
+ </Button>
139
+ </TooltipTrigger>
140
+ <TooltipContent>List view</TooltipContent>
141
+ </Tooltip>
142
+ <Tooltip>
143
+ <TooltipTrigger>
144
+ <Button
145
+ variant="ghost"
146
+ size="icon-xs"
147
+ aria-label="Grid view"
148
+ className={cn(viewMode === "grid" && "text-primary")}
149
+ onClick={() => setViewMode("grid")}
150
+ >
151
+ <Grid size={18} />
152
+ </Button>
153
+ </TooltipTrigger>
154
+ <TooltipContent>Grid view</TooltipContent>
155
+ </Tooltip>
156
+ </div>
157
+ )}
158
+ </div>
159
+
160
+ {/* Category tabs */}
161
+ {categories && categories.length > 0 && (
162
+ <Tabs
163
+ value={activeCategoryUid ?? "all"}
164
+ onValueChange={(v) => setActiveCategoryUid(v === "all" ? null : v)}
165
+ className="mb-2"
166
+ >
167
+ <TabsList>
168
+ <TabsTrigger value="all">All</TabsTrigger>
169
+ {categories.map((cat) => (
170
+ <TabsTrigger key={cat.uid} value={cat.uid}>{cat.label}</TabsTrigger>
171
+ ))}
172
+ </TabsList>
173
+ </Tabs>
174
+ )}
175
+
176
+ {/* Resource list */}
177
+ {filtered.length === 0 ? (
178
+ <EmptyState title={emptyMessage} description="Try adjusting your search or filter." />
179
+ ) : viewMode === "list" ? (
180
+ <Card>
181
+ {filtered.map(renderResource)}
182
+ </Card>
183
+ ) : (
184
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-2">
185
+ {filtered.map((resource) => (
186
+ <Card
187
+ key={resource.uid}
188
+ className={cn(
189
+ "transition-colors",
190
+ !readOnly && "cursor-pointer hover:border-primary",
191
+ )}
192
+ onClick={() => !readOnly && onResourceClick(resource)}
193
+ >
194
+ <CardContent className="pt-4 pb-4">
195
+ <div className="flex gap-1 items-center mb-1">
196
+ <LearningObjectIcon type={TYPE_TO_ICON[resource.type] ?? "document"} size={20} />
197
+ <span className="font-semibold text-sm text-foreground truncate">
198
+ {resource.name}
199
+ </span>
200
+ </div>
201
+ {resource.description && (
202
+ <p className="text-sm text-muted-foreground mb-1 truncate">
203
+ {resource.description}
204
+ </p>
205
+ )}
206
+ {resource.fileSize != null && (
207
+ <span className="text-xs text-muted-foreground">
208
+ {formatBytes(resource.fileSize)}
209
+ </span>
210
+ )}
211
+ </CardContent>
212
+ </Card>
213
+ ))}
214
+ </div>
215
+ )}
216
+ </div>
217
+ );
218
+ }
@@ -0,0 +1,57 @@
1
+
2
+ /**
3
+ * ResourceLibrary section — a searchable resource catalog.
4
+ *
5
+ * Displays downloadable course resources in a grid or list view with
6
+ * search, category tabs, and view mode toggling.
7
+ *
8
+ * @example
9
+ * <ResourceLibrary
10
+ * resources={resources}
11
+ * categories={categories}
12
+ * onResourceClick={(r) => download(r)}
13
+ * />
14
+ */
15
+ export interface ResourceLibraryProps {
16
+ /** Resources to display */
17
+ resources: Resource[];
18
+ /** Optional categories for tab filtering */
19
+ categories?: { uid: string; label: string }[];
20
+ /** Called when the user clicks a resource */
21
+ onResourceClick: (resource: Resource) => void;
22
+ /** Called when the user downloads a resource */
23
+ onDownload?: (resource: Resource) => void;
24
+ /** Layout view mode */
25
+ viewMode?: "grid" | "list";
26
+ /** Whether the user can toggle between grid and list */
27
+ allowViewToggle?: boolean;
28
+ /** Whether to show search */
29
+ showSearch?: boolean;
30
+ /** Empty state message */
31
+ emptyMessage?: string;
32
+ /** When true, disables interactions */
33
+ readOnly?: boolean;
34
+ /** CSS class name for the root element */
35
+ className?: string;
36
+ /** Inline styles for the root element */
37
+ style?: React.CSSProperties;
38
+ }
39
+
40
+ export interface Resource {
41
+ /** Unique identifier */
42
+ uid: string;
43
+ /** Resource name */
44
+ name: string;
45
+ /** Optional description */
46
+ description?: string;
47
+ /** Resource type */
48
+ type: "pdf" | "document" | "video" | "link" | "image" | "archive" | "other";
49
+ /** Resource URL */
50
+ url: string;
51
+ /** File size in bytes */
52
+ fileSize?: number;
53
+ /** Category UID for filtering */
54
+ categoryUid?: string;
55
+ /** Date added as ISO string */
56
+ addedAt?: string;
57
+ }
@@ -0,0 +1,170 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { QuestionRenderer } from "../../questions";
3
+ import type { SessionAnswer } from "../../questions/types";
4
+ import { Button } from "../../ui/button";
5
+ import { Card, CardContent } from "../../ui/card";
6
+ import { cn } from "../../lib/utils";
7
+ import type { ScrollableQuizProps } from "./types";
8
+
9
+ export function ScrollableQuiz({
10
+ questions,
11
+ initialAnswers = [],
12
+ onSubmit,
13
+ onAnswerChange,
14
+ showNavigator = true,
15
+ showQuestionNumbers = true,
16
+ questionGroups,
17
+ isSubmitting = false,
18
+ readOnly = false,
19
+ className,
20
+ style,
21
+ }: ScrollableQuizProps) {
22
+ const [sessionAnswers, setSessionAnswers] = useState<SessionAnswer[]>(initialAnswers);
23
+ const [activeUid, setActiveUid] = useState<string | null>(questions[0]?.uid ?? null);
24
+ const questionRefs = useRef<Map<string, HTMLElement>>(new Map());
25
+
26
+ const answeredCount = useMemo(() => {
27
+ const answered = new Set(sessionAnswers.map((a) => a.uid));
28
+ return questions.filter((q) => answered.has(q.uid)).length;
29
+ }, [questions, sessionAnswers]);
30
+
31
+ useEffect(() => {
32
+ const observer = new IntersectionObserver(
33
+ (entries) => {
34
+ for (const entry of entries) {
35
+ if (entry.isIntersecting) {
36
+ setActiveUid(entry.target.getAttribute("data-question-uid"));
37
+ }
38
+ }
39
+ },
40
+ { rootMargin: "-20% 0px -60% 0px" },
41
+ );
42
+
43
+ questionRefs.current.forEach((el) => observer.observe(el));
44
+ return () => observer.disconnect();
45
+ }, [questions]);
46
+
47
+ const setRef = useCallback((uid: string, el: HTMLElement | null) => {
48
+ if (el) questionRefs.current.set(uid, el);
49
+ else questionRefs.current.delete(uid);
50
+ }, []);
51
+
52
+ function handleAnswer(questionUid: string, rawAnswers: { uid: string; content?: string }[]) {
53
+ const newAnswers: SessionAnswer[] = rawAnswers.map((a) => ({
54
+ uid: questionUid,
55
+ answerUid: a.uid,
56
+ content: a.content,
57
+ }));
58
+ setSessionAnswers((prev) => {
59
+ const filtered = prev.filter((a) => a.uid !== questionUid);
60
+ const merged = [...filtered, ...newAnswers];
61
+ onAnswerChange?.(merged);
62
+ return merged;
63
+ });
64
+ }
65
+
66
+ function scrollToQuestion(uid: string) {
67
+ questionRefs.current.get(uid)?.scrollIntoView({ behavior: "smooth", block: "center" });
68
+ }
69
+
70
+ const orderedQuestions = useMemo(() => {
71
+ if (!questionGroups) return [{ label: null, questions }];
72
+ const grouped: { label: string | null; questions: typeof questions }[] = questionGroups.map(
73
+ (g) => ({
74
+ label: g.label,
75
+ questions: g.questionUids
76
+ .map((uid) => questions.find((q) => q.uid === uid))
77
+ .filter(Boolean) as typeof questions,
78
+ }),
79
+ );
80
+ const groupedUids = new Set(questionGroups.flatMap((g) => g.questionUids));
81
+ const ungrouped = questions.filter((q) => !groupedUids.has(q.uid));
82
+ if (ungrouped.length > 0) grouped.push({ label: null, questions: ungrouped });
83
+ return grouped;
84
+ }, [questions, questionGroups]);
85
+
86
+ let globalIndex = 0;
87
+
88
+ return (
89
+ <div className={cn("flex gap-3", className)} style={style}>
90
+ {/* Main content */}
91
+ <div className="flex-1 min-w-0">
92
+ {orderedQuestions.map((group, gi) => (
93
+ <div key={gi}>
94
+ {group.label && (
95
+ <p className={cn("text-lg font-semibold mb-2 text-foreground", gi > 0 && "mt-4")}>
96
+ {group.label}
97
+ </p>
98
+ )}
99
+ {group.questions.map((q) => {
100
+ const qIndex = globalIndex++;
101
+ return (
102
+ <Card
103
+ key={q.uid}
104
+ ref={(el: HTMLElement | null) => setRef(q.uid, el)}
105
+ data-question-uid={q.uid}
106
+ className="mb-2"
107
+ >
108
+ <CardContent className="pt-6">
109
+ {showQuestionNumbers && (
110
+ <p className="font-semibold text-sm text-muted-foreground mb-1">
111
+ Question {qIndex + 1}
112
+ </p>
113
+ )}
114
+ <QuestionRenderer
115
+ question={q}
116
+ sessionAnswers={sessionAnswers.filter((a) => a.uid === q.uid)}
117
+ onAnswer={(answers) => handleAnswer(q.uid, answers)}
118
+ readOnly={readOnly}
119
+ />
120
+ </CardContent>
121
+ </Card>
122
+ );
123
+ })}
124
+ </div>
125
+ ))}
126
+ <div className="mt-3 flex justify-between items-center">
127
+ <span className="text-sm text-muted-foreground">
128
+ {answeredCount} of {questions.length} answered
129
+ </span>
130
+ <Button
131
+ onClick={() => onSubmit(sessionAnswers)}
132
+ disabled={isSubmitting || readOnly}
133
+ >
134
+ {isSubmitting ? "Submitting..." : "Submit"}
135
+ </Button>
136
+ </div>
137
+ </div>
138
+
139
+ {/* Sidebar navigator */}
140
+ {showNavigator && (
141
+ <Card className="hidden md:block w-50 shrink-0 sticky top-4 self-start p-3">
142
+ <p className="font-semibold text-sm mb-1 text-foreground">Questions</p>
143
+ <div className="flex flex-wrap gap-1">
144
+ {questions.map((q, i) => {
145
+ const isAnswered = sessionAnswers.some((a) => a.uid === q.uid);
146
+ const isActive = activeUid === q.uid;
147
+ return (
148
+ <button
149
+ type="button"
150
+ key={q.uid}
151
+ className={cn(
152
+ "inline-flex items-center justify-center text-xs font-medium min-w-9 px-1 py-0.5 rounded-full cursor-pointer border transition-colors",
153
+ isActive
154
+ ? "bg-primary border-primary text-primary-foreground"
155
+ : isAnswered
156
+ ? "border-success text-success bg-transparent"
157
+ : "border-border text-foreground bg-transparent hover:bg-muted",
158
+ )}
159
+ onClick={() => scrollToQuestion(q.uid)}
160
+ >
161
+ {i + 1}
162
+ </button>
163
+ );
164
+ })}
165
+ </div>
166
+ </Card>
167
+ )}
168
+ </div>
169
+ );
170
+ }
@@ -0,0 +1,40 @@
1
+ import type { QuestionData, SessionAnswer } from "../../questions/types";
2
+
3
+ /**
4
+ * ScrollableQuiz section — all questions on a single scrollable page.
5
+ *
6
+ * Renders every question in a vertical list with a sticky sidebar navigator
7
+ * that highlights the currently visible question. Supports optional question
8
+ * grouping by section labels.
9
+ *
10
+ * @example
11
+ * <ScrollableQuiz
12
+ * questions={questions}
13
+ * onSubmit={(answers) => submit(answers)}
14
+ * showNavigator
15
+ * />
16
+ */
17
+ export interface ScrollableQuizProps {
18
+ /** Ordered list of questions to present */
19
+ questions: QuestionData[];
20
+ /** Pre-populated answers for resuming */
21
+ initialAnswers?: SessionAnswer[];
22
+ /** Called when the user clicks Submit with the full answers array */
23
+ onSubmit: (answers: SessionAnswer[]) => void;
24
+ /** Called whenever the user changes any answer (useful for auto-save) */
25
+ onAnswerChange?: (answers: SessionAnswer[]) => void;
26
+ /** Whether to show the sticky sidebar navigator */
27
+ showNavigator?: boolean;
28
+ /** Whether to show question numbers beside each question */
29
+ showQuestionNumbers?: boolean;
30
+ /** Optional grouping of questions into labeled sections */
31
+ questionGroups?: { label: string; questionUids: string[] }[];
32
+ /** Whether the submit action is in flight */
33
+ isSubmitting?: boolean;
34
+ /** When true, all inputs are disabled */
35
+ readOnly?: boolean;
36
+ /** CSS class name for the root element */
37
+ className?: string;
38
+ /** Inline styles for the root element */
39
+ style?: React.CSSProperties;
40
+ }
@@ -0,0 +1,180 @@
1
+ import { useState, useMemo } from "react";
2
+ import { LikertScale, StarRating } from "../../feedback";
3
+ import { Button } from "../../ui/button";
4
+ import { Card, CardContent } from "../../ui/card";
5
+ import { Progress } from "../../ui/progress";
6
+ import { Textarea } from "../../ui/textarea";
7
+ import type { SurveyFormProps, SurveyAnswer } from "./types";
8
+
9
+ export function SurveyForm({
10
+ title,
11
+ description,
12
+ questions,
13
+ initialAnswers = [],
14
+ onSubmit,
15
+ onAnswerChange,
16
+ showProgress = true,
17
+ requireAll = false,
18
+ submitLabel = "Submit Survey",
19
+ isSubmitting = false,
20
+ readOnly = false,
21
+ className,
22
+ style,
23
+ }: SurveyFormProps) {
24
+ const [answers, setAnswers] = useState<SurveyAnswer[]>(initialAnswers);
25
+
26
+ const answeredCount = useMemo(() => {
27
+ const answered = new Set(answers.map((a) => a.questionUid));
28
+ return questions.filter((q) => answered.has(q.uid)).length;
29
+ }, [questions, answers]);
30
+
31
+ const canSubmit = !requireAll || answeredCount === questions.length;
32
+
33
+ function setAnswer(questionUid: string, value: string | number) {
34
+ setAnswers((prev) => {
35
+ const filtered = prev.filter((a) => a.questionUid !== questionUid);
36
+ const next = [...filtered, { questionUid, value }];
37
+ onAnswerChange?.(next);
38
+ return next;
39
+ });
40
+ }
41
+
42
+ function getAnswer(questionUid: string): SurveyAnswer | undefined {
43
+ return answers.find((a) => a.questionUid === questionUid);
44
+ }
45
+
46
+ return (
47
+ <div className={className} style={style}>
48
+ <p className="text-xl font-bold text-foreground mb-2">{title}</p>
49
+ {description && (
50
+ <div className="mb-2 text-muted-foreground text-sm">
51
+ {typeof description === "string" ? (
52
+ <span>{description}</span>
53
+ ) : (
54
+ description
55
+ )}
56
+ </div>
57
+ )}
58
+ {showProgress && (
59
+ <div className="mb-3">
60
+ <Progress value={(answeredCount / questions.length) * 100} />
61
+ <span className="block text-xs text-muted-foreground mt-0.5">
62
+ {answeredCount} of {questions.length} answered
63
+ </span>
64
+ </div>
65
+ )}
66
+
67
+ {questions.map((q, index) => {
68
+ const answer = getAnswer(q.uid);
69
+ return (
70
+ <Card key={q.uid} className="mb-2"><CardContent className="pt-6">
71
+ <p className="font-medium text-foreground mb-2">
72
+ {index + 1}. {q.content}
73
+ {q.required && (
74
+ <span className="text-destructive ml-0.5">*</span>
75
+ )}
76
+ </p>
77
+
78
+ {q.type === "likert" && (
79
+ <LikertScale
80
+ value={answer ? Number(answer.value) : null}
81
+ onChange={(v) => setAnswer(q.uid, v)}
82
+ points={q.scalePoints}
83
+ lowLabel={q.scaleLabels?.low}
84
+ highLabel={q.scaleLabels?.high}
85
+ readOnly={readOnly}
86
+ />
87
+ )}
88
+
89
+ {q.type === "rating" && (
90
+ <StarRating
91
+ value={answer ? Number(answer.value) : 0}
92
+ onChange={(v) => setAnswer(q.uid, v)}
93
+ readOnly={readOnly}
94
+ />
95
+ )}
96
+
97
+ {q.type === "open_text" && (
98
+ <Textarea
99
+ placeholder="Type your response..."
100
+ value={(answer?.value as string) ?? ""}
101
+ onChange={(e) => setAnswer(q.uid, e.target.value)}
102
+ disabled={readOnly}
103
+ className="min-h-24"
104
+ />
105
+ )}
106
+
107
+ {q.type === "choice" && q.answers && (
108
+ <div className="flex flex-col gap-2">
109
+ {q.answers.map((opt) => (
110
+ <label key={opt.uid} className="flex items-center gap-2 cursor-pointer py-1 text-sm text-foreground has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
111
+ <input
112
+ type="radio"
113
+ className="accent-primary m-0 shrink-0"
114
+ name={`survey-q-${q.uid}`}
115
+ value={opt.uid}
116
+ checked={(answer?.value as string) === opt.uid}
117
+ onChange={() => setAnswer(q.uid, opt.uid)}
118
+ disabled={readOnly}
119
+ />
120
+ <span>{opt.content}</span>
121
+ </label>
122
+ ))}
123
+ </div>
124
+ )}
125
+
126
+ {q.type === "multiple_choice" && q.answers && (
127
+ <div className="flex flex-col gap-2">
128
+ {q.answers.map((opt) => {
129
+ const selected = answers
130
+ .filter((a) => a.questionUid === q.uid)
131
+ .map((a) => String(a.value));
132
+ return (
133
+ <label key={opt.uid} className="flex items-center gap-2 cursor-pointer py-1 text-sm text-foreground has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
134
+ <input
135
+ type="checkbox"
136
+ className="accent-primary m-0 shrink-0"
137
+ checked={selected.includes(opt.uid)}
138
+ disabled={readOnly}
139
+ onChange={(e) => {
140
+ const prev = answers.filter((a) => a.questionUid === q.uid);
141
+ let next: SurveyAnswer[];
142
+ if (e.target.checked) {
143
+ next = [...prev, { questionUid: q.uid, value: opt.uid }];
144
+ } else {
145
+ next = prev.filter((a) => String(a.value) !== opt.uid);
146
+ }
147
+ setAnswers((all) => [
148
+ ...all.filter((a) => a.questionUid !== q.uid),
149
+ ...next,
150
+ ]);
151
+ onAnswerChange?.([
152
+ ...answers.filter((a) => a.questionUid !== q.uid),
153
+ ...next,
154
+ ]);
155
+ }}
156
+ />
157
+ <span>{opt.content}</span>
158
+ </label>
159
+ );
160
+ })}
161
+ </div>
162
+ )}
163
+ </CardContent></Card>
164
+ );
165
+ })}
166
+
167
+ <div className="flex justify-between items-center mt-3">
168
+ <span className="text-sm text-muted-foreground">
169
+ {answeredCount} of {questions.length} answered
170
+ </span>
171
+ <Button
172
+ onClick={() => onSubmit(answers)}
173
+ disabled={!canSubmit || isSubmitting || readOnly}
174
+ >
175
+ {isSubmitting ? "Submitting..." : submitLabel}
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ );
180
+ }