@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/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
|
+
}
|
package/src/progress.tsx
ADDED
|
@@ -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
|
+
}
|