@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/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Skyblue UI
|
|
2
|
+
|
|
3
|
+
React UI components published as `@rahulapgm/skyblue-ui`.
|
|
4
|
+
|
|
5
|
+
## Local Development
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Build
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm run typecheck
|
|
16
|
+
npm run build
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Publish
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm publish
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The package is configured for public npm publishing.
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rahulapgm/skyblue-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Skyblue UI components for GetOttam web experiences.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"main": "./src/index.ts",
|
|
10
|
+
"module": "./src/index.ts",
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts",
|
|
14
|
+
"./*": "./src/*.tsx",
|
|
15
|
+
"./utils": "./src/utils.ts"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup src/index.ts src/*.tsx src/utils.ts --format esm,cjs --dts --clean --external react --external react-dom --external next --external framer-motion --external lucide-react",
|
|
23
|
+
"build:preview": "vite build",
|
|
24
|
+
"dev": "vite --host 0.0.0.0",
|
|
25
|
+
"preview": "vite preview --host 0.0.0.0",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"framer-motion": "^12.38.0",
|
|
34
|
+
"lucide-react": "^1.7.0",
|
|
35
|
+
"next": "^16.2.2",
|
|
36
|
+
"react": "^19.1.0",
|
|
37
|
+
"react-dom": "^19.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.19.25",
|
|
41
|
+
"@types/react": "^19.2.7",
|
|
42
|
+
"@types/react-dom": "^19.2.3",
|
|
43
|
+
"@tailwindcss/vite": "^4.1.17",
|
|
44
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
45
|
+
"framer-motion": "^12.38.0",
|
|
46
|
+
"lucide-react": "^1.7.0",
|
|
47
|
+
"next": "16.2.2",
|
|
48
|
+
"react": "19.1.0",
|
|
49
|
+
"react-dom": "19.1.0",
|
|
50
|
+
"tailwindcss": "^4.1.17",
|
|
51
|
+
"tsup": "^8.5.1",
|
|
52
|
+
"typescript": "^5.9.3",
|
|
53
|
+
"vite": "^7.2.4"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion, useReducedMotion, type HTMLMotionProps } from "framer-motion";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "./utils";
|
|
7
|
+
|
|
8
|
+
type RevealFrom = "up" | "down" | "left" | "right";
|
|
9
|
+
|
|
10
|
+
type RevealProps = HTMLMotionProps<"div"> & {
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
delay?: number;
|
|
13
|
+
duration?: number;
|
|
14
|
+
amount?: number;
|
|
15
|
+
once?: boolean;
|
|
16
|
+
from?: RevealFrom;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type FloatProps = HTMLMotionProps<"div"> & {
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
x?: number[];
|
|
22
|
+
y?: number[];
|
|
23
|
+
scale?: number[];
|
|
24
|
+
duration?: number;
|
|
25
|
+
ease?: "easeInOut" | "easeIn" | "easeOut" | "linear";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function getRevealInitial(from: RevealFrom) {
|
|
29
|
+
if (from === "left") {
|
|
30
|
+
return { opacity: 0, x: -24 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (from === "right") {
|
|
34
|
+
return { opacity: 0, x: 24 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (from === "down") {
|
|
38
|
+
return { opacity: 0, y: -20 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { opacity: 0, y: 20 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getRevealVisible(from: RevealFrom) {
|
|
45
|
+
if (from === "left" || from === "right") {
|
|
46
|
+
return { opacity: 1, x: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { opacity: 1, y: 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Reveal({
|
|
53
|
+
children,
|
|
54
|
+
className,
|
|
55
|
+
delay = 0,
|
|
56
|
+
duration = 0.6,
|
|
57
|
+
amount = 0.18,
|
|
58
|
+
once = true,
|
|
59
|
+
from = "up",
|
|
60
|
+
...props
|
|
61
|
+
}: RevealProps) {
|
|
62
|
+
const prefersReducedMotion = useReducedMotion();
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<motion.div
|
|
66
|
+
{...props}
|
|
67
|
+
initial={prefersReducedMotion ? false : getRevealInitial(from)}
|
|
68
|
+
whileInView={prefersReducedMotion ? undefined : getRevealVisible(from)}
|
|
69
|
+
viewport={{ once, amount }}
|
|
70
|
+
transition={prefersReducedMotion ? { duration: 0 } : { duration, delay, ease: "easeOut" }}
|
|
71
|
+
className={cn(className)}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</motion.div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function Float({
|
|
79
|
+
children,
|
|
80
|
+
className,
|
|
81
|
+
x,
|
|
82
|
+
y = [0, 16, 0],
|
|
83
|
+
scale,
|
|
84
|
+
duration = 5,
|
|
85
|
+
ease = "easeInOut",
|
|
86
|
+
...props
|
|
87
|
+
}: FloatProps) {
|
|
88
|
+
const prefersReducedMotion = useReducedMotion();
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<motion.div
|
|
92
|
+
{...props}
|
|
93
|
+
animate={
|
|
94
|
+
prefersReducedMotion
|
|
95
|
+
? { opacity: 1 }
|
|
96
|
+
: {
|
|
97
|
+
...(x ? { x } : {}),
|
|
98
|
+
...(y ? { y } : {}),
|
|
99
|
+
...(scale ? { scale } : {}),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
transition={
|
|
103
|
+
prefersReducedMotion
|
|
104
|
+
? { duration: 0 }
|
|
105
|
+
: {
|
|
106
|
+
duration,
|
|
107
|
+
repeat: Infinity,
|
|
108
|
+
repeatType: "mirror",
|
|
109
|
+
ease,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
className={cn(className)}
|
|
113
|
+
>
|
|
114
|
+
{children}
|
|
115
|
+
</motion.div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Search, X } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type KeyboardEvent,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
|
|
14
|
+
import { Label } from "./label";
|
|
15
|
+
import { Loader } from "./loader";
|
|
16
|
+
import { cn } from "./utils";
|
|
17
|
+
|
|
18
|
+
export type BasicAutocompleteOption = {
|
|
19
|
+
label: string;
|
|
20
|
+
value: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
keywords?: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type AutocompleteProps<TOption> = {
|
|
26
|
+
label?: string;
|
|
27
|
+
value: string;
|
|
28
|
+
options: readonly TOption[];
|
|
29
|
+
onValueChange: (value: string) => void;
|
|
30
|
+
onSelect: (option: TOption) => void | Promise<void>;
|
|
31
|
+
getOptionKey: (option: TOption) => string;
|
|
32
|
+
getOptionLabel: (option: TOption) => string;
|
|
33
|
+
getOptionDescription?: (option: TOption) => string | null | undefined;
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
emptyText?: string;
|
|
36
|
+
helperText?: string;
|
|
37
|
+
loading?: boolean;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
required?: boolean;
|
|
40
|
+
minimumQueryLength?: number;
|
|
41
|
+
className?: string;
|
|
42
|
+
inputClassName?: string;
|
|
43
|
+
listClassName?: string;
|
|
44
|
+
renderOption?: (option: TOption, state: { active: boolean }) => ReactNode;
|
|
45
|
+
onClear?: () => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function Autocomplete<TOption>({
|
|
49
|
+
label,
|
|
50
|
+
value,
|
|
51
|
+
options,
|
|
52
|
+
onValueChange,
|
|
53
|
+
onSelect,
|
|
54
|
+
getOptionKey,
|
|
55
|
+
getOptionLabel,
|
|
56
|
+
getOptionDescription,
|
|
57
|
+
placeholder = "Search",
|
|
58
|
+
emptyText = "No matches found",
|
|
59
|
+
helperText,
|
|
60
|
+
loading = false,
|
|
61
|
+
disabled = false,
|
|
62
|
+
required = false,
|
|
63
|
+
minimumQueryLength = 0,
|
|
64
|
+
className,
|
|
65
|
+
inputClassName,
|
|
66
|
+
listClassName,
|
|
67
|
+
renderOption,
|
|
68
|
+
onClear,
|
|
69
|
+
}: AutocompleteProps<TOption>) {
|
|
70
|
+
const id = useId();
|
|
71
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
72
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
73
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
74
|
+
const [hasFocus, setHasFocus] = useState(false);
|
|
75
|
+
const [dismissed, setDismissed] = useState(false);
|
|
76
|
+
const listboxId = `${id}-listbox`;
|
|
77
|
+
const trimmedValue = value.trim();
|
|
78
|
+
const canShowDropdown = trimmedValue.length >= minimumQueryLength;
|
|
79
|
+
const open = !dismissed && hasFocus && !disabled && canShowDropdown;
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
function handlePointerDown(event: MouseEvent) {
|
|
83
|
+
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
|
84
|
+
setHasFocus(false);
|
|
85
|
+
setDismissed(true);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
document.addEventListener("mousedown", handlePointerDown);
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
document.removeEventListener("mousedown", handlePointerDown);
|
|
93
|
+
};
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
const safeActiveIndex = useMemo(() => {
|
|
97
|
+
if (!options.length) {
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Math.min(activeIndex, options.length - 1);
|
|
102
|
+
}, [activeIndex, options.length]);
|
|
103
|
+
|
|
104
|
+
function closeDropdown() {
|
|
105
|
+
setHasFocus(false);
|
|
106
|
+
setDismissed(true);
|
|
107
|
+
setActiveIndex(0);
|
|
108
|
+
inputRef.current?.blur();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleSelect(option: TOption) {
|
|
112
|
+
await onSelect(option);
|
|
113
|
+
closeDropdown();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
117
|
+
if (!open && event.key === "ArrowDown" && canShowDropdown) {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
setDismissed(false);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!open) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (event.key === "ArrowDown") {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
setActiveIndex((current) => (current + 1) % Math.max(options.length, 1));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (event.key === "ArrowUp") {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
setActiveIndex((current) => (current - 1 + Math.max(options.length, 1)) % Math.max(options.length, 1));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (event.key === "Enter" && options[safeActiveIndex]) {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
void handleSelect(options[safeActiveIndex]);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (event.key === "Escape" || event.key === "Tab") {
|
|
146
|
+
setHasFocus(false);
|
|
147
|
+
setDismissed(true);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div ref={wrapperRef} className={cn("relative w-full", className)}>
|
|
153
|
+
{label ? (
|
|
154
|
+
<Label htmlFor={id} requiredMark={required} className="mb-2 block">
|
|
155
|
+
{label}
|
|
156
|
+
</Label>
|
|
157
|
+
) : null}
|
|
158
|
+
|
|
159
|
+
<div className="flex min-h-12 items-center gap-3 rounded-[1rem] border border-(--line-soft) bg-(--surface-card) px-4 shadow-(--shadow-sm)">
|
|
160
|
+
<Search className="h-4 w-4 text-(--ink-subtle)" />
|
|
161
|
+
<input
|
|
162
|
+
ref={inputRef}
|
|
163
|
+
id={id}
|
|
164
|
+
value={value}
|
|
165
|
+
disabled={disabled}
|
|
166
|
+
onChange={(event) => {
|
|
167
|
+
onValueChange(event.target.value);
|
|
168
|
+
setHasFocus(true);
|
|
169
|
+
setDismissed(false);
|
|
170
|
+
setActiveIndex(0);
|
|
171
|
+
}}
|
|
172
|
+
onFocus={() => {
|
|
173
|
+
setHasFocus(true);
|
|
174
|
+
setDismissed(false);
|
|
175
|
+
}}
|
|
176
|
+
onKeyDown={handleKeyDown}
|
|
177
|
+
placeholder={placeholder}
|
|
178
|
+
autoComplete="off"
|
|
179
|
+
role="combobox"
|
|
180
|
+
aria-expanded={open}
|
|
181
|
+
aria-controls={listboxId}
|
|
182
|
+
aria-autocomplete="list"
|
|
183
|
+
aria-activedescendant={
|
|
184
|
+
open && options[safeActiveIndex] ? `${id}-option-${getOptionKey(options[safeActiveIndex])}` : undefined
|
|
185
|
+
}
|
|
186
|
+
className={cn(
|
|
187
|
+
"type-body min-h-12 w-full bg-transparent text-(--foreground) outline-none placeholder:text-(--ink-subtle)",
|
|
188
|
+
inputClassName,
|
|
189
|
+
)}
|
|
190
|
+
/>
|
|
191
|
+
{loading ? <Loader label={null} size="sm" className="gap-0" /> : null}
|
|
192
|
+
{!loading && value ? (
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={() => {
|
|
196
|
+
onValueChange("");
|
|
197
|
+
onClear?.();
|
|
198
|
+
setActiveIndex(0);
|
|
199
|
+
setDismissed(false);
|
|
200
|
+
}}
|
|
201
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-(--ink-subtle) transition-colors hover:bg-(--color-surface-hover)"
|
|
202
|
+
aria-label="Clear search"
|
|
203
|
+
>
|
|
204
|
+
<X className="h-4 w-4" />
|
|
205
|
+
</button>
|
|
206
|
+
) : null}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{helperText ? <p className="type-caption mt-2 text-(--ink-subtle)">{helperText}</p> : null}
|
|
210
|
+
|
|
211
|
+
{open && canShowDropdown ? (
|
|
212
|
+
<div
|
|
213
|
+
id={listboxId}
|
|
214
|
+
role="listbox"
|
|
215
|
+
aria-labelledby={id}
|
|
216
|
+
className={cn(
|
|
217
|
+
"absolute z-20 mt-2 w-full rounded-[1.2rem] border border-(--line-soft) bg-(--surface-card) p-1.5 shadow-(--shadow-lg)",
|
|
218
|
+
listClassName,
|
|
219
|
+
)}
|
|
220
|
+
>
|
|
221
|
+
{options.length > 0 ? (
|
|
222
|
+
<div className="space-y-1">
|
|
223
|
+
{options.map((option, index) => {
|
|
224
|
+
const optionKey = getOptionKey(option);
|
|
225
|
+
const active = safeActiveIndex === index;
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<button
|
|
229
|
+
key={optionKey}
|
|
230
|
+
id={`${id}-option-${optionKey}`}
|
|
231
|
+
type="button"
|
|
232
|
+
role="option"
|
|
233
|
+
aria-selected={active}
|
|
234
|
+
onMouseDown={(event) => {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
}}
|
|
237
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
238
|
+
onClick={() => {
|
|
239
|
+
void handleSelect(option);
|
|
240
|
+
}}
|
|
241
|
+
className={cn(
|
|
242
|
+
"block w-full rounded-[0.95rem] px-3.5 py-2.5 text-left transition-colors",
|
|
243
|
+
active ? "bg-(--color-surface-hover)" : "bg-transparent hover:bg-(--color-surface-hover)",
|
|
244
|
+
)}
|
|
245
|
+
>
|
|
246
|
+
{renderOption ? (
|
|
247
|
+
renderOption(option, { active })
|
|
248
|
+
) : (
|
|
249
|
+
<>
|
|
250
|
+
<span className="type-body block font-medium text-(--foreground)">{getOptionLabel(option)}</span>
|
|
251
|
+
{getOptionDescription?.(option) ? (
|
|
252
|
+
<span className="type-caption mt-1 block text-(--ink-subtle)">
|
|
253
|
+
{getOptionDescription(option)}
|
|
254
|
+
</span>
|
|
255
|
+
) : null}
|
|
256
|
+
</>
|
|
257
|
+
)}
|
|
258
|
+
</button>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</div>
|
|
262
|
+
) : !loading ? (
|
|
263
|
+
<div className="px-4 py-3">
|
|
264
|
+
<p className="type-caption text-(--ink-subtle)">{emptyText}</p>
|
|
265
|
+
</div>
|
|
266
|
+
) : null}
|
|
267
|
+
</div>
|
|
268
|
+
) : null}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
package/src/badge.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./utils";
|
|
4
|
+
|
|
5
|
+
type BadgeTone = "brand" | "neutral" | "success" | "warning" | "error" | "info";
|
|
6
|
+
type BadgeSize = "sm" | "md";
|
|
7
|
+
|
|
8
|
+
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
tone?: BadgeTone;
|
|
11
|
+
size?: BadgeSize;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const toneClasses: Record<BadgeTone, string> = {
|
|
15
|
+
brand: "bg-(--color-brand-primary-light) text-(--color-brand-primary)",
|
|
16
|
+
neutral: "bg-(--surface-chip) text-(--ink-muted)",
|
|
17
|
+
success: "bg-(--color-status-success-light) text-(--color-status-success)",
|
|
18
|
+
warning: "bg-(--color-status-warning-light) text-(--color-status-warning)",
|
|
19
|
+
error: "bg-(--color-status-error-light) text-(--color-status-error)",
|
|
20
|
+
info: "bg-(--color-status-info-light) text-(--color-status-info)",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sizeClasses: Record<BadgeSize, string> = {
|
|
24
|
+
sm: "px-2.5 py-1 type-caption",
|
|
25
|
+
md: "px-3 py-1.5 type-caption",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function Badge({ children, tone = "neutral", size = "md", className, ...props }: BadgeProps) {
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
{...props}
|
|
32
|
+
className={cn(
|
|
33
|
+
"inline-flex items-center rounded-full font-extrabold",
|
|
34
|
+
toneClasses[tone],
|
|
35
|
+
sizeClasses[size],
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
}
|
package/src/button.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "./utils";
|
|
5
|
+
|
|
6
|
+
export type ButtonVariant = "primary" | "secondary" | "tertiary" | "destructive";
|
|
7
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
8
|
+
|
|
9
|
+
type SharedButtonProps = {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
variant?: ButtonVariant;
|
|
12
|
+
size?: ButtonSize;
|
|
13
|
+
className?: string;
|
|
14
|
+
fullWidth?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ButtonProps =
|
|
18
|
+
| (SharedButtonProps &
|
|
19
|
+
ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
20
|
+
href?: undefined;
|
|
21
|
+
})
|
|
22
|
+
| (SharedButtonProps & {
|
|
23
|
+
href: string;
|
|
24
|
+
ariaLabel?: string;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const buttonBaseClasses =
|
|
28
|
+
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-2xl font-extrabold no-underline transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-4 disabled:pointer-events-none disabled:translate-y-0 disabled:opacity-50";
|
|
29
|
+
|
|
30
|
+
export const buttonVariantClasses: Record<ButtonVariant, string> = {
|
|
31
|
+
primary:
|
|
32
|
+
"bg-(--color-brand-primary) !text-(--color-fg-inverse) visited:!text-(--color-fg-inverse) hover:!text-(--color-fg-inverse) active:!text-(--color-fg-inverse) shadow-(--shadow-primary) hover:bg-(--color-brand-primary-hover) active:bg-(--color-brand-primary-active) focus-visible:ring-(--color-brand-primary-light)",
|
|
33
|
+
secondary:
|
|
34
|
+
"border border-(--color-brand-secondary) bg-(--color-brand-secondary) !text-(--color-fg-inverse) visited:!text-(--color-fg-inverse) hover:!text-(--color-fg-inverse) active:!text-(--color-fg-inverse) shadow-(--shadow-md) hover:bg-(--color-brand-secondary-light) active:border-(--color-bg-dark) active:bg-(--color-bg-dark) focus-visible:ring-(--color-brand-primary-light)",
|
|
35
|
+
tertiary:
|
|
36
|
+
"border border-(--color-brand-secondary) bg-(--surface-card) !text-(--color-brand-secondary) visited:!text-(--color-brand-secondary) hover:!text-(--color-brand-secondary) active:!text-(--color-brand-secondary) shadow-(--shadow-sm) hover:bg-(--color-surface-muted) focus-visible:ring-(--color-brand-primary-light)",
|
|
37
|
+
destructive:
|
|
38
|
+
"bg-[#ff3131] !text-(--color-fg-inverse) visited:!text-(--color-fg-inverse) hover:!text-(--color-fg-inverse) active:!text-(--color-fg-inverse) shadow-(--shadow-md) hover:bg-[#e62b2b] active:bg-[#cc2525] focus-visible:ring-(--color-status-error-light)",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const buttonSizeClasses: Record<ButtonSize, string> = {
|
|
42
|
+
sm: "min-h-10 px-4 type-body",
|
|
43
|
+
md: "min-h-12 px-5 type-body",
|
|
44
|
+
lg: "min-h-14 px-7 type-title",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function Button(props: ButtonProps) {
|
|
48
|
+
const { children, variant = "primary", size = "md", className, fullWidth = false } = props;
|
|
49
|
+
|
|
50
|
+
const classes = cn(
|
|
51
|
+
buttonBaseClasses,
|
|
52
|
+
buttonVariantClasses[variant],
|
|
53
|
+
buttonSizeClasses[size],
|
|
54
|
+
fullWidth && "w-full",
|
|
55
|
+
className,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if ("href" in props && props.href) {
|
|
59
|
+
const { href, ariaLabel } = props;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Link href={href} aria-label={ariaLabel} className={classes}>
|
|
63
|
+
{children}
|
|
64
|
+
</Link>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { type = "button", ...buttonProps } = props as Extract<ButtonProps, { href?: undefined }>;
|
|
69
|
+
const {
|
|
70
|
+
children: _children,
|
|
71
|
+
variant: _variant,
|
|
72
|
+
size: _size,
|
|
73
|
+
className: _className,
|
|
74
|
+
fullWidth: _fullWidth,
|
|
75
|
+
...nativeButtonProps
|
|
76
|
+
} = buttonProps;
|
|
77
|
+
void _children;
|
|
78
|
+
void _variant;
|
|
79
|
+
void _size;
|
|
80
|
+
void _className;
|
|
81
|
+
void _fullWidth;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<button {...nativeButtonProps} type={type} className={classes}>
|
|
85
|
+
{children}
|
|
86
|
+
</button>
|
|
87
|
+
);
|
|
88
|
+
}
|
package/src/card.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./utils";
|
|
4
|
+
|
|
5
|
+
type CardTone = "surface" | "glass" | "muted" | "dark";
|
|
6
|
+
type CardPadding = "sm" | "md" | "lg" | "xl";
|
|
7
|
+
|
|
8
|
+
type CardProps = HTMLAttributes<HTMLDivElement> & {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
tone?: CardTone;
|
|
11
|
+
padding?: CardPadding;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const toneClasses: Record<CardTone, string> = {
|
|
15
|
+
surface: "bg-(--surface-card) border border-(--line-soft) shadow-(--shadow-md)",
|
|
16
|
+
glass: "surface-glass border border-(--line-soft) shadow-(--shadow-md)",
|
|
17
|
+
muted: "bg-(--color-surface-muted) border border-(--line-soft) shadow-(--shadow-sm)",
|
|
18
|
+
dark: "bg-(--color-bg-dark) border border-white/10 text-(--color-fg-inverse) shadow-(--shadow-lg)",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const paddingClasses: Record<CardPadding, string> = {
|
|
22
|
+
sm: "gutter-card-sm",
|
|
23
|
+
md: "gutter-card-md",
|
|
24
|
+
lg: "gutter-card-lg",
|
|
25
|
+
xl: "gutter-card-xl",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function Card({ children, tone = "surface", padding = "md", className, ...props }: CardProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
{...props}
|
|
32
|
+
className={cn("rounded-xl", toneClasses[tone], paddingClasses[padding], className)}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
package/src/checkbox.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 CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function Checkbox({ label, description, className, id, ...props }: CheckboxProps) {
|
|
12
|
+
const checkboxId = id ?? label.toLowerCase().replace(/\s+/g, "-");
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<label
|
|
16
|
+
htmlFor={checkboxId}
|
|
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={checkboxId}
|
|
25
|
+
type="checkbox"
|
|
26
|
+
className="mt-1 h-4 w-4 rounded border-(--color-line-strong) accent-(--color-brand-primary)"
|
|
27
|
+
/>
|
|
28
|
+
<span>
|
|
29
|
+
<Label htmlFor={checkboxId}>{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
|
+
}
|