@rahulapgm/skyblue-ui 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 +25 -0
- package/package.json +55 -0
- package/src/animation.tsx +117 -0
- package/src/autocomplete.tsx +271 -0
- package/src/badge.tsx +42 -0
- package/src/button.tsx +88 -0
- package/src/card.tsx +37 -0
- package/src/checkbox.tsx +36 -0
- package/src/chip.tsx +97 -0
- package/src/cluster.tsx +60 -0
- package/src/container.tsx +39 -0
- package/src/datepicker.tsx +59 -0
- package/src/drawer.tsx +126 -0
- package/src/dropdown.tsx +202 -0
- package/src/feature-card.tsx +33 -0
- package/src/floating-button.tsx +51 -0
- package/src/grid.tsx +94 -0
- package/src/hero-banner.tsx +82 -0
- package/src/icon-button.tsx +78 -0
- package/src/index.ts +32 -0
- package/src/input.tsx +62 -0
- package/src/label.tsx +20 -0
- package/src/loader.tsx +90 -0
- package/src/menu.tsx +100 -0
- package/src/message-box.tsx +46 -0
- package/src/metric.tsx +41 -0
- package/src/modal.tsx +110 -0
- package/src/navigation.tsx +147 -0
- package/src/number-input.tsx +127 -0
- package/src/pagination.tsx +102 -0
- package/src/phone-number-input.tsx +95 -0
- package/src/progress.tsx +65 -0
- package/src/radio.tsx +36 -0
- package/src/result.tsx +43 -0
- package/src/section.tsx +36 -0
- package/src/select.tsx +78 -0
- package/src/skeleton.tsx +17 -0
- package/src/stack.tsx +35 -0
- package/src/stat-card.tsx +69 -0
- package/src/steps.tsx +115 -0
- package/src/swatch.tsx +70 -0
- package/src/switch.tsx +60 -0
- package/src/table.tsx +88 -0
- package/src/tabs.tsx +100 -0
- package/src/textarea.tsx +52 -0
- package/src/timeline.tsx +60 -0
- package/src/toast.tsx +144 -0
- package/src/tooltip.tsx +56 -0
- package/src/utils.ts +3 -0
package/src/tabs.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, useState, type KeyboardEvent } from "react";
|
|
4
|
+
|
|
5
|
+
import { Badge } from "./badge";
|
|
6
|
+
import { cn } from "./utils";
|
|
7
|
+
|
|
8
|
+
export type TabItem = {
|
|
9
|
+
label: string;
|
|
10
|
+
value: string;
|
|
11
|
+
content: React.ReactNode;
|
|
12
|
+
badge?: React.ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type TabsProps = {
|
|
16
|
+
items: TabItem[];
|
|
17
|
+
defaultValue?: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
onValueChange?: (value: string) => void;
|
|
20
|
+
className?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function Tabs({ items, defaultValue, value, onValueChange, className }: TabsProps) {
|
|
24
|
+
const baseId = useId();
|
|
25
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.value ?? "");
|
|
26
|
+
const activeValue = value ?? internalValue;
|
|
27
|
+
const activeItem = items.find((item) => item.value === activeValue) ?? items[0];
|
|
28
|
+
|
|
29
|
+
function handleChange(nextValue: string) {
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
setInternalValue(nextValue);
|
|
32
|
+
}
|
|
33
|
+
onValueChange?.(nextValue);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
|
37
|
+
if (!items.length) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let nextIndex = index;
|
|
42
|
+
|
|
43
|
+
if (event.key === "ArrowRight") {
|
|
44
|
+
nextIndex = (index + 1) % items.length;
|
|
45
|
+
} else if (event.key === "ArrowLeft") {
|
|
46
|
+
nextIndex = (index - 1 + items.length) % items.length;
|
|
47
|
+
} else if (event.key === "Home") {
|
|
48
|
+
nextIndex = 0;
|
|
49
|
+
} else if (event.key === "End") {
|
|
50
|
+
nextIndex = items.length - 1;
|
|
51
|
+
} else {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
handleChange(items[nextIndex].value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className={cn("space-y-4", className)}>
|
|
61
|
+
<div
|
|
62
|
+
role="tablist"
|
|
63
|
+
aria-label="Content tabs"
|
|
64
|
+
className="inline-flex flex-wrap gap-2 rounded-full border border-(--line-soft) bg-(--surface-card) p-1 shadow-(--shadow-sm)"
|
|
65
|
+
>
|
|
66
|
+
{items.map((item, index) => (
|
|
67
|
+
<button
|
|
68
|
+
key={item.value}
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => handleChange(item.value)}
|
|
71
|
+
onKeyDown={(event) => handleKeyDown(event, index)}
|
|
72
|
+
id={`${baseId}-tab-${item.value}`}
|
|
73
|
+
role="tab"
|
|
74
|
+
aria-selected={item.value === activeValue}
|
|
75
|
+
aria-controls={`${baseId}-panel-${item.value}`}
|
|
76
|
+
tabIndex={item.value === activeValue ? 0 : -1}
|
|
77
|
+
className={cn(
|
|
78
|
+
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-extrabold transition-colors",
|
|
79
|
+
item.value === activeValue
|
|
80
|
+
? "bg-(--color-brand-primary) text-white"
|
|
81
|
+
: "text-(--ink-muted) hover:bg-(--color-surface-hover)",
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<span>{item.label}</span>
|
|
85
|
+
{item.badge ? <Badge size="sm">{item.badge}</Badge> : null}
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
{activeItem ? (
|
|
90
|
+
<div
|
|
91
|
+
id={`${baseId}-panel-${activeItem.value}`}
|
|
92
|
+
role="tabpanel"
|
|
93
|
+
aria-labelledby={`${baseId}-tab-${activeItem.value}`}
|
|
94
|
+
>
|
|
95
|
+
{activeItem.content}
|
|
96
|
+
</div>
|
|
97
|
+
) : null}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
package/src/textarea.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { TextareaHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
import { Label } from "./label";
|
|
4
|
+
import { cn } from "./utils";
|
|
5
|
+
|
|
6
|
+
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|
7
|
+
label?: string;
|
|
8
|
+
helperText?: string;
|
|
9
|
+
errorText?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function Textarea({
|
|
13
|
+
label,
|
|
14
|
+
helperText,
|
|
15
|
+
errorText,
|
|
16
|
+
className,
|
|
17
|
+
id,
|
|
18
|
+
required,
|
|
19
|
+
rows = 5,
|
|
20
|
+
...props
|
|
21
|
+
}: TextareaProps) {
|
|
22
|
+
const textareaId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
|
23
|
+
const hasError = Boolean(errorText);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="w-full">
|
|
27
|
+
{label ? (
|
|
28
|
+
<Label htmlFor={textareaId} requiredMark={required} className="mb-2 block">
|
|
29
|
+
{label}
|
|
30
|
+
</Label>
|
|
31
|
+
) : null}
|
|
32
|
+
<textarea
|
|
33
|
+
{...props}
|
|
34
|
+
id={textareaId}
|
|
35
|
+
required={required}
|
|
36
|
+
rows={rows}
|
|
37
|
+
className={cn(
|
|
38
|
+
"type-body min-h-32 w-full rounded-2xl border bg-(--surface-card) px-4 py-3 text-(--foreground) shadow-(--shadow-sm) outline-none transition-colors placeholder:text-(--ink-subtle)",
|
|
39
|
+
hasError
|
|
40
|
+
? "border-(--color-status-error)"
|
|
41
|
+
: "border-(--line-soft) hover:border-(--color-line-strong)",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
/>
|
|
45
|
+
{errorText ? (
|
|
46
|
+
<p className="type-caption mt-2 text-(--color-status-error)">{errorText}</p>
|
|
47
|
+
) : helperText ? (
|
|
48
|
+
<p className="type-caption mt-2 text-(--ink-subtle)">{helperText}</p>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/timeline.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "./badge";
|
|
4
|
+
import { cn } from "./utils";
|
|
5
|
+
|
|
6
|
+
type TimelineStatus = "completed" | "current" | "upcoming";
|
|
7
|
+
|
|
8
|
+
export type TimelineItem = {
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
meta?: ReactNode;
|
|
12
|
+
status?: TimelineStatus;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type TimelineProps = {
|
|
16
|
+
items: TimelineItem[];
|
|
17
|
+
className?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const dotClasses: Record<TimelineStatus, string> = {
|
|
21
|
+
completed: "bg-(--color-status-success)",
|
|
22
|
+
current: "bg-(--color-brand-primary) ring-4 ring-(--color-brand-primary-light)",
|
|
23
|
+
upcoming: "bg-(--color-line-strong)",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function Timeline({ items, className }: TimelineProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={cn("space-y-4", className)}>
|
|
29
|
+
{items.map((item, index) => {
|
|
30
|
+
const status = item.status ?? "upcoming";
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div key={`${item.title}-${index}`} className="flex gap-4">
|
|
34
|
+
<div className="flex w-6 flex-col items-center">
|
|
35
|
+
<span className={cn("mt-1 h-3 w-3 rounded-full", dotClasses[status])} />
|
|
36
|
+
{index < items.length - 1 ? (
|
|
37
|
+
<span className="mt-2 h-full min-h-10 w-px bg-(--line-soft)" />
|
|
38
|
+
) : null}
|
|
39
|
+
</div>
|
|
40
|
+
<div className="flex-1 rounded-2xl border border-(--line-soft) bg-(--surface-card) p-4 shadow-(--shadow-sm)">
|
|
41
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
42
|
+
<h3 className="type-title">{item.title}</h3>
|
|
43
|
+
<Badge
|
|
44
|
+
tone={status === "completed" ? "success" : status === "current" ? "brand" : "neutral"}
|
|
45
|
+
size="sm"
|
|
46
|
+
>
|
|
47
|
+
{status}
|
|
48
|
+
</Badge>
|
|
49
|
+
</div>
|
|
50
|
+
{item.description ? (
|
|
51
|
+
<p className="type-body mt-2 text-(--ink-muted)">{item.description}</p>
|
|
52
|
+
) : null}
|
|
53
|
+
{item.meta ? <div className="mt-3">{item.meta}</div> : null}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
})}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
package/src/toast.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
type PropsWithChildren,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { CheckCircle2, Info, OctagonAlert, TriangleAlert, X } from "lucide-react";
|
|
13
|
+
|
|
14
|
+
import { cn } from "./utils";
|
|
15
|
+
|
|
16
|
+
type ToastTone = "success" | "info" | "warning" | "error";
|
|
17
|
+
|
|
18
|
+
type ToastItem = {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
tone: ToastTone;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type ToastContextValue = {
|
|
26
|
+
showToast: (toast: Omit<ToastItem, "id">) => void;
|
|
27
|
+
dismissToast: (id: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
31
|
+
|
|
32
|
+
const toneClasses: Record<ToastTone, string> = {
|
|
33
|
+
success: "border-(--color-status-success-light) bg-(--surface-card) text-(--foreground)",
|
|
34
|
+
info: "border-(--color-status-info-light) bg-(--surface-card) text-(--foreground)",
|
|
35
|
+
warning: "border-(--color-status-warning-light) bg-(--surface-card) text-(--foreground)",
|
|
36
|
+
error: "border-(--color-status-error-light) bg-(--surface-card) text-(--foreground)",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const toneIcons = {
|
|
40
|
+
success: <CheckCircle2 className="h-5 w-5 text-(--color-status-success)" />,
|
|
41
|
+
info: <Info className="h-5 w-5 text-(--color-status-info)" />,
|
|
42
|
+
warning: <TriangleAlert className="h-5 w-5 text-(--color-status-warning)" />,
|
|
43
|
+
error: <OctagonAlert className="h-5 w-5 text-(--color-status-error)" />,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
function createToastId() {
|
|
47
|
+
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
48
|
+
return globalThis.crypto.randomUUID();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ToastProvider({ children }: PropsWithChildren) {
|
|
55
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
56
|
+
|
|
57
|
+
const value = useMemo<ToastContextValue>(
|
|
58
|
+
() => ({
|
|
59
|
+
showToast: (toast) => {
|
|
60
|
+
const id = createToastId();
|
|
61
|
+
setToasts((current) => [...current, { ...toast, id }]);
|
|
62
|
+
window.setTimeout(() => {
|
|
63
|
+
setToasts((current) => current.filter((item) => item.id !== id));
|
|
64
|
+
}, 3500);
|
|
65
|
+
},
|
|
66
|
+
dismissToast: (id) => {
|
|
67
|
+
setToasts((current) => current.filter((item) => item.id !== id));
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
[],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<ToastContext.Provider value={value}>
|
|
75
|
+
{children}
|
|
76
|
+
<ToastViewport toasts={toasts} onDismiss={value.dismissToast} />
|
|
77
|
+
</ToastContext.Provider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useToast() {
|
|
82
|
+
const context = useContext(ToastContext);
|
|
83
|
+
|
|
84
|
+
if (!context) {
|
|
85
|
+
throw new Error("useToast must be used within ToastProvider");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return context;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type ToastViewportProps = {
|
|
92
|
+
toasts: ToastItem[];
|
|
93
|
+
onDismiss: (id: string) => void;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function ToastViewport({ toasts, onDismiss }: ToastViewportProps) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="pointer-events-none fixed bottom-6 left-6 right-6 z-50 flex flex-col gap-3 sm:left-auto sm:w-full sm:max-w-sm">
|
|
99
|
+
<AnimatePresence initial={false}>
|
|
100
|
+
{toasts.map((toast) => (
|
|
101
|
+
<Toast key={toast.id} {...toast} onDismiss={() => onDismiss(toast.id)} />
|
|
102
|
+
))}
|
|
103
|
+
</AnimatePresence>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type ToastProps = ToastItem & {
|
|
109
|
+
onDismiss?: () => void;
|
|
110
|
+
action?: ReactNode;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function Toast({ title, description, tone, onDismiss, action }: ToastProps) {
|
|
114
|
+
return (
|
|
115
|
+
<motion.div
|
|
116
|
+
layout
|
|
117
|
+
initial={{ opacity: 0, y: 18, scale: 0.96 }}
|
|
118
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
119
|
+
exit={{ opacity: 0, x: 24, scale: 0.96 }}
|
|
120
|
+
transition={{ duration: 0.22, ease: "easeOut" }}
|
|
121
|
+
className={cn("pointer-events-auto rounded-lg border p-4 shadow-(--shadow-lg)", toneClasses[tone])}
|
|
122
|
+
role="status"
|
|
123
|
+
>
|
|
124
|
+
<div className="flex items-start gap-3">
|
|
125
|
+
<div className="mt-0.5">{toneIcons[tone]}</div>
|
|
126
|
+
<div className="min-w-0 flex-1">
|
|
127
|
+
<p className="type-title">{title}</p>
|
|
128
|
+
{description ? <p className="type-body mt-1 text-(--ink-muted)">{description}</p> : null}
|
|
129
|
+
{action ? <div className="mt-3">{action}</div> : null}
|
|
130
|
+
</div>
|
|
131
|
+
{onDismiss ? (
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={onDismiss}
|
|
135
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-(--ink-subtle) transition-colors hover:bg-(--color-surface-hover)"
|
|
136
|
+
aria-label="Dismiss toast"
|
|
137
|
+
>
|
|
138
|
+
<X className="h-4 w-4" />
|
|
139
|
+
</button>
|
|
140
|
+
) : null}
|
|
141
|
+
</div>
|
|
142
|
+
</motion.div>
|
|
143
|
+
);
|
|
144
|
+
}
|
package/src/tooltip.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type FocusEvent, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "./utils";
|
|
6
|
+
|
|
7
|
+
type TooltipSide = "top" | "right" | "bottom" | "left";
|
|
8
|
+
|
|
9
|
+
type TooltipProps = {
|
|
10
|
+
content: ReactNode;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
side?: TooltipSide;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const sideClasses: Record<TooltipSide, string> = {
|
|
17
|
+
top: "bottom-full left-1/2 mb-2 -translate-x-1/2",
|
|
18
|
+
right: "left-full top-1/2 ml-2 -translate-y-1/2",
|
|
19
|
+
bottom: "left-1/2 top-full mt-2 -translate-x-1/2",
|
|
20
|
+
left: "right-full top-1/2 mr-2 -translate-y-1/2",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function Tooltip({ content, children, side = "top", className }: TooltipProps) {
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
|
|
26
|
+
function handleFocus(event: FocusEvent<HTMLSpanElement>) {
|
|
27
|
+
const focusTarget = event.target;
|
|
28
|
+
|
|
29
|
+
if (focusTarget instanceof HTMLElement && focusTarget.matches(":focus-visible")) {
|
|
30
|
+
setOpen(true);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
className={cn("relative inline-flex", className)}
|
|
37
|
+
onMouseEnter={() => setOpen(true)}
|
|
38
|
+
onMouseLeave={() => setOpen(false)}
|
|
39
|
+
onFocusCapture={handleFocus}
|
|
40
|
+
onBlurCapture={() => setOpen(false)}
|
|
41
|
+
onPointerDownCapture={() => setOpen(false)}
|
|
42
|
+
>
|
|
43
|
+
{children}
|
|
44
|
+
<span
|
|
45
|
+
role="tooltip"
|
|
46
|
+
className={cn(
|
|
47
|
+
"type-caption pointer-events-none absolute z-2147483647 whitespace-nowrap rounded-2xl bg-(--color-bg-dark) px-3 py-2 font-bold text-white shadow-(--shadow-lg) transition-opacity",
|
|
48
|
+
open ? "visible opacity-100" : "invisible opacity-0",
|
|
49
|
+
sideClasses[side],
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{content}
|
|
53
|
+
</span>
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
}
|
package/src/utils.ts
ADDED