@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/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
|
+
}
|