@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.
Files changed (49) hide show
  1. package/README.md +25 -0
  2. package/package.json +55 -0
  3. package/src/animation.tsx +117 -0
  4. package/src/autocomplete.tsx +271 -0
  5. package/src/badge.tsx +42 -0
  6. package/src/button.tsx +88 -0
  7. package/src/card.tsx +37 -0
  8. package/src/checkbox.tsx +36 -0
  9. package/src/chip.tsx +97 -0
  10. package/src/cluster.tsx +60 -0
  11. package/src/container.tsx +39 -0
  12. package/src/datepicker.tsx +59 -0
  13. package/src/drawer.tsx +126 -0
  14. package/src/dropdown.tsx +202 -0
  15. package/src/feature-card.tsx +33 -0
  16. package/src/floating-button.tsx +51 -0
  17. package/src/grid.tsx +94 -0
  18. package/src/hero-banner.tsx +82 -0
  19. package/src/icon-button.tsx +78 -0
  20. package/src/index.ts +32 -0
  21. package/src/input.tsx +62 -0
  22. package/src/label.tsx +20 -0
  23. package/src/loader.tsx +90 -0
  24. package/src/menu.tsx +100 -0
  25. package/src/message-box.tsx +46 -0
  26. package/src/metric.tsx +41 -0
  27. package/src/modal.tsx +110 -0
  28. package/src/navigation.tsx +147 -0
  29. package/src/number-input.tsx +127 -0
  30. package/src/pagination.tsx +102 -0
  31. package/src/phone-number-input.tsx +95 -0
  32. package/src/progress.tsx +65 -0
  33. package/src/radio.tsx +36 -0
  34. package/src/result.tsx +43 -0
  35. package/src/section.tsx +36 -0
  36. package/src/select.tsx +78 -0
  37. package/src/skeleton.tsx +17 -0
  38. package/src/stack.tsx +35 -0
  39. package/src/stat-card.tsx +69 -0
  40. package/src/steps.tsx +115 -0
  41. package/src/swatch.tsx +70 -0
  42. package/src/switch.tsx +60 -0
  43. package/src/table.tsx +88 -0
  44. package/src/tabs.tsx +100 -0
  45. package/src/textarea.tsx +52 -0
  46. package/src/timeline.tsx +60 -0
  47. package/src/toast.tsx +144 -0
  48. package/src/tooltip.tsx +56 -0
  49. 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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ export function cn(...classes: Array<string | false | null | undefined>) {
2
+ return classes.filter(Boolean).join(" ");
3
+ }