@hilum/ui 3.2.2 → 3.2.4

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.
@@ -5,7 +5,9 @@ import { PanelLeft } from "lucide-react";
5
5
  import { Slot } from "radix-ui";
6
6
  import { cn } from "../lib/utils";
7
7
  import { Button } from "./button";
8
+ import { Input } from "./input";
8
9
  import { Separator } from "./separator";
10
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet";
9
11
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
10
12
 
11
13
  /* ------------------------------------------------------------------ */
@@ -13,8 +15,27 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tool
13
15
  /* ------------------------------------------------------------------ */
14
16
 
15
17
  const SIDEBAR_COOKIE_NAME = "sidebar:state";
18
+ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
16
19
  const SIDEBAR_WIDTH = "16rem";
20
+ const SIDEBAR_WIDTH_MOBILE = "18rem";
17
21
  const SIDEBAR_WIDTH_ICON = "3.5rem";
22
+ const SIDEBAR_KEYBOARD_SHORTCUT = "b";
23
+ const MOBILE_BREAKPOINT = 768;
24
+
25
+ function useIsMobile() {
26
+ const [isMobile, setIsMobile] = React.useState(false);
27
+
28
+ React.useEffect(() => {
29
+ const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
30
+ const onChange = () => setIsMobile(mediaQuery.matches);
31
+
32
+ onChange();
33
+ mediaQuery.addEventListener("change", onChange);
34
+ return () => mediaQuery.removeEventListener("change", onChange);
35
+ }, []);
36
+
37
+ return isMobile;
38
+ }
18
39
 
19
40
  /* ------------------------------------------------------------------ */
20
41
  /* Context */
@@ -23,7 +44,10 @@ const SIDEBAR_WIDTH_ICON = "3.5rem";
23
44
  interface SidebarContextValue {
24
45
  state: "expanded" | "collapsed";
25
46
  open: boolean;
26
- setOpen: (open: boolean) => void;
47
+ setOpen: React.Dispatch<React.SetStateAction<boolean>>;
48
+ openMobile: boolean;
49
+ setOpenMobile: React.Dispatch<React.SetStateAction<boolean>>;
50
+ isMobile: boolean;
27
51
  toggleSidebar: () => void;
28
52
  }
29
53
 
