@hydralms/components 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ForumBoard-CHXU3mjC.js +2207 -0
- package/dist/ForumBoard-d1w5-r6n.cjs +1 -0
- package/dist/assessment-toolbar/assessment-toolbar.d.ts +1 -1
- package/dist/assessment-toolbar/index.d.ts +5 -1
- package/dist/assessment-toolbar/question-header-bar.d.ts +2 -0
- package/dist/assessment-toolbar/question-materials-drawer.d.ts +2 -0
- package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
- package/dist/assessment-toolbar/types.d.ts +52 -4
- package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
- package/dist/common/index.d.ts +2 -1
- package/dist/common/stepper.d.ts +6 -0
- package/dist/common/types.d.ts +37 -0
- package/dist/components.css +1 -1
- package/dist/content/attachment-list.d.ts +6 -0
- package/dist/content/content-block.d.ts +1 -1
- package/dist/content/index.d.ts +2 -1
- package/dist/content/types.d.ts +39 -0
- package/dist/curriculum/curriculum-item.d.ts +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +551 -312
- package/dist/modules/AssignmentModule/AssignmentModule.d.ts +8 -0
- package/dist/modules/AssignmentModule/types.d.ts +65 -0
- package/dist/modules/CertificateModule/CertificateModule.d.ts +9 -0
- package/dist/modules/CertificateModule/types.d.ts +49 -0
- package/dist/modules/DiscussionModule/DiscussionModule.d.ts +8 -0
- package/dist/modules/DiscussionModule/types.d.ts +47 -0
- package/dist/modules/ExamModule/ExamModule.d.ts +8 -0
- package/dist/modules/ExamModule/types.d.ts +64 -0
- package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +9 -0
- package/dist/modules/GradeCenterModule/types.d.ts +54 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +1 -1
- package/dist/modules/QuizModule/types.d.ts +6 -1
- package/dist/modules/SurveyModule/SurveyModule.d.ts +7 -0
- package/dist/modules/SurveyModule/types.d.ts +49 -0
- package/dist/modules/index.d.ts +12 -0
- package/dist/modules.cjs +1 -0
- package/dist/modules.js +1422 -0
- package/dist/progress/achievement-badge.d.ts +6 -0
- package/dist/progress/activity-timeline.d.ts +6 -0
- package/dist/progress/index.d.ts +4 -1
- package/dist/progress/stat-card.d.ts +1 -1
- package/dist/progress/streak-badge.d.ts +6 -0
- package/dist/progress/types.d.ts +97 -0
- package/dist/questions/essay.d.ts +1 -1
- package/dist/questions/hotspot.d.ts +21 -0
- package/dist/questions/index.d.ts +9 -1
- package/dist/questions/inline-choice.d.ts +21 -0
- package/dist/questions/matching.d.ts +22 -0
- package/dist/questions/numeric.d.ts +11 -0
- package/dist/questions/ordering.d.ts +12 -0
- package/dist/questions/scenario.d.ts +23 -0
- package/dist/questions/scoring.d.ts +22 -0
- package/dist/questions/spreadsheet.d.ts +29 -0
- package/dist/questions/types.d.ts +106 -1
- package/dist/questions/use-drag-reorder.d.ts +17 -0
- package/dist/sections/CertificateViewer/types.d.ts +7 -5
- package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
- package/dist/sections/ExamSession/types.d.ts +6 -1
- package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
- package/dist/sections/ForumBoard/types.d.ts +64 -0
- package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
- package/dist/sections/QuizSession/types.d.ts +6 -1
- package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
- package/dist/sections/RequirementsChecklist/types.d.ts +37 -0
- package/dist/sections/RubricView/RubricView.d.ts +9 -0
- package/dist/sections/RubricView/types.d.ts +50 -0
- package/dist/sections/index.d.ts +7 -1
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +250 -1715
- package/dist/social/post-card.d.ts +1 -1
- package/dist/tabs-DRM2Iq_J.cjs +172 -0
- package/dist/tabs-Wf3h_Cx3.js +21580 -0
- package/dist/ui/alert.d.ts +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/drawer.d.ts +84 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/progress.d.ts +1 -1
- package/dist/ui/rich-text-editor.d.ts +30 -0
- package/dist/ui/rich-text-toolbar.d.ts +8 -0
- package/dist/utils/array-utils.d.ts +4 -0
- package/dist/utils/flatten-leaves.d.ts +6 -0
- package/dist/utils/format-file-size.d.ts +1 -0
- package/dist/utils/format-timestamp.d.ts +1 -0
- package/dist/utils/is-empty-html.d.ts +5 -0
- package/dist/utils/shuffle.d.ts +1 -0
- package/dist/utils/string-utils.d.ts +12 -0
- package/dist/video/video-bookmark.d.ts +1 -1
- package/dist/video/video-playlist-item.d.ts +1 -1
- package/package.json +141 -3
- package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
- package/src/assessment-toolbar/index.ts +6 -0
- package/src/assessment-toolbar/question-header-bar.tsx +61 -0
- package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
- package/src/assessment-toolbar/question-navigator.tsx +3 -31
- package/src/assessment-toolbar/timer-display.tsx +2 -2
- package/src/assessment-toolbar/types.ts +54 -4
- package/src/assessment-toolbar/use-countdown.ts +153 -0
- package/src/common/index.ts +3 -0
- package/src/common/search-input.tsx +7 -6
- package/src/common/stepper.tsx +100 -0
- package/src/common/types.ts +39 -0
- package/src/content/attachment-list.tsx +90 -0
- package/src/content/content-block.tsx +4 -2
- package/src/content/file-upload-zone.tsx +1 -6
- package/src/content/index.ts +3 -0
- package/src/content/types.ts +41 -0
- package/src/curriculum/curriculum-item.tsx +7 -3
- package/src/feedback/feedback-banner.tsx +12 -14
- package/src/flashcards/flashcard-deck.tsx +1 -9
- package/src/flashcards/flashcard.tsx +1 -1
- package/src/modules/AssignmentModule/AssignmentModule.tsx +305 -0
- package/src/modules/AssignmentModule/types.ts +73 -0
- package/src/modules/CertificateModule/CertificateModule.tsx +161 -0
- package/src/modules/CertificateModule/types.ts +47 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +44 -48
- package/src/modules/DiscussionModule/DiscussionModule.tsx +110 -0
- package/src/modules/DiscussionModule/types.ts +54 -0
- package/src/modules/ExamModule/ExamModule.tsx +285 -0
- package/src/modules/ExamModule/types.ts +66 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +29 -16
- package/src/modules/GradeCenterModule/GradeCenterModule.tsx +169 -0
- package/src/modules/GradeCenterModule/types.ts +63 -0
- package/src/modules/QuizModule/QuizModule.tsx +88 -88
- package/src/modules/QuizModule/types.ts +6 -1
- package/src/modules/SurveyModule/SurveyModule.tsx +180 -0
- package/src/modules/SurveyModule/types.ts +51 -0
- package/src/modules/index.ts +24 -0
- package/src/progress/achievement-badge.tsx +52 -0
- package/src/progress/activity-timeline.tsx +84 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/stat-card.tsx +30 -18
- package/src/progress/streak-badge.tsx +35 -0
- package/src/progress/types.ts +101 -0
- package/src/questions/choice.tsx +7 -9
- package/src/questions/essay.tsx +23 -25
- package/src/questions/fill-in-the-blank.tsx +13 -16
- package/src/questions/hotspot.tsx +154 -0
- package/src/questions/index.ts +16 -0
- package/src/questions/inline-choice.tsx +151 -0
- package/src/questions/matching.tsx +228 -0
- package/src/questions/multiple-choice.tsx +7 -9
- package/src/questions/numeric.tsx +102 -0
- package/src/questions/ordering.tsx +159 -0
- package/src/questions/question-renderer.tsx +21 -0
- package/src/questions/scenario.tsx +140 -0
- package/src/questions/scoring.ts +201 -0
- package/src/questions/spreadsheet.tsx +259 -0
- package/src/questions/true-false.tsx +7 -9
- package/src/questions/types.ts +123 -1
- package/src/questions/use-drag-reorder.ts +80 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +2 -15
- package/src/sections/AssessmentReview/AssessmentReview.tsx +13 -2
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +7 -5
- package/src/sections/CertificateViewer/CertificateViewer.tsx +409 -56
- package/src/sections/CertificateViewer/types.ts +13 -5
- package/src/sections/CourseOutline/CourseOutline.tsx +4 -14
- package/src/sections/DiscussionThread/DiscussionThread.tsx +13 -10
- package/src/sections/ExamSession/ExamSession.tsx +44 -7
- package/src/sections/ExamSession/types.ts +6 -1
- package/src/sections/ForumBoard/ForumBoard.tsx +284 -0
- package/src/sections/ForumBoard/types.ts +67 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +1 -1
- package/src/sections/LecturePlayer/LecturePlayer.tsx +1 -1
- package/src/sections/LessonPage/LessonPage.tsx +5 -9
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +15 -26
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +65 -65
- package/src/sections/QuizSession/QuizSession.tsx +67 -8
- package/src/sections/QuizSession/types.ts +6 -1
- package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +107 -0
- package/src/sections/RequirementsChecklist/types.ts +38 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +4 -9
- package/src/sections/RubricView/RubricView.tsx +138 -0
- package/src/sections/RubricView/types.ts +52 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +23 -9
- package/src/sections/SurveyForm/SurveyForm.tsx +8 -5
- package/src/sections/index.ts +20 -1
- package/src/social/post-card.tsx +8 -19
- package/src/social/user-avatar.tsx +1 -0
- package/src/styles/globals.css +13 -0
- package/src/ui/drawer.tsx +600 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/rich-text-editor.tsx +109 -0
- package/src/ui/rich-text-toolbar.tsx +156 -0
- package/src/utils/array-utils.ts +17 -0
- package/src/utils/flatten-leaves.ts +17 -0
- package/src/utils/format-file-size.ts +5 -0
- package/src/utils/format-timestamp.ts +13 -0
- package/src/utils/is-empty-html.ts +7 -0
- package/src/utils/shuffle.ts +8 -0
- package/src/utils/string-utils.ts +30 -0
- package/src/video/video-bookmark.tsx +4 -3
- package/src/video/video-chapter-list.tsx +9 -4
- package/src/video/video-player.tsx +11 -4
- package/src/video/video-playlist-item.tsx +8 -3
- package/src/video/video-thumbnail-card.tsx +4 -0
- package/src/video/video-transcript.tsx +8 -5
- package/dist/table-BrS5cDQu.js +0 -2510
- package/dist/table-D6AkBBEo.cjs +0 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { BookOpen, Flag } from "lucide-react";
|
|
2
|
+
import { Button } from "../ui/button";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import type { QuestionHeaderBarProps } from "./types";
|
|
5
|
+
|
|
6
|
+
export const QuestionHeaderBar = ({
|
|
7
|
+
questionNumber,
|
|
8
|
+
totalQuestions,
|
|
9
|
+
isFlagged,
|
|
10
|
+
onToggleFlag,
|
|
11
|
+
hasMaterials = false,
|
|
12
|
+
onOpenMaterials,
|
|
13
|
+
readOnly = false,
|
|
14
|
+
}: QuestionHeaderBarProps) => {
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex items-center justify-between pb-3 border-b border-border">
|
|
17
|
+
<span className="text-sm font-semibold text-foreground">
|
|
18
|
+
Question {questionNumber}{" "}
|
|
19
|
+
<span className="font-normal text-muted-foreground">
|
|
20
|
+
of {totalQuestions}
|
|
21
|
+
</span>
|
|
22
|
+
</span>
|
|
23
|
+
|
|
24
|
+
{!readOnly && (
|
|
25
|
+
<div className="flex items-center gap-1">
|
|
26
|
+
{hasMaterials && onOpenMaterials && (
|
|
27
|
+
<Button
|
|
28
|
+
variant="ghost"
|
|
29
|
+
size="sm"
|
|
30
|
+
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
|
31
|
+
onClick={onOpenMaterials}
|
|
32
|
+
>
|
|
33
|
+
<BookOpen size={14} />
|
|
34
|
+
Related Material
|
|
35
|
+
</Button>
|
|
36
|
+
)}
|
|
37
|
+
|
|
38
|
+
{onToggleFlag && (
|
|
39
|
+
<Button
|
|
40
|
+
variant="ghost"
|
|
41
|
+
size="icon"
|
|
42
|
+
className={cn(
|
|
43
|
+
"size-8",
|
|
44
|
+
isFlagged
|
|
45
|
+
? "text-warning hover:text-warning/80"
|
|
46
|
+
: "text-muted-foreground hover:text-warning",
|
|
47
|
+
)}
|
|
48
|
+
onClick={onToggleFlag}
|
|
49
|
+
aria-label={isFlagged ? "Unflag question" : "Flag question"}
|
|
50
|
+
>
|
|
51
|
+
<Flag
|
|
52
|
+
size={14}
|
|
53
|
+
fill={isFlagged ? "currentColor" : "none"}
|
|
54
|
+
/>
|
|
55
|
+
</Button>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Drawer,
|
|
3
|
+
DrawerContent,
|
|
4
|
+
DrawerHeader,
|
|
5
|
+
DrawerTitle,
|
|
6
|
+
DrawerBody,
|
|
7
|
+
DrawerClose,
|
|
8
|
+
} from "../ui/drawer";
|
|
9
|
+
import { ContentBlock } from "../content";
|
|
10
|
+
import type { QuestionMaterialsDrawerProps } from "./types";
|
|
11
|
+
|
|
12
|
+
export const QuestionMaterialsDrawer = ({
|
|
13
|
+
open,
|
|
14
|
+
onOpenChange,
|
|
15
|
+
materials,
|
|
16
|
+
questionNumber,
|
|
17
|
+
}: QuestionMaterialsDrawerProps) => {
|
|
18
|
+
if (!materials.length) return null;
|
|
19
|
+
|
|
20
|
+
const drawerTitle = questionNumber
|
|
21
|
+
? `Question ${questionNumber} — Related Material`
|
|
22
|
+
: "Related Material";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Drawer open={open} onOpenChange={onOpenChange} side="right">
|
|
26
|
+
<DrawerContent size="lg" scrollLock={false}>
|
|
27
|
+
<DrawerHeader className="flex-row items-center justify-between">
|
|
28
|
+
<DrawerTitle>{drawerTitle}</DrawerTitle>
|
|
29
|
+
<DrawerClose />
|
|
30
|
+
</DrawerHeader>
|
|
31
|
+
<DrawerBody>
|
|
32
|
+
<div className="space-y-6">
|
|
33
|
+
{materials.map((material, mi) => (
|
|
34
|
+
<section key={mi}>
|
|
35
|
+
{material.label && (
|
|
36
|
+
<h3 className="text-sm font-semibold text-foreground mb-3">
|
|
37
|
+
{material.label}
|
|
38
|
+
</h3>
|
|
39
|
+
)}
|
|
40
|
+
<div className="space-y-4">
|
|
41
|
+
{material.blocks.map((block, bi) => (
|
|
42
|
+
<ContentBlock key={bi} block={block} readOnly />
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
{mi < materials.length - 1 && (
|
|
46
|
+
<div className="border-b border-border mt-6" />
|
|
47
|
+
)}
|
|
48
|
+
</section>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</DrawerBody>
|
|
52
|
+
</DrawerContent>
|
|
53
|
+
</Drawer>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { Flag } from "lucide-react";
|
|
2
1
|
import type { QuestionNavigatorProps } from "./types";
|
|
3
|
-
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
|
|
4
2
|
import { cn } from "../lib/utils";
|
|
5
3
|
|
|
6
4
|
const CHIP_CLASSES = {
|
|
@@ -24,7 +22,6 @@ export const QuestionNavigator = ({
|
|
|
24
22
|
questions,
|
|
25
23
|
currentQuestionUid,
|
|
26
24
|
onNavigate,
|
|
27
|
-
onToggleFlag,
|
|
28
25
|
readOnly = false,
|
|
29
26
|
}: QuestionNavigatorProps) => {
|
|
30
27
|
const getTooltipContent = (q: (typeof questions)[0]): string => {
|
|
@@ -36,7 +33,7 @@ export const QuestionNavigator = ({
|
|
|
36
33
|
};
|
|
37
34
|
|
|
38
35
|
return (
|
|
39
|
-
<div className="flex
|
|
36
|
+
<div className="flex gap-1">
|
|
40
37
|
{questions.map((q) => {
|
|
41
38
|
const variant = getChipVariant(q, currentQuestionUid);
|
|
42
39
|
return (
|
|
@@ -44,40 +41,15 @@ export const QuestionNavigator = ({
|
|
|
44
41
|
key={q.uid}
|
|
45
42
|
title={getTooltipContent(q)}
|
|
46
43
|
className={cn(
|
|
47
|
-
"inline-flex items-center
|
|
44
|
+
"inline-flex items-center justify-center px-1.5 py-0.5 rounded border text-[11px] leading-tight transition-all duration-150 min-w-5.5",
|
|
48
45
|
CHIP_CLASSES[variant],
|
|
49
46
|
readOnly ? "cursor-default" : "cursor-pointer hover:shadow-sm hover:-translate-y-px",
|
|
50
47
|
)}
|
|
51
48
|
onClick={readOnly ? undefined : () => onNavigate?.(q.uid)}
|
|
52
49
|
>
|
|
53
|
-
<span className="
|
|
50
|
+
<span className="font-semibold min-w-3.5 text-center">
|
|
54
51
|
{q.sequence + 1}
|
|
55
52
|
</span>
|
|
56
|
-
{!readOnly && onToggleFlag && (
|
|
57
|
-
<Tooltip>
|
|
58
|
-
<TooltipTrigger>
|
|
59
|
-
<span
|
|
60
|
-
role="button"
|
|
61
|
-
aria-label={q.isFlagged ? "Unflag question" : "Flag question"}
|
|
62
|
-
className={cn(
|
|
63
|
-
"inline-flex cursor-pointer transition-colors duration-150",
|
|
64
|
-
q.isFlagged
|
|
65
|
-
? "text-warning"
|
|
66
|
-
: variant === "current"
|
|
67
|
-
? "text-primary-foreground/60 hover:text-primary-foreground"
|
|
68
|
-
: "text-muted-foreground hover:text-warning",
|
|
69
|
-
)}
|
|
70
|
-
onClick={(e: React.MouseEvent) => {
|
|
71
|
-
e.stopPropagation();
|
|
72
|
-
onToggleFlag(q.uid);
|
|
73
|
-
}}
|
|
74
|
-
>
|
|
75
|
-
<Flag size={11} fill={q.isFlagged ? "currentColor" : "none"} />
|
|
76
|
-
</span>
|
|
77
|
-
</TooltipTrigger>
|
|
78
|
-
<TooltipContent>{q.isFlagged ? "Unflag question" : "Flag question"}</TooltipContent>
|
|
79
|
-
</Tooltip>
|
|
80
|
-
)}
|
|
81
53
|
</div>
|
|
82
54
|
);
|
|
83
55
|
})}
|
|
@@ -52,7 +52,7 @@ export const TimerDisplay = ({
|
|
|
52
52
|
<span className="inline-flex leading-none">
|
|
53
53
|
<Clock size={13} />
|
|
54
54
|
</span>
|
|
55
|
-
<span className="text-xs font-semibold font-mono tracking-wide">
|
|
55
|
+
<span className="text-xs font-semibold font-mono tracking-wide" aria-live="polite" aria-atomic="true">
|
|
56
56
|
{displayTime}
|
|
57
57
|
</span>
|
|
58
58
|
</div>
|
|
@@ -66,7 +66,7 @@ export const TimerDisplay = ({
|
|
|
66
66
|
</span>
|
|
67
67
|
<span className={cn("text-sm", classes.bold && "font-semibold")}>
|
|
68
68
|
{hasTimeLimit ? "Time remaining: " : "Time elapsed: "}
|
|
69
|
-
<span className="font-mono font-semibold">{displayTime}</span>
|
|
69
|
+
<span className="font-mono font-semibold" aria-live="polite" aria-atomic="true">{displayTime}</span>
|
|
70
70
|
</span>
|
|
71
71
|
</div>
|
|
72
72
|
);
|
|
@@ -37,8 +37,6 @@ export interface AssessmentToolbarProps {
|
|
|
37
37
|
questions?: QuestionNavigatorItem[];
|
|
38
38
|
/** Called when the user navigates to a specific question via the navigator */
|
|
39
39
|
onNavigateToQuestion?: (questionUid: string) => void;
|
|
40
|
-
/** Called when the user flags or unflags a question */
|
|
41
|
-
onToggleFlag?: (questionUid: string) => void;
|
|
42
40
|
/** UID of the currently active question */
|
|
43
41
|
currentQuestionUid?: string;
|
|
44
42
|
/** Whether the assessment has been completed/submitted */
|
|
@@ -72,14 +70,12 @@ export interface TimerDisplayProps {
|
|
|
72
70
|
* questions={questions}
|
|
73
71
|
* currentQuestionUid={currentUid}
|
|
74
72
|
* onNavigate={handleNavigate}
|
|
75
|
-
* onToggleFlag={handleFlag}
|
|
76
73
|
* />
|
|
77
74
|
*/
|
|
78
75
|
export interface QuestionNavigatorProps {
|
|
79
76
|
questions: QuestionNavigatorItem[];
|
|
80
77
|
currentQuestionUid?: string;
|
|
81
78
|
onNavigate?: (questionUid: string) => void;
|
|
82
|
-
onToggleFlag?: (questionUid: string) => void;
|
|
83
79
|
readOnly?: boolean;
|
|
84
80
|
}
|
|
85
81
|
|
|
@@ -90,3 +86,57 @@ export interface QuestionNavigatorItem {
|
|
|
90
86
|
isAnswered: boolean;
|
|
91
87
|
isSkipped: boolean;
|
|
92
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* QuestionHeaderBar displays question number, flag toggle, and optional
|
|
92
|
+
* materials button inside the question Card above the QuestionRenderer.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* <QuestionHeaderBar
|
|
96
|
+
* questionNumber={3}
|
|
97
|
+
* totalQuestions={10}
|
|
98
|
+
* isFlagged={false}
|
|
99
|
+
* onToggleFlag={() => toggleFlag(questionUid)}
|
|
100
|
+
* hasMaterials
|
|
101
|
+
* onOpenMaterials={() => setDrawerOpen(true)}
|
|
102
|
+
* />
|
|
103
|
+
*/
|
|
104
|
+
export interface QuestionHeaderBarProps {
|
|
105
|
+
/** 1-based question number */
|
|
106
|
+
questionNumber: number;
|
|
107
|
+
/** Total number of questions */
|
|
108
|
+
totalQuestions: number;
|
|
109
|
+
/** Whether this question is currently flagged */
|
|
110
|
+
isFlagged: boolean;
|
|
111
|
+
/** Called when the user toggles the flag */
|
|
112
|
+
onToggleFlag?: () => void;
|
|
113
|
+
/** Whether the current question has related materials available */
|
|
114
|
+
hasMaterials?: boolean;
|
|
115
|
+
/** Called when the user clicks the materials button */
|
|
116
|
+
onOpenMaterials?: () => void;
|
|
117
|
+
/** When true, hides interactive elements */
|
|
118
|
+
readOnly?: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* QuestionMaterialsDrawer renders a slide-in panel with content blocks
|
|
123
|
+
* linked to the current question for open-book reference.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* <QuestionMaterialsDrawer
|
|
127
|
+
* open={open}
|
|
128
|
+
* onOpenChange={setOpen}
|
|
129
|
+
* materials={currentMaterials}
|
|
130
|
+
* questionNumber={3}
|
|
131
|
+
* />
|
|
132
|
+
*/
|
|
133
|
+
export interface QuestionMaterialsDrawerProps {
|
|
134
|
+
/** Whether the drawer is open */
|
|
135
|
+
open: boolean;
|
|
136
|
+
/** Called when the drawer should open or close */
|
|
137
|
+
onOpenChange: (open: boolean) => void;
|
|
138
|
+
/** One or more material groups to display in the drawer */
|
|
139
|
+
materials: import("../questions/types").QuestionMaterial[];
|
|
140
|
+
/** Question number for the title display */
|
|
141
|
+
questionNumber?: number;
|
|
142
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UseCountdownOptions {
|
|
4
|
+
/** Total countdown duration in seconds */
|
|
5
|
+
durationSeconds: number;
|
|
6
|
+
/** Seconds remaining at which to trigger warning state. @default 60 */
|
|
7
|
+
warningThresholdSeconds?: number;
|
|
8
|
+
/** Called once when the countdown reaches zero */
|
|
9
|
+
onExpire?: () => void;
|
|
10
|
+
/** Called once when the countdown enters warning territory */
|
|
11
|
+
onWarning?: () => void;
|
|
12
|
+
/** Whether to start the countdown immediately. @default false */
|
|
13
|
+
autoStart?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseCountdownReturn {
|
|
17
|
+
/** Seconds remaining in the countdown */
|
|
18
|
+
timeRemaining: number;
|
|
19
|
+
/** Whether the countdown is actively running */
|
|
20
|
+
isRunning: boolean;
|
|
21
|
+
/** Whether the countdown has been paused */
|
|
22
|
+
isPaused: boolean;
|
|
23
|
+
/** Whether the countdown has reached zero */
|
|
24
|
+
isExpired: boolean;
|
|
25
|
+
/** Whether the countdown is within the warning threshold */
|
|
26
|
+
isWarning: boolean;
|
|
27
|
+
/** Start or restart the countdown */
|
|
28
|
+
start: () => void;
|
|
29
|
+
/** Pause the countdown */
|
|
30
|
+
pause: () => void;
|
|
31
|
+
/** Resume after pausing */
|
|
32
|
+
resume: () => void;
|
|
33
|
+
/** Reset to the original duration, stopped */
|
|
34
|
+
reset: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type TimerState = "idle" | "running" | "paused" | "expired";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* useCountdown manages a countdown timer lifecycle with warning and expiry callbacks.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const { timeRemaining, isWarning, start } = useCountdown({
|
|
44
|
+
* durationSeconds: 300,
|
|
45
|
+
* warningThresholdSeconds: 60,
|
|
46
|
+
* onExpire: () => handleAutoSubmit(),
|
|
47
|
+
* });
|
|
48
|
+
*/
|
|
49
|
+
export function useCountdown({
|
|
50
|
+
durationSeconds,
|
|
51
|
+
warningThresholdSeconds = 60,
|
|
52
|
+
onExpire,
|
|
53
|
+
onWarning,
|
|
54
|
+
autoStart = false,
|
|
55
|
+
}: UseCountdownOptions): UseCountdownReturn {
|
|
56
|
+
const [timeRemaining, setTimeRemaining] = useState(durationSeconds);
|
|
57
|
+
const stateRef = useRef<TimerState>(autoStart ? "running" : "idle");
|
|
58
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
59
|
+
const onExpireRef = useRef(onExpire);
|
|
60
|
+
const onWarningRef = useRef(onWarning);
|
|
61
|
+
const warningFiredRef = useRef(false);
|
|
62
|
+
|
|
63
|
+
// Keep callback refs current without recreating intervals
|
|
64
|
+
onExpireRef.current = onExpire;
|
|
65
|
+
onWarningRef.current = onWarning;
|
|
66
|
+
|
|
67
|
+
const clearTimer = useCallback(() => {
|
|
68
|
+
if (intervalRef.current != null) {
|
|
69
|
+
clearInterval(intervalRef.current);
|
|
70
|
+
intervalRef.current = null;
|
|
71
|
+
}
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const startInterval = useCallback(() => {
|
|
75
|
+
clearTimer();
|
|
76
|
+
intervalRef.current = setInterval(() => {
|
|
77
|
+
setTimeRemaining((prev) => {
|
|
78
|
+
const next = prev - 1;
|
|
79
|
+
|
|
80
|
+
// Warning check
|
|
81
|
+
if (
|
|
82
|
+
!warningFiredRef.current &&
|
|
83
|
+
next <= warningThresholdSeconds &&
|
|
84
|
+
next > 0
|
|
85
|
+
) {
|
|
86
|
+
warningFiredRef.current = true;
|
|
87
|
+
onWarningRef.current?.();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Expiry check
|
|
91
|
+
if (next <= 0) {
|
|
92
|
+
clearTimer();
|
|
93
|
+
stateRef.current = "expired";
|
|
94
|
+
onExpireRef.current?.();
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
}, 1000);
|
|
101
|
+
}, [clearTimer, warningThresholdSeconds]);
|
|
102
|
+
|
|
103
|
+
const start = useCallback(() => {
|
|
104
|
+
setTimeRemaining(durationSeconds);
|
|
105
|
+
warningFiredRef.current = false;
|
|
106
|
+
stateRef.current = "running";
|
|
107
|
+
startInterval();
|
|
108
|
+
}, [durationSeconds, startInterval]);
|
|
109
|
+
|
|
110
|
+
const pause = useCallback(() => {
|
|
111
|
+
if (stateRef.current !== "running") return;
|
|
112
|
+
clearTimer();
|
|
113
|
+
stateRef.current = "paused";
|
|
114
|
+
// Force re-render so isPaused updates
|
|
115
|
+
setTimeRemaining((prev) => prev);
|
|
116
|
+
}, [clearTimer]);
|
|
117
|
+
|
|
118
|
+
const resume = useCallback(() => {
|
|
119
|
+
if (stateRef.current !== "paused") return;
|
|
120
|
+
stateRef.current = "running";
|
|
121
|
+
startInterval();
|
|
122
|
+
}, [startInterval]);
|
|
123
|
+
|
|
124
|
+
const reset = useCallback(() => {
|
|
125
|
+
clearTimer();
|
|
126
|
+
stateRef.current = "idle";
|
|
127
|
+
warningFiredRef.current = false;
|
|
128
|
+
setTimeRemaining(durationSeconds);
|
|
129
|
+
}, [clearTimer, durationSeconds]);
|
|
130
|
+
|
|
131
|
+
// Auto-start on mount if requested
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (autoStart) {
|
|
134
|
+
startInterval();
|
|
135
|
+
}
|
|
136
|
+
return clearTimer;
|
|
137
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
138
|
+
|
|
139
|
+
const state = stateRef.current;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
timeRemaining,
|
|
143
|
+
isRunning: state === "running",
|
|
144
|
+
isPaused: state === "paused",
|
|
145
|
+
isExpired: state === "expired",
|
|
146
|
+
isWarning:
|
|
147
|
+
timeRemaining <= warningThresholdSeconds && timeRemaining > 0,
|
|
148
|
+
start,
|
|
149
|
+
pause,
|
|
150
|
+
resume,
|
|
151
|
+
reset,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/common/index.ts
CHANGED
|
@@ -3,10 +3,13 @@ export { ConfirmDialog } from "./confirm-dialog";
|
|
|
3
3
|
export { SearchInput } from "./search-input";
|
|
4
4
|
export { StatusBadge } from "./status-badge";
|
|
5
5
|
export { DueDateDisplay } from "./due-date-display";
|
|
6
|
+
export { Stepper } from "./stepper";
|
|
6
7
|
export type {
|
|
7
8
|
EmptyStateProps,
|
|
8
9
|
ConfirmDialogProps,
|
|
9
10
|
SearchInputProps,
|
|
10
11
|
StatusBadgeProps,
|
|
11
12
|
DueDateDisplayProps,
|
|
13
|
+
StepperProps,
|
|
14
|
+
StepDefinition,
|
|
12
15
|
} from "./types";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useMemo, useRef } from "react";
|
|
2
2
|
import { Search, X } from "lucide-react";
|
|
3
3
|
import { debounce } from "../utils/debounce";
|
|
4
4
|
import type { SearchInputProps } from "./types";
|
|
@@ -16,12 +16,13 @@ export function SearchInput({
|
|
|
16
16
|
}: SearchInputProps) {
|
|
17
17
|
const [localValue, setLocalValue] = useState(value);
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}, [value]);
|
|
19
|
+
const onChangeRef = useRef(onChange);
|
|
20
|
+
onChangeRef.current = onChange;
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const debouncedOnChange = useMemo(
|
|
23
|
+
() => debounce((val: string) => onChangeRef.current(val), debounceMs),
|
|
24
|
+
[debounceMs],
|
|
25
|
+
);
|
|
25
26
|
|
|
26
27
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
27
28
|
const next = e.target.value;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import type { StepperProps } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stepper renders a visual step indicator for multi-step flows,
|
|
7
|
+
* showing completed, current, and upcoming steps with labels.
|
|
8
|
+
*/
|
|
9
|
+
export function Stepper({
|
|
10
|
+
steps,
|
|
11
|
+
currentStep,
|
|
12
|
+
orientation = "horizontal",
|
|
13
|
+
variant = "default",
|
|
14
|
+
className,
|
|
15
|
+
style,
|
|
16
|
+
}: StepperProps) {
|
|
17
|
+
const isHorizontal = orientation === "horizontal";
|
|
18
|
+
const isCompact = variant === "compact";
|
|
19
|
+
|
|
20
|
+
const circleSize = isCompact ? "w-6 h-6 text-xs" : "w-8 h-8 text-sm";
|
|
21
|
+
const iconSize = isCompact ? 12 : 16;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className={cn(
|
|
26
|
+
"flex",
|
|
27
|
+
isHorizontal ? "flex-row items-start" : "flex-col",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
style={style}
|
|
31
|
+
>
|
|
32
|
+
{steps.map((step, index) => {
|
|
33
|
+
const isCompleted = index < currentStep;
|
|
34
|
+
const isCurrent = index === currentStep;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div key={index} className={cn("flex", isHorizontal ? "flex-1 flex-row items-start" : "flex-row items-start")}>
|
|
38
|
+
{/* Step indicator */}
|
|
39
|
+
<div className={cn("flex", isHorizontal ? "flex-col items-center" : "flex-row items-start gap-3")}>
|
|
40
|
+
{/* Circle */}
|
|
41
|
+
<div
|
|
42
|
+
className={cn(
|
|
43
|
+
"rounded-full flex items-center justify-center shrink-0 font-semibold transition-colors",
|
|
44
|
+
circleSize,
|
|
45
|
+
isCompleted && "bg-primary text-primary-foreground",
|
|
46
|
+
isCurrent && "border-2 border-primary bg-primary/10 text-primary",
|
|
47
|
+
!isCompleted && !isCurrent && "border-2 border-border bg-muted text-muted-foreground",
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{isCompleted ? (
|
|
51
|
+
<Check size={iconSize} />
|
|
52
|
+
) : (
|
|
53
|
+
<span>{index + 1}</span>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{/* Label */}
|
|
58
|
+
<div className={cn(isHorizontal ? "mt-1.5 text-center" : "pt-0.5")}>
|
|
59
|
+
<p
|
|
60
|
+
className={cn(
|
|
61
|
+
"text-sm font-medium leading-tight",
|
|
62
|
+
isCurrent ? "text-foreground" : "text-muted-foreground",
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{step.label}
|
|
66
|
+
</p>
|
|
67
|
+
{!isCompact && step.description && (
|
|
68
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
69
|
+
{step.description}
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Connector line */}
|
|
76
|
+
{index < steps.length - 1 && (
|
|
77
|
+
isHorizontal ? (
|
|
78
|
+
<div
|
|
79
|
+
className={cn(
|
|
80
|
+
"flex-1 h-0.5 self-center mt-0 mx-2",
|
|
81
|
+
isCompact ? "mt-3" : "mt-4",
|
|
82
|
+
isCompleted ? "bg-primary" : "bg-border",
|
|
83
|
+
)}
|
|
84
|
+
/>
|
|
85
|
+
) : (
|
|
86
|
+
<div
|
|
87
|
+
className={cn(
|
|
88
|
+
"w-0.5 h-6 ml-3.5",
|
|
89
|
+
isCompact && "ml-2.5",
|
|
90
|
+
isCompleted ? "bg-primary" : "bg-border",
|
|
91
|
+
)}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
package/src/common/types.ts
CHANGED
|
@@ -127,3 +127,42 @@ export interface DueDateDisplayProps {
|
|
|
127
127
|
/** Inline styles for the root element */
|
|
128
128
|
style?: React.CSSProperties;
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A single step definition used by the Stepper component.
|
|
133
|
+
*/
|
|
134
|
+
export interface StepDefinition {
|
|
135
|
+
/** Step label text */
|
|
136
|
+
label: string;
|
|
137
|
+
/** Optional secondary description */
|
|
138
|
+
description?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Stepper renders a visual step indicator for multi-step flows,
|
|
143
|
+
* showing completed, current, and upcoming steps with labels.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* <Stepper
|
|
147
|
+
* steps={[
|
|
148
|
+
* { label: "Setup" },
|
|
149
|
+
* { label: "Questions" },
|
|
150
|
+
* { label: "Results" },
|
|
151
|
+
* ]}
|
|
152
|
+
* currentStep={1}
|
|
153
|
+
* />
|
|
154
|
+
*/
|
|
155
|
+
export interface StepperProps {
|
|
156
|
+
/** Ordered list of steps to display */
|
|
157
|
+
steps: StepDefinition[];
|
|
158
|
+
/** Zero-based index of the currently active step */
|
|
159
|
+
currentStep: number;
|
|
160
|
+
/** Layout orientation. @default 'horizontal' */
|
|
161
|
+
orientation?: "horizontal" | "vertical";
|
|
162
|
+
/** Visual density variant. @default 'default' */
|
|
163
|
+
variant?: "default" | "compact";
|
|
164
|
+
/** CSS class name for the root element */
|
|
165
|
+
className?: string;
|
|
166
|
+
/** Inline styles for the root element */
|
|
167
|
+
style?: React.CSSProperties;
|
|
168
|
+
}
|