@exitvibing/hqui 0.1.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/README.md +45 -0
- package/dist/index.css +1 -0
- package/dist/index.d.mts +180 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +1049 -0
- package/dist/index.mjs +988 -0
- package/docs/components.md +304 -0
- package/docs/design-system.md +85 -0
- package/docs/extended-components.md +201 -0
- package/docs/setup.md +146 -0
- package/package.json +33 -0
- package/src/components/Badge.tsx +40 -0
- package/src/components/Button.tsx +50 -0
- package/src/components/Card.tsx +72 -0
- package/src/components/Checkbox.tsx +47 -0
- package/src/components/HighlightText.tsx +24 -0
- package/src/components/Input.tsx +21 -0
- package/src/components/ProgressBar.tsx +64 -0
- package/src/components/Separator.tsx +25 -0
- package/src/components/Switch.tsx +43 -0
- package/src/components/Table.tsx +87 -0
- package/src/components/Tabs.tsx +122 -0
- package/src/components/ThemeToggle.tsx +40 -0
- package/src/components/Tooltip.tsx +120 -0
- package/src/components/extended/ArrowButton.tsx +28 -0
- package/src/components/extended/ChooseList.tsx +38 -0
- package/src/components/extended/Counter.tsx +74 -0
- package/src/components/extended/Popup.tsx +78 -0
- package/src/components/extended/StatusBar.tsx +45 -0
- package/src/components/extended/WeekViewCalendar.tsx +126 -0
- package/src/index.css +119 -0
- package/src/index.ts +25 -0
- package/src/lib/cn.ts +6 -0
- package/tailwind.config.ts +64 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, forwardRef } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
interface TabsContextValue {
|
|
5
|
+
value: string;
|
|
6
|
+
onValueChange: (value: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const TabsContext = createContext<TabsContextValue>({
|
|
10
|
+
value: "",
|
|
11
|
+
onValueChange: () => {},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
15
|
+
defaultValue?: string;
|
|
16
|
+
value?: string;
|
|
17
|
+
onValueChange?: (value: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
|
21
|
+
(
|
|
22
|
+
{
|
|
23
|
+
className,
|
|
24
|
+
defaultValue = "",
|
|
25
|
+
value: controlledValue,
|
|
26
|
+
onValueChange,
|
|
27
|
+
children,
|
|
28
|
+
...props
|
|
29
|
+
},
|
|
30
|
+
ref,
|
|
31
|
+
) => {
|
|
32
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
33
|
+
const isControlled = controlledValue !== undefined;
|
|
34
|
+
const currentValue = isControlled ? controlledValue : uncontrolledValue;
|
|
35
|
+
|
|
36
|
+
const handleChange = (newValue: string) => {
|
|
37
|
+
if (!isControlled) setUncontrolledValue(newValue);
|
|
38
|
+
onValueChange?.(newValue);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<TabsContext.Provider
|
|
43
|
+
value={{ value: currentValue, onValueChange: handleChange }}
|
|
44
|
+
>
|
|
45
|
+
<div ref={ref} className={cn("w-full", className)} {...props}>
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
</TabsContext.Provider>
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
Tabs.displayName = "Tabs";
|
|
53
|
+
|
|
54
|
+
export const TabsList = forwardRef<
|
|
55
|
+
HTMLDivElement,
|
|
56
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
57
|
+
>(({ className, ...props }, ref) => (
|
|
58
|
+
<div
|
|
59
|
+
ref={ref}
|
|
60
|
+
className={cn(
|
|
61
|
+
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
));
|
|
67
|
+
TabsList.displayName = "TabsList";
|
|
68
|
+
|
|
69
|
+
export interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
70
|
+
value: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const TabsTrigger = forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
|
74
|
+
({ className, value, ...props }, ref) => {
|
|
75
|
+
const ctx = useContext(TabsContext);
|
|
76
|
+
const isActive = ctx.value === value;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<button
|
|
80
|
+
ref={ref}
|
|
81
|
+
type="button"
|
|
82
|
+
role="tab"
|
|
83
|
+
aria-selected={isActive}
|
|
84
|
+
data-state={isActive ? "active" : "inactive"}
|
|
85
|
+
className={cn(
|
|
86
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
87
|
+
isActive
|
|
88
|
+
? "bg-background text-foreground shadow"
|
|
89
|
+
: "hover:bg-background/50 hover:text-foreground",
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
onClick={() => ctx.onValueChange(value)}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
TabsTrigger.displayName = "TabsTrigger";
|
|
99
|
+
|
|
100
|
+
export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
101
|
+
value: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const TabsContent = forwardRef<HTMLDivElement, TabsContentProps>(
|
|
105
|
+
({ className, value, ...props }, ref) => {
|
|
106
|
+
const ctx = useContext(TabsContext);
|
|
107
|
+
if (ctx.value !== value) return null;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
ref={ref}
|
|
112
|
+
role="tabpanel"
|
|
113
|
+
className={cn(
|
|
114
|
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 animate-fade-in",
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
{...props}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
TabsContent.displayName = "TabsContent";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React, { forwardRef, useEffect, useState } from "react";
|
|
2
|
+
import { Moon, Sun } from "lucide-react";
|
|
3
|
+
import { Button } from "./Button";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface ThemeToggleProps {
|
|
7
|
+
className?: string;
|
|
8
|
+
defaultTheme?: "light" | "dark";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const ThemeToggle = forwardRef<HTMLButtonElement, ThemeToggleProps>(
|
|
12
|
+
({ className, defaultTheme }, ref) => {
|
|
13
|
+
const [isDark, setIsDark] = useState(() => {
|
|
14
|
+
if (typeof window === "undefined") return defaultTheme === "dark";
|
|
15
|
+
return document.documentElement.classList.contains("dark");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (isDark) {
|
|
20
|
+
document.documentElement.classList.add("dark");
|
|
21
|
+
} else {
|
|
22
|
+
document.documentElement.classList.remove("dark");
|
|
23
|
+
}
|
|
24
|
+
}, [isDark]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Button
|
|
28
|
+
ref={ref}
|
|
29
|
+
variant="ghost"
|
|
30
|
+
size="icon"
|
|
31
|
+
className={cn("rounded-full", className)}
|
|
32
|
+
onClick={() => setIsDark((prev) => !prev)}
|
|
33
|
+
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
|
34
|
+
>
|
|
35
|
+
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
36
|
+
</Button>
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
ThemeToggle.displayName = "ThemeToggle";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
HTMLAttributes,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useState,
|
|
5
|
+
useRef,
|
|
6
|
+
useEffect,
|
|
7
|
+
useCallback,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { cn } from "../lib/cn";
|
|
10
|
+
|
|
11
|
+
export interface TooltipProps extends Omit<
|
|
12
|
+
HTMLAttributes<HTMLDivElement>,
|
|
13
|
+
"content"
|
|
14
|
+
> {
|
|
15
|
+
content: React.ReactNode;
|
|
16
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
17
|
+
delayMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
|
|
21
|
+
(
|
|
22
|
+
{ className, content, side = "top", delayMs = 200, children, ...props },
|
|
23
|
+
ref,
|
|
24
|
+
) => {
|
|
25
|
+
const [visible, setVisible] = useState(false);
|
|
26
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
27
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
30
|
+
|
|
31
|
+
const show = () => {
|
|
32
|
+
timeoutRef.current = setTimeout(() => setVisible(true), delayMs);
|
|
33
|
+
};
|
|
34
|
+
const hide = () => {
|
|
35
|
+
clearTimeout(timeoutRef.current);
|
|
36
|
+
setVisible(false);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const updatePosition = useCallback(() => {
|
|
40
|
+
if (!wrapperRef.current || !tooltipRef.current) return;
|
|
41
|
+
const trigger = wrapperRef.current.getBoundingClientRect();
|
|
42
|
+
const tip = tooltipRef.current.getBoundingClientRect();
|
|
43
|
+
const gap = 8;
|
|
44
|
+
|
|
45
|
+
let top = 0;
|
|
46
|
+
let left = 0;
|
|
47
|
+
|
|
48
|
+
switch (side) {
|
|
49
|
+
case "top":
|
|
50
|
+
top = trigger.top - tip.height - gap;
|
|
51
|
+
left = trigger.left + trigger.width / 2 - tip.width / 2;
|
|
52
|
+
break;
|
|
53
|
+
case "bottom":
|
|
54
|
+
top = trigger.bottom + gap;
|
|
55
|
+
left = trigger.left + trigger.width / 2 - tip.width / 2;
|
|
56
|
+
break;
|
|
57
|
+
case "left":
|
|
58
|
+
top = trigger.top + trigger.height / 2 - tip.height / 2;
|
|
59
|
+
left = trigger.left - tip.width - gap;
|
|
60
|
+
break;
|
|
61
|
+
case "right":
|
|
62
|
+
top = trigger.top + trigger.height / 2 - tip.height / 2;
|
|
63
|
+
left = trigger.right + gap;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setPosition({ top, left });
|
|
68
|
+
}, [side]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (visible) {
|
|
72
|
+
// Use requestAnimationFrame so the tooltip DOM is painted before measuring
|
|
73
|
+
requestAnimationFrame(updatePosition);
|
|
74
|
+
}
|
|
75
|
+
}, [visible, updatePosition]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
return () => clearTimeout(timeoutRef.current);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<div
|
|
84
|
+
ref={(node) => {
|
|
85
|
+
(
|
|
86
|
+
wrapperRef as React.MutableRefObject<HTMLDivElement | null>
|
|
87
|
+
).current = node;
|
|
88
|
+
if (typeof ref === "function") ref(node);
|
|
89
|
+
else if (ref)
|
|
90
|
+
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
|
|
91
|
+
node;
|
|
92
|
+
}}
|
|
93
|
+
className={cn("inline-flex", className)}
|
|
94
|
+
onMouseEnter={show}
|
|
95
|
+
onMouseLeave={hide}
|
|
96
|
+
onFocus={show}
|
|
97
|
+
onBlur={hide}
|
|
98
|
+
{...props}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</div>
|
|
102
|
+
{visible && (
|
|
103
|
+
<div
|
|
104
|
+
ref={tooltipRef}
|
|
105
|
+
role="tooltip"
|
|
106
|
+
className="fixed z-[9999] rounded-md bg-foreground px-3 py-1.5 text-xs text-background shadow-md whitespace-nowrap pointer-events-none"
|
|
107
|
+
style={{
|
|
108
|
+
top: position.top,
|
|
109
|
+
left: position.left,
|
|
110
|
+
animation: "popup-fade-in 0.1s ease-out",
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{content}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
Tooltip.displayName = "Tooltip";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { forwardRef } from "react";
|
|
2
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
3
|
+
import { Button, ButtonProps } from "../Button";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface ArrowButtonProps extends ButtonProps {
|
|
7
|
+
direction?: "left" | "right";
|
|
8
|
+
text?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const ArrowButton = forwardRef<HTMLButtonElement, ArrowButtonProps>(
|
|
12
|
+
({ direction = "right", text, className, children, ...props }, ref) => {
|
|
13
|
+
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Button
|
|
17
|
+
ref={ref}
|
|
18
|
+
className={cn("gap-1", !text && !children && "px-2", className)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
{direction === "left" && <Icon className="h-4 w-4" />}
|
|
22
|
+
{(text || children) && <span>{text || children}</span>}
|
|
23
|
+
{direction === "right" && <Icon className="h-4 w-4" />}
|
|
24
|
+
</Button>
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
ArrowButton.displayName = "ArrowButton";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React, { SelectHTMLAttributes, forwardRef } from "react";
|
|
2
|
+
import { ChevronDown } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
|
|
5
|
+
export interface ChooseListProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
6
|
+
options: { label: string; value: string | number }[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const ChooseList = forwardRef<HTMLSelectElement, ChooseListProps>(
|
|
10
|
+
({ className, options, ...props }, ref) => {
|
|
11
|
+
return (
|
|
12
|
+
<div className="relative inline-block w-full">
|
|
13
|
+
<select
|
|
14
|
+
ref={ref}
|
|
15
|
+
className={cn(
|
|
16
|
+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring appearance-none cursor-pointer pr-10",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
{options.map((opt) => (
|
|
22
|
+
<option
|
|
23
|
+
key={opt.value}
|
|
24
|
+
value={opt.value}
|
|
25
|
+
className="bg-card text-foreground"
|
|
26
|
+
>
|
|
27
|
+
{opt.label}
|
|
28
|
+
</option>
|
|
29
|
+
))}
|
|
30
|
+
</select>
|
|
31
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground">
|
|
32
|
+
<ChevronDown className="h-4 w-4" />
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
ChooseList.displayName = "ChooseList";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { useState, forwardRef } from "react";
|
|
2
|
+
import { Plus, Minus } from "lucide-react";
|
|
3
|
+
import { Button } from "../Button";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface CounterProps {
|
|
7
|
+
initialValue?: number;
|
|
8
|
+
min?: number;
|
|
9
|
+
max?: number;
|
|
10
|
+
step?: number;
|
|
11
|
+
onChange?: (value: number) => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Counter = forwardRef<HTMLDivElement, CounterProps>(
|
|
16
|
+
(
|
|
17
|
+
{
|
|
18
|
+
initialValue = 0,
|
|
19
|
+
min = Number.MIN_SAFE_INTEGER,
|
|
20
|
+
max = Number.MAX_SAFE_INTEGER,
|
|
21
|
+
step = 1,
|
|
22
|
+
onChange,
|
|
23
|
+
className,
|
|
24
|
+
},
|
|
25
|
+
ref,
|
|
26
|
+
) => {
|
|
27
|
+
const [value, setValue] = useState(initialValue);
|
|
28
|
+
|
|
29
|
+
const handleIncrement = () => {
|
|
30
|
+
const next = Math.min(max, value + step);
|
|
31
|
+
setValue(next);
|
|
32
|
+
onChange?.(next);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const handleDecrement = () => {
|
|
36
|
+
const next = Math.max(min, value - step);
|
|
37
|
+
setValue(next);
|
|
38
|
+
onChange?.(next);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={ref}
|
|
44
|
+
className={cn(
|
|
45
|
+
"inline-flex items-center rounded-lg border border-border bg-card",
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
<Button
|
|
50
|
+
variant="ghost"
|
|
51
|
+
size="icon"
|
|
52
|
+
className="h-8 w-8 rounded-r-none"
|
|
53
|
+
onClick={handleDecrement}
|
|
54
|
+
disabled={value <= min}
|
|
55
|
+
>
|
|
56
|
+
<Minus className="h-3.5 w-3.5" />
|
|
57
|
+
</Button>
|
|
58
|
+
<span className="w-10 text-center text-sm font-medium tabular-nums border-x border-border">
|
|
59
|
+
{value}
|
|
60
|
+
</span>
|
|
61
|
+
<Button
|
|
62
|
+
variant="ghost"
|
|
63
|
+
size="icon"
|
|
64
|
+
className="h-8 w-8 rounded-l-none"
|
|
65
|
+
onClick={handleIncrement}
|
|
66
|
+
disabled={value >= max}
|
|
67
|
+
>
|
|
68
|
+
<Plus className="h-3.5 w-3.5" />
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
Counter.displayName = "Counter";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React, { HTMLAttributes, forwardRef, useEffect } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { Button } from "../Button";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
|
|
6
|
+
export interface PopupProps extends Omit<
|
|
7
|
+
HTMLAttributes<HTMLDivElement>,
|
|
8
|
+
"title"
|
|
9
|
+
> {
|
|
10
|
+
open: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
title?: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Popup = forwardRef<HTMLDivElement, PopupProps>(
|
|
16
|
+
({ className, open, onClose, title, children, ...props }, ref) => {
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (open) {
|
|
19
|
+
document.body.style.overflow = "hidden";
|
|
20
|
+
} else {
|
|
21
|
+
document.body.style.overflow = "";
|
|
22
|
+
}
|
|
23
|
+
return () => {
|
|
24
|
+
document.body.style.overflow = "";
|
|
25
|
+
};
|
|
26
|
+
}, [open]);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
30
|
+
if (e.key === "Escape") onClose();
|
|
31
|
+
};
|
|
32
|
+
if (open) document.addEventListener("keydown", handleEscape);
|
|
33
|
+
return () => document.removeEventListener("keydown", handleEscape);
|
|
34
|
+
}, [open, onClose]);
|
|
35
|
+
|
|
36
|
+
if (!open) return null;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
40
|
+
{/* Backdrop */}
|
|
41
|
+
<div
|
|
42
|
+
className="fixed inset-0 bg-background/80 backdrop-blur-sm"
|
|
43
|
+
style={{ animation: "popup-fade-in 0.15s ease-out" }}
|
|
44
|
+
onClick={onClose}
|
|
45
|
+
/>
|
|
46
|
+
{/* Content */}
|
|
47
|
+
<div
|
|
48
|
+
ref={ref}
|
|
49
|
+
role="dialog"
|
|
50
|
+
aria-modal="true"
|
|
51
|
+
className={cn(
|
|
52
|
+
"relative z-50 w-full max-w-lg rounded-xl border border-border bg-card p-6 shadow-2xl",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
style={{ animation: "popup-scale-in 0.15s ease-out" }}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
<div className="flex items-center justify-between mb-4">
|
|
59
|
+
{title && (
|
|
60
|
+
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
|
61
|
+
)}
|
|
62
|
+
<Button
|
|
63
|
+
variant="ghost"
|
|
64
|
+
size="icon"
|
|
65
|
+
className="h-7 w-7 rounded-full ml-auto"
|
|
66
|
+
onClick={onClose}
|
|
67
|
+
aria-label="Close popup"
|
|
68
|
+
>
|
|
69
|
+
<X className="h-4 w-4" />
|
|
70
|
+
</Button>
|
|
71
|
+
</div>
|
|
72
|
+
<div className="text-sm text-muted-foreground">{children}</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
Popup.displayName = "Popup";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React, { HTMLAttributes, forwardRef } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface StatusBarProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
status?: "idle" | "working" | "error" | "ready" | "live";
|
|
6
|
+
label?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const statusStyles = {
|
|
10
|
+
idle: "bg-muted-foreground",
|
|
11
|
+
working: "bg-[hsl(var(--blue))] animate-pulse",
|
|
12
|
+
error: "bg-[hsl(var(--orange))]",
|
|
13
|
+
ready: "bg-[hsl(var(--purple))]",
|
|
14
|
+
live: "bg-[hsl(var(--purple))] animate-pulse",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const StatusBar = forwardRef<HTMLDivElement, StatusBarProps>(
|
|
18
|
+
({ className, status = "idle", label, children, ...props }, ref) => {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
ref={ref}
|
|
22
|
+
role="status"
|
|
23
|
+
className={cn(
|
|
24
|
+
"inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1",
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
>
|
|
29
|
+
<span
|
|
30
|
+
className={cn("h-2 w-2 rounded-full", statusStyles[status])}
|
|
31
|
+
aria-hidden="true"
|
|
32
|
+
/>
|
|
33
|
+
<span className="text-xs font-semibold uppercase tracking-wider">
|
|
34
|
+
{label || status}
|
|
35
|
+
</span>
|
|
36
|
+
{children && (
|
|
37
|
+
<span className="text-xs text-muted-foreground border-l border-border pl-2">
|
|
38
|
+
{children}
|
|
39
|
+
</span>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
StatusBar.displayName = "StatusBar";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React, { HTMLAttributes, forwardRef } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface WeekViewEvent {
|
|
5
|
+
id: string;
|
|
6
|
+
dayIndex: number;
|
|
7
|
+
startHour: number;
|
|
8
|
+
durationHours: number;
|
|
9
|
+
title: string;
|
|
10
|
+
color?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WeekViewCalendarProps extends HTMLAttributes<HTMLDivElement> {
|
|
14
|
+
events?: WeekViewEvent[];
|
|
15
|
+
startHour?: number;
|
|
16
|
+
endHour?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const WeekViewCalendar = forwardRef<
|
|
20
|
+
HTMLDivElement,
|
|
21
|
+
WeekViewCalendarProps
|
|
22
|
+
>(({ className, events = [], startHour = 8, endHour = 18, ...props }, ref) => {
|
|
23
|
+
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
24
|
+
const hours = Array.from(
|
|
25
|
+
{ length: endHour - startHour },
|
|
26
|
+
(_, i) => i + startHour,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
ref={ref}
|
|
32
|
+
className={cn(
|
|
33
|
+
"flex flex-col rounded-lg border border-border overflow-hidden bg-card",
|
|
34
|
+
className,
|
|
35
|
+
)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
{/* Header */}
|
|
39
|
+
<div className="grid grid-cols-8 border-b border-border bg-muted/30">
|
|
40
|
+
<div className="flex items-center justify-center border-r border-border p-2 text-xs font-medium text-muted-foreground w-16">
|
|
41
|
+
Time
|
|
42
|
+
</div>
|
|
43
|
+
{days.map((day, i) => (
|
|
44
|
+
<div
|
|
45
|
+
key={day}
|
|
46
|
+
className={cn(
|
|
47
|
+
"p-2 text-center text-sm font-semibold",
|
|
48
|
+
i < 6 && "border-r border-border",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{day}
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Body */}
|
|
57
|
+
<div className="relative overflow-y-auto max-h-[500px] custom-scrollbar">
|
|
58
|
+
{hours.map((hour, hIndex) => (
|
|
59
|
+
<div
|
|
60
|
+
key={hour}
|
|
61
|
+
className={cn(
|
|
62
|
+
"grid grid-cols-8",
|
|
63
|
+
hIndex < hours.length - 1 && "border-b border-border/50",
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<div className="flex items-start justify-center border-r border-border p-2 text-xs text-muted-foreground w-16 sticky left-0 bg-card">
|
|
67
|
+
{`${hour === 0 ? 12 : hour > 12 ? hour - 12 : hour} ${hour >= 12 ? "PM" : "AM"}`}
|
|
68
|
+
</div>
|
|
69
|
+
{days.map((_, dIndex) => (
|
|
70
|
+
<div
|
|
71
|
+
key={dIndex}
|
|
72
|
+
className={cn(
|
|
73
|
+
"h-16 relative",
|
|
74
|
+
dIndex < 6 && "border-r border-border/50",
|
|
75
|
+
)}
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
|
|
81
|
+
{/* Events layer */}
|
|
82
|
+
<div
|
|
83
|
+
className="absolute inset-0 z-10 pointer-events-none"
|
|
84
|
+
style={{ paddingLeft: "4rem" }}
|
|
85
|
+
>
|
|
86
|
+
<div className="relative w-full h-full">
|
|
87
|
+
{events.map((event) => {
|
|
88
|
+
if (event.startHour < startHour || event.startHour >= endHour)
|
|
89
|
+
return null;
|
|
90
|
+
const top =
|
|
91
|
+
((event.startHour - startHour) / (endHour - startHour)) * 100;
|
|
92
|
+
const height =
|
|
93
|
+
(event.durationHours / (endHour - startHour)) * 100;
|
|
94
|
+
const left = (event.dayIndex / 7) * 100;
|
|
95
|
+
const width = 100 / 7;
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
key={event.id}
|
|
100
|
+
className="absolute p-0.5 pointer-events-auto"
|
|
101
|
+
style={{
|
|
102
|
+
top: `${top}%`,
|
|
103
|
+
left: `${left}%`,
|
|
104
|
+
height: `${height}%`,
|
|
105
|
+
width: `${width}%`,
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<div
|
|
109
|
+
className="h-full w-full rounded-md border border-white/10 p-1.5 text-xs font-semibold text-white overflow-hidden shadow-sm transition-transform hover:scale-[1.02] hover:shadow-md cursor-pointer"
|
|
110
|
+
style={{
|
|
111
|
+
backgroundColor: event.color || "hsl(var(--primary))",
|
|
112
|
+
}}
|
|
113
|
+
title={event.title}
|
|
114
|
+
>
|
|
115
|
+
{event.title}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
WeekViewCalendar.displayName = "WeekViewCalendar";
|