@@ -54,32 +78,59 @@ function SidebarProvider({
54
78
  children,
55
79
  ...props
56
80
  }: SidebarProviderProps) {
81
+ const isMobile = useIsMobile();
82
+ const [openMobile, setOpenMobile] = React.useState(false);
57
83
  const [_open, _setOpen] = React.useState(defaultOpen);
58
84
 
59
85
  const open = openProp !== undefined ? openProp : _open;
60
86
 
61
87
  const setOpen = React.useCallback(
62
- (value: boolean) => {
88
+ (value: React.SetStateAction<boolean>) => {
89
+ const nextOpen = typeof value === "function" ? value(open) : value;
63
90
  if (onOpenChange) {
64
- onOpenChange(value);
91
+ onOpenChange(nextOpen);
65
92
  } else {
66
- _setOpen(value);
93
+ _setOpen(nextOpen);
67
94
  }
68
- // Persist to cookie
69
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${value}; path=/; max-age=${60 * 60 * 24 * 7}`;
95
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${nextOpen}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
70
96
  },
71
- [onOpenChange],
97
+ [onOpenChange, open],
72
98
  );
73
99
 
74
100
  const toggleSidebar = React.useCallback(() => {
75
- setOpen(!open);
76
- }, [open, setOpen]);
101
+ if (isMobile) {
102
+ setOpenMobile((currentOpen) => !currentOpen);
103
+ return;
104
+ }
105
+
106
+ setOpen((currentOpen) => !currentOpen);
107
+ }, [isMobile, setOpen]);
108
+
109
+ React.useEffect(() => {
110
+ function handleKeyDown(event: KeyboardEvent) {
111
+ if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
112
+ event.preventDefault();
113
+ toggleSidebar();
114
+ }
115
+ }
116
+
117
+ window.addEventListener("keydown", handleKeyDown);
118
+ return () => window.removeEventListener("keydown", handleKeyDown);
119
+ }, [toggleSidebar]);
77
120
 
78
121
  const state = open ? "expanded" : "collapsed";
79
122
 
80
123
  const contextValue = React.useMemo<SidebarContextValue>(
81
- () => ({ state, open, setOpen, toggleSidebar }),
82
- [state, open, setOpen, toggleSidebar],
124
+ () => ({
125
+ state,
126
+ open,
127
+ setOpen,
128
+ openMobile,
129
+ setOpenMobile,
130
+ isMobile,
131
+ toggleSidebar,
132
+ }),
133
+ [state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar],
83
134
  );
84
135
 
85
136
  return (
@@ -92,6 +143,7 @@ function SidebarProvider({
92
143
  style={
93
144
  {
94
145
  "--sidebar-width": SIDEBAR_WIDTH,
146
+ "--sidebar-width-mobile": SIDEBAR_WIDTH_MOBILE,
95
147
  "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
96
148
  ...style,
97
149
  } as React.CSSProperties
@@ -109,7 +161,7 @@ function SidebarProvider({
109
161
  /* Sidebar */
110
162
  /* ------------------------------------------------------------------ */
111
163
 
112
- interface SidebarProps extends React.ComponentProps<"aside"> {
164
+ interface SidebarProps extends React.ComponentProps<"div"> {
113
165
  side?: "left" | "right";
114
166
  variant?: "sidebar" | "floating" | "inset";
115
167
  collapsible?: "offcanvas" | "icon" | "none";
@@ -123,7 +175,7 @@ function Sidebar({
123
175
  children,
124
176
  ...props
125
177
  }: SidebarProps) {
126
- const { state } = useSidebar();
178
+ const { state, isMobile, openMobile, setOpenMobile } = useSidebar();
127
179
 
128
180
  if (collapsible === "none") {
129
181
  return (
@@ -143,6 +195,28 @@ function Sidebar({
143
195
  );
144
196
  }
145
197
 
198
+ if (isMobile) {
199
+ return (
200
+ <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
201
+ <SheetContent
202
+ data-sidebar="sidebar"
203
+ data-mobile="true"
204
+ side={side}
205
+ className={cn(
206
+ "w-(--sidebar-width-mobile) max-w-[85dvw] bg-card p-0 text-foreground [&>button]:hidden",
207
+ className,
208
+ )}
209
+ >
210
+ <SheetHeader className="sr-only">
211
+ <SheetTitle>Sidebar</SheetTitle>
212
+ <SheetDescription>Displays the mobile sidebar.</SheetDescription>
213
+ </SheetHeader>
214
+ <div className="flex h-full w-full flex-col">{children}</div>
215
+ </SheetContent>
216
+ </Sheet>
217
+ );
218
+ }
219
+
146
220
  if (collapsible === "offcanvas") {
147
221
  return (
148
222
  <aside
@@ -247,7 +321,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
247
321
  return (
248
322
  <main
249
323
  data-sidebar="inset"
250
- className={cn("relative flex min-h-svh flex-1 flex-col bg-card overflow-hidden", className)}
324
+ className={cn("relative flex min-h-svh flex-1 flex-col overflow-hidden bg-background", className)}
251
325
  {...props}
252
326
  />
253
327
  );
@@ -297,6 +371,16 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
297
371
  );
298
372
  }
299
373
 
374
+ function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
375
+ return (
376
+ <Input
377
+ data-sidebar="input"
378
+ className={cn("h-8 w-full bg-background shadow-none", className)}
379
+ {...props}
380
+ />
381
+ );
382
+ }
383
+
300
384
  /* ------------------------------------------------------------------ */
301
385
  /* Group components */
302
386
  /* ------------------------------------------------------------------ */
@@ -619,6 +703,7 @@ export {
619
703
  SidebarGroupContent,
620
704
  SidebarGroupLabel,
621
705
  SidebarHeader,
706
+ SidebarInput,
622
707
  SidebarInset,
623
708
  SidebarMenu,
624
709
  SidebarMenuAction,
@@ -632,5 +717,11 @@ export {
632
717
  SidebarRail,
633
718
  SidebarSeparator,
634
719
  SidebarTrigger,
720
+ SIDEBAR_COOKIE_MAX_AGE,
721
+ SIDEBAR_COOKIE_NAME,
722
+ SIDEBAR_KEYBOARD_SHORTCUT,
723
+ SIDEBAR_WIDTH,
724
+ SIDEBAR_WIDTH_ICON,
725
+ SIDEBAR_WIDTH_MOBILE,
635
726
  useSidebar,
636
727
  };
@@ -1,19 +1,52 @@
1
1
  import * as React from "react";
2
- import { TrendingUp, TrendingDown, Minus } from "lucide-react";
2
+ import { TrendingUp, TrendingDown, Minus, Plus } from "lucide-react";
3
3
  import { cn } from "../lib/utils";
4
4
 
5
5
  interface StatCardProps {
6
- label: string;
7
- value: string | number;
6
+ label?: string;
7
+ title?: string;
8
+ value?: string | number;
8
9
  trend?: {
9
10
  value: string;
10
11
  direction: "up" | "down" | "neutral";
11
12
  };
12
13
  icon?: React.ReactNode;
14
+ children?: React.ReactNode;
15
+ actionButtons?: React.ReactNode;
13
16
  className?: string;
17
+ containerClassName?: string;
18
+ titleClassName?: string;
19
+ valueClassName?: string;
14
20
  }
15
21
 
16
- function StatCard({ label, value, trend, icon, className }: StatCardProps) {
22
+ interface StatCardGridProps {
23
+ children: React.ReactNode;
24
+ maxSlots?: number;
25
+ onAddCard?: () => void;
26
+ className?: string;
27
+ showEmptySlots?: boolean;
28
+ }
29
+
30
+ interface StatCardSlotProps {
31
+ onAddCard?: () => void;
32
+ className?: string;
33
+ label?: string;
34
+ }
35
+
36
+ function StatCard({
37
+ label,
38
+ title,
39
+ value,
40
+ trend,
41
+ icon,
42
+ children,
43
+ actionButtons,
44
+ className,
45
+ containerClassName,
46
+ titleClassName,
47
+ valueClassName,
48
+ }: StatCardProps) {
49
+ const displayLabel = title ?? label;
17
50
  const trendColor = {
18
51
  up: "text-muted-foreground bg-brand-secondary/30 rounded-full px-2 py-0.5 w-fit",
19
52
  down: "text-destructive",
@@ -27,16 +60,34 @@ function StatCard({ label, value, trend, icon, className }: StatCardProps) {
27
60
  };
28
61
 
29
62
  return (
30
- <div className={cn("rounded-xl border border-border bg-card p-5", className)}>
63
+ <div
64
+ className={cn(
65
+ "min-w-0 rounded-xl border border-border bg-card p-4 shadow-natural sm:p-5",
66
+ containerClassName,
67
+ className,
68
+ )}
69
+ data-slot="stat-card"
70
+ >
31
71
  <div className="flex items-start justify-between gap-3">
32
- <p className="label text-muted-foreground">{label}</p>
72
+ {displayLabel && (
73
+ <p className={cn("label min-w-0 text-muted-foreground", titleClassName)}>
74
+ {displayLabel}
75
+ </p>
76
+ )}
33
77
  {icon && (
34
78
  <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
35
79
  {icon}
36
80
  </div>
37
81
  )}
82
+ {actionButtons && <div className="flex shrink-0 items-center gap-2">{actionButtons}</div>}
38
83
  </div>
39
- <p className="heading-xl mt-2 tabular-nums text-foreground">{value}</p>
84
+ {value !== undefined ? (
85
+ <p className={cn("heading-xl mt-2 tabular-nums text-foreground", valueClassName)}>
86
+ {value}
87
+ </p>
88
+ ) : children ? (
89
+ <div className="mt-2">{children}</div>
90
+ ) : null}
40
91
  {trend &&
41
92
  (() => {
42
93
  const Icon = TrendIcon[trend.direction];
@@ -53,4 +104,67 @@ function StatCard({ label, value, trend, icon, className }: StatCardProps) {
53
104
 
54
105
  StatCard.displayName = "StatCard";
55
106
 
56
- export { StatCard };
107
+ function StatCardSlot({ onAddCard, className, label = "Add card" }: StatCardSlotProps) {
108
+ return (
109
+ <button
110
+ type="button"
111
+ className={cn(
112
+ "flex min-h-28 w-full min-w-0 items-center justify-center rounded-xl border border-dashed border-border bg-card p-4 text-muted-foreground shadow-natural",
113
+ "transition-[background-color,border-color,color,scale] duration-150 hover:border-ground-300 hover:bg-muted hover:text-foreground active:scale-[0.96]",
114
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30",
115
+ className,
116
+ )}
117
+ onClick={onAddCard}
118
+ >
119
+ <span className="flex flex-col items-center gap-1.5">
120
+ <Plus className="size-4" aria-hidden="true" />
121
+ <span className="caption font-medium">{label}</span>
122
+ </span>
123
+ </button>
124
+ );
125
+ }
126
+
127
+ StatCardSlot.displayName = "StatCardSlot";
128
+
129
+ function StatCardGrid({
130
+ children,
131
+ maxSlots = 6,
132
+ onAddCard,
133
+ className,
134
+ showEmptySlots = false,
135
+ }: StatCardGridProps) {
136
+ const childrenArray = React.Children.toArray(children);
137
+ const emptySlots = showEmptySlots ? Math.max(0, maxSlots - childrenArray.length) : 0;
138
+
139
+ return (
140
+ <div
141
+ className={cn(
142
+ "grid min-w-0 grid-cols-2 items-stretch gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6",
143
+ className,
144
+ )}
145
+ data-slot="stat-card-grid"
146
+ >
147
+ {childrenArray.map((child, index) => (
148
+ <div key={`filled-${index}`} className="min-w-0">
149
+ {child}
150
+ </div>
151
+ ))}
152
+ {Array.from({ length: emptySlots }, (_, index) => (
153
+ <div key={`empty-${index}`} className="min-w-0">
154
+ <StatCardSlot {...(onAddCard ? { onAddCard } : {})} />
155
+ </div>
156
+ ))}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ StatCardGrid.displayName = "StatCardGrid";
162
+
163
+ export {
164
+ StatCard,
165
+ StatCardGrid,
166
+ StatCardSlot,
167
+ type StatCardProps,
168
+ type StatCardGridProps,
169
+ type StatCardSlotProps,
170
+ };
@@ -0,0 +1,144 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Badge } from "./badge";
5
+ import { cn } from "../lib/utils";
6
+
7
+ type StatusBadgeVariant = NonNullable<React.ComponentProps<typeof Badge>["variant"]>;
8
+
9
+ interface StatusBadgeProps extends Omit<
10
+ React.ComponentProps<typeof Badge>,
11
+ "children" | "variant"
12
+ > {
13
+ status?: string | null;
14
+ label?: React.ReactNode;
15
+ variant?: StatusBadgeVariant;
16
+ variantMap?: Record<string, StatusBadgeVariant>;
17
+ labelMap?: Record<string, React.ReactNode>;
18
+ showDot?: boolean;
19
+ dotClassName?: string;
20
+ }
21
+
22
+ const DEFAULT_STATUS_VARIANT: Record<string, StatusBadgeVariant> = {
23
+ active: "success",
24
+ approved: "success",
25
+ complete: "success",
26
+ completed: "success",
27
+ confirmed: "success",
28
+ delivered: "success",
29
+ enabled: "success",
30
+ healthy: "success",
31
+ optimized: "success",
32
+ paid: "success",
33
+ published: "success",
34
+ resolved: "success",
35
+ translated: "success",
36
+ verified: "success",
37
+
38
+ degraded: "warning",
39
+ flagged: "warning",
40
+ held_for_review: "warning",
41
+ pending: "warning",
42
+ processing: "warning",
43
+ retrying: "warning",
44
+ scheduled: "warning",
45
+ trial: "warning",
46
+ warning: "warning",
47
+
48
+ archived: "secondary",
49
+ disabled: "secondary",
50
+ draft: "secondary",
51
+ inactive: "secondary",
52
+ missing: "secondary",
53
+ neutral: "secondary",
54
+ skipped: "secondary",
55
+
56
+ blocked: "destructive",
57
+ cancelled: "destructive",
58
+ critical: "destructive",
59
+ dead_letter: "destructive",
60
+ deleted: "destructive",
61
+ error: "destructive",
62
+ failed: "destructive",
63
+ rejected: "destructive",
64
+ suspended: "destructive",
65
+ unpaid: "destructive",
66
+ outdated: "destructive",
67
+ };
68
+
69
+ const DEFAULT_DOT_CLASS: Record<StatusBadgeVariant, string> = {
70
+ default: "bg-background",
71
+ secondary: "bg-muted-foreground",
72
+ outline: "bg-muted-foreground",
73
+ brand: "bg-background",
74
+ success: "bg-emerald-500",
75
+ warning: "bg-amber-500",
76
+ destructive: "bg-destructive",
77
+ };
78
+
79
+ function normalizeStatus(status?: string | null) {
80
+ return String(status ?? "unknown")
81
+ .trim()
82
+ .toLowerCase()
83
+ .replace(/[\s-]+/g, "_");
84
+ }
85
+
86
+ function statusLabel(status?: string | null) {
87
+ const normalized = normalizeStatus(status);
88
+ if (normalized === "unknown") return "Unknown";
89
+
90
+ return normalized
91
+ .split("_")
92
+ .filter(Boolean)
93
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
94
+ .join(" ");
95
+ }
96
+
97
+ function statusBadgeVariantFor(
98
+ status?: string | null,
99
+ variantMap?: Record<string, StatusBadgeVariant>,
100
+ ) {
101
+ const normalized = normalizeStatus(status);
102
+ return variantMap?.[normalized] ?? DEFAULT_STATUS_VARIANT[normalized] ?? "outline";
103
+ }
104
+
105
+ function StatusBadge({
106
+ status,
107
+ label,
108
+ variant,
109
+ variantMap,
110
+ labelMap,
111
+ showDot = false,
112
+ dotClassName,
113
+ className,
114
+ ...props
115
+ }: StatusBadgeProps) {
116
+ const normalized = normalizeStatus(status);
117
+ const resolvedVariant = variant ?? statusBadgeVariantFor(normalized, variantMap);
118
+ const resolvedLabel = label ?? labelMap?.[normalized] ?? statusLabel(normalized);
119
+
120
+ return (
121
+ <Badge
122
+ variant={resolvedVariant}
123
+ className={cn("max-w-full whitespace-nowrap", showDot && "pl-2", className)}
124
+ {...props}
125
+ >
126
+ {showDot && (
127
+ <span
128
+ className={cn(
129
+ "size-1.5 shrink-0 rounded-full",
130
+ DEFAULT_DOT_CLASS[resolvedVariant],
131
+ dotClassName,
132
+ )}
133
+ aria-hidden="true"
134
+ />
135
+ )}
136
+ <span className="min-w-0 truncate">{resolvedLabel}</span>
137
+ </Badge>
138
+ );
139
+ }
140
+
141
+ StatusBadge.displayName = "StatusBadge";
142
+
143
+ export { StatusBadge, statusBadgeVariantFor, statusLabel };
144
+ export type { StatusBadgeProps, StatusBadgeVariant };
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from "./card";
12
+ import { CardHeading } from "./card-heading";
13
+
14
+ interface TitledCardProps {
15
+ title?: string;
16
+ subtitle?: string;
17
+ children?: React.ReactNode;
18
+ actionButtons?: React.ReactNode;
19
+ icon?: React.ComponentType<{ className?: string }>;
20
+ className?: string;
21
+ contentClassName?: string;
22
+ containerClassName?: string;
23
+ titleClassName?: string;
24
+ }
25
+
26
+ function TitledCard({
27
+ title,
28
+ subtitle,
29
+ children,
30
+ actionButtons,
31
+ icon: Icon,
32
+ className,
33
+ contentClassName,
34
+ containerClassName,
35
+ titleClassName,
36
+ }: TitledCardProps) {
37
+ const hasHeader = Boolean(title || subtitle || actionButtons);
38
+ const canUseCardHeading = Boolean(title) && !actionButtons && !titleClassName;
39
+
40
+ return (
41
+ <Card
42
+ className={cn(
43
+ "min-w-0 overflow-hidden border border-border bg-card shadow-natural",
44
+ "max-sm:rounded-none max-sm:border-0 max-sm:bg-transparent max-sm:shadow-none",
45
+ containerClassName,
46
+ className,
47
+ )}
48
+ data-slot="titled-card"
49
+ >
50
+ {canUseCardHeading ? (
51
+ <CardHeading
52
+ title={title!}
53
+ {...(subtitle ? { description: subtitle } : {})}
54
+ className="max-sm:border-b-0 max-sm:px-0 max-sm:pb-3"
55
+ >
56
+ {Icon && <Icon className="size-5 shrink-0 text-muted-foreground" />}
57
+ </CardHeading>
58
+ ) : hasHeader ? (
59
+ <CardHeader className="flex flex-col gap-3 border-b border-border p-4 max-sm:border-b-0 max-sm:px-0 max-sm:pb-3 sm:flex-row sm:items-start sm:justify-between sm:p-5">
60
+ <div className="min-w-0 flex-1">
61
+ {title && (
62
+ <CardTitle
63
+ className={cn(
64
+ "flex min-w-0 items-center gap-2 text-base leading-tight sm:text-lg",
65
+ titleClassName,
66
+ )}
67
+ >
68
+ {Icon && <Icon className="size-5 shrink-0 text-muted-foreground" />}
69
+ <span className="min-w-0 truncate">{title}</span>
70
+ </CardTitle>
71
+ )}
72
+ {subtitle && (
73
+ <CardDescription className="mt-1 max-w-3xl text-sm leading-5">
74
+ {subtitle}
75
+ </CardDescription>
76
+ )}
77
+ </div>
78
+ {actionButtons && (
79
+ <div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
80
+ {actionButtons}
81
+ </div>
82
+ )}
83
+ </CardHeader>
84
+ ) : null}
85
+ {children && (
86
+ <CardContent
87
+ className={cn(
88
+ hasHeader ? "p-4 sm:p-5" : "p-0 sm:p-5",
89
+ "min-w-0 max-sm:px-0",
90
+ contentClassName,
91
+ )}
92
+ >
93
+ {children}
94
+ </CardContent>
95
+ )}
96
+ </Card>
97
+ );
98
+ }
99
+
100
+ TitledCard.displayName = "TitledCard";
101
+
102
+ export { TitledCard, type TitledCardProps };