@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
package/src/ui/input.tsx
ADDED
|
@@ -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 };
|
package/src/ui/slot.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/ui/table.tsx
ADDED
|
@@ -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
|
+
};
|
package/src/ui/tabs.tsx
ADDED
|
@@ -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
|
+
});
|