@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.
Files changed (131) hide show
  1. package/package.json +3 -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,21 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:opacity-50",
12
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
13
+ "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ );
19
+ }
20
+
21
+ export { Input };
@@ -0,0 +1,73 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "../lib/utils";
5
+
6
+ const progressVariants = cva("h-full rounded-full transition-all duration-300", {
7
+ variants: {
8
+ variant: {
9
+ default: "bg-primary",
10
+ success: "bg-success",
11
+ warning: "bg-warning",
12
+ info: "bg-info",
13
+ },
14
+ size: {
15
+ sm: "",
16
+ default: "",
17
+ },
18
+ },
19
+ defaultVariants: {
20
+ variant: "default",
21
+ size: "default",
22
+ },
23
+ });
24
+
25
+ const trackVariants = cva("bg-muted overflow-hidden rounded-full w-full", {
26
+ variants: {
27
+ size: {
28
+ sm: "h-1.5",
29
+ default: "h-2.5",
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ size: "default",
34
+ },
35
+ });
36
+
37
+ interface ProgressProps
38
+ extends Omit<React.ComponentProps<"div">, "children">,
39
+ VariantProps<typeof progressVariants> {
40
+ value?: number;
41
+ max?: number;
42
+ }
43
+
44
+ function Progress({
45
+ className,
46
+ value = 0,
47
+ max = 100,
48
+ variant,
49
+ size,
50
+ ...props
51
+ }: ProgressProps) {
52
+ const percentage = Math.min(100, Math.max(0, (value / max) * 100));
53
+
54
+ return (
55
+ <div
56
+ data-slot="progress"
57
+ role="progressbar"
58
+ aria-valuenow={value}
59
+ aria-valuemin={0}
60
+ aria-valuemax={max}
61
+ className={cn(trackVariants({ size }), className)}
62
+ {...props}
63
+ >
64
+ <div
65
+ className={progressVariants({ variant, size })}
66
+ style={{ width: `${percentage}%` }}
67
+ />
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export { Progress, progressVariants };
73
+ export type { ProgressProps };
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ function Separator({
6
+ className,
7
+ orientation = "horizontal",
8
+ decorative = true,
9
+ ...props
10
+ }: React.ComponentProps<"div"> & {
11
+ orientation?: "horizontal" | "vertical";
12
+ decorative?: boolean;
13
+ }) {
14
+ return (
15
+ <div
16
+ data-slot="separator"
17
+ role={decorative ? "none" : "separator"}
18
+ aria-orientation={!decorative ? orientation : undefined}
19
+ className={cn(
20
+ "bg-border shrink-0",
21
+ orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ );
27
+ }
28
+
29
+ export { Separator };
@@ -0,0 +1,15 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
6
+ return (
7
+ <div
8
+ data-slot="skeleton"
9
+ className={cn("bg-muted animate-pulse rounded-md", className)}
10
+ {...props}
11
+ />
12
+ );
13
+ }
14
+
15
+ export { Skeleton };
@@ -0,0 +1,48 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Minimal Slot implementation for the asChild pattern.
5
+ * Merges parent props onto a single React element child.
6
+ */
7
+ export function Slot({
8
+ children,
9
+ ...props
10
+ }: React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }) {
11
+ if (React.isValidElement(children)) {
12
+ return React.cloneElement(children, {
13
+ ...mergeProps(props, children.props as Record<string, unknown>),
14
+ ref: (children as React.ReactElement & { ref?: React.Ref<unknown> }).ref,
15
+ } as Record<string, unknown>);
16
+ }
17
+
18
+ if (React.Children.count(children) > 1) {
19
+ React.Children.only(null);
20
+ }
21
+
22
+ return null;
23
+ }
24
+
25
+ function mergeProps(
26
+ slotProps: Record<string, unknown>,
27
+ childProps: Record<string, unknown>,
28
+ ) {
29
+ const overrideProps: Record<string, unknown> = { ...childProps };
30
+
31
+ for (const propName in childProps) {
32
+ const slotVal = slotProps[propName];
33
+ const childVal = childProps[propName];
34
+
35
+ if (propName === "style") {
36
+ overrideProps[propName] = { ...(slotVal as object), ...(childVal as object) };
37
+ } else if (propName === "className") {
38
+ overrideProps[propName] = [slotVal, childVal].filter(Boolean).join(" ");
39
+ } else if (typeof slotVal === "function" && typeof childVal === "function") {
40
+ overrideProps[propName] = (...args: unknown[]) => {
41
+ childVal(...args);
42
+ slotVal(...args);
43
+ };
44
+ }
45
+ }
46
+
47
+ return { ...slotProps, ...overrideProps };
48
+ }
@@ -0,0 +1,108 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
6
+ return (
7
+ <div data-slot="table-container" className="relative w-full overflow-auto">
8
+ <table
9
+ data-slot="table"
10
+ className={cn("w-full caption-bottom text-sm", className)}
11
+ {...props}
12
+ />
13
+ </div>
14
+ );
15
+ }
16
+
17
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
18
+ return (
19
+ <thead
20
+ data-slot="table-header"
21
+ className={cn("[&_tr]:border-b", className)}
22
+ {...props}
23
+ />
24
+ );
25
+ }
26
+
27
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
28
+ return (
29
+ <tbody
30
+ data-slot="table-body"
31
+ className={cn("[&_tr:last-child]:border-0", className)}
32
+ {...props}
33
+ />
34
+ );
35
+ }
36
+
37
+ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
38
+ return (
39
+ <tfoot
40
+ data-slot="table-footer"
41
+ className={cn(
42
+ "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
43
+ className,
44
+ )}
45
+ {...props}
46
+ />
47
+ );
48
+ }
49
+
50
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
51
+ return (
52
+ <tr
53
+ data-slot="table-row"
54
+ className={cn(
55
+ "border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
56
+ className,
57
+ )}
58
+ {...props}
59
+ />
60
+ );
61
+ }
62
+
63
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
64
+ return (
65
+ <th
66
+ data-slot="table-head"
67
+ className={cn(
68
+ "text-muted-foreground h-10 px-3 text-left align-middle font-semibold whitespace-nowrap text-sm [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
69
+ className,
70
+ )}
71
+ {...props}
72
+ />
73
+ );
74
+ }
75
+
76
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
77
+ return (
78
+ <td
79
+ data-slot="table-cell"
80
+ className={cn(
81
+ "px-3 py-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
82
+ className,
83
+ )}
84
+ {...props}
85
+ />
86
+ );
87
+ }
88
+
89
+ function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
90
+ return (
91
+ <caption
92
+ data-slot="table-caption"
93
+ className={cn("text-muted-foreground mt-4 text-sm", className)}
94
+ {...props}
95
+ />
96
+ );
97
+ }
98
+
99
+ export {
100
+ Table,
101
+ TableHeader,
102
+ TableBody,
103
+ TableFooter,
104
+ TableRow,
105
+ TableHead,
106
+ TableCell,
107
+ TableCaption,
108
+ };
@@ -0,0 +1,147 @@
1
+ import * as React from "react";
2
+ import { createContext, useContext, useState, useRef, useCallback } from "react";
3
+
4
+ import { cn } from "../lib/utils";
5
+
6
+ /* --------------------------------- Context -------------------------------- */
7
+
8
+ interface TabsContextValue {
9
+ selectedValue: string;
10
+ onSelect: (value: string) => void;
11
+ }
12
+
13
+ const TabsContext = createContext<TabsContextValue | null>(null);
14
+
15
+ function useTabsContext() {
16
+ const ctx = useContext(TabsContext);
17
+ if (!ctx) throw new Error("Tabs compound components must be used within <Tabs>");
18
+ return ctx;
19
+ }
20
+
21
+ /* ---------------------------------- Tabs ---------------------------------- */
22
+
23
+ interface TabsProps extends React.ComponentProps<"div"> {
24
+ value?: string;
25
+ defaultValue?: string;
26
+ onValueChange?: (value: string) => void;
27
+ }
28
+
29
+ function Tabs({
30
+ value,
31
+ defaultValue,
32
+ onValueChange,
33
+ className,
34
+ ...props
35
+ }: TabsProps) {
36
+ const [internalValue, setInternalValue] = useState(defaultValue ?? "");
37
+ const selectedValue = value ?? internalValue;
38
+
39
+ const onSelect = useCallback(
40
+ (v: string) => {
41
+ if (value === undefined) setInternalValue(v);
42
+ onValueChange?.(v);
43
+ },
44
+ [value, onValueChange],
45
+ );
46
+
47
+ return (
48
+ <TabsContext.Provider value={{ selectedValue, onSelect }}>
49
+ <div data-slot="tabs" className={className} {...props} />
50
+ </TabsContext.Provider>
51
+ );
52
+ }
53
+
54
+ /* -------------------------------- TabsList -------------------------------- */
55
+
56
+ function TabsList({
57
+ className,
58
+ children,
59
+ ...props
60
+ }: React.ComponentProps<"div">) {
61
+ const listRef = useRef<HTMLDivElement>(null);
62
+
63
+ const handleKeyDown = (e: React.KeyboardEvent) => {
64
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
65
+
66
+ const list = listRef.current;
67
+ if (!list) return;
68
+
69
+ const tabs = Array.from(
70
+ list.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])'),
71
+ );
72
+ const current = tabs.indexOf(document.activeElement as HTMLButtonElement);
73
+ if (current === -1) return;
74
+
75
+ e.preventDefault();
76
+ const next =
77
+ e.key === "ArrowRight"
78
+ ? (current + 1) % tabs.length
79
+ : (current - 1 + tabs.length) % tabs.length;
80
+ tabs[next].focus();
81
+ };
82
+
83
+ return (
84
+ <div
85
+ ref={listRef}
86
+ role="tablist"
87
+ data-slot="tabs-list"
88
+ className={cn(
89
+ "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
90
+ className,
91
+ )}
92
+ onKeyDown={handleKeyDown}
93
+ {...props}
94
+ >
95
+ {children}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /* ------------------------------- TabsTrigger ------------------------------ */
101
+
102
+ interface TabsTriggerProps extends React.ComponentProps<"button"> {
103
+ value: string;
104
+ }
105
+
106
+ function TabsTrigger({ value, className, ...props }: TabsTriggerProps) {
107
+ const { selectedValue, onSelect } = useTabsContext();
108
+ const isSelected = selectedValue === value;
109
+
110
+ return (
111
+ <button
112
+ role="tab"
113
+ type="button"
114
+ data-slot="tabs-trigger"
115
+ aria-selected={isSelected}
116
+ {...(isSelected ? { "data-selected": "" } : {})}
117
+ className={cn(
118
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 data-selected:bg-background data-selected:text-foreground data-selected:shadow-sm",
119
+ className,
120
+ )}
121
+ onClick={() => onSelect(value)}
122
+ {...props}
123
+ />
124
+ );
125
+ }
126
+
127
+ /* ------------------------------- TabsContent ------------------------------ */
128
+
129
+ interface TabsContentProps extends React.ComponentProps<"div"> {
130
+ value: string;
131
+ }
132
+
133
+ function TabsContent({ value, className, ...props }: TabsContentProps) {
134
+ const { selectedValue } = useTabsContext();
135
+ if (selectedValue !== value) return null;
136
+
137
+ return (
138
+ <div
139
+ role="tabpanel"
140
+ data-slot="tabs-content"
141
+ className={cn("pt-2", className)}
142
+ {...props}
143
+ />
144
+ );
145
+ }
146
+
147
+ export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
+ return (
7
+ <textarea
8
+ data-slot="textarea"
9
+ className={cn(
10
+ "placeholder:text-muted-foreground border-input flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50",
11
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
12
+ "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ export { Textarea };
@@ -0,0 +1,177 @@
1
+ import * as React from "react";
2
+ import {
3
+ createContext,
4
+ useContext,
5
+ useState,
6
+ useRef,
7
+ useCallback,
8
+ useLayoutEffect,
9
+ useId,
10
+ cloneElement,
11
+ isValidElement,
12
+ } from "react";
13
+ import { createPortal } from "react-dom";
14
+
15
+ import { cn } from "../lib/utils";
16
+
17
+ /* --------------------------------- Context -------------------------------- */
18
+
19
+ interface TooltipContextValue {
20
+ open: boolean;
21
+ show: () => void;
22
+ hide: () => void;
23
+ triggerRef: React.RefObject<HTMLElement | null>;
24
+ tooltipId: string;
25
+ }
26
+
27
+ const TooltipContext = createContext<TooltipContextValue | null>(null);
28
+
29
+ function useTooltipContext() {
30
+ const ctx = useContext(TooltipContext);
31
+ if (!ctx) throw new Error("Tooltip compound components must be used within <Tooltip>");
32
+ return ctx;
33
+ }
34
+
35
+ /* --------------------------------- Tooltip -------------------------------- */
36
+
37
+ interface TooltipProps {
38
+ children: React.ReactNode;
39
+ delayDuration?: number;
40
+ }
41
+
42
+ function Tooltip({ children, delayDuration = 300 }: TooltipProps) {
43
+ const [open, setOpen] = useState(false);
44
+ const triggerRef = useRef<HTMLElement | null>(null);
45
+ const delayRef = useRef<ReturnType<typeof setTimeout>>(undefined);
46
+ const tooltipId = useId();
47
+
48
+ const show = useCallback(() => {
49
+ clearTimeout(delayRef.current);
50
+ delayRef.current = setTimeout(() => setOpen(true), delayDuration);
51
+ }, [delayDuration]);
52
+
53
+ const hide = useCallback(() => {
54
+ clearTimeout(delayRef.current);
55
+ setOpen(false);
56
+ }, []);
57
+
58
+ return (
59
+ <TooltipContext.Provider value={{ open, show, hide, triggerRef, tooltipId }}>
60
+ {children}
61
+ </TooltipContext.Provider>
62
+ );
63
+ }
64
+
65
+ /* ------------------------------ TooltipTrigger ----------------------------- */
66
+
67
+ interface TooltipTriggerProps {
68
+ children: React.ReactElement<Record<string, unknown>>;
69
+ }
70
+
71
+ function TooltipTrigger({ children }: TooltipTriggerProps) {
72
+ const { show, hide, triggerRef, tooltipId, open } = useTooltipContext();
73
+
74
+ if (!isValidElement(children)) return children;
75
+
76
+ return cloneElement(children, {
77
+ ref: (node: HTMLElement | null) => {
78
+ triggerRef.current = node;
79
+ // Forward ref if child has one
80
+ const childRef = (children as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref;
81
+ if (typeof childRef === "function") childRef(node);
82
+ else if (childRef && typeof childRef === "object") {
83
+ (childRef as React.MutableRefObject<HTMLElement | null>).current = node;
84
+ }
85
+ },
86
+ onMouseEnter: (e: React.MouseEvent) => {
87
+ show();
88
+ const handler = (children.props as Record<string, Function | undefined>).onMouseEnter;
89
+ handler?.(e);
90
+ },
91
+ onMouseLeave: (e: React.MouseEvent) => {
92
+ hide();
93
+ const handler = (children.props as Record<string, Function | undefined>).onMouseLeave;
94
+ handler?.(e);
95
+ },
96
+ onFocus: (e: React.FocusEvent) => {
97
+ show();
98
+ const handler = (children.props as Record<string, Function | undefined>).onFocus;
99
+ handler?.(e);
100
+ },
101
+ onBlur: (e: React.FocusEvent) => {
102
+ hide();
103
+ const handler = (children.props as Record<string, Function | undefined>).onBlur;
104
+ handler?.(e);
105
+ },
106
+ "aria-describedby": open ? tooltipId : undefined,
107
+ } as Record<string, unknown>);
108
+ }
109
+
110
+ /* ------------------------------ TooltipContent ----------------------------- */
111
+
112
+ interface TooltipContentProps extends React.ComponentProps<"div"> {
113
+ sideOffset?: number;
114
+ }
115
+
116
+ function TooltipContent({
117
+ className,
118
+ sideOffset = 8,
119
+ children,
120
+ style,
121
+ ...props
122
+ }: TooltipContentProps) {
123
+ const { open, triggerRef, tooltipId } = useTooltipContext();
124
+ const popupRef = useRef<HTMLDivElement>(null);
125
+ const [position, setPosition] = useState<React.CSSProperties>({
126
+ position: "fixed",
127
+ top: 0,
128
+ left: 0,
129
+ visibility: "hidden",
130
+ });
131
+
132
+ useLayoutEffect(() => {
133
+ if (!open || !triggerRef.current || !popupRef.current) return;
134
+
135
+ const trigger = triggerRef.current.getBoundingClientRect();
136
+ const popup = popupRef.current.getBoundingClientRect();
137
+
138
+ // Default: above trigger, centered horizontally
139
+ let top = trigger.top - popup.height - sideOffset;
140
+ let left = trigger.left + trigger.width / 2 - popup.width / 2;
141
+
142
+ // Flip below if overflows top
143
+ if (top < 4) {
144
+ top = trigger.bottom + sideOffset;
145
+ }
146
+
147
+ // Clamp horizontally
148
+ if (left < 4) left = 4;
149
+ if (left + popup.width > window.innerWidth - 4) {
150
+ left = window.innerWidth - popup.width - 4;
151
+ }
152
+
153
+ setPosition({ position: "fixed", top, left, visibility: "visible" });
154
+ }, [open, sideOffset, triggerRef]);
155
+
156
+ if (!open) return null;
157
+
158
+ return createPortal(
159
+ <div
160
+ ref={popupRef}
161
+ id={tooltipId}
162
+ role="tooltip"
163
+ data-slot="tooltip-content"
164
+ className={cn(
165
+ "bg-primary text-primary-foreground z-50 max-w-60 rounded-md px-2.5 py-1.5 text-xs leading-snug shadow-md animate-in fade-in-0 zoom-in-95",
166
+ className,
167
+ )}
168
+ style={{ ...position, ...style }}
169
+ {...props}
170
+ >
171
+ {children}
172
+ </div>,
173
+ document.body,
174
+ );
175
+ }
176
+
177
+ export { Tooltip, TooltipTrigger, TooltipContent };
@@ -0,0 +1,59 @@
1
+ import { debounce } from "./debounce";
2
+
3
+ describe("debounce", () => {
4
+ beforeEach(() => {
5
+ vi.useFakeTimers();
6
+ });
7
+
8
+ afterEach(() => {
9
+ vi.useRealTimers();
10
+ });
11
+
12
+ it("delays function execution", () => {
13
+ const fn = vi.fn();
14
+ const debounced = debounce(fn, 100);
15
+
16
+ debounced();
17
+ expect(fn).not.toHaveBeenCalled();
18
+
19
+ vi.advanceTimersByTime(100);
20
+ expect(fn).toHaveBeenCalledOnce();
21
+ });
22
+
23
+ it("resets the timer on subsequent calls", () => {
24
+ const fn = vi.fn();
25
+ const debounced = debounce(fn, 100);
26
+
27
+ debounced();
28
+ vi.advanceTimersByTime(50);
29
+ debounced();
30
+ vi.advanceTimersByTime(50);
31
+
32
+ expect(fn).not.toHaveBeenCalled();
33
+
34
+ vi.advanceTimersByTime(50);
35
+ expect(fn).toHaveBeenCalledOnce();
36
+ });
37
+
38
+ it("passes arguments to the debounced function", () => {
39
+ const fn = vi.fn();
40
+ const debounced = debounce(fn, 100);
41
+
42
+ debounced("hello", "world");
43
+ vi.advanceTimersByTime(100);
44
+
45
+ expect(fn).toHaveBeenCalledWith("hello", "world");
46
+ });
47
+
48
+ it("uses arguments from the last call", () => {
49
+ const fn = vi.fn();
50
+ const debounced = debounce(fn, 100);
51
+
52
+ debounced("first");
53
+ debounced("second");
54
+ vi.advanceTimersByTime(100);
55
+
56
+ expect(fn).toHaveBeenCalledOnce();
57
+ expect(fn).toHaveBeenCalledWith("second");
58
+ });
59
+ });