@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/grid.tsx ADDED
@@ -0,0 +1,94 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ type GridColumns = 1 | 2 | 3 | 4 | 5 | 6 | 12;
6
+ type GridGap = "xs" | "sm" | "md" | "lg" | "xl";
7
+
8
+ type GridProps = HTMLAttributes<HTMLDivElement> & {
9
+ children: ReactNode;
10
+ cols?: GridColumns;
11
+ sm?: GridColumns;
12
+ md?: GridColumns;
13
+ lg?: GridColumns;
14
+ xl?: GridColumns;
15
+ gap?: GridGap;
16
+ };
17
+
18
+ const columnClasses: Record<GridColumns, string> = {
19
+ 1: "grid-cols-1",
20
+ 2: "grid-cols-2",
21
+ 3: "grid-cols-3",
22
+ 4: "grid-cols-4",
23
+ 5: "grid-cols-5",
24
+ 6: "grid-cols-6",
25
+ 12: "grid-cols-12",
26
+ };
27
+
28
+ const smColumnClasses: Record<GridColumns, string> = {
29
+ 1: "sm:grid-cols-1",
30
+ 2: "sm:grid-cols-2",
31
+ 3: "sm:grid-cols-3",
32
+ 4: "sm:grid-cols-4",
33
+ 5: "sm:grid-cols-5",
34
+ 6: "sm:grid-cols-6",
35
+ 12: "sm:grid-cols-12",
36
+ };
37
+
38
+ const mdColumnClasses: Record<GridColumns, string> = {
39
+ 1: "md:grid-cols-1",
40
+ 2: "md:grid-cols-2",
41
+ 3: "md:grid-cols-3",
42
+ 4: "md:grid-cols-4",
43
+ 5: "md:grid-cols-5",
44
+ 6: "md:grid-cols-6",
45
+ 12: "md:grid-cols-12",
46
+ };
47
+
48
+ const lgColumnClasses: Record<GridColumns, string> = {
49
+ 1: "lg:grid-cols-1",
50
+ 2: "lg:grid-cols-2",
51
+ 3: "lg:grid-cols-3",
52
+ 4: "lg:grid-cols-4",
53
+ 5: "lg:grid-cols-5",
54
+ 6: "lg:grid-cols-6",
55
+ 12: "lg:grid-cols-12",
56
+ };
57
+
58
+ const xlColumnClasses: Record<GridColumns, string> = {
59
+ 1: "xl:grid-cols-1",
60
+ 2: "xl:grid-cols-2",
61
+ 3: "xl:grid-cols-3",
62
+ 4: "xl:grid-cols-4",
63
+ 5: "xl:grid-cols-5",
64
+ 6: "xl:grid-cols-6",
65
+ 12: "xl:grid-cols-12",
66
+ };
67
+
68
+ const gapClasses: Record<GridGap, string> = {
69
+ xs: "gutter-grid-xs",
70
+ sm: "gutter-grid-sm",
71
+ md: "gutter-grid-md",
72
+ lg: "gutter-grid-lg",
73
+ xl: "gutter-grid-xl",
74
+ };
75
+
76
+ export function Grid({ children, cols = 1, sm, md, lg, xl, gap = "md", className, ...props }: GridProps) {
77
+ return (
78
+ <div
79
+ {...props}
80
+ className={cn(
81
+ "grid",
82
+ columnClasses[cols],
83
+ gapClasses[gap],
84
+ sm && smColumnClasses[sm],
85
+ md && mdColumnClasses[md],
86
+ lg && lgColumnClasses[lg],
87
+ xl && xlColumnClasses[xl],
88
+ className,
89
+ )}
90
+ >
91
+ {children}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,82 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { Badge } from "./badge";
4
+ import { Button } from "./button";
5
+ import { Card } from "./card";
6
+ import { cn } from "./utils";
7
+
8
+ type HeroBannerAction = {
9
+ label: string;
10
+ variant?: "primary" | "secondary" | "tertiary" | "destructive";
11
+ href?: string;
12
+ onClick?: () => void;
13
+ ariaLabel?: string;
14
+ };
15
+
16
+ type HeroBannerProps = {
17
+ eyebrow?: string;
18
+ title: string;
19
+ description: string;
20
+ media?: ReactNode;
21
+ actions?: HeroBannerAction[];
22
+ highlights?: ReactNode;
23
+ className?: string;
24
+ };
25
+
26
+ export function HeroBanner({
27
+ eyebrow,
28
+ title,
29
+ description,
30
+ media,
31
+ actions,
32
+ highlights,
33
+ className,
34
+ }: HeroBannerProps) {
35
+ return (
36
+ <Card
37
+ tone="glass"
38
+ padding="xl"
39
+ className={cn("overflow-hidden rounded-[calc(var(--radius-xl)+8px)]", className)}
40
+ >
41
+ <div className="grid items-center gap-8 lg:grid-cols-[1fr_0.9fr]">
42
+ <div>
43
+ {eyebrow ? <Badge tone="brand">{eyebrow}</Badge> : null}
44
+ <h1
45
+ className="type-display mt-4 max-w-3xl"
46
+ style={{ letterSpacing: "var(--global-letter-spacing)" }}
47
+ >
48
+ {title}
49
+ </h1>
50
+ <p className="type-block mt-4 max-w-2xl text-(--ink-muted)">{description}</p>
51
+ {actions?.length ? (
52
+ <div className="mt-6 flex flex-wrap gap-3">
53
+ {actions.map((action) =>
54
+ action.href ? (
55
+ <Button
56
+ key={`${action.href}-${action.label}`}
57
+ href={action.href}
58
+ ariaLabel={action.ariaLabel}
59
+ variant={action.variant ?? "primary"}
60
+ >
61
+ {action.label}
62
+ </Button>
63
+ ) : (
64
+ <Button
65
+ key={action.label}
66
+ onClick={action.onClick}
67
+ aria-label={action.ariaLabel}
68
+ variant={action.variant ?? "primary"}
69
+ >
70
+ {action.label}
71
+ </Button>
72
+ ),
73
+ )}
74
+ </div>
75
+ ) : null}
76
+ {highlights ? <div className="mt-6">{highlights}</div> : null}
77
+ </div>
78
+ {media ? <div>{media}</div> : null}
79
+ </div>
80
+ </Card>
81
+ );
82
+ }
@@ -0,0 +1,78 @@
1
+ import Link from "next/link";
2
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
3
+
4
+ import { buttonBaseClasses, buttonVariantClasses, type ButtonVariant } from "./button";
5
+ import { cn } from "./utils";
6
+
7
+ type IconButtonSize = "sm" | "md" | "lg";
8
+ type IconButtonVariant = ButtonVariant | "ghost";
9
+
10
+ type SharedIconButtonProps = {
11
+ icon: ReactNode;
12
+ ariaLabel: string;
13
+ variant?: IconButtonVariant;
14
+ size?: IconButtonSize;
15
+ className?: string;
16
+ };
17
+
18
+ type IconButtonProps =
19
+ | (SharedIconButtonProps &
20
+ ButtonHTMLAttributes<HTMLButtonElement> & {
21
+ href?: undefined;
22
+ })
23
+ | (SharedIconButtonProps & {
24
+ href: string;
25
+ });
26
+
27
+ const iconButtonVariantClasses: Record<IconButtonVariant, string> = {
28
+ ...buttonVariantClasses,
29
+ ghost: "bg-transparent text-(--foreground) shadow-none hover:bg-(--color-surface-hover)",
30
+ };
31
+
32
+ const iconButtonSizeClasses: Record<IconButtonSize, string> = {
33
+ sm: "h-10 w-10",
34
+ md: "h-12 w-12",
35
+ lg: "h-14 w-14",
36
+ };
37
+
38
+ export function IconButton(props: IconButtonProps) {
39
+ const { icon, ariaLabel, variant = "tertiary", size = "md", className } = props;
40
+
41
+ const classes = cn(
42
+ buttonBaseClasses,
43
+ "rounded-full p-0",
44
+ iconButtonVariantClasses[variant],
45
+ iconButtonSizeClasses[size],
46
+ className,
47
+ );
48
+
49
+ if ("href" in props && props.href) {
50
+ return (
51
+ <Link href={props.href} aria-label={ariaLabel} className={classes}>
52
+ {icon}
53
+ </Link>
54
+ );
55
+ }
56
+
57
+ const {
58
+ type = "button",
59
+ ariaLabel: omittedAriaLabel,
60
+ icon: omittedIcon,
61
+ variant: omittedVariant,
62
+ size: omittedSize,
63
+ className: omittedClassName,
64
+ ...buttonProps
65
+ } = props as Extract<IconButtonProps, { href?: undefined }>;
66
+
67
+ void omittedAriaLabel;
68
+ void omittedIcon;
69
+ void omittedVariant;
70
+ void omittedSize;
71
+ void omittedClassName;
72
+
73
+ return (
74
+ <button {...buttonProps} type={type} aria-label={ariaLabel} className={classes}>
75
+ {icon}
76
+ </button>
77
+ );
78
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export * from "./badge";
2
+ export * from "./button";
3
+ export * from "./card";
4
+ export * from "./checkbox";
5
+ export * from "./cluster";
6
+ export * from "./container";
7
+ export * from "./datepicker";
8
+ export * from "./feature-card";
9
+ export * from "./floating-button";
10
+ export * from "./grid";
11
+ export * from "./hero-banner";
12
+ export * from "./icon-button";
13
+ export * from "./input";
14
+ export * from "./label";
15
+ export * from "./loader";
16
+ export * from "./message-box";
17
+ export * from "./metric";
18
+ export * from "./navigation";
19
+ export * from "./radio";
20
+ export * from "./progress";
21
+ export * from "./result";
22
+ export * from "./section";
23
+ export * from "./select";
24
+ export * from "./skeleton";
25
+ export * from "./stat-card";
26
+ export * from "./stack";
27
+ export * from "./table";
28
+ export * from "./textarea";
29
+ export * from "./timeline";
30
+ export * from "./utils";
31
+ export * from "./phone-number-input";
32
+ export * from "./steps";
package/src/input.tsx ADDED
@@ -0,0 +1,62 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+
3
+ import { Label } from "./label";
4
+ import { cn } from "./utils";
5
+
6
+ type InputProps = InputHTMLAttributes<HTMLInputElement> & {
7
+ label?: string;
8
+ helperText?: string;
9
+ errorText?: string;
10
+ leadingSlot?: React.ReactNode;
11
+ trailingSlot?: React.ReactNode;
12
+ };
13
+
14
+ export function Input({
15
+ label,
16
+ helperText,
17
+ errorText,
18
+ leadingSlot,
19
+ trailingSlot,
20
+ className,
21
+ id,
22
+ required,
23
+ ...props
24
+ }: InputProps) {
25
+ const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
26
+ const hasError = Boolean(errorText);
27
+
28
+ return (
29
+ <div className="w-full">
30
+ {label ? (
31
+ <Label htmlFor={inputId} requiredMark={required} className="mb-2 block">
32
+ {label}
33
+ </Label>
34
+ ) : null}
35
+ <div
36
+ className={cn(
37
+ "flex min-h-12 items-center gap-3 rounded-2xl border bg-(--surface-card) px-4 shadow-(--shadow-sm) transition-colors",
38
+ hasError
39
+ ? "border-(--color-status-error)"
40
+ : "border-(--line-soft) hover:border-(--color-line-strong)",
41
+ )}
42
+ >
43
+ {leadingSlot}
44
+ <input
45
+ {...props}
46
+ id={inputId}
47
+ required={required}
48
+ className={cn(
49
+ "type-body min-h-12 w-full bg-transparent text-(--foreground) outline-none placeholder:text-(--ink-subtle)",
50
+ className,
51
+ )}
52
+ />
53
+ {trailingSlot}
54
+ </div>
55
+ {errorText ? (
56
+ <p className="type-caption mt-2 text-(--color-status-error)">{errorText}</p>
57
+ ) : helperText ? (
58
+ <p className="type-caption mt-2 text-(--ink-subtle)">{helperText}</p>
59
+ ) : null}
60
+ </div>
61
+ );
62
+ }
package/src/label.tsx ADDED
@@ -0,0 +1,20 @@
1
+ import type { LabelHTMLAttributes, ReactNode } from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ type LabelProps = LabelHTMLAttributes<HTMLLabelElement> & {
6
+ children: ReactNode;
7
+ requiredMark?: boolean;
8
+ };
9
+
10
+ export function Label({ children, requiredMark = false, className, ...props }: LabelProps) {
11
+ return (
12
+ <label
13
+ {...props}
14
+ className={cn("type-title inline-flex items-center gap-1 text-(--foreground)", className)}
15
+ >
16
+ <span>{children}</span>
17
+ {requiredMark ? <span className="text-(--color-status-error)">*</span> : null}
18
+ </label>
19
+ );
20
+ }
package/src/loader.tsx ADDED
@@ -0,0 +1,90 @@
1
+ import { LoaderCircle } from "lucide-react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ type LoaderSize = "sm" | "md" | "lg";
6
+ type LoaderTone = "brand" | "neutral" | "inverse";
7
+
8
+ type LoaderProps = {
9
+ label?: string | null;
10
+ size?: LoaderSize;
11
+ tone?: LoaderTone;
12
+ className?: string;
13
+ };
14
+
15
+ const sizeClasses: Record<LoaderSize, string> = {
16
+ sm: "h-4 w-4",
17
+ md: "h-6 w-6",
18
+ lg: "h-8 w-8",
19
+ };
20
+
21
+ const toneClasses: Record<LoaderTone, string> = {
22
+ brand: "text-(--color-brand-primary)",
23
+ neutral: "text-(--ink-muted)",
24
+ inverse: "text-white",
25
+ };
26
+
27
+ export function Loader({ label = "Loading", size = "md", tone = "brand", className }: LoaderProps) {
28
+ return (
29
+ <span className={cn("inline-flex items-center gap-2", className)} role="status" aria-live="polite">
30
+ <LoaderCircle className={cn("animate-spin", sizeClasses[size], toneClasses[tone])} />
31
+ {label ? <span className="type-caption text-(--ink-muted)">{label}</span> : null}
32
+ </span>
33
+ );
34
+ }
35
+
36
+ type PageLoaderOverlayProps = {
37
+ label?: string;
38
+ description?: string;
39
+ className?: string;
40
+ };
41
+
42
+ type BlockLoaderOverlayProps = {
43
+ label?: string;
44
+ description?: string;
45
+ className?: string;
46
+ };
47
+
48
+ export function PageLoaderOverlay({ label = "Loading", description, className }: PageLoaderOverlayProps) {
49
+ return (
50
+ <div
51
+ className={cn(
52
+ "fixed inset-0 z-100 flex items-center justify-center bg-white/70 backdrop-blur-sm",
53
+ className,
54
+ )}
55
+ role="status"
56
+ aria-live="polite"
57
+ aria-label={label}
58
+ >
59
+ <div className="flex min-w-60 max-w-sm flex-col items-center gap-3 rounded-lg border border-(--line-soft) bg-(--surface-card) px-6 py-5 text-center shadow-(--shadow-lg)">
60
+ <LoaderCircle className={cn("animate-spin", sizeClasses.lg, toneClasses.brand)} />
61
+ <p className="type-title text-(--foreground)">{label}</p>
62
+ {description ? <p className="type-body text-(--ink-muted)">{description}</p> : null}
63
+ </div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export function BlockLoaderOverlay({
69
+ label = "Loading",
70
+ description,
71
+ className,
72
+ }: BlockLoaderOverlayProps) {
73
+ return (
74
+ <div
75
+ className={cn(
76
+ "absolute inset-0 z-20 flex items-center justify-center rounded-[inherit] bg-white/72 p-4 backdrop-blur-sm",
77
+ className,
78
+ )}
79
+ role="status"
80
+ aria-live="polite"
81
+ aria-label={label}
82
+ >
83
+ <div className="flex min-w-56 max-w-xs flex-col items-center gap-3 rounded-2xl border border-(--line-soft) bg-(--surface-card) px-5 py-4 text-center shadow-(--shadow-md)">
84
+ <LoaderCircle className={cn("animate-spin", sizeClasses.md, toneClasses.brand)} />
85
+ <p className="type-title text-(--foreground)">{label}</p>
86
+ {description ? <p className="type-body text-(--ink-muted)">{description}</p> : null}
87
+ </div>
88
+ </div>
89
+ );
90
+ }
package/src/menu.tsx ADDED
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { ChevronDown } from "lucide-react";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+
6
+ import { Button, type ButtonSize, type ButtonVariant } from "./button";
7
+ import { cn } from "./utils";
8
+
9
+ export type MenuItem = {
10
+ label: string;
11
+ description?: string;
12
+ onSelect?: () => void;
13
+ disabled?: boolean;
14
+ tone?: "default" | "destructive";
15
+ };
16
+
17
+ type MenuProps = {
18
+ label: string;
19
+ items: MenuItem[];
20
+ variant?: ButtonVariant;
21
+ size?: ButtonSize;
22
+ className?: string;
23
+ };
24
+
25
+ export function Menu({ label, items, variant = "tertiary", size = "sm", className }: MenuProps) {
26
+ const id = useId();
27
+ const wrapperRef = useRef<HTMLDivElement>(null);
28
+ const [open, setOpen] = useState(false);
29
+
30
+ useEffect(() => {
31
+ function handlePointerDown(event: MouseEvent) {
32
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
33
+ setOpen(false);
34
+ }
35
+ }
36
+
37
+ function handleKeyDown(event: KeyboardEvent) {
38
+ if (event.key === "Escape") {
39
+ setOpen(false);
40
+ }
41
+ }
42
+
43
+ document.addEventListener("mousedown", handlePointerDown);
44
+ window.addEventListener("keydown", handleKeyDown);
45
+
46
+ return () => {
47
+ document.removeEventListener("mousedown", handlePointerDown);
48
+ window.removeEventListener("keydown", handleKeyDown);
49
+ };
50
+ }, []);
51
+
52
+ return (
53
+ <div ref={wrapperRef} className={cn("relative inline-flex", className)}>
54
+ <Button
55
+ variant={variant}
56
+ size={size}
57
+ onClick={() => setOpen((current) => !current)}
58
+ aria-label={label}
59
+ >
60
+ {label}
61
+ <ChevronDown className={cn("h-4 w-4 transition-transform", open && "rotate-180")} />
62
+ </Button>
63
+
64
+ {open ? (
65
+ <div
66
+ id={`${id}-menu`}
67
+ role="menu"
68
+ className="absolute right-0 top-full z-30 mt-2 min-w-60 rounded-xl border border-(--line-soft) bg-(--surface-card) p-2 shadow-(--shadow-lg)"
69
+ >
70
+ {items.map((item) => (
71
+ <button
72
+ key={item.label}
73
+ type="button"
74
+ role="menuitem"
75
+ disabled={item.disabled}
76
+ onClick={() => {
77
+ if (item.disabled) {
78
+ return;
79
+ }
80
+
81
+ item.onSelect?.();
82
+ setOpen(false);
83
+ }}
84
+ className={cn(
85
+ "block w-full rounded-2xl px-4 py-3 text-left transition-colors hover:bg-(--color-surface-hover)",
86
+ item.disabled && "cursor-not-allowed opacity-50",
87
+ item.tone === "destructive" ? "text-(--color-status-error)" : "text-(--foreground)",
88
+ )}
89
+ >
90
+ <span className="type-body block">{item.label}</span>
91
+ {item.description ? (
92
+ <span className="type-caption mt-1 block text-(--ink-subtle)">{item.description}</span>
93
+ ) : null}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ ) : null}
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,46 @@
1
+ import type { ReactNode } from "react";
2
+ import { CircleAlert, CircleCheckBig, Info, TriangleAlert } from "lucide-react";
3
+
4
+ import { cn } from "./utils";
5
+
6
+ type MessageTone = "info" | "success" | "warning" | "error";
7
+
8
+ type MessageBoxProps = {
9
+ tone?: MessageTone;
10
+ title: string;
11
+ description?: string;
12
+ actions?: ReactNode;
13
+ className?: string;
14
+ };
15
+
16
+ const toneClasses: Record<MessageTone, string> = {
17
+ info: "border-(--color-status-info-light) bg-(--color-status-info-light)/60",
18
+ success: "border-(--color-status-success-light) bg-(--color-status-success-light)/70",
19
+ warning: "border-(--color-status-warning-light) bg-(--color-status-warning-light)/80",
20
+ error: "border-(--color-status-error-light) bg-(--color-status-error-light)/70",
21
+ };
22
+
23
+ const toneIcons: Record<MessageTone, ReactNode> = {
24
+ info: <Info className="h-5 w-5 text-(--color-status-info)" />,
25
+ success: <CircleCheckBig className="h-5 w-5 text-(--color-status-success)" />,
26
+ warning: <TriangleAlert className="h-5 w-5 text-(--color-status-warning)" />,
27
+ error: <CircleAlert className="h-5 w-5 text-(--color-status-error)" />,
28
+ };
29
+
30
+ export function MessageBox({ tone = "info", title, description, actions, className }: MessageBoxProps) {
31
+ return (
32
+ <div
33
+ className={cn("rounded-xl border p-4 shadow-(--shadow-sm)", toneClasses[tone], className)}
34
+ role="alert"
35
+ >
36
+ <div className="flex items-start gap-3">
37
+ <div className="mt-0.5">{toneIcons[tone]}</div>
38
+ <div className="min-w-0 flex-1">
39
+ <p className="type-title">{title}</p>
40
+ {description ? <p className="type-body mt-2 text-(--ink-muted)">{description}</p> : null}
41
+ {actions ? <div className="mt-3 flex flex-wrap gap-3">{actions}</div> : null}
42
+ </div>
43
+ </div>
44
+ </div>
45
+ );
46
+ }
package/src/metric.tsx ADDED
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ type MetricTone = "default" | "brand" | "success" | "warning";
6
+
7
+ type MetricProps = {
8
+ label: string;
9
+ value: ReactNode;
10
+ change?: string;
11
+ icon?: ReactNode;
12
+ tone?: MetricTone;
13
+ className?: string;
14
+ };
15
+
16
+ const toneClasses: Record<MetricTone, string> = {
17
+ default: "text-(--foreground)",
18
+ brand: "text-(--color-brand-primary)",
19
+ success: "text-(--color-status-success)",
20
+ warning: "text-(--color-status-warning)",
21
+ };
22
+
23
+ export function Metric({ label, value, change, icon, tone = "default", className }: MetricProps) {
24
+ return (
25
+ <div
26
+ className={cn(
27
+ "rounded-xl border border-(--line-soft) bg-(--surface-card) p-5 shadow-(--shadow-sm)",
28
+ className,
29
+ )}
30
+ >
31
+ <div className="flex items-start justify-between gap-4">
32
+ <div>
33
+ <p className="type-caption text-(--ink-subtle)">{label}</p>
34
+ <p className={cn("mt-2 text-3xl font-extrabold", toneClasses[tone])}>{value}</p>
35
+ {change ? <p className="type-caption mt-2 text-(--ink-muted)">{change}</p> : null}
36
+ </div>
37
+ {icon ? <div className={cn("text-xl", toneClasses[tone])}>{icon}</div> : null}
38
+ </div>
39
+ </div>
40
+ );
41
+ }