@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/modal.tsx ADDED
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { AnimatePresence, motion } from "framer-motion";
4
+ import { useEffect, useId, type ReactNode } from "react";
5
+ import { X } from "lucide-react";
6
+ import { createPortal } from "react-dom";
7
+
8
+ import { Button } from "./button";
9
+ import { Card } from "./card";
10
+ import { cn } from "./utils";
11
+
12
+ type ModalProps = {
13
+ open: boolean;
14
+ onClose: () => void;
15
+ title: string;
16
+ description?: string;
17
+ children: ReactNode;
18
+ footer?: ReactNode;
19
+ className?: string;
20
+ };
21
+
22
+ export function Modal({ open, onClose, title, description, children, footer, className }: ModalProps) {
23
+ const titleId = useId();
24
+ const descriptionId = useId();
25
+ const mounted = typeof document !== "undefined";
26
+
27
+ useEffect(() => {
28
+ if (!open) {
29
+ return;
30
+ }
31
+
32
+ const previousOverflow = document.body.style.overflow;
33
+ document.body.style.overflow = "hidden";
34
+
35
+ function handleKeyDown(event: KeyboardEvent) {
36
+ if (event.key === "Escape") {
37
+ onClose();
38
+ }
39
+ }
40
+
41
+ window.addEventListener("keydown", handleKeyDown);
42
+
43
+ return () => {
44
+ document.body.style.overflow = previousOverflow;
45
+ window.removeEventListener("keydown", handleKeyDown);
46
+ };
47
+ }, [open, onClose]);
48
+
49
+ if (!mounted) {
50
+ return null;
51
+ }
52
+
53
+ return createPortal(
54
+ <AnimatePresence>
55
+ {open ? (
56
+ <motion.div
57
+ className="fixed inset-0 z-[60] flex items-center justify-center p-4 md:p-6"
58
+ initial={{ opacity: 0 }}
59
+ animate={{ opacity: 1 }}
60
+ exit={{ opacity: 0 }}
61
+ transition={{ duration: 0.2, ease: "easeOut" }}
62
+ >
63
+ <motion.div className="absolute inset-0 bg-[rgba(11,26,42,0.48)]" onClick={onClose} aria-hidden />
64
+ <motion.div
65
+ initial={{ opacity: 0, y: 20, scale: 0.96 }}
66
+ animate={{ opacity: 1, y: 0, scale: 1 }}
67
+ exit={{ opacity: 0, y: 12, scale: 0.98 }}
68
+ transition={{ duration: 0.24, ease: "easeOut" }}
69
+ className={cn("relative z-10 w-full max-w-2xl", className)}
70
+ >
71
+ <Card
72
+ role="dialog"
73
+ aria-modal="true"
74
+ aria-labelledby={titleId}
75
+ aria-describedby={description ? descriptionId : undefined}
76
+ className="flex max-h-[calc(100vh-2rem)] w-full flex-col overflow-hidden md:max-h-[calc(100vh-3rem)]"
77
+ tone="surface"
78
+ padding="lg"
79
+ >
80
+ <div className="flex items-start justify-between gap-4">
81
+ <div>
82
+ <h2 id={titleId} className="type-subheading">
83
+ {title}
84
+ </h2>
85
+ {description ? (
86
+ <p id={descriptionId} className="type-body mt-2 text-(--ink-muted)">
87
+ {description}
88
+ </p>
89
+ ) : null}
90
+ </div>
91
+ <Button
92
+ onClick={onClose}
93
+ variant="tertiary"
94
+ size="sm"
95
+ aria-label="Close modal"
96
+ className="min-h-10 px-3"
97
+ >
98
+ <X className="h-4 w-4" />
99
+ </Button>
100
+ </div>
101
+ <div className="mt-6 min-h-0 overflow-y-auto pr-1">{children}</div>
102
+ {footer ? <div className="mt-6 flex flex-wrap justify-end gap-3">{footer}</div> : null}
103
+ </Card>
104
+ </motion.div>
105
+ </motion.div>
106
+ ) : null}
107
+ </AnimatePresence>,
108
+ document.body,
109
+ );
110
+ }
@@ -0,0 +1,147 @@
1
+ import Link from "next/link";
2
+ import type { ComponentType, MouseEventHandler, ReactNode } from "react";
3
+ import { ChevronRight } from "lucide-react";
4
+
5
+ import { cn } from "./utils";
6
+
7
+ export type NavigationItem = {
8
+ label: string;
9
+ href: string;
10
+ icon?: ComponentType<{ className?: string }>;
11
+ active?: boolean;
12
+ badge?: ReactNode;
13
+ onClick?: MouseEventHandler<HTMLAnchorElement>;
14
+ };
15
+
16
+ type NavigationBarProps = {
17
+ items: NavigationItem[];
18
+ brand?: ReactNode;
19
+ actions?: ReactNode;
20
+ className?: string;
21
+ };
22
+
23
+ type NavigationRailProps = {
24
+ items: NavigationItem[];
25
+ footer?: ReactNode;
26
+ className?: string;
27
+ };
28
+
29
+ type BreadcrumbItem = {
30
+ label: string;
31
+ href?: string;
32
+ };
33
+
34
+ type BreadcrumbsProps = {
35
+ items: BreadcrumbItem[];
36
+ className?: string;
37
+ };
38
+
39
+ export function NavigationBar({ items, brand, actions, className }: NavigationBarProps) {
40
+ return (
41
+ <nav
42
+ className={cn(
43
+ "surface-glass flex items-center justify-between gap-6 rounded-xl border border-(--line-soft) px-5 py-4 shadow-(--shadow-sm)",
44
+ className,
45
+ )}
46
+ >
47
+ <div className="flex items-center gap-6">
48
+ {brand}
49
+ {items.length > 0 ? (
50
+ <div className="hidden items-center gap-2 md:flex">
51
+ {items.map((item, index) => {
52
+ const Icon = item.icon;
53
+
54
+ return (
55
+ <Link
56
+ key={`${item.href}-${item.label}-${index}`}
57
+ href={item.href}
58
+ onClick={item.onClick}
59
+ className={cn(
60
+ "type-body inline-flex items-center gap-2 rounded-full px-4 py-2 font-bold transition-colors",
61
+ item.active
62
+ ? "bg-(--color-brand-primary-light) text-(--color-brand-primary)"
63
+ : "text-(--ink-muted) hover:bg-(--color-surface-hover)",
64
+ )}
65
+ >
66
+ {Icon ? <Icon className="h-4 w-4" /> : null}
67
+ <span>{item.label}</span>
68
+ {item.badge ? (
69
+ <span className="type-caption rounded-full bg-(--surface-card) px-2 py-0.5 text-(--foreground)">
70
+ {item.badge}
71
+ </span>
72
+ ) : null}
73
+ </Link>
74
+ );
75
+ })}
76
+ </div>
77
+ ) : null}
78
+ </div>
79
+ {actions}
80
+ </nav>
81
+ );
82
+ }
83
+
84
+ export function NavigationRail({ items, footer, className }: NavigationRailProps) {
85
+ return (
86
+ <aside
87
+ className={cn(
88
+ "surface-glass flex w-full max-w-70 flex-col rounded-xl border border-(--line-soft) p-4 shadow-(--shadow-sm)",
89
+ className,
90
+ )}
91
+ >
92
+ <div className="space-y-2">
93
+ {items.map((item, index) => {
94
+ const Icon = item.icon;
95
+
96
+ return (
97
+ <Link
98
+ key={`${item.href}-${item.label}-${index}`}
99
+ href={item.href}
100
+ onClick={item.onClick}
101
+ className={cn(
102
+ "type-body flex items-center justify-between rounded-2xl px-4 py-3 font-bold transition-colors",
103
+ item.active
104
+ ? "bg-(--color-brand-primary-light) text-(--color-brand-primary)"
105
+ : "text-(--ink-muted) hover:bg-(--color-surface-hover)",
106
+ )}
107
+ >
108
+ <span className="flex items-center gap-3">
109
+ {Icon ? <Icon className="h-4 w-4" /> : null}
110
+ {item.label}
111
+ </span>
112
+ {item.badge ? (
113
+ <span className="type-caption rounded-full bg-(--surface-card) px-2 py-0.5 text-(--foreground)">
114
+ {item.badge}
115
+ </span>
116
+ ) : null}
117
+ </Link>
118
+ );
119
+ })}
120
+ </div>
121
+ {footer ? <div className="mt-4 border-t border-(--line-soft) pt-4">{footer}</div> : null}
122
+ </aside>
123
+ );
124
+ }
125
+
126
+ export function Breadcrumbs({ items, className }: BreadcrumbsProps) {
127
+ return (
128
+ <nav aria-label="Breadcrumb" className={cn("flex items-center gap-2", className)}>
129
+ {items.map((item, index) => {
130
+ const isLast = index === items.length - 1;
131
+
132
+ return (
133
+ <div key={`${item.label}-${index}`} className="flex items-center gap-2">
134
+ {item.href && !isLast ? (
135
+ <Link href={item.href} className="type-caption text-(--ink-subtle) hover:text-(--foreground)">
136
+ {item.label}
137
+ </Link>
138
+ ) : (
139
+ <span className="type-caption text-(--foreground)">{item.label}</span>
140
+ )}
141
+ {!isLast ? <ChevronRight className="h-4 w-4 text-(--ink-subtle)" /> : null}
142
+ </div>
143
+ );
144
+ })}
145
+ </nav>
146
+ );
147
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import { Minus, Plus } from "lucide-react";
4
+ import { useId, useState } from "react";
5
+
6
+ import { Label } from "./label";
7
+ import { cn } from "./utils";
8
+
9
+ type NumberInputProps = {
10
+ label?: string;
11
+ helperText?: string;
12
+ errorText?: string;
13
+ value?: number;
14
+ defaultValue?: number;
15
+ min?: number;
16
+ max?: number;
17
+ step?: number;
18
+ placeholder?: string;
19
+ required?: boolean;
20
+ disabled?: boolean;
21
+ id?: string;
22
+ className?: string;
23
+ onValueChange?: (value: number) => void;
24
+ };
25
+
26
+ function clamp(value: number, min?: number, max?: number) {
27
+ let nextValue = value;
28
+
29
+ if (min !== undefined) {
30
+ nextValue = Math.max(nextValue, min);
31
+ }
32
+
33
+ if (max !== undefined) {
34
+ nextValue = Math.min(nextValue, max);
35
+ }
36
+
37
+ return nextValue;
38
+ }
39
+
40
+ export function NumberInput({
41
+ label,
42
+ helperText,
43
+ errorText,
44
+ value,
45
+ defaultValue = 0,
46
+ min,
47
+ max,
48
+ step = 1,
49
+ placeholder,
50
+ required = false,
51
+ disabled = false,
52
+ id,
53
+ className,
54
+ onValueChange,
55
+ }: NumberInputProps) {
56
+ const generatedId = useId();
57
+ const inputId = id ?? generatedId;
58
+ const [internalValue, setInternalValue] = useState(defaultValue);
59
+ const currentValue = value ?? internalValue;
60
+ const hasError = Boolean(errorText);
61
+
62
+ function commitValue(nextValue: number) {
63
+ const safeValue = clamp(nextValue, min, max);
64
+
65
+ if (value === undefined) {
66
+ setInternalValue(safeValue);
67
+ }
68
+
69
+ onValueChange?.(safeValue);
70
+ }
71
+
72
+ return (
73
+ <div className="w-full">
74
+ {label ? (
75
+ <Label htmlFor={inputId} requiredMark={required} className="mb-2 block">
76
+ {label}
77
+ </Label>
78
+ ) : null}
79
+ <div
80
+ className={cn(
81
+ "flex min-h-12 items-center rounded-2xl border bg-(--surface-card) shadow-(--shadow-sm) transition-colors",
82
+ hasError
83
+ ? "border-(--color-status-error)"
84
+ : "border-(--line-soft) hover:border-(--color-line-strong)",
85
+ className,
86
+ )}
87
+ >
88
+ <button
89
+ type="button"
90
+ onClick={() => commitValue(currentValue - step)}
91
+ disabled={disabled || (min !== undefined && currentValue <= min)}
92
+ className="inline-flex h-12 w-12 items-center justify-center rounded-l-2xl text-(--foreground) transition-colors hover:bg-(--color-surface-hover) disabled:cursor-not-allowed disabled:opacity-50"
93
+ aria-label="Decrease value"
94
+ >
95
+ <Minus className="h-4 w-4" />
96
+ </button>
97
+ <input
98
+ id={inputId}
99
+ type="number"
100
+ value={currentValue}
101
+ min={min}
102
+ max={max}
103
+ step={step}
104
+ placeholder={placeholder}
105
+ required={required}
106
+ disabled={disabled}
107
+ onChange={(event) => commitValue(Number(event.target.value))}
108
+ className="type-body min-h-12 w-full bg-transparent px-3 text-center text-(--foreground) outline-none disabled:cursor-not-allowed"
109
+ />
110
+ <button
111
+ type="button"
112
+ onClick={() => commitValue(currentValue + step)}
113
+ disabled={disabled || (max !== undefined && currentValue >= max)}
114
+ className="inline-flex h-12 w-12 items-center justify-center rounded-r-2xl text-(--foreground) transition-colors hover:bg-(--color-surface-hover) disabled:cursor-not-allowed disabled:opacity-50"
115
+ aria-label="Increase value"
116
+ >
117
+ <Plus className="h-4 w-4" />
118
+ </button>
119
+ </div>
120
+ {errorText ? (
121
+ <p className="type-caption mt-2 text-(--color-status-error)">{errorText}</p>
122
+ ) : helperText ? (
123
+ <p className="type-caption mt-2 text-(--ink-subtle)">{helperText}</p>
124
+ ) : null}
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
4
+
5
+ import { Button } from "./button";
6
+ import { cn } from "./utils";
7
+
8
+ type PaginationProps = {
9
+ pageCount: number;
10
+ currentPage?: number;
11
+ defaultPage?: number;
12
+ onPageChange?: (page: number) => void;
13
+ siblingCount?: number;
14
+ className?: string;
15
+ };
16
+
17
+ type PageItem = number | "ellipsis";
18
+
19
+ function createPageItems(pageCount: number, currentPage: number, siblingCount: number) {
20
+ if (pageCount <= 1) {
21
+ return [1];
22
+ }
23
+
24
+ const items: PageItem[] = [1];
25
+ const startPage = Math.max(2, currentPage - siblingCount);
26
+ const endPage = Math.min(pageCount - 1, currentPage + siblingCount);
27
+
28
+ if (startPage > 2) {
29
+ items.push("ellipsis");
30
+ }
31
+
32
+ for (let page = startPage; page <= endPage; page += 1) {
33
+ items.push(page);
34
+ }
35
+
36
+ if (endPage < pageCount - 1) {
37
+ items.push("ellipsis");
38
+ }
39
+
40
+ if (pageCount > 1) {
41
+ items.push(pageCount);
42
+ }
43
+
44
+ return items;
45
+ }
46
+
47
+ export function Pagination({
48
+ pageCount,
49
+ currentPage = 1,
50
+ onPageChange,
51
+ siblingCount = 1,
52
+ className,
53
+ }: PaginationProps) {
54
+ const safePageCount = Math.max(pageCount, 1);
55
+ const safeCurrentPage = Math.min(Math.max(currentPage, 1), safePageCount);
56
+ const items = createPageItems(safePageCount, safeCurrentPage, siblingCount);
57
+
58
+ return (
59
+ <nav className={cn("flex items-center gap-2", className)} aria-label="Pagination">
60
+ <Button
61
+ variant="tertiary"
62
+ size="sm"
63
+ onClick={() => onPageChange?.(safeCurrentPage - 1)}
64
+ disabled={safeCurrentPage <= 1}
65
+ aria-label="Previous page"
66
+ >
67
+ <ChevronLeft className="h-4 w-4" />
68
+ </Button>
69
+
70
+ {items.map((item, index) =>
71
+ item === "ellipsis" ? (
72
+ <span
73
+ key={`ellipsis-${index}`}
74
+ className="inline-flex min-h-10 min-w-10 items-center justify-center text-(--ink-subtle)"
75
+ >
76
+ <MoreHorizontal className="h-4 w-4" />
77
+ </span>
78
+ ) : (
79
+ <Button
80
+ key={item}
81
+ variant={item === safeCurrentPage ? "primary" : "tertiary"}
82
+ size="sm"
83
+ onClick={() => onPageChange?.(item)}
84
+ aria-label={`Go to page ${item}`}
85
+ >
86
+ {item}
87
+ </Button>
88
+ ),
89
+ )}
90
+
91
+ <Button
92
+ variant="tertiary"
93
+ size="sm"
94
+ onClick={() => onPageChange?.(safeCurrentPage + 1)}
95
+ disabled={safeCurrentPage >= safePageCount}
96
+ aria-label="Next page"
97
+ >
98
+ <ChevronRight className="h-4 w-4" />
99
+ </Button>
100
+ </nav>
101
+ );
102
+ }
@@ -0,0 +1,95 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+
3
+ import { Label } from "./label";
4
+ import { cn } from "./utils";
5
+
6
+ type PhoneNumberInputProps = Omit<
7
+ InputHTMLAttributes<HTMLInputElement>,
8
+ "type" | "inputMode" | "pattern" | "value" | "defaultValue" | "onChange"
9
+ > & {
10
+ label?: string;
11
+ helperText?: string;
12
+ errorText?: string;
13
+ value?: string;
14
+ defaultValue?: string;
15
+ onValueChange?: (value: string) => void;
16
+ };
17
+
18
+ const INDIA_PHONE_PREFIX = "+91";
19
+
20
+ function normalizeDigits(value: string | undefined) {
21
+ const digits = (value ?? "").replace(/\D/g, "");
22
+
23
+ if (digits.startsWith("91") && digits.length > 10) {
24
+ return digits.slice(2, 12);
25
+ }
26
+
27
+ return digits.slice(0, 10);
28
+ }
29
+
30
+ export function PhoneNumberInput({
31
+ label,
32
+ helperText,
33
+ errorText,
34
+ className,
35
+ id,
36
+ required,
37
+ value,
38
+ defaultValue,
39
+ onValueChange,
40
+ placeholder = "9876543210",
41
+ ...props
42
+ }: PhoneNumberInputProps) {
43
+ const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
44
+ const hasError = Boolean(errorText);
45
+ const resolvedValue = value !== undefined ? normalizeDigits(value) : undefined;
46
+ const resolvedDefaultValue = defaultValue !== undefined ? normalizeDigits(defaultValue) : undefined;
47
+
48
+ return (
49
+ <div className="w-full">
50
+ {label ? (
51
+ <Label htmlFor={inputId} requiredMark={required} className="mb-2 block">
52
+ {label}
53
+ </Label>
54
+ ) : null}
55
+ <div
56
+ className={cn(
57
+ "flex min-h-12 items-center gap-3 rounded-2xl border bg-(--surface-card) px-4 shadow-(--shadow-sm) transition-colors",
58
+ hasError
59
+ ? "border-(--color-status-error)"
60
+ : "border-(--line-soft) hover:border-(--color-line-strong)",
61
+ )}
62
+ >
63
+ <span className="type-body shrink-0 text-(--foreground)">{INDIA_PHONE_PREFIX}</span>
64
+ <span className="h-6 w-px bg-(--line-soft)" aria-hidden="true" />
65
+ <input
66
+ {...props}
67
+ id={inputId}
68
+ required={required}
69
+ type="text"
70
+ inputMode="numeric"
71
+ pattern="[0-9]*"
72
+ autoComplete="tel-national"
73
+ maxLength={10}
74
+ value={resolvedValue}
75
+ defaultValue={resolvedDefaultValue}
76
+ placeholder={placeholder}
77
+ onChange={(event) => {
78
+ const nextDigits = normalizeDigits(event.target.value);
79
+ event.target.value = nextDigits;
80
+ onValueChange?.(nextDigits);
81
+ }}
82
+ className={cn(
83
+ "type-body min-h-12 w-full bg-transparent text-(--foreground) outline-none placeholder:text-(--ink-subtle)",
84
+ className,
85
+ )}
86
+ />
87
+ </div>
88
+ {errorText ? (
89
+ <p className="type-body mt-2 text-(--color-status-error)">{errorText}</p>
90
+ ) : helperText ? (
91
+ <p className="type-body mt-2 text-(--ink-subtle)">{helperText}</p>
92
+ ) : null}
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,65 @@
1
+ import { cn } from "./utils";
2
+
3
+ type ProgressTone = "brand" | "success" | "warning" | "error";
4
+ type ProgressSize = "sm" | "md" | "lg";
5
+
6
+ type ProgressProps = {
7
+ value: number;
8
+ max?: number;
9
+ label?: string;
10
+ showValue?: boolean;
11
+ tone?: ProgressTone;
12
+ size?: ProgressSize;
13
+ className?: string;
14
+ };
15
+
16
+ const toneClasses: Record<ProgressTone, string> = {
17
+ brand: "bg-(--color-brand-primary)",
18
+ success: "bg-(--color-status-success)",
19
+ warning: "bg-(--color-status-warning)",
20
+ error: "bg-(--color-status-error)",
21
+ };
22
+
23
+ const sizeClasses: Record<ProgressSize, string> = {
24
+ sm: "h-2",
25
+ md: "h-3",
26
+ lg: "h-4",
27
+ };
28
+
29
+ export function Progress({
30
+ value,
31
+ max = 100,
32
+ label,
33
+ showValue = true,
34
+ tone = "brand",
35
+ size = "md",
36
+ className,
37
+ }: ProgressProps) {
38
+ const safeMax = Math.max(max, 1);
39
+ const clampedValue = Math.min(Math.max(value, 0), safeMax);
40
+ const percentage = Math.round((clampedValue / safeMax) * 100);
41
+
42
+ return (
43
+ <div className={cn("space-y-2", className)}>
44
+ {label || showValue ? (
45
+ <div className="flex items-center justify-between gap-3">
46
+ {label ? <p className="type-caption text-(--foreground)">{label}</p> : <span />}
47
+ {showValue ? <span className="type-caption text-(--ink-subtle)">{percentage}%</span> : null}
48
+ </div>
49
+ ) : null}
50
+ <div
51
+ className={cn("overflow-hidden rounded-full bg-(--color-surface-muted)", sizeClasses[size])}
52
+ role="progressbar"
53
+ aria-label={label}
54
+ aria-valuemin={0}
55
+ aria-valuemax={safeMax}
56
+ aria-valuenow={clampedValue}
57
+ >
58
+ <div
59
+ className={cn("h-full rounded-full transition-[width] duration-300 ease-out", toneClasses[tone])}
60
+ style={{ width: `${percentage}%` }}
61
+ />
62
+ </div>
63
+ </div>
64
+ );
65
+ }
package/src/radio.tsx ADDED
@@ -0,0 +1,36 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+
3
+ import { Label } from "./label";
4
+ import { cn } from "./utils";
5
+
6
+ type RadioProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
7
+ label: string;
8
+ description?: string;
9
+ };
10
+
11
+ export function Radio({ label, description, className, id, ...props }: RadioProps) {
12
+ const radioId = id ?? label.toLowerCase().replace(/\s+/g, "-");
13
+
14
+ return (
15
+ <label
16
+ htmlFor={radioId}
17
+ className={cn(
18
+ "flex cursor-pointer items-start gap-3 rounded-2xl border border-(--line-soft) bg-(--surface-card) p-4 shadow-(--shadow-sm)",
19
+ className,
20
+ )}
21
+ >
22
+ <input
23
+ {...props}
24
+ id={radioId}
25
+ type="radio"
26
+ className="mt-1 h-4 w-4 border-(--color-line-strong) accent-(--color-brand-primary)"
27
+ />
28
+ <span>
29
+ <Label htmlFor={radioId}>{label}</Label>
30
+ {description ? (
31
+ <span className="type-caption mt-1 block text-(--ink-subtle)">{description}</span>
32
+ ) : null}
33
+ </span>
34
+ </label>
35
+ );
36
+ }