react-principles-cli 0.0.1
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/dist/index.d.ts +1 -0
- package/dist/index.js +4440 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4440 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/index.ts
|
|
27
|
+
var import_commander = require("commander");
|
|
28
|
+
var import_picocolors3 = __toESM(require("picocolors"));
|
|
29
|
+
|
|
30
|
+
// src/commands/init.ts
|
|
31
|
+
var import_fs4 = require("fs");
|
|
32
|
+
var import_path4 = require("path");
|
|
33
|
+
var import_prompts = __toESM(require("prompts"));
|
|
34
|
+
var import_picocolors = __toESM(require("picocolors"));
|
|
35
|
+
|
|
36
|
+
// src/utils/fs.ts
|
|
37
|
+
var import_fs = require("fs");
|
|
38
|
+
var import_path = require("path");
|
|
39
|
+
var CONFIG_FILE = "components.json";
|
|
40
|
+
var DEFAULT_CONFIG = {
|
|
41
|
+
framework: "next",
|
|
42
|
+
rsc: true,
|
|
43
|
+
tsx: true,
|
|
44
|
+
componentsDir: "src/components/ui",
|
|
45
|
+
hooksDir: "src/hooks",
|
|
46
|
+
libDir: "src/lib",
|
|
47
|
+
aliases: {
|
|
48
|
+
components: "@/components/ui",
|
|
49
|
+
hooks: "@/hooks",
|
|
50
|
+
lib: "@/lib"
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
function readConfig(cwd) {
|
|
54
|
+
const path = (0, import_path.join)(cwd, CONFIG_FILE);
|
|
55
|
+
if (!(0, import_fs.existsSync)(path)) return null;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse((0, import_fs.readFileSync)(path, "utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function writeConfig(config, cwd) {
|
|
63
|
+
(0, import_fs.writeFileSync)((0, import_path.join)(cwd, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
64
|
+
}
|
|
65
|
+
function getDefaultConfig() {
|
|
66
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
67
|
+
}
|
|
68
|
+
function detectTsconfigAliases(cwd) {
|
|
69
|
+
const candidates = ["tsconfig.json", "tsconfig.app.json", "jsconfig.json"];
|
|
70
|
+
for (const file of candidates) {
|
|
71
|
+
const tsConfigPath = (0, import_path.join)(cwd, file);
|
|
72
|
+
if (!(0, import_fs.existsSync)(tsConfigPath)) continue;
|
|
73
|
+
try {
|
|
74
|
+
const raw = (0, import_fs.readFileSync)(tsConfigPath, "utf8").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
75
|
+
const tsConfig = JSON.parse(raw);
|
|
76
|
+
const paths = tsConfig.compilerOptions?.paths ?? {};
|
|
77
|
+
const aliases = {};
|
|
78
|
+
for (const [alias] of Object.entries(paths)) {
|
|
79
|
+
const clean = alias.replace(/\/\*$/, "");
|
|
80
|
+
if (clean === "@") {
|
|
81
|
+
aliases.components = `${clean}/components/ui`;
|
|
82
|
+
aliases.hooks = `${clean}/hooks`;
|
|
83
|
+
aliases.lib = `${clean}/lib`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return aliases;
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
function detectFramework(cwd) {
|
|
93
|
+
const pkgPath = (0, import_path.join)(cwd, "package.json");
|
|
94
|
+
if (!(0, import_fs.existsSync)(pkgPath)) return "other";
|
|
95
|
+
try {
|
|
96
|
+
const pkg = JSON.parse((0, import_fs.readFileSync)(pkgPath, "utf8"));
|
|
97
|
+
const deps = {
|
|
98
|
+
...pkg.dependencies,
|
|
99
|
+
...pkg.devDependencies
|
|
100
|
+
};
|
|
101
|
+
if (deps["next"]) return "next";
|
|
102
|
+
if (deps["remix"] || deps["@remix-run/react"]) return "remix";
|
|
103
|
+
if (deps["vite"] || deps["@vitejs/plugin-react"]) return "vite";
|
|
104
|
+
return "other";
|
|
105
|
+
} catch {
|
|
106
|
+
return "other";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function resolveOutputPath(outputFile, target, config, cwd) {
|
|
110
|
+
const base = target === "components" ? config.componentsDir : target === "hooks" ? config.hooksDir : config.libDir;
|
|
111
|
+
return (0, import_path.join)(cwd, base, outputFile);
|
|
112
|
+
}
|
|
113
|
+
function writeFile(content, outputPath) {
|
|
114
|
+
const dir = (0, import_path.dirname)(outputPath);
|
|
115
|
+
if (!(0, import_fs.existsSync)(dir)) {
|
|
116
|
+
(0, import_fs.mkdirSync)(dir, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
if ((0, import_fs.existsSync)(outputPath)) return false;
|
|
119
|
+
(0, import_fs.writeFileSync)(outputPath, content, "utf8");
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/registry/templates.ts
|
|
124
|
+
var TEMPLATES = {
|
|
125
|
+
"utils": `import { type ClassValue, clsx } from "clsx";
|
|
126
|
+
import { twMerge } from "tailwind-merge";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Merges class names using clsx and resolves Tailwind CSS conflicts
|
|
130
|
+
* with tailwind-merge. Accepts the same arguments as clsx.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* cn("px-2 py-1", "px-4") // => "px-4 py-1"
|
|
134
|
+
* cn("text-red-500", condition && "text-blue-500")
|
|
135
|
+
*/
|
|
136
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
137
|
+
return twMerge(clsx(inputs));
|
|
138
|
+
}`,
|
|
139
|
+
"use-animated-mount": `"use client";
|
|
140
|
+
|
|
141
|
+
import { useState, useEffect } from "react";
|
|
142
|
+
|
|
143
|
+
// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
144
|
+
|
|
145
|
+
export interface AnimatedMountResult {
|
|
146
|
+
/** Whether the component should be in the DOM (stays true during exit animation) */
|
|
147
|
+
mounted: boolean;
|
|
148
|
+
/** Whether the "open" state is active \u2014 drives CSS enter/exit classes */
|
|
149
|
+
visible: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// \u2500\u2500\u2500 Hook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Manages mount/unmount timing for animated overlays.
|
|
156
|
+
*
|
|
157
|
+
* - \`mounted\` controls DOM presence. Stays \`true\` during the exit animation
|
|
158
|
+
* so the element can animate out before being removed.
|
|
159
|
+
* - \`visible\` drives CSS classes. Flip \`visible\` immediately on open/close;
|
|
160
|
+
* flip \`mounted\` only after the exit \`duration\` has elapsed.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* const { mounted, visible } = useAnimatedMount(open, 200);
|
|
164
|
+
* if (!mounted) return null;
|
|
165
|
+
* return (
|
|
166
|
+
* <div className={visible ? "animate-in fade-in" : "animate-out fade-out"}>
|
|
167
|
+
* {children}
|
|
168
|
+
* </div>
|
|
169
|
+
* );
|
|
170
|
+
*/
|
|
171
|
+
export function useAnimatedMount(open: boolean, duration = 200): AnimatedMountResult {
|
|
172
|
+
const [mounted, setMounted] = useState(open);
|
|
173
|
+
const [visible, setVisible] = useState(false);
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (open) {
|
|
177
|
+
setMounted(true);
|
|
178
|
+
// Double rAF ensures the element is painted before adding the visible
|
|
179
|
+
// class, guaranteeing the CSS transition fires on the first frame.
|
|
180
|
+
const raf = requestAnimationFrame(() => {
|
|
181
|
+
requestAnimationFrame(() => setVisible(true));
|
|
182
|
+
});
|
|
183
|
+
return () => cancelAnimationFrame(raf);
|
|
184
|
+
} else {
|
|
185
|
+
setVisible(false);
|
|
186
|
+
const t = setTimeout(() => setMounted(false), duration);
|
|
187
|
+
return () => clearTimeout(t);
|
|
188
|
+
}
|
|
189
|
+
}, [open, duration]);
|
|
190
|
+
|
|
191
|
+
return { mounted, visible };
|
|
192
|
+
}`,
|
|
193
|
+
"Accordion": `import {
|
|
194
|
+
createContext,
|
|
195
|
+
useContext,
|
|
196
|
+
useState,
|
|
197
|
+
type HTMLAttributes,
|
|
198
|
+
type ButtonHTMLAttributes,
|
|
199
|
+
type ReactNode,
|
|
200
|
+
} from "react";
|
|
201
|
+
import { cn } from "@/lib/utils";
|
|
202
|
+
|
|
203
|
+
// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
204
|
+
|
|
205
|
+
export type AccordionType = "single" | "multiple";
|
|
206
|
+
|
|
207
|
+
export interface AccordionProps {
|
|
208
|
+
type?: AccordionType;
|
|
209
|
+
defaultValue?: string | string[];
|
|
210
|
+
value?: string | string[];
|
|
211
|
+
onChange?: (value: string | string[]) => void;
|
|
212
|
+
collapsible?: boolean;
|
|
213
|
+
children: ReactNode;
|
|
214
|
+
className?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
|
|
218
|
+
value: string;
|
|
219
|
+
children: ReactNode;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface AccordionTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
223
|
+
children: ReactNode;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
227
|
+
children: ReactNode;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// \u2500\u2500\u2500 Context \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
231
|
+
|
|
232
|
+
interface AccordionCtx {
|
|
233
|
+
isOpen: (value: string) => boolean;
|
|
234
|
+
toggle: (value: string) => void;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface ItemCtx {
|
|
238
|
+
value: string;
|
|
239
|
+
open: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const AccordionContext = createContext<AccordionCtx | null>(null);
|
|
243
|
+
const ItemContext = createContext<ItemCtx | null>(null);
|
|
244
|
+
|
|
245
|
+
function useAccordion() {
|
|
246
|
+
const ctx = useContext(AccordionContext);
|
|
247
|
+
if (!ctx) throw new Error("AccordionTrigger/Content must be inside <AccordionItem>");
|
|
248
|
+
return ctx;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function useItem() {
|
|
252
|
+
const ctx = useContext(ItemContext);
|
|
253
|
+
if (!ctx) throw new Error("AccordionTrigger/Content must be inside <AccordionItem>");
|
|
254
|
+
return ctx;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// \u2500\u2500\u2500 Components \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
258
|
+
|
|
259
|
+
export function Accordion({
|
|
260
|
+
type = "single",
|
|
261
|
+
defaultValue,
|
|
262
|
+
value: controlledValue,
|
|
263
|
+
onChange,
|
|
264
|
+
collapsible = true,
|
|
265
|
+
children,
|
|
266
|
+
className,
|
|
267
|
+
}: AccordionProps) {
|
|
268
|
+
const toSet = (v?: string | string[]): Set<string> => {
|
|
269
|
+
if (!v) return new Set();
|
|
270
|
+
return new Set(Array.isArray(v) ? v : [v]);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const [internal, setInternal] = useState<Set<string>>(() => toSet(defaultValue));
|
|
274
|
+
const isControlled = controlledValue !== undefined;
|
|
275
|
+
const active = isControlled ? toSet(controlledValue) : internal;
|
|
276
|
+
|
|
277
|
+
const toggle = (val: string) => {
|
|
278
|
+
let next: Set<string>;
|
|
279
|
+
|
|
280
|
+
if (type === "single") {
|
|
281
|
+
if (active.has(val)) {
|
|
282
|
+
next = collapsible ? new Set() : new Set([val]);
|
|
283
|
+
} else {
|
|
284
|
+
next = new Set([val]);
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
next = new Set(active);
|
|
288
|
+
if (next.has(val)) { next.delete(val); } else { next.add(val); }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!isControlled) setInternal(next);
|
|
292
|
+
|
|
293
|
+
if (onChange) {
|
|
294
|
+
const arr = [...next];
|
|
295
|
+
onChange(type === "single" ? (arr[0] ?? "") : arr);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const isOpen = (val: string) => active.has(val);
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<AccordionContext.Provider value={{ isOpen, toggle }}>
|
|
303
|
+
<div className={cn("w-full divide-y divide-slate-200 dark:divide-[#1f2937] rounded-xl border border-slate-200 dark:border-[#1f2937] overflow-hidden", className)}>
|
|
304
|
+
{children}
|
|
305
|
+
</div>
|
|
306
|
+
</AccordionContext.Provider>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
Accordion.Item = function AccordionItem({ value, children, className, ...props }: AccordionItemProps) {
|
|
311
|
+
const { isOpen } = useAccordion();
|
|
312
|
+
const open = isOpen(value);
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<ItemContext.Provider value={{ value, open }}>
|
|
316
|
+
<div className={cn("bg-white dark:bg-[#161b22]", className)} {...props}>
|
|
317
|
+
{children}
|
|
318
|
+
</div>
|
|
319
|
+
</ItemContext.Provider>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
Accordion.Trigger = function AccordionTrigger({ children, className, ...props }: AccordionTriggerProps) {
|
|
324
|
+
const { toggle } = useAccordion();
|
|
325
|
+
const { value, open } = useItem();
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
aria-expanded={open}
|
|
331
|
+
onClick={() => toggle(value)}
|
|
332
|
+
className={cn(
|
|
333
|
+
"flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium",
|
|
334
|
+
"text-slate-900 dark:text-white",
|
|
335
|
+
"hover:bg-slate-50 dark:hover:bg-[#1f2937] transition-colors",
|
|
336
|
+
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40",
|
|
337
|
+
className
|
|
338
|
+
)}
|
|
339
|
+
{...props}
|
|
340
|
+
>
|
|
341
|
+
<span>{children}</span>
|
|
342
|
+
<svg
|
|
343
|
+
className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200", open && "rotate-180")}
|
|
344
|
+
viewBox="0 0 16 16"
|
|
345
|
+
fill="none"
|
|
346
|
+
>
|
|
347
|
+
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
348
|
+
</svg>
|
|
349
|
+
</button>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
Accordion.Content = function AccordionContent({ children, className, ...props }: AccordionContentProps) {
|
|
354
|
+
const { open } = useItem();
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<div
|
|
358
|
+
style={{
|
|
359
|
+
display: "grid",
|
|
360
|
+
gridTemplateRows: open ? "1fr" : "0fr",
|
|
361
|
+
transition: "grid-template-rows 0.2s ease",
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
<div style={{ overflow: "hidden" }}>
|
|
365
|
+
<div
|
|
366
|
+
className={cn("px-5 pb-4 text-sm text-slate-600 dark:text-slate-400 leading-relaxed", className)}
|
|
367
|
+
{...props}
|
|
368
|
+
>
|
|
369
|
+
{children}
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}`,
|
|
375
|
+
"Alert": `import type { ButtonHTMLAttributes, HTMLAttributes } from "react";
|
|
376
|
+
import { cn } from "@/lib/utils";
|
|
377
|
+
|
|
378
|
+
export type AlertVariant = "default" | "success" | "warning" | "error" | "info";
|
|
379
|
+
|
|
380
|
+
export interface AlertProps extends HTMLAttributes<HTMLDivElement> {
|
|
381
|
+
variant?: AlertVariant;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const VARIANT_CLASSES: Record<AlertVariant, string> = {
|
|
385
|
+
default: "border-slate-200 bg-white dark:border-[#1f2937] dark:bg-[#161b22]",
|
|
386
|
+
success: "border-green-300 bg-green-50 dark:border-green-900 dark:bg-green-950/30",
|
|
387
|
+
warning: "border-amber-300 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30",
|
|
388
|
+
error: "border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30",
|
|
389
|
+
info: "border-blue-300 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30",
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
export function Alert({ variant = "default", className, ...props }: AlertProps) {
|
|
393
|
+
return (
|
|
394
|
+
<div
|
|
395
|
+
role="alert"
|
|
396
|
+
className={cn("rounded-xl border p-4", VARIANT_CLASSES[variant], className)}
|
|
397
|
+
{...props}
|
|
398
|
+
/>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
Alert.Title = function AlertTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
|
403
|
+
return (
|
|
404
|
+
<h4
|
|
405
|
+
className={cn("text-sm font-semibold text-slate-900 dark:text-white", className)}
|
|
406
|
+
{...props}
|
|
407
|
+
/>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
Alert.Description = function AlertDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
|
412
|
+
return (
|
|
413
|
+
<p
|
|
414
|
+
className={cn("mt-1 text-xs leading-relaxed text-slate-600 dark:text-slate-400", className)}
|
|
415
|
+
{...props}
|
|
416
|
+
/>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
Alert.Footer = function AlertFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
421
|
+
return (
|
|
422
|
+
<div
|
|
423
|
+
className={cn("mt-3 flex items-center gap-2", className)}
|
|
424
|
+
{...props}
|
|
425
|
+
/>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
Alert.Action = function AlertAction({ className, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
430
|
+
return (
|
|
431
|
+
<button
|
|
432
|
+
type="button"
|
|
433
|
+
className={cn(
|
|
434
|
+
"rounded-md bg-primary px-2.5 py-1.5 text-xs font-medium text-white transition-opacity hover:opacity-90",
|
|
435
|
+
className
|
|
436
|
+
)}
|
|
437
|
+
{...props}
|
|
438
|
+
/>
|
|
439
|
+
);
|
|
440
|
+
}`,
|
|
441
|
+
"AlertDialog": `import { useEffect } from "react";
|
|
442
|
+
import { createPortal } from "react-dom";
|
|
443
|
+
import { cn } from "@/lib/utils";
|
|
444
|
+
import { useAnimatedMount } from "@/hooks/use-animated-mount";
|
|
445
|
+
|
|
446
|
+
// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
447
|
+
|
|
448
|
+
export type AlertDialogVariant = "destructive" | "warning" | "default";
|
|
449
|
+
|
|
450
|
+
export interface AlertDialogProps {
|
|
451
|
+
open: boolean;
|
|
452
|
+
onClose: () => void;
|
|
453
|
+
onConfirm: () => void;
|
|
454
|
+
title: string;
|
|
455
|
+
description: string;
|
|
456
|
+
cancelLabel?: string;
|
|
457
|
+
confirmLabel?: string;
|
|
458
|
+
variant?: AlertDialogVariant;
|
|
459
|
+
isLoading?: boolean;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// \u2500\u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
463
|
+
|
|
464
|
+
const CONFIRM_CLASSES: Record<AlertDialogVariant, string> = {
|
|
465
|
+
destructive: "bg-red-500 hover:bg-red-600 text-white focus-visible:ring-red-400/40",
|
|
466
|
+
warning: "bg-amber-500 hover:bg-amber-600 text-white focus-visible:ring-amber-400/40",
|
|
467
|
+
default: "bg-primary hover:bg-primary/90 text-white focus-visible:ring-primary/40",
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const ICON_BG: Record<AlertDialogVariant, string> = {
|
|
471
|
+
destructive: "bg-red-100 dark:bg-red-900/30 text-red-500",
|
|
472
|
+
warning: "bg-amber-100 dark:bg-amber-900/30 text-amber-500",
|
|
473
|
+
default: "bg-primary/10 text-primary",
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// \u2500\u2500\u2500 Icons \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
477
|
+
|
|
478
|
+
function VariantIcon({ variant }: { variant: AlertDialogVariant }) {
|
|
479
|
+
if (variant === "destructive") {
|
|
480
|
+
return (
|
|
481
|
+
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="none">
|
|
482
|
+
<path d="M10 2a8 8 0 1 0 0 16A8 8 0 0 0 10 2zm0 5v4m0 2.5v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
483
|
+
</svg>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
if (variant === "warning") {
|
|
487
|
+
return (
|
|
488
|
+
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="none">
|
|
489
|
+
<path d="M8.485 3.495a1.75 1.75 0 0 1 3.03 0l6.28 10.875A1.75 1.75 0 0 1 16.28 17H3.72a1.75 1.75 0 0 1-1.515-2.63L8.485 3.495zM10 8v3m0 2.5v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
490
|
+
</svg>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return (
|
|
494
|
+
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="none">
|
|
495
|
+
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.5" />
|
|
496
|
+
<path d="M10 6v4m0 2.5v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
497
|
+
</svg>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// \u2500\u2500\u2500 Spinner \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
502
|
+
|
|
503
|
+
function Spinner() {
|
|
504
|
+
return (
|
|
505
|
+
<svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
|
|
506
|
+
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
|
|
507
|
+
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
508
|
+
</svg>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
513
|
+
|
|
514
|
+
export function AlertDialog({
|
|
515
|
+
open,
|
|
516
|
+
onClose,
|
|
517
|
+
onConfirm,
|
|
518
|
+
title,
|
|
519
|
+
description,
|
|
520
|
+
cancelLabel = "Cancel",
|
|
521
|
+
confirmLabel = "Confirm",
|
|
522
|
+
variant = "default",
|
|
523
|
+
isLoading = false,
|
|
524
|
+
}: AlertDialogProps) {
|
|
525
|
+
const { mounted, visible } = useAnimatedMount(open, 200);
|
|
526
|
+
|
|
527
|
+
// Scroll lock only \u2014 no Escape dismiss (by design)
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
if (!open) return;
|
|
530
|
+
document.body.style.overflow = "hidden";
|
|
531
|
+
return () => { document.body.style.overflow = ""; };
|
|
532
|
+
}, [open]);
|
|
533
|
+
|
|
534
|
+
if (!mounted) return null;
|
|
535
|
+
|
|
536
|
+
const panel = (
|
|
537
|
+
// Backdrop \u2014 clicking does NOT close (intentional)
|
|
538
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
539
|
+
<div
|
|
540
|
+
className={cn(
|
|
541
|
+
"absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-200",
|
|
542
|
+
visible ? "opacity-100" : "opacity-0"
|
|
543
|
+
)}
|
|
544
|
+
/>
|
|
545
|
+
|
|
546
|
+
<div
|
|
547
|
+
role="alertdialog"
|
|
548
|
+
aria-modal="true"
|
|
549
|
+
aria-labelledby="alert-title"
|
|
550
|
+
aria-describedby="alert-desc"
|
|
551
|
+
className={cn(
|
|
552
|
+
"relative w-full max-w-md rounded-2xl bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#1f2937] shadow-2xl shadow-black/20",
|
|
553
|
+
"transition-all duration-200",
|
|
554
|
+
visible ? "opacity-100 scale-100" : "opacity-0 scale-95"
|
|
555
|
+
)}
|
|
556
|
+
>
|
|
557
|
+
<div className="p-6">
|
|
558
|
+
{/* Icon + Title */}
|
|
559
|
+
<div className="flex items-start gap-4">
|
|
560
|
+
<div className={cn("mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full", ICON_BG[variant])}>
|
|
561
|
+
<VariantIcon variant={variant} />
|
|
562
|
+
</div>
|
|
563
|
+
<div className="flex-1 min-w-0">
|
|
564
|
+
<h2 id="alert-title" className="text-base font-semibold text-slate-900 dark:text-white leading-snug">
|
|
565
|
+
{title}
|
|
566
|
+
</h2>
|
|
567
|
+
<p id="alert-desc" className="mt-1.5 text-sm text-slate-500 dark:text-slate-400 leading-relaxed">
|
|
568
|
+
{description}
|
|
569
|
+
</p>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
{/* Footer */}
|
|
575
|
+
<div className="px-6 pb-6 flex items-center justify-end gap-3">
|
|
576
|
+
<button
|
|
577
|
+
onClick={onClose}
|
|
578
|
+
disabled={isLoading}
|
|
579
|
+
className="px-4 py-2 rounded-lg text-sm font-medium text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-[#1f2937] hover:bg-slate-50 dark:hover:bg-[#1f2937] disabled:opacity-50 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40"
|
|
580
|
+
>
|
|
581
|
+
{cancelLabel}
|
|
582
|
+
</button>
|
|
583
|
+
<button
|
|
584
|
+
onClick={onConfirm}
|
|
585
|
+
disabled={isLoading}
|
|
586
|
+
className={cn(
|
|
587
|
+
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-70 transition-colors focus-visible:outline-hidden focus-visible:ring-2",
|
|
588
|
+
CONFIRM_CLASSES[variant]
|
|
589
|
+
)}
|
|
590
|
+
>
|
|
591
|
+
{isLoading && <Spinner />}
|
|
592
|
+
{confirmLabel}
|
|
593
|
+
</button>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
return createPortal(panel, document.body);
|
|
600
|
+
}`,
|
|
601
|
+
"Avatar": `"use client";
|
|
602
|
+
/* eslint-disable @next/next/no-img-element */
|
|
603
|
+
|
|
604
|
+
import { createContext, useContext, useState, type ImgHTMLAttributes, type ReactNode } from "react";
|
|
605
|
+
import { cn } from "@/lib/utils";
|
|
606
|
+
|
|
607
|
+
export type AvatarSize = "sm" | "md" | "lg" | "xl";
|
|
608
|
+
|
|
609
|
+
export interface AvatarProps {
|
|
610
|
+
size?: AvatarSize;
|
|
611
|
+
className?: string;
|
|
612
|
+
children?: ReactNode;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
interface AvatarContextValue {
|
|
616
|
+
hasImageError: boolean;
|
|
617
|
+
setHasImageError: (value: boolean) => void;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const AvatarContext = createContext<AvatarContextValue | null>(null);
|
|
621
|
+
|
|
622
|
+
function useAvatarContext() {
|
|
623
|
+
const context = useContext(AvatarContext);
|
|
624
|
+
if (!context) throw new Error("Avatar sub-components must be used inside <Avatar>");
|
|
625
|
+
return context;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const SIZE_CLASSES: Record<AvatarSize, string> = {
|
|
629
|
+
sm: "h-8 w-8 text-xs",
|
|
630
|
+
md: "h-10 w-10 text-sm",
|
|
631
|
+
lg: "h-14 w-14 text-base",
|
|
632
|
+
xl: "h-20 w-20 text-lg",
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
export function Avatar({ size = "md", className, children }: AvatarProps) {
|
|
636
|
+
const [hasImageError, setHasImageError] = useState(false);
|
|
637
|
+
|
|
638
|
+
return (
|
|
639
|
+
<AvatarContext.Provider value={{ hasImageError, setHasImageError }}>
|
|
640
|
+
<span
|
|
641
|
+
className={cn(
|
|
642
|
+
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-slate-100 text-slate-700 dark:bg-[#1f2937] dark:text-slate-200",
|
|
643
|
+
SIZE_CLASSES[size],
|
|
644
|
+
className
|
|
645
|
+
)}
|
|
646
|
+
>
|
|
647
|
+
{children}
|
|
648
|
+
</span>
|
|
649
|
+
</AvatarContext.Provider>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
Avatar.Image = function AvatarImage({ className, onError, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) {
|
|
654
|
+
const { hasImageError, setHasImageError } = useAvatarContext();
|
|
655
|
+
|
|
656
|
+
if (hasImageError) return null;
|
|
657
|
+
|
|
658
|
+
return (
|
|
659
|
+
<img
|
|
660
|
+
className={cn("h-full w-full object-cover", className)}
|
|
661
|
+
alt={alt ?? ""}
|
|
662
|
+
onError={(event) => {
|
|
663
|
+
setHasImageError(true);
|
|
664
|
+
onError?.(event);
|
|
665
|
+
}}
|
|
666
|
+
{...props}
|
|
667
|
+
/>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
Avatar.Fallback = function AvatarFallback({ className, children }: { className?: string; children: ReactNode }) {
|
|
672
|
+
const { hasImageError } = useAvatarContext();
|
|
673
|
+
if (!hasImageError) return null;
|
|
674
|
+
|
|
675
|
+
return <span className={cn("font-semibold uppercase", className)}>{children}</span>;
|
|
676
|
+
}`,
|
|
677
|
+
"Badge": `import type { ReactNode } from "react";
|
|
678
|
+
import { cn } from "@/lib/utils";
|
|
679
|
+
|
|
680
|
+
export type BadgeVariant = "default" | "success" | "warning" | "error" | "info" | "outline";
|
|
681
|
+
export type BadgeSize = "sm" | "md" | "lg";
|
|
682
|
+
|
|
683
|
+
export interface BadgeProps {
|
|
684
|
+
variant?: BadgeVariant;
|
|
685
|
+
size?: BadgeSize;
|
|
686
|
+
children: ReactNode;
|
|
687
|
+
className?: string;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
|
691
|
+
default: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
|
|
692
|
+
success: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
|
|
693
|
+
warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
|
|
694
|
+
error: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
|
|
695
|
+
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
|
696
|
+
outline: "border border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400",
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const SIZE_CLASSES: Record<BadgeSize, string> = {
|
|
700
|
+
sm: "text-[10px] px-2 py-0.5",
|
|
701
|
+
md: "text-xs px-2.5 py-0.5",
|
|
702
|
+
lg: "text-sm px-3 py-1",
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
export function Badge({ variant = "default", size = "md", children, className }: BadgeProps) {
|
|
706
|
+
return (
|
|
707
|
+
<span
|
|
708
|
+
className={cn(
|
|
709
|
+
"inline-flex items-center font-medium rounded-full",
|
|
710
|
+
VARIANT_CLASSES[variant],
|
|
711
|
+
SIZE_CLASSES[size],
|
|
712
|
+
className,
|
|
713
|
+
)}
|
|
714
|
+
>
|
|
715
|
+
{children}
|
|
716
|
+
</span>
|
|
717
|
+
);
|
|
718
|
+
}`,
|
|
719
|
+
"Breadcrumb": `import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
|
720
|
+
import { cn } from "@/lib/utils";
|
|
721
|
+
|
|
722
|
+
export function Breadcrumb({ className, ...props }: HTMLAttributes<HTMLElement>) {
|
|
723
|
+
return <nav aria-label="Breadcrumb" className={cn("w-full", className)} {...props} />;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
Breadcrumb.List = function BreadcrumbList({ className, ...props }: HTMLAttributes<HTMLOListElement>) {
|
|
727
|
+
return <ol className={cn("flex items-center gap-1.5 text-sm", className)} {...props} />;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
Breadcrumb.Item = function BreadcrumbItem({ className, ...props }: HTMLAttributes<HTMLLIElement>) {
|
|
731
|
+
return <li className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
Breadcrumb.Link = function BreadcrumbLink({ className, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
735
|
+
return (
|
|
736
|
+
<a
|
|
737
|
+
className={cn(
|
|
738
|
+
"text-slate-500 transition-colors hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
|
|
739
|
+
className
|
|
740
|
+
)}
|
|
741
|
+
{...props}
|
|
742
|
+
/>
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
Breadcrumb.Page = function BreadcrumbPage({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
|
747
|
+
return <span className={cn("font-medium text-slate-900 dark:text-white", className)} {...props} />;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
Breadcrumb.Separator = function BreadcrumbSeparator({ className, children = <span aria-hidden="true">/</span> }: { className?: string; children?: ReactNode }) {
|
|
751
|
+
return <span className={cn("text-slate-400 dark:text-slate-500", className)}>{children}</span>;
|
|
752
|
+
}`,
|
|
753
|
+
"Button": `import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
754
|
+
import { cn } from "@/lib/utils";
|
|
755
|
+
|
|
756
|
+
export type ButtonVariant = "primary" | "secondary" | "ghost" | "destructive" | "outline";
|
|
757
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
758
|
+
|
|
759
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
760
|
+
variant?: ButtonVariant;
|
|
761
|
+
size?: ButtonSize;
|
|
762
|
+
isLoading?: boolean;
|
|
763
|
+
children: ReactNode;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
|
767
|
+
primary:
|
|
768
|
+
"bg-primary text-white hover:bg-primary/90 focus-visible:ring-primary/40",
|
|
769
|
+
secondary:
|
|
770
|
+
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 focus-visible:ring-slate-400/40",
|
|
771
|
+
ghost:
|
|
772
|
+
"text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 focus-visible:ring-slate-400/40",
|
|
773
|
+
destructive:
|
|
774
|
+
"bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600 focus-visible:ring-red-500/40",
|
|
775
|
+
outline:
|
|
776
|
+
"border border-slate-300 text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800/50 focus-visible:ring-slate-400/40",
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const SIZE_CLASSES: Record<ButtonSize, string> = {
|
|
780
|
+
sm: "text-xs px-3 py-1.5 h-7 gap-1.5",
|
|
781
|
+
md: "text-sm px-4 py-2 h-9 gap-2",
|
|
782
|
+
lg: "text-base px-6 py-2.5 h-11 gap-2",
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
function Spinner() {
|
|
786
|
+
return (
|
|
787
|
+
<svg className="animate-spin h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
788
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
789
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
790
|
+
</svg>
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function Button({
|
|
795
|
+
variant = "primary",
|
|
796
|
+
size = "md",
|
|
797
|
+
isLoading = false,
|
|
798
|
+
disabled,
|
|
799
|
+
children,
|
|
800
|
+
className,
|
|
801
|
+
...props
|
|
802
|
+
}: ButtonProps) {
|
|
803
|
+
return (
|
|
804
|
+
<button
|
|
805
|
+
{...props}
|
|
806
|
+
disabled={disabled || isLoading}
|
|
807
|
+
className={cn(
|
|
808
|
+
"inline-flex items-center justify-center font-semibold rounded-lg transition-all",
|
|
809
|
+
"focus-visible:outline-hidden focus-visible:ring-2",
|
|
810
|
+
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
811
|
+
VARIANT_CLASSES[variant],
|
|
812
|
+
SIZE_CLASSES[size],
|
|
813
|
+
className,
|
|
814
|
+
)}
|
|
815
|
+
>
|
|
816
|
+
{isLoading && <Spinner />}
|
|
817
|
+
{children}
|
|
818
|
+
</button>
|
|
819
|
+
);
|
|
820
|
+
}`,
|
|
821
|
+
"Card": `import type { HTMLAttributes } from "react";
|
|
822
|
+
import { cn } from "@/lib/utils";
|
|
823
|
+
|
|
824
|
+
export type CardVariant = "default" | "elevated" | "flat";
|
|
825
|
+
|
|
826
|
+
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|
827
|
+
variant?: CardVariant;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const CARD_VARIANT_CLASSES: Record<CardVariant, string> = {
|
|
831
|
+
default: "bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#1f2937]",
|
|
832
|
+
elevated: "bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#1f2937] shadow-lg shadow-slate-200/60 dark:shadow-black/30",
|
|
833
|
+
flat: "bg-slate-50 dark:bg-[#0d1117] border border-transparent",
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
export function Card({ variant = "default", className, children, ...props }: CardProps) {
|
|
837
|
+
return (
|
|
838
|
+
<div className={cn("rounded-xl", CARD_VARIANT_CLASSES[variant], className)} {...props}>
|
|
839
|
+
{children}
|
|
840
|
+
</div>
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
Card.Header = function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
845
|
+
return (
|
|
846
|
+
<div className={cn("p-6 pb-4", className)} {...props}>
|
|
847
|
+
{children}
|
|
848
|
+
</div>
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
Card.Title = function CardTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
|
853
|
+
return (
|
|
854
|
+
<h3 className={cn("text-base font-bold text-slate-900 dark:text-white leading-snug", className)} {...props}>
|
|
855
|
+
{children}
|
|
856
|
+
</h3>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
Card.Description = function CardDescription({ className, children, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
|
861
|
+
return (
|
|
862
|
+
<p className={cn("mt-1 text-sm text-slate-500 dark:text-slate-400 leading-relaxed", className)} {...props}>
|
|
863
|
+
{children}
|
|
864
|
+
</p>
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
Card.Content = function CardContent({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
869
|
+
return (
|
|
870
|
+
<div className={cn("px-6 pb-4", className)} {...props}>
|
|
871
|
+
{children}
|
|
872
|
+
</div>
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
Card.Footer = function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
877
|
+
return (
|
|
878
|
+
<div className={cn("px-6 pb-6 flex items-center gap-3", className)} {...props}>
|
|
879
|
+
{children}
|
|
880
|
+
</div>
|
|
881
|
+
);
|
|
882
|
+
}`,
|
|
883
|
+
"Checkbox": `import { useRef, useEffect } from "react";
|
|
884
|
+
import { cn } from "@/lib/utils";
|
|
885
|
+
|
|
886
|
+
export type CheckboxSize = "sm" | "md" | "lg";
|
|
887
|
+
|
|
888
|
+
export interface CheckboxProps {
|
|
889
|
+
checked?: boolean;
|
|
890
|
+
defaultChecked?: boolean;
|
|
891
|
+
indeterminate?: boolean;
|
|
892
|
+
disabled?: boolean;
|
|
893
|
+
size?: CheckboxSize;
|
|
894
|
+
label?: string;
|
|
895
|
+
description?: string;
|
|
896
|
+
id?: string;
|
|
897
|
+
name?: string;
|
|
898
|
+
onChange?: (checked: boolean) => void;
|
|
899
|
+
className?: string;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const BOX_SIZES: Record<CheckboxSize, string> = {
|
|
903
|
+
sm: "h-4 w-4",
|
|
904
|
+
md: "h-5 w-5",
|
|
905
|
+
lg: "h-6 w-6",
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const ICON_SIZES: Record<CheckboxSize, string> = {
|
|
909
|
+
sm: "h-2.5 w-2.5",
|
|
910
|
+
md: "h-3 w-3",
|
|
911
|
+
lg: "h-3.5 w-3.5",
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const LABEL_SIZES: Record<CheckboxSize, string> = {
|
|
915
|
+
sm: "text-xs",
|
|
916
|
+
md: "text-sm",
|
|
917
|
+
lg: "text-base",
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
function CheckIcon({ className }: { className?: string }) {
|
|
921
|
+
return (
|
|
922
|
+
<svg className={className} viewBox="0 0 12 12" fill="none">
|
|
923
|
+
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
924
|
+
</svg>
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function MinusIcon({ className }: { className?: string }) {
|
|
929
|
+
return (
|
|
930
|
+
<svg className={className} viewBox="0 0 12 12" fill="none">
|
|
931
|
+
<path d="M2.5 6h7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
|
932
|
+
</svg>
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function Checkbox({
|
|
937
|
+
checked,
|
|
938
|
+
defaultChecked,
|
|
939
|
+
indeterminate = false,
|
|
940
|
+
disabled = false,
|
|
941
|
+
size = "md",
|
|
942
|
+
label,
|
|
943
|
+
description,
|
|
944
|
+
id,
|
|
945
|
+
name,
|
|
946
|
+
onChange,
|
|
947
|
+
className,
|
|
948
|
+
}: CheckboxProps) {
|
|
949
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
950
|
+
|
|
951
|
+
useEffect(() => {
|
|
952
|
+
if (inputRef.current) {
|
|
953
|
+
inputRef.current.indeterminate = indeterminate;
|
|
954
|
+
}
|
|
955
|
+
}, [indeterminate]);
|
|
956
|
+
|
|
957
|
+
const isChecked = checked ?? false;
|
|
958
|
+
const isFilled = isChecked || indeterminate;
|
|
959
|
+
|
|
960
|
+
return (
|
|
961
|
+
<label
|
|
962
|
+
className={cn(
|
|
963
|
+
"inline-flex items-start gap-3",
|
|
964
|
+
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
|
965
|
+
className,
|
|
966
|
+
)}
|
|
967
|
+
>
|
|
968
|
+
<div className="relative mt-0.5 shrink-0">
|
|
969
|
+
<input
|
|
970
|
+
ref={inputRef}
|
|
971
|
+
type="checkbox"
|
|
972
|
+
id={id}
|
|
973
|
+
name={name}
|
|
974
|
+
checked={checked}
|
|
975
|
+
defaultChecked={defaultChecked}
|
|
976
|
+
disabled={disabled}
|
|
977
|
+
onChange={(e) => onChange?.(e.target.checked)}
|
|
978
|
+
className="sr-only"
|
|
979
|
+
/>
|
|
980
|
+
<div
|
|
981
|
+
className={cn(
|
|
982
|
+
"flex items-center justify-center rounded-sm border-2 transition-all",
|
|
983
|
+
BOX_SIZES[size],
|
|
984
|
+
isFilled
|
|
985
|
+
? "bg-primary border-primary"
|
|
986
|
+
: "bg-white dark:bg-[#0d1117] border-slate-300 dark:border-slate-600",
|
|
987
|
+
!disabled && !isFilled && "hover:border-primary",
|
|
988
|
+
)}
|
|
989
|
+
>
|
|
990
|
+
{isChecked && <CheckIcon className={cn("text-white", ICON_SIZES[size])} />}
|
|
991
|
+
{indeterminate && !isChecked && <MinusIcon className={cn("text-white", ICON_SIZES[size])} />}
|
|
992
|
+
</div>
|
|
993
|
+
</div>
|
|
994
|
+
|
|
995
|
+
{(label ?? description) && (
|
|
996
|
+
<div className="min-w-0">
|
|
997
|
+
{label && (
|
|
998
|
+
<span className={cn("block font-medium text-slate-900 dark:text-white leading-tight", LABEL_SIZES[size])}>
|
|
999
|
+
{label}
|
|
1000
|
+
</span>
|
|
1001
|
+
)}
|
|
1002
|
+
{description && (
|
|
1003
|
+
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-relaxed">
|
|
1004
|
+
{description}
|
|
1005
|
+
</p>
|
|
1006
|
+
)}
|
|
1007
|
+
</div>
|
|
1008
|
+
)}
|
|
1009
|
+
</label>
|
|
1010
|
+
);
|
|
1011
|
+
}`,
|
|
1012
|
+
"Combobox": `import { useEffect, useMemo, useRef, useState } from "react";
|
|
1013
|
+
import { cn } from "@/lib/utils";
|
|
1014
|
+
|
|
1015
|
+
export interface ComboboxOption {
|
|
1016
|
+
label: string;
|
|
1017
|
+
value: string;
|
|
1018
|
+
description?: string;
|
|
1019
|
+
disabled?: boolean;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export interface ComboboxProps {
|
|
1023
|
+
options: ComboboxOption[];
|
|
1024
|
+
value?: string;
|
|
1025
|
+
defaultValue?: string;
|
|
1026
|
+
onValueChange?: (value: string) => void;
|
|
1027
|
+
placeholder?: string;
|
|
1028
|
+
emptyText?: string;
|
|
1029
|
+
label?: string;
|
|
1030
|
+
description?: string;
|
|
1031
|
+
className?: string;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
export function Combobox({
|
|
1035
|
+
options,
|
|
1036
|
+
value,
|
|
1037
|
+
defaultValue,
|
|
1038
|
+
onValueChange,
|
|
1039
|
+
placeholder = "Search...",
|
|
1040
|
+
emptyText = "No results",
|
|
1041
|
+
label,
|
|
1042
|
+
description,
|
|
1043
|
+
className,
|
|
1044
|
+
}: ComboboxProps) {
|
|
1045
|
+
const [query, setQuery] = useState("");
|
|
1046
|
+
const [open, setOpen] = useState(false);
|
|
1047
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
1048
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
|
|
1049
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1050
|
+
const isControlled = value !== undefined;
|
|
1051
|
+
const selectedValue = isControlled ? value : internalValue;
|
|
1052
|
+
|
|
1053
|
+
const selectedOption = useMemo(
|
|
1054
|
+
() => options.find((option) => option.value === selectedValue),
|
|
1055
|
+
[options, selectedValue]
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
const filtered = useMemo(() => {
|
|
1059
|
+
const q = query.trim().toLowerCase();
|
|
1060
|
+
if (!q) return options;
|
|
1061
|
+
return options.filter(
|
|
1062
|
+
(option) =>
|
|
1063
|
+
option.label.toLowerCase().includes(q) || option.description?.toLowerCase().includes(q)
|
|
1064
|
+
);
|
|
1065
|
+
}, [options, query]);
|
|
1066
|
+
|
|
1067
|
+
useEffect(() => {
|
|
1068
|
+
if (!open) return;
|
|
1069
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
1070
|
+
if (!containerRef.current?.contains(event.target as Node)) {
|
|
1071
|
+
setOpen(false);
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
window.addEventListener("mousedown", onPointerDown);
|
|
1075
|
+
return () => window.removeEventListener("mousedown", onPointerDown);
|
|
1076
|
+
}, [open]);
|
|
1077
|
+
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
if (selectedOption && !open) {
|
|
1080
|
+
setQuery(selectedOption.label);
|
|
1081
|
+
}
|
|
1082
|
+
}, [selectedOption, open]);
|
|
1083
|
+
|
|
1084
|
+
const selectValue = (nextValue: string) => {
|
|
1085
|
+
if (!isControlled) setInternalValue(nextValue);
|
|
1086
|
+
onValueChange?.(nextValue);
|
|
1087
|
+
const nextOption = options.find((option) => option.value === nextValue);
|
|
1088
|
+
setQuery(nextOption?.label ?? "");
|
|
1089
|
+
setOpen(false);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
return (
|
|
1093
|
+
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
1094
|
+
{label && <label className="text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}
|
|
1095
|
+
|
|
1096
|
+
<div ref={containerRef} className="relative">
|
|
1097
|
+
<input
|
|
1098
|
+
value={query}
|
|
1099
|
+
onChange={(event) => {
|
|
1100
|
+
setQuery(event.target.value);
|
|
1101
|
+
setOpen(true);
|
|
1102
|
+
setHighlightedIndex(0);
|
|
1103
|
+
}}
|
|
1104
|
+
onFocus={() => setOpen(true)}
|
|
1105
|
+
onKeyDown={(event) => {
|
|
1106
|
+
if (!open && event.key === "ArrowDown") {
|
|
1107
|
+
setOpen(true);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (event.key === "ArrowDown") {
|
|
1111
|
+
event.preventDefault();
|
|
1112
|
+
setHighlightedIndex((index) => Math.min(index + 1, filtered.length - 1));
|
|
1113
|
+
}
|
|
1114
|
+
if (event.key === "ArrowUp") {
|
|
1115
|
+
event.preventDefault();
|
|
1116
|
+
setHighlightedIndex((index) => Math.max(index - 1, 0));
|
|
1117
|
+
}
|
|
1118
|
+
if (event.key === "Enter") {
|
|
1119
|
+
event.preventDefault();
|
|
1120
|
+
const option = filtered[highlightedIndex];
|
|
1121
|
+
if (option && !option.disabled) selectValue(option.value);
|
|
1122
|
+
}
|
|
1123
|
+
if (event.key === "Escape") {
|
|
1124
|
+
setOpen(false);
|
|
1125
|
+
}
|
|
1126
|
+
}}
|
|
1127
|
+
placeholder={placeholder}
|
|
1128
|
+
className={cn(
|
|
1129
|
+
"h-10 w-full rounded-lg border border-slate-200 bg-white px-3.5 text-sm text-slate-900 outline-hidden transition-all",
|
|
1130
|
+
"hover:border-slate-300 focus:border-primary focus:ring-2 focus:ring-primary/20",
|
|
1131
|
+
"dark:border-[#1f2937] dark:bg-[#0d1117] dark:text-white dark:hover:border-slate-600"
|
|
1132
|
+
)}
|
|
1133
|
+
/>
|
|
1134
|
+
<span className="material-symbols-outlined pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[18px] text-slate-400">
|
|
1135
|
+
{open ? "expand_less" : "expand_more"}
|
|
1136
|
+
</span>
|
|
1137
|
+
|
|
1138
|
+
{open && (
|
|
1139
|
+
<div className="absolute z-40 mt-2 max-h-64 w-full overflow-y-auto rounded-xl border border-slate-200 bg-white p-1 shadow-lg dark:border-[#1f2937] dark:bg-[#161b22]">
|
|
1140
|
+
{filtered.length === 0 && (
|
|
1141
|
+
<p className="px-3 py-2 text-xs text-slate-500 dark:text-slate-400">{emptyText}</p>
|
|
1142
|
+
)}
|
|
1143
|
+
{filtered.map((option, index) => {
|
|
1144
|
+
const isSelected = selectedValue === option.value;
|
|
1145
|
+
const isHighlighted = index === highlightedIndex;
|
|
1146
|
+
return (
|
|
1147
|
+
<button
|
|
1148
|
+
key={option.value}
|
|
1149
|
+
type="button"
|
|
1150
|
+
disabled={option.disabled}
|
|
1151
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
1152
|
+
onClick={() => !option.disabled && selectValue(option.value)}
|
|
1153
|
+
className={cn(
|
|
1154
|
+
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left transition-colors",
|
|
1155
|
+
isHighlighted && "bg-primary/10",
|
|
1156
|
+
!isHighlighted && "hover:bg-slate-50 dark:hover:bg-[#0d1117]",
|
|
1157
|
+
option.disabled && "cursor-not-allowed opacity-50"
|
|
1158
|
+
)}
|
|
1159
|
+
>
|
|
1160
|
+
<span
|
|
1161
|
+
className={cn(
|
|
1162
|
+
"mt-0.5 material-symbols-outlined text-[16px]",
|
|
1163
|
+
isSelected ? "text-primary" : "text-transparent"
|
|
1164
|
+
)}
|
|
1165
|
+
>
|
|
1166
|
+
check
|
|
1167
|
+
</span>
|
|
1168
|
+
<span className="min-w-0 flex-1">
|
|
1169
|
+
<span className="block truncate text-sm text-slate-900 dark:text-white">{option.label}</span>
|
|
1170
|
+
{option.description && (
|
|
1171
|
+
<span className="mt-0.5 block truncate text-xs text-slate-500 dark:text-slate-400">
|
|
1172
|
+
{option.description}
|
|
1173
|
+
</span>
|
|
1174
|
+
)}
|
|
1175
|
+
</span>
|
|
1176
|
+
</button>
|
|
1177
|
+
);
|
|
1178
|
+
})}
|
|
1179
|
+
</div>
|
|
1180
|
+
)}
|
|
1181
|
+
</div>
|
|
1182
|
+
|
|
1183
|
+
{description && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
|
|
1184
|
+
</div>
|
|
1185
|
+
);
|
|
1186
|
+
}`,
|
|
1187
|
+
"Command": `import { createContext, useContext, useMemo, useState, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react";
|
|
1188
|
+
import { cn } from "@/lib/utils";
|
|
1189
|
+
|
|
1190
|
+
interface CommandContextValue {
|
|
1191
|
+
query: string;
|
|
1192
|
+
setQuery: (query: string) => void;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const CommandContext = createContext<CommandContextValue | null>(null);
|
|
1196
|
+
|
|
1197
|
+
function useCommandContext() {
|
|
1198
|
+
const context = useContext(CommandContext);
|
|
1199
|
+
if (!context) throw new Error("Command sub-components must be used inside <Command>");
|
|
1200
|
+
return context;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export function Command({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
1204
|
+
const [query, setQuery] = useState("");
|
|
1205
|
+
|
|
1206
|
+
return (
|
|
1207
|
+
<CommandContext.Provider value={{ query, setQuery }}>
|
|
1208
|
+
<div
|
|
1209
|
+
className={cn(
|
|
1210
|
+
"rounded-xl border border-slate-200 bg-white dark:border-[#1f2937] dark:bg-[#161b22]",
|
|
1211
|
+
className
|
|
1212
|
+
)}
|
|
1213
|
+
{...props}
|
|
1214
|
+
/>
|
|
1215
|
+
</CommandContext.Provider>
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
Command.Input = function CommandInput({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
|
1220
|
+
const { query, setQuery } = useCommandContext();
|
|
1221
|
+
|
|
1222
|
+
return (
|
|
1223
|
+
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2 dark:border-[#1f2937]">
|
|
1224
|
+
<span className="material-symbols-outlined text-[18px] text-slate-400">search</span>
|
|
1225
|
+
<input
|
|
1226
|
+
value={query}
|
|
1227
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
1228
|
+
className={cn(
|
|
1229
|
+
"h-8 w-full bg-transparent text-sm text-slate-900 outline-hidden placeholder:text-slate-400 dark:text-white",
|
|
1230
|
+
className
|
|
1231
|
+
)}
|
|
1232
|
+
{...props}
|
|
1233
|
+
/>
|
|
1234
|
+
</div>
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
Command.List = function CommandList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
1239
|
+
return <div className={cn("max-h-64 overflow-y-auto p-1", className)} {...props} />;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
interface CommandItemProps extends HTMLAttributes<HTMLButtonElement> {
|
|
1243
|
+
value: string;
|
|
1244
|
+
keywords?: string[];
|
|
1245
|
+
children: ReactNode;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
Command.Item = function CommandItem({ value, keywords, className, children, ...props }: CommandItemProps) {
|
|
1249
|
+
const { query } = useCommandContext();
|
|
1250
|
+
|
|
1251
|
+
const visible = useMemo(() => {
|
|
1252
|
+
const normalized = query.trim().toLowerCase();
|
|
1253
|
+
if (!normalized) return true;
|
|
1254
|
+
const terms = [value, ...(keywords ?? [])].join(" ").toLowerCase();
|
|
1255
|
+
return terms.includes(normalized);
|
|
1256
|
+
}, [query, value, keywords]);
|
|
1257
|
+
|
|
1258
|
+
if (!visible) return null;
|
|
1259
|
+
|
|
1260
|
+
return (
|
|
1261
|
+
<button
|
|
1262
|
+
type="button"
|
|
1263
|
+
className={cn(
|
|
1264
|
+
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-slate-700 transition-colors hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-[#0d1117]",
|
|
1265
|
+
className
|
|
1266
|
+
)}
|
|
1267
|
+
{...props}
|
|
1268
|
+
>
|
|
1269
|
+
{children}
|
|
1270
|
+
</button>
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
Command.Empty = function CommandEmpty({ className, children = "No results" }: { className?: string; children?: ReactNode }) {
|
|
1275
|
+
const { query } = useCommandContext();
|
|
1276
|
+
if (!query.trim()) return null;
|
|
1277
|
+
return <p className={cn("px-3 py-5 text-center text-xs text-slate-500 dark:text-slate-400", className)}>{children}</p>;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
Command.Group = function CommandGroup({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
1281
|
+
return <div className={cn("space-y-1", className)} {...props} />;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
Command.Label = function CommandLabel({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
|
1285
|
+
return (
|
|
1286
|
+
<p
|
|
1287
|
+
className={cn("px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400", className)}
|
|
1288
|
+
{...props}
|
|
1289
|
+
/>
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
Command.Separator = function CommandSeparator({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
1294
|
+
return <div className={cn("my-1 h-px bg-slate-200 dark:bg-[#1f2937]", className)} {...props} />;
|
|
1295
|
+
}`,
|
|
1296
|
+
"DatePicker": `import { forwardRef, type InputHTMLAttributes } from "react";
|
|
1297
|
+
import { cn } from "@/lib/utils";
|
|
1298
|
+
|
|
1299
|
+
export interface DatePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
1300
|
+
label?: string;
|
|
1301
|
+
description?: string;
|
|
1302
|
+
error?: string;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function DatePickerRoot(
|
|
1306
|
+
{ label, description, error, className, id, ...props },
|
|
1307
|
+
ref
|
|
1308
|
+
) {
|
|
1309
|
+
const inputId = id ?? (label ? label.toLowerCase().replace(/\\s+/g, "-") : undefined);
|
|
1310
|
+
|
|
1311
|
+
return (
|
|
1312
|
+
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
1313
|
+
{label && (
|
|
1314
|
+
<label htmlFor={inputId} className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
|
1315
|
+
{label}
|
|
1316
|
+
</label>
|
|
1317
|
+
)}
|
|
1318
|
+
<input
|
|
1319
|
+
ref={ref}
|
|
1320
|
+
id={inputId}
|
|
1321
|
+
type="date"
|
|
1322
|
+
className={cn(
|
|
1323
|
+
"h-10 w-full rounded-lg border border-slate-200 bg-white px-3.5 text-sm text-slate-900 outline-hidden transition-all",
|
|
1324
|
+
"hover:border-slate-300 focus:border-primary focus:ring-2 focus:ring-primary/20",
|
|
1325
|
+
"dark:border-[#1f2937] dark:bg-[#0d1117] dark:text-white dark:hover:border-slate-600",
|
|
1326
|
+
error && "border-red-400 focus:border-red-400 focus:ring-red-400/20 dark:border-red-500"
|
|
1327
|
+
)}
|
|
1328
|
+
{...props}
|
|
1329
|
+
/>
|
|
1330
|
+
{description && !error && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
|
|
1331
|
+
{error && <p className="text-xs text-red-500 dark:text-red-400">{error}</p>}
|
|
1332
|
+
</div>
|
|
1333
|
+
);
|
|
1334
|
+
});`,
|
|
1335
|
+
"Dialog": `"use client";
|
|
1336
|
+
|
|
1337
|
+
import { useEffect, useRef, type HTMLAttributes, type ReactNode } from "react";
|
|
1338
|
+
import { createPortal } from "react-dom";
|
|
1339
|
+
import { cn } from "@/lib/utils";
|
|
1340
|
+
import { useAnimatedMount } from "@/hooks/use-animated-mount";
|
|
1341
|
+
|
|
1342
|
+
// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1343
|
+
|
|
1344
|
+
export type DialogSize = "sm" | "md" | "lg" | "xl";
|
|
1345
|
+
|
|
1346
|
+
export interface DialogProps {
|
|
1347
|
+
open: boolean;
|
|
1348
|
+
onClose: () => void;
|
|
1349
|
+
size?: DialogSize;
|
|
1350
|
+
children: ReactNode;
|
|
1351
|
+
className?: string;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
export interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
1355
|
+
children: ReactNode;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export interface DialogTitleProps extends HTMLAttributes<HTMLHeadingElement> {
|
|
1359
|
+
children: ReactNode;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
export interface DialogDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
|
|
1363
|
+
children: ReactNode;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
export interface DialogContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
1367
|
+
children: ReactNode;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
export interface DialogFooterProps extends HTMLAttributes<HTMLDivElement> {
|
|
1371
|
+
children: ReactNode;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// \u2500\u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1375
|
+
|
|
1376
|
+
const SIZE_CLASSES: Record<DialogSize, string> = {
|
|
1377
|
+
sm: "max-w-sm",
|
|
1378
|
+
md: "max-w-md",
|
|
1379
|
+
lg: "max-w-lg",
|
|
1380
|
+
xl: "max-w-xl",
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
// \u2500\u2500\u2500 Sub-components \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1384
|
+
|
|
1385
|
+
Dialog.Header = function DialogHeader({ children, className, ...props }: DialogHeaderProps) {
|
|
1386
|
+
return (
|
|
1387
|
+
<div className={cn("px-6 pt-6 pb-4", className)} {...props}>
|
|
1388
|
+
{children}
|
|
1389
|
+
</div>
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
Dialog.Title = function DialogTitle({ children, className, ...props }: DialogTitleProps) {
|
|
1394
|
+
return (
|
|
1395
|
+
<h2 className={cn("text-lg font-semibold text-slate-900 dark:text-white pr-8", className)} {...props}>
|
|
1396
|
+
{children}
|
|
1397
|
+
</h2>
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
Dialog.Description = function DialogDescription({ children, className, ...props }: DialogDescriptionProps) {
|
|
1402
|
+
return (
|
|
1403
|
+
<p className={cn("mt-1.5 text-sm text-slate-500 dark:text-slate-400 leading-relaxed", className)} {...props}>
|
|
1404
|
+
{children}
|
|
1405
|
+
</p>
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
Dialog.Content = function DialogContent({ children, className, ...props }: DialogContentProps) {
|
|
1410
|
+
return (
|
|
1411
|
+
<div className={cn("px-6 py-2", className)} {...props}>
|
|
1412
|
+
{children}
|
|
1413
|
+
</div>
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
Dialog.Footer = function DialogFooter({ children, className, ...props }: DialogFooterProps) {
|
|
1418
|
+
return (
|
|
1419
|
+
<div className={cn("px-6 py-4 flex items-center justify-end gap-3 border-t border-slate-100 dark:border-[#1f2937]", className)} {...props}>
|
|
1420
|
+
{children}
|
|
1421
|
+
</div>
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// \u2500\u2500\u2500 Dialog \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1426
|
+
|
|
1427
|
+
export function Dialog({ open, onClose, size = "md", children, className }: DialogProps) {
|
|
1428
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
1429
|
+
const { mounted, visible } = useAnimatedMount(open, 200);
|
|
1430
|
+
|
|
1431
|
+
useEffect(() => {
|
|
1432
|
+
if (!open) return;
|
|
1433
|
+
|
|
1434
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
1435
|
+
if (e.key === "Escape") onClose();
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
document.addEventListener("keydown", handleKey);
|
|
1439
|
+
document.body.style.overflow = "hidden";
|
|
1440
|
+
|
|
1441
|
+
return () => {
|
|
1442
|
+
document.removeEventListener("keydown", handleKey);
|
|
1443
|
+
document.body.style.overflow = "";
|
|
1444
|
+
};
|
|
1445
|
+
}, [open, onClose]);
|
|
1446
|
+
|
|
1447
|
+
if (!mounted) return null;
|
|
1448
|
+
|
|
1449
|
+
const panel = (
|
|
1450
|
+
<div
|
|
1451
|
+
ref={overlayRef}
|
|
1452
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
1453
|
+
onClick={(e) => {
|
|
1454
|
+
if (e.target === overlayRef.current) onClose();
|
|
1455
|
+
}}
|
|
1456
|
+
>
|
|
1457
|
+
{/* Backdrop */}
|
|
1458
|
+
<div
|
|
1459
|
+
className={cn(
|
|
1460
|
+
"absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-200",
|
|
1461
|
+
visible ? "opacity-100" : "opacity-0"
|
|
1462
|
+
)}
|
|
1463
|
+
/>
|
|
1464
|
+
|
|
1465
|
+
{/* Panel */}
|
|
1466
|
+
<div
|
|
1467
|
+
role="dialog"
|
|
1468
|
+
aria-modal="true"
|
|
1469
|
+
className={cn(
|
|
1470
|
+
"relative w-full rounded-2xl bg-white dark:bg-[#161b22] shadow-2xl shadow-black/20",
|
|
1471
|
+
"border border-slate-200 dark:border-[#1f2937]",
|
|
1472
|
+
"transition-all duration-200",
|
|
1473
|
+
visible ? "opacity-100 scale-100" : "opacity-0 scale-95",
|
|
1474
|
+
SIZE_CLASSES[size],
|
|
1475
|
+
className
|
|
1476
|
+
)}
|
|
1477
|
+
>
|
|
1478
|
+
{/* Close button */}
|
|
1479
|
+
<button
|
|
1480
|
+
onClick={onClose}
|
|
1481
|
+
className="absolute right-4 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-[#1f2937] hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
|
|
1482
|
+
aria-label="Close dialog"
|
|
1483
|
+
>
|
|
1484
|
+
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none">
|
|
1485
|
+
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
1486
|
+
</svg>
|
|
1487
|
+
</button>
|
|
1488
|
+
|
|
1489
|
+
{children}
|
|
1490
|
+
</div>
|
|
1491
|
+
</div>
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
return createPortal(panel, document.body);
|
|
1495
|
+
}`,
|
|
1496
|
+
"Drawer": `"use client";
|
|
1497
|
+
|
|
1498
|
+
import { useEffect, useRef, type HTMLAttributes, type ReactNode } from "react";
|
|
1499
|
+
import { createPortal } from "react-dom";
|
|
1500
|
+
import { cn } from "@/lib/utils";
|
|
1501
|
+
import { useAnimatedMount } from "@/hooks/use-animated-mount";
|
|
1502
|
+
|
|
1503
|
+
// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1504
|
+
|
|
1505
|
+
export type DrawerSide = "right" | "left";
|
|
1506
|
+
export type DrawerSize = "sm" | "md" | "lg" | "full";
|
|
1507
|
+
|
|
1508
|
+
export interface DrawerProps {
|
|
1509
|
+
open: boolean;
|
|
1510
|
+
onClose: () => void;
|
|
1511
|
+
side?: DrawerSide;
|
|
1512
|
+
size?: DrawerSize;
|
|
1513
|
+
children: ReactNode;
|
|
1514
|
+
className?: string;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
export interface DrawerHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
1518
|
+
children: ReactNode;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
export interface DrawerTitleProps extends HTMLAttributes<HTMLHeadingElement> {
|
|
1522
|
+
children: ReactNode;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
export interface DrawerDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
|
|
1526
|
+
children: ReactNode;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
export interface DrawerContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
1530
|
+
children: ReactNode;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
export interface DrawerFooterProps extends HTMLAttributes<HTMLDivElement> {
|
|
1534
|
+
children: ReactNode;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// \u2500\u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1538
|
+
|
|
1539
|
+
const SIZE_CLASSES: Record<DrawerSize, string> = {
|
|
1540
|
+
sm: "w-80",
|
|
1541
|
+
md: "w-96",
|
|
1542
|
+
lg: "w-lg",
|
|
1543
|
+
full: "w-full",
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
const SIDE_CLASSES: Record<DrawerSide, { panel: string; hidden: string }> = {
|
|
1547
|
+
right: { panel: "right-0 inset-y-0", hidden: "translate-x-full" },
|
|
1548
|
+
left: { panel: "left-0 inset-y-0", hidden: "-translate-x-full" },
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
// \u2500\u2500\u2500 Sub-components \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1552
|
+
|
|
1553
|
+
Drawer.Header = function DrawerHeader({ children, className, ...props }: DrawerHeaderProps) {
|
|
1554
|
+
return (
|
|
1555
|
+
<div className={cn("px-6 pt-6 pb-4 border-b border-slate-100 dark:border-[#1f2937]", className)} {...props}>
|
|
1556
|
+
{children}
|
|
1557
|
+
</div>
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
Drawer.Title = function DrawerTitle({ children, className, ...props }: DrawerTitleProps) {
|
|
1562
|
+
return (
|
|
1563
|
+
<h2 className={cn("text-lg font-semibold text-slate-900 dark:text-white pr-8", className)} {...props}>
|
|
1564
|
+
{children}
|
|
1565
|
+
</h2>
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
Drawer.Description = function DrawerDescription({ children, className, ...props }: DrawerDescriptionProps) {
|
|
1570
|
+
return (
|
|
1571
|
+
<p className={cn("mt-1 text-sm text-slate-500 dark:text-slate-400 leading-relaxed", className)} {...props}>
|
|
1572
|
+
{children}
|
|
1573
|
+
</p>
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
Drawer.Content = function DrawerContent({ children, className, ...props }: DrawerContentProps) {
|
|
1578
|
+
return (
|
|
1579
|
+
<div className={cn("flex-1 overflow-y-auto px-6 py-4", className)} {...props}>
|
|
1580
|
+
{children}
|
|
1581
|
+
</div>
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
Drawer.Footer = function DrawerFooter({ children, className, ...props }: DrawerFooterProps) {
|
|
1586
|
+
return (
|
|
1587
|
+
<div className={cn("px-6 py-4 border-t border-slate-100 dark:border-[#1f2937] flex items-center justify-end gap-3 shrink-0", className)} {...props}>
|
|
1588
|
+
{children}
|
|
1589
|
+
</div>
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// \u2500\u2500\u2500 Drawer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1594
|
+
|
|
1595
|
+
export function Drawer({ open, onClose, side = "right", size = "md", children, className }: DrawerProps) {
|
|
1596
|
+
const backdropRef = useRef<HTMLDivElement>(null);
|
|
1597
|
+
const { mounted, visible } = useAnimatedMount(open, 300);
|
|
1598
|
+
|
|
1599
|
+
useEffect(() => {
|
|
1600
|
+
if (!open) return;
|
|
1601
|
+
|
|
1602
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
1603
|
+
if (e.key === "Escape") onClose();
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
document.addEventListener("keydown", handleKey);
|
|
1607
|
+
document.body.style.overflow = "hidden";
|
|
1608
|
+
|
|
1609
|
+
return () => {
|
|
1610
|
+
document.removeEventListener("keydown", handleKey);
|
|
1611
|
+
document.body.style.overflow = "";
|
|
1612
|
+
};
|
|
1613
|
+
}, [open, onClose]);
|
|
1614
|
+
|
|
1615
|
+
if (!mounted) return null;
|
|
1616
|
+
|
|
1617
|
+
const { panel, hidden } = SIDE_CLASSES[side];
|
|
1618
|
+
|
|
1619
|
+
const drawer = (
|
|
1620
|
+
<div
|
|
1621
|
+
ref={backdropRef}
|
|
1622
|
+
className="fixed inset-0 z-50 flex"
|
|
1623
|
+
onClick={(e) => {
|
|
1624
|
+
if (e.target === backdropRef.current) onClose();
|
|
1625
|
+
}}
|
|
1626
|
+
>
|
|
1627
|
+
{/* Backdrop */}
|
|
1628
|
+
<div
|
|
1629
|
+
className={cn(
|
|
1630
|
+
"absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-300",
|
|
1631
|
+
visible ? "opacity-100" : "opacity-0"
|
|
1632
|
+
)}
|
|
1633
|
+
/>
|
|
1634
|
+
|
|
1635
|
+
{/* Panel */}
|
|
1636
|
+
<div
|
|
1637
|
+
role="dialog"
|
|
1638
|
+
aria-modal="true"
|
|
1639
|
+
className={cn(
|
|
1640
|
+
"absolute flex flex-col h-full bg-white dark:bg-[#161b22]",
|
|
1641
|
+
"border-slate-200 dark:border-[#1f2937]",
|
|
1642
|
+
side === "right" ? "border-l" : "border-r",
|
|
1643
|
+
"shadow-2xl shadow-black/20",
|
|
1644
|
+
"transition-transform duration-300 ease-in-out",
|
|
1645
|
+
visible ? "translate-x-0" : hidden,
|
|
1646
|
+
SIZE_CLASSES[size],
|
|
1647
|
+
panel,
|
|
1648
|
+
className
|
|
1649
|
+
)}
|
|
1650
|
+
>
|
|
1651
|
+
{/* Close button */}
|
|
1652
|
+
<button
|
|
1653
|
+
onClick={onClose}
|
|
1654
|
+
className="absolute right-4 top-4 z-10 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-[#1f2937] hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
|
|
1655
|
+
aria-label="Close drawer"
|
|
1656
|
+
>
|
|
1657
|
+
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none">
|
|
1658
|
+
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
1659
|
+
</svg>
|
|
1660
|
+
</button>
|
|
1661
|
+
|
|
1662
|
+
{children}
|
|
1663
|
+
</div>
|
|
1664
|
+
</div>
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
return createPortal(drawer, document.body);
|
|
1668
|
+
}`,
|
|
1669
|
+
"DropdownMenu": `import { createContext, useCallback, useContext, useEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from "react";
|
|
1670
|
+
import { cn } from "@/lib/utils";
|
|
1671
|
+
|
|
1672
|
+
interface DropdownMenuContextValue {
|
|
1673
|
+
open: boolean;
|
|
1674
|
+
setOpen: (open: boolean) => void;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
|
|
1678
|
+
|
|
1679
|
+
function useDropdownMenuContext() {
|
|
1680
|
+
const context = useContext(DropdownMenuContext);
|
|
1681
|
+
if (!context) throw new Error("DropdownMenu sub-components must be used inside <DropdownMenu>");
|
|
1682
|
+
return context;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
export interface DropdownMenuProps {
|
|
1686
|
+
open?: boolean;
|
|
1687
|
+
defaultOpen?: boolean;
|
|
1688
|
+
onOpenChange?: (open: boolean) => void;
|
|
1689
|
+
children: ReactNode;
|
|
1690
|
+
className?: string;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
export interface DropdownMenuTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
1694
|
+
children: ReactNode;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
export interface DropdownMenuContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
1698
|
+
children: ReactNode;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
export interface DropdownMenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
1702
|
+
inset?: boolean;
|
|
1703
|
+
onSelect?: () => void;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export function DropdownMenu({ open, defaultOpen = false, onOpenChange, children, className }: DropdownMenuProps) {
|
|
1707
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
1708
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1709
|
+
const isControlled = open !== undefined;
|
|
1710
|
+
const isOpen = isControlled ? open : internalOpen;
|
|
1711
|
+
|
|
1712
|
+
const setOpen = useCallback((next: boolean) => {
|
|
1713
|
+
if (!isControlled) setInternalOpen(next);
|
|
1714
|
+
onOpenChange?.(next);
|
|
1715
|
+
}, [isControlled, onOpenChange]);
|
|
1716
|
+
|
|
1717
|
+
useEffect(() => {
|
|
1718
|
+
if (!isOpen) return;
|
|
1719
|
+
|
|
1720
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
1721
|
+
if (!containerRef.current?.contains(event.target as Node)) {
|
|
1722
|
+
setOpen(false);
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
|
|
1726
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
1727
|
+
if (event.key === "Escape") setOpen(false);
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
window.addEventListener("mousedown", onPointerDown);
|
|
1731
|
+
window.addEventListener("keydown", onKeyDown);
|
|
1732
|
+
|
|
1733
|
+
return () => {
|
|
1734
|
+
window.removeEventListener("mousedown", onPointerDown);
|
|
1735
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
1736
|
+
};
|
|
1737
|
+
}, [isOpen, setOpen]);
|
|
1738
|
+
|
|
1739
|
+
return (
|
|
1740
|
+
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
|
|
1741
|
+
<div ref={containerRef} className={cn("relative inline-block", className)}>
|
|
1742
|
+
{children}
|
|
1743
|
+
</div>
|
|
1744
|
+
</DropdownMenuContext.Provider>
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
DropdownMenu.Trigger = function DropdownMenuTrigger({ children, className, onClick, ...props }: DropdownMenuTriggerProps) {
|
|
1749
|
+
const { open, setOpen } = useDropdownMenuContext();
|
|
1750
|
+
|
|
1751
|
+
return (
|
|
1752
|
+
<button
|
|
1753
|
+
type="button"
|
|
1754
|
+
onClick={(event) => {
|
|
1755
|
+
onClick?.(event);
|
|
1756
|
+
setOpen(!open);
|
|
1757
|
+
}}
|
|
1758
|
+
className={cn(
|
|
1759
|
+
"inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 transition-all",
|
|
1760
|
+
"hover:bg-slate-50 dark:border-[#1f2937] dark:bg-[#0d1117] dark:text-slate-200 dark:hover:bg-[#161b22]",
|
|
1761
|
+
className
|
|
1762
|
+
)}
|
|
1763
|
+
{...props}
|
|
1764
|
+
>
|
|
1765
|
+
{children}
|
|
1766
|
+
</button>
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
DropdownMenu.Content = function DropdownMenuContent({ children, className, ...props }: DropdownMenuContentProps) {
|
|
1771
|
+
const { open } = useDropdownMenuContext();
|
|
1772
|
+
if (!open) return null;
|
|
1773
|
+
|
|
1774
|
+
return (
|
|
1775
|
+
<div
|
|
1776
|
+
className={cn(
|
|
1777
|
+
"absolute right-0 top-[calc(100%+8px)] z-50 min-w-56 rounded-xl border border-slate-200 bg-white p-1 shadow-xl",
|
|
1778
|
+
"dark:border-[#1f2937] dark:bg-[#161b22]",
|
|
1779
|
+
className
|
|
1780
|
+
)}
|
|
1781
|
+
{...props}
|
|
1782
|
+
>
|
|
1783
|
+
{children}
|
|
1784
|
+
</div>
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
DropdownMenu.Label = function DropdownMenuLabel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
1789
|
+
return (
|
|
1790
|
+
<div
|
|
1791
|
+
className={cn("px-2.5 py-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400", className)}
|
|
1792
|
+
{...props}
|
|
1793
|
+
/>
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
DropdownMenu.Separator = function DropdownMenuSeparator({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
1798
|
+
return <div className={cn("my-1 h-px bg-slate-200 dark:bg-[#1f2937]", className)} {...props} />;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
DropdownMenu.Item = function DropdownMenuItem({ inset = false, onSelect, onClick, className, disabled, ...props }: DropdownMenuItemProps) {
|
|
1802
|
+
const { setOpen } = useDropdownMenuContext();
|
|
1803
|
+
|
|
1804
|
+
return (
|
|
1805
|
+
<button
|
|
1806
|
+
type="button"
|
|
1807
|
+
disabled={disabled}
|
|
1808
|
+
onClick={(event) => {
|
|
1809
|
+
onClick?.(event);
|
|
1810
|
+
if (disabled) return;
|
|
1811
|
+
onSelect?.();
|
|
1812
|
+
setOpen(false);
|
|
1813
|
+
}}
|
|
1814
|
+
className={cn(
|
|
1815
|
+
"flex w-full items-center rounded-lg px-2.5 py-2 text-left text-sm text-slate-700 transition-all",
|
|
1816
|
+
"hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-[#0d1117]",
|
|
1817
|
+
inset && "pl-8",
|
|
1818
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
1819
|
+
className
|
|
1820
|
+
)}
|
|
1821
|
+
{...props}
|
|
1822
|
+
/>
|
|
1823
|
+
);
|
|
1824
|
+
}`,
|
|
1825
|
+
"FloatingLines": `import { useEffect, useRef } from "react";
|
|
1826
|
+
import {
|
|
1827
|
+
Scene,
|
|
1828
|
+
OrthographicCamera,
|
|
1829
|
+
WebGLRenderer,
|
|
1830
|
+
PlaneGeometry,
|
|
1831
|
+
Mesh,
|
|
1832
|
+
ShaderMaterial,
|
|
1833
|
+
Vector3,
|
|
1834
|
+
Vector2,
|
|
1835
|
+
Clock,
|
|
1836
|
+
} from "three";
|
|
1837
|
+
|
|
1838
|
+
const vertexShader = \`
|
|
1839
|
+
precision highp float;
|
|
1840
|
+
|
|
1841
|
+
void main() {
|
|
1842
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
1843
|
+
}
|
|
1844
|
+
\`;
|
|
1845
|
+
|
|
1846
|
+
const fragmentShader = \`
|
|
1847
|
+
precision highp float;
|
|
1848
|
+
|
|
1849
|
+
uniform float iTime;
|
|
1850
|
+
uniform vec3 iResolution;
|
|
1851
|
+
uniform float animationSpeed;
|
|
1852
|
+
|
|
1853
|
+
uniform bool enableTop;
|
|
1854
|
+
uniform bool enableMiddle;
|
|
1855
|
+
uniform bool enableBottom;
|
|
1856
|
+
|
|
1857
|
+
uniform int topLineCount;
|
|
1858
|
+
uniform int middleLineCount;
|
|
1859
|
+
uniform int bottomLineCount;
|
|
1860
|
+
|
|
1861
|
+
uniform float topLineDistance;
|
|
1862
|
+
uniform float middleLineDistance;
|
|
1863
|
+
uniform float bottomLineDistance;
|
|
1864
|
+
|
|
1865
|
+
uniform vec3 topWavePosition;
|
|
1866
|
+
uniform vec3 middleWavePosition;
|
|
1867
|
+
uniform vec3 bottomWavePosition;
|
|
1868
|
+
|
|
1869
|
+
uniform vec2 iMouse;
|
|
1870
|
+
uniform bool interactive;
|
|
1871
|
+
uniform float bendRadius;
|
|
1872
|
+
uniform float bendStrength;
|
|
1873
|
+
uniform float bendInfluence;
|
|
1874
|
+
|
|
1875
|
+
uniform bool parallax;
|
|
1876
|
+
uniform float parallaxStrength;
|
|
1877
|
+
uniform vec2 parallaxOffset;
|
|
1878
|
+
|
|
1879
|
+
uniform vec3 lineGradient[8];
|
|
1880
|
+
uniform int lineGradientCount;
|
|
1881
|
+
|
|
1882
|
+
const vec3 BLACK = vec3(0.0);
|
|
1883
|
+
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
|
|
1884
|
+
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
|
|
1885
|
+
|
|
1886
|
+
mat2 rotate(float r) {
|
|
1887
|
+
return mat2(cos(r), sin(r), -sin(r), cos(r));
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
vec3 background_color(vec2 uv) {
|
|
1891
|
+
vec3 col = vec3(0.0);
|
|
1892
|
+
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
|
|
1893
|
+
float m = uv.y - y;
|
|
1894
|
+
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
|
|
1895
|
+
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
|
|
1896
|
+
return col * 0.5;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
vec3 getLineColor(float t, vec3 baseColor) {
|
|
1900
|
+
if (lineGradientCount <= 0) return baseColor;
|
|
1901
|
+
|
|
1902
|
+
vec3 gradientColor;
|
|
1903
|
+
if (lineGradientCount == 1) {
|
|
1904
|
+
gradientColor = lineGradient[0];
|
|
1905
|
+
} else {
|
|
1906
|
+
float clampedT = clamp(t, 0.0, 0.9999);
|
|
1907
|
+
float scaled = clampedT * float(lineGradientCount - 1);
|
|
1908
|
+
int idx = int(floor(scaled));
|
|
1909
|
+
float f = fract(scaled);
|
|
1910
|
+
int idx2 = min(idx + 1, lineGradientCount - 1);
|
|
1911
|
+
vec3 c1 = lineGradient[idx];
|
|
1912
|
+
vec3 c2 = lineGradient[idx2];
|
|
1913
|
+
gradientColor = mix(c1, c2, f);
|
|
1914
|
+
}
|
|
1915
|
+
return gradientColor * 0.5;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) {
|
|
1919
|
+
float time = iTime * animationSpeed;
|
|
1920
|
+
float x_offset = offset;
|
|
1921
|
+
float x_movement = time * 0.1;
|
|
1922
|
+
float amp = sin(offset + time * 0.2) * 0.3;
|
|
1923
|
+
float y = sin(uv.x + x_offset + x_movement) * amp;
|
|
1924
|
+
|
|
1925
|
+
if (shouldBend) {
|
|
1926
|
+
vec2 d = screenUv - mouseUv;
|
|
1927
|
+
float influence = exp(-dot(d, d) * bendRadius);
|
|
1928
|
+
float bendOffset = (mouseUv.y - screenUv.y) * influence * bendStrength * bendInfluence;
|
|
1929
|
+
y += bendOffset;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
float m = uv.y - y;
|
|
1933
|
+
return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|
1937
|
+
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
|
|
1938
|
+
baseUv.y *= -1.0;
|
|
1939
|
+
|
|
1940
|
+
if (parallax) baseUv += parallaxOffset;
|
|
1941
|
+
|
|
1942
|
+
vec3 col = vec3(0.0);
|
|
1943
|
+
vec3 b = lineGradientCount > 0 ? vec3(0.0) : background_color(baseUv);
|
|
1944
|
+
|
|
1945
|
+
vec2 mouseUv = vec2(0.0);
|
|
1946
|
+
if (interactive) {
|
|
1947
|
+
mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
|
|
1948
|
+
mouseUv.y *= -1.0;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
if (enableBottom) {
|
|
1952
|
+
for (int i = 0; i < bottomLineCount; ++i) {
|
|
1953
|
+
float fi = float(i);
|
|
1954
|
+
float t = fi / max(float(bottomLineCount - 1), 1.0);
|
|
1955
|
+
vec3 lineCol = getLineColor(t, b);
|
|
1956
|
+
float angle = bottomWavePosition.z * log(length(baseUv) + 1.0);
|
|
1957
|
+
vec2 ruv = baseUv * rotate(angle);
|
|
1958
|
+
col += lineCol * wave(
|
|
1959
|
+
ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y),
|
|
1960
|
+
1.5 + 0.2 * fi, baseUv, mouseUv, interactive
|
|
1961
|
+
) * 0.2;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
if (enableMiddle) {
|
|
1966
|
+
for (int i = 0; i < middleLineCount; ++i) {
|
|
1967
|
+
float fi = float(i);
|
|
1968
|
+
float t = fi / max(float(middleLineCount - 1), 1.0);
|
|
1969
|
+
vec3 lineCol = getLineColor(t, b);
|
|
1970
|
+
float angle = middleWavePosition.z * log(length(baseUv) + 1.0);
|
|
1971
|
+
vec2 ruv = baseUv * rotate(angle);
|
|
1972
|
+
col += lineCol * wave(
|
|
1973
|
+
ruv + vec2(middleLineDistance * fi + middleWavePosition.x, middleWavePosition.y),
|
|
1974
|
+
2.0 + 0.15 * fi, baseUv, mouseUv, interactive
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
if (enableTop) {
|
|
1980
|
+
for (int i = 0; i < topLineCount; ++i) {
|
|
1981
|
+
float fi = float(i);
|
|
1982
|
+
float t = fi / max(float(topLineCount - 1), 1.0);
|
|
1983
|
+
vec3 lineCol = getLineColor(t, b);
|
|
1984
|
+
float angle = topWavePosition.z * log(length(baseUv) + 1.0);
|
|
1985
|
+
vec2 ruv = baseUv * rotate(angle);
|
|
1986
|
+
ruv.x *= -1.0;
|
|
1987
|
+
col += lineCol * wave(
|
|
1988
|
+
ruv + vec2(topLineDistance * fi + topWavePosition.x, topWavePosition.y),
|
|
1989
|
+
1.0 + 0.2 * fi, baseUv, mouseUv, interactive
|
|
1990
|
+
) * 0.1;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
fragColor = vec4(col, 1.0);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
void main() {
|
|
1998
|
+
vec4 color = vec4(0.0);
|
|
1999
|
+
mainImage(color, gl_FragCoord.xy);
|
|
2000
|
+
gl_FragColor = color;
|
|
2001
|
+
}
|
|
2002
|
+
\`;
|
|
2003
|
+
|
|
2004
|
+
const MAX_GRADIENT_STOPS = 8;
|
|
2005
|
+
|
|
2006
|
+
function hexToVec3(hex: string): Vector3 {
|
|
2007
|
+
const value = hex.trim().replace(/^#/, "");
|
|
2008
|
+
let r = 255, g = 255, b = 255;
|
|
2009
|
+
|
|
2010
|
+
if (value.length === 3) {
|
|
2011
|
+
r = parseInt((value[0] ?? "f") + (value[0] ?? "f"), 16);
|
|
2012
|
+
g = parseInt((value[1] ?? "f") + (value[1] ?? "f"), 16);
|
|
2013
|
+
b = parseInt((value[2] ?? "f") + (value[2] ?? "f"), 16);
|
|
2014
|
+
} else if (value.length === 6) {
|
|
2015
|
+
r = parseInt(value.slice(0, 2), 16);
|
|
2016
|
+
g = parseInt(value.slice(2, 4), 16);
|
|
2017
|
+
b = parseInt(value.slice(4, 6), 16);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
return new Vector3(r / 255, g / 255, b / 255);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
export interface WavePosition {
|
|
2024
|
+
x?: number;
|
|
2025
|
+
y?: number;
|
|
2026
|
+
rotate?: number;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
export interface FloatingLinesProps {
|
|
2030
|
+
linesGradient?: string[];
|
|
2031
|
+
enabledWaves?: Array<"top" | "middle" | "bottom">;
|
|
2032
|
+
lineCount?: number | number[];
|
|
2033
|
+
lineDistance?: number | number[];
|
|
2034
|
+
topWavePosition?: WavePosition;
|
|
2035
|
+
middleWavePosition?: WavePosition;
|
|
2036
|
+
bottomWavePosition?: WavePosition;
|
|
2037
|
+
animationSpeed?: number;
|
|
2038
|
+
interactive?: boolean;
|
|
2039
|
+
bendRadius?: number;
|
|
2040
|
+
bendStrength?: number;
|
|
2041
|
+
mouseDamping?: number;
|
|
2042
|
+
parallax?: boolean;
|
|
2043
|
+
parallaxStrength?: number;
|
|
2044
|
+
mixBlendMode?: React.CSSProperties["mixBlendMode"];
|
|
2045
|
+
className?: string;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
type WaveType = "top" | "middle" | "bottom";
|
|
2049
|
+
|
|
2050
|
+
export function FloatingLines({
|
|
2051
|
+
linesGradient,
|
|
2052
|
+
enabledWaves = ["top", "middle", "bottom"],
|
|
2053
|
+
lineCount = 6,
|
|
2054
|
+
lineDistance = 5,
|
|
2055
|
+
topWavePosition,
|
|
2056
|
+
middleWavePosition,
|
|
2057
|
+
bottomWavePosition = { x: 2.0, y: -0.7, rotate: -1 },
|
|
2058
|
+
animationSpeed = 1,
|
|
2059
|
+
interactive = true,
|
|
2060
|
+
bendRadius = 5.0,
|
|
2061
|
+
bendStrength = -0.5,
|
|
2062
|
+
mouseDamping = 0.05,
|
|
2063
|
+
parallax = true,
|
|
2064
|
+
parallaxStrength = 0.2,
|
|
2065
|
+
mixBlendMode = "screen",
|
|
2066
|
+
className,
|
|
2067
|
+
}: FloatingLinesProps) {
|
|
2068
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
2069
|
+
const targetMouseRef = useRef(new Vector2(-1000, -1000));
|
|
2070
|
+
const currentMouseRef = useRef(new Vector2(-1000, -1000));
|
|
2071
|
+
const targetInfluenceRef = useRef(0);
|
|
2072
|
+
const currentInfluenceRef = useRef(0);
|
|
2073
|
+
const targetParallaxRef = useRef(new Vector2(0, 0));
|
|
2074
|
+
const currentParallaxRef = useRef(new Vector2(0, 0));
|
|
2075
|
+
|
|
2076
|
+
const getLineCount = (waveType: WaveType): number => {
|
|
2077
|
+
if (typeof lineCount === "number") return lineCount;
|
|
2078
|
+
if (!enabledWaves.includes(waveType)) return 0;
|
|
2079
|
+
const index = enabledWaves.indexOf(waveType);
|
|
2080
|
+
return lineCount[index] ?? 6;
|
|
2081
|
+
};
|
|
2082
|
+
|
|
2083
|
+
const getLineDistance = (waveType: WaveType): number => {
|
|
2084
|
+
if (typeof lineDistance === "number") return lineDistance;
|
|
2085
|
+
if (!enabledWaves.includes(waveType)) return 0.1;
|
|
2086
|
+
const index = enabledWaves.indexOf(waveType);
|
|
2087
|
+
return lineDistance[index] ?? 0.1;
|
|
2088
|
+
};
|
|
2089
|
+
|
|
2090
|
+
const topLineCount = enabledWaves.includes("top") ? getLineCount("top") : 0;
|
|
2091
|
+
const middleLineCount = enabledWaves.includes("middle") ? getLineCount("middle") : 0;
|
|
2092
|
+
const bottomLineCount = enabledWaves.includes("bottom") ? getLineCount("bottom") : 0;
|
|
2093
|
+
|
|
2094
|
+
const topLineDistance = enabledWaves.includes("top") ? getLineDistance("top") * 0.01 : 0.01;
|
|
2095
|
+
const middleLineDistance = enabledWaves.includes("middle") ? getLineDistance("middle") * 0.01 : 0.01;
|
|
2096
|
+
const bottomLineDistance = enabledWaves.includes("bottom") ? getLineDistance("bottom") * 0.01 : 0.01;
|
|
2097
|
+
|
|
2098
|
+
useEffect(() => {
|
|
2099
|
+
if (!containerRef.current) return;
|
|
2100
|
+
|
|
2101
|
+
const scene = new Scene();
|
|
2102
|
+
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
2103
|
+
camera.position.z = 1;
|
|
2104
|
+
|
|
2105
|
+
const renderer = new WebGLRenderer({ antialias: true, alpha: false });
|
|
2106
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
2107
|
+
renderer.domElement.style.width = "100%";
|
|
2108
|
+
renderer.domElement.style.height = "100%";
|
|
2109
|
+
containerRef.current.appendChild(renderer.domElement);
|
|
2110
|
+
|
|
2111
|
+
const uniforms = {
|
|
2112
|
+
iTime: { value: 0 },
|
|
2113
|
+
iResolution: { value: new Vector3(1, 1, 1) },
|
|
2114
|
+
animationSpeed: { value: animationSpeed },
|
|
2115
|
+
|
|
2116
|
+
enableTop: { value: enabledWaves.includes("top") },
|
|
2117
|
+
enableMiddle: { value: enabledWaves.includes("middle") },
|
|
2118
|
+
enableBottom: { value: enabledWaves.includes("bottom") },
|
|
2119
|
+
|
|
2120
|
+
topLineCount: { value: topLineCount },
|
|
2121
|
+
middleLineCount: { value: middleLineCount },
|
|
2122
|
+
bottomLineCount: { value: bottomLineCount },
|
|
2123
|
+
|
|
2124
|
+
topLineDistance: { value: topLineDistance },
|
|
2125
|
+
middleLineDistance: { value: middleLineDistance },
|
|
2126
|
+
bottomLineDistance: { value: bottomLineDistance },
|
|
2127
|
+
|
|
2128
|
+
topWavePosition: {
|
|
2129
|
+
value: new Vector3(
|
|
2130
|
+
topWavePosition?.x ?? 10.0,
|
|
2131
|
+
topWavePosition?.y ?? 0.5,
|
|
2132
|
+
topWavePosition?.rotate ?? -0.4,
|
|
2133
|
+
),
|
|
2134
|
+
},
|
|
2135
|
+
middleWavePosition: {
|
|
2136
|
+
value: new Vector3(
|
|
2137
|
+
middleWavePosition?.x ?? 5.0,
|
|
2138
|
+
middleWavePosition?.y ?? 0.0,
|
|
2139
|
+
middleWavePosition?.rotate ?? 0.2,
|
|
2140
|
+
),
|
|
2141
|
+
},
|
|
2142
|
+
bottomWavePosition: {
|
|
2143
|
+
value: new Vector3(
|
|
2144
|
+
bottomWavePosition?.x ?? 2.0,
|
|
2145
|
+
bottomWavePosition?.y ?? -0.7,
|
|
2146
|
+
bottomWavePosition?.rotate ?? 0.4,
|
|
2147
|
+
),
|
|
2148
|
+
},
|
|
2149
|
+
|
|
2150
|
+
iMouse: { value: new Vector2(-1000, -1000) },
|
|
2151
|
+
interactive: { value: interactive },
|
|
2152
|
+
bendRadius: { value: bendRadius },
|
|
2153
|
+
bendStrength: { value: bendStrength },
|
|
2154
|
+
bendInfluence: { value: 0 },
|
|
2155
|
+
|
|
2156
|
+
parallax: { value: parallax },
|
|
2157
|
+
parallaxStrength: { value: parallaxStrength },
|
|
2158
|
+
parallaxOffset: { value: new Vector2(0, 0) },
|
|
2159
|
+
|
|
2160
|
+
lineGradient: {
|
|
2161
|
+
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1)),
|
|
2162
|
+
},
|
|
2163
|
+
lineGradientCount: { value: 0 },
|
|
2164
|
+
};
|
|
2165
|
+
|
|
2166
|
+
if (linesGradient && linesGradient.length > 0) {
|
|
2167
|
+
const stops = linesGradient.slice(0, MAX_GRADIENT_STOPS);
|
|
2168
|
+
uniforms.lineGradientCount.value = stops.length;
|
|
2169
|
+
stops.forEach((hex, i) => {
|
|
2170
|
+
const color = hexToVec3(hex);
|
|
2171
|
+
const gradientEntry = uniforms.lineGradient.value[i];
|
|
2172
|
+
if (gradientEntry) gradientEntry.set(color.x, color.y, color.z);
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
const material = new ShaderMaterial({ uniforms, vertexShader, fragmentShader });
|
|
2177
|
+
const geometry = new PlaneGeometry(2, 2);
|
|
2178
|
+
const mesh = new Mesh(geometry, material);
|
|
2179
|
+
scene.add(mesh);
|
|
2180
|
+
|
|
2181
|
+
const clock = new Clock();
|
|
2182
|
+
|
|
2183
|
+
const setSize = () => {
|
|
2184
|
+
const el = containerRef.current;
|
|
2185
|
+
if (!el) return;
|
|
2186
|
+
const width = el.clientWidth || 1;
|
|
2187
|
+
const height = el.clientHeight || 1;
|
|
2188
|
+
renderer.setSize(width, height, false);
|
|
2189
|
+
const cw = renderer.domElement.width;
|
|
2190
|
+
const ch = renderer.domElement.height;
|
|
2191
|
+
uniforms.iResolution.value.set(cw, ch, 1);
|
|
2192
|
+
};
|
|
2193
|
+
|
|
2194
|
+
setSize();
|
|
2195
|
+
|
|
2196
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(setSize) : null;
|
|
2197
|
+
if (ro && containerRef.current) ro.observe(containerRef.current);
|
|
2198
|
+
|
|
2199
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
2200
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
2201
|
+
const x = event.clientX - rect.left;
|
|
2202
|
+
const y = event.clientY - rect.top;
|
|
2203
|
+
const dpr = renderer.getPixelRatio();
|
|
2204
|
+
targetMouseRef.current.set(x * dpr, (rect.height - y) * dpr);
|
|
2205
|
+
targetInfluenceRef.current = 1.0;
|
|
2206
|
+
|
|
2207
|
+
if (parallax) {
|
|
2208
|
+
const cx = rect.width / 2;
|
|
2209
|
+
const cy = rect.height / 2;
|
|
2210
|
+
targetParallaxRef.current.set(
|
|
2211
|
+
((x - cx) / rect.width) * parallaxStrength,
|
|
2212
|
+
(-(y - cy) / rect.height) * parallaxStrength,
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
};
|
|
2216
|
+
|
|
2217
|
+
const handlePointerLeave = () => {
|
|
2218
|
+
targetInfluenceRef.current = 0.0;
|
|
2219
|
+
};
|
|
2220
|
+
|
|
2221
|
+
if (interactive) {
|
|
2222
|
+
renderer.domElement.addEventListener("pointermove", handlePointerMove);
|
|
2223
|
+
renderer.domElement.addEventListener("pointerleave", handlePointerLeave);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
let raf = 0;
|
|
2227
|
+
const renderLoop = () => {
|
|
2228
|
+
uniforms.iTime.value = clock.getElapsedTime();
|
|
2229
|
+
|
|
2230
|
+
if (interactive) {
|
|
2231
|
+
currentMouseRef.current.lerp(targetMouseRef.current, mouseDamping);
|
|
2232
|
+
uniforms.iMouse.value.copy(currentMouseRef.current);
|
|
2233
|
+
currentInfluenceRef.current +=
|
|
2234
|
+
(targetInfluenceRef.current - currentInfluenceRef.current) * mouseDamping;
|
|
2235
|
+
uniforms.bendInfluence.value = currentInfluenceRef.current;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
if (parallax) {
|
|
2239
|
+
currentParallaxRef.current.lerp(targetParallaxRef.current, mouseDamping);
|
|
2240
|
+
uniforms.parallaxOffset.value.copy(currentParallaxRef.current);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
renderer.render(scene, camera);
|
|
2244
|
+
raf = requestAnimationFrame(renderLoop);
|
|
2245
|
+
};
|
|
2246
|
+
renderLoop();
|
|
2247
|
+
|
|
2248
|
+
return () => {
|
|
2249
|
+
cancelAnimationFrame(raf);
|
|
2250
|
+
ro?.disconnect();
|
|
2251
|
+
|
|
2252
|
+
if (interactive) {
|
|
2253
|
+
renderer.domElement.removeEventListener("pointermove", handlePointerMove);
|
|
2254
|
+
renderer.domElement.removeEventListener("pointerleave", handlePointerLeave);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
geometry.dispose();
|
|
2258
|
+
material.dispose();
|
|
2259
|
+
renderer.dispose();
|
|
2260
|
+
if (renderer.domElement.parentElement) {
|
|
2261
|
+
renderer.domElement.parentElement.removeChild(renderer.domElement);
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
}, [
|
|
2265
|
+
linesGradient,
|
|
2266
|
+
enabledWaves,
|
|
2267
|
+
lineCount,
|
|
2268
|
+
lineDistance,
|
|
2269
|
+
topWavePosition,
|
|
2270
|
+
middleWavePosition,
|
|
2271
|
+
bottomWavePosition,
|
|
2272
|
+
animationSpeed,
|
|
2273
|
+
interactive,
|
|
2274
|
+
bendRadius,
|
|
2275
|
+
bendStrength,
|
|
2276
|
+
mouseDamping,
|
|
2277
|
+
parallax,
|
|
2278
|
+
parallaxStrength,
|
|
2279
|
+
topLineCount,
|
|
2280
|
+
middleLineCount,
|
|
2281
|
+
bottomLineCount,
|
|
2282
|
+
topLineDistance,
|
|
2283
|
+
middleLineDistance,
|
|
2284
|
+
bottomLineDistance,
|
|
2285
|
+
]);
|
|
2286
|
+
|
|
2287
|
+
return (
|
|
2288
|
+
<div
|
|
2289
|
+
ref={containerRef}
|
|
2290
|
+
className={className}
|
|
2291
|
+
style={{ mixBlendMode, position: "absolute", inset: 0, width: "100%", height: "100%" }}
|
|
2292
|
+
/>
|
|
2293
|
+
);
|
|
2294
|
+
}`,
|
|
2295
|
+
"Input": `import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
|
|
2296
|
+
import { cn } from "@/lib/utils";
|
|
2297
|
+
|
|
2298
|
+
// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2299
|
+
|
|
2300
|
+
export type InputSize = "sm" | "md" | "lg";
|
|
2301
|
+
export type InputVariant = "default" | "filled" | "ghost";
|
|
2302
|
+
|
|
2303
|
+
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
|
2304
|
+
label?: string;
|
|
2305
|
+
description?: string;
|
|
2306
|
+
error?: string;
|
|
2307
|
+
size?: InputSize;
|
|
2308
|
+
variant?: InputVariant;
|
|
2309
|
+
leadingIcon?: ReactNode;
|
|
2310
|
+
trailingIcon?: ReactNode;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// \u2500\u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2314
|
+
|
|
2315
|
+
const SIZE_CLASSES: Record<InputSize, { input: string; label: string; icon: string }> = {
|
|
2316
|
+
sm: { input: "h-8 px-3 text-xs", label: "text-xs", icon: "h-3.5 w-3.5" },
|
|
2317
|
+
md: { input: "h-10 px-3.5 text-sm", label: "text-sm", icon: "h-4 w-4" },
|
|
2318
|
+
lg: { input: "h-12 px-4 text-base", label: "text-sm", icon: "h-4.5 w-4.5" },
|
|
2319
|
+
};
|
|
2320
|
+
|
|
2321
|
+
const VARIANT_BASE: Record<InputVariant, string> = {
|
|
2322
|
+
default:
|
|
2323
|
+
"border border-slate-200 dark:border-[#1f2937] bg-white dark:bg-[#0d1117] " +
|
|
2324
|
+
"hover:border-slate-300 dark:hover:border-slate-600 " +
|
|
2325
|
+
"focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20",
|
|
2326
|
+
filled:
|
|
2327
|
+
"border border-transparent bg-slate-100 dark:bg-[#161b22] " +
|
|
2328
|
+
"hover:bg-slate-150 dark:hover:bg-[#1f2937] " +
|
|
2329
|
+
"focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 focus-within:bg-white dark:focus-within:bg-[#0d1117]",
|
|
2330
|
+
ghost:
|
|
2331
|
+
"border border-transparent bg-transparent " +
|
|
2332
|
+
"hover:bg-slate-50 dark:hover:bg-[#161b22] " +
|
|
2333
|
+
"focus-within:border-primary dark:focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20",
|
|
2334
|
+
};
|
|
2335
|
+
|
|
2336
|
+
const ERROR_OVERRIDE =
|
|
2337
|
+
"border-red-400 dark:border-red-500 focus-within:border-red-400 dark:focus-within:border-red-500 focus-within:ring-red-400/20";
|
|
2338
|
+
|
|
2339
|
+
// \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2340
|
+
|
|
2341
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(function InputRoot(
|
|
2342
|
+
{
|
|
2343
|
+
label,
|
|
2344
|
+
description,
|
|
2345
|
+
error,
|
|
2346
|
+
size = "md",
|
|
2347
|
+
variant = "default",
|
|
2348
|
+
leadingIcon,
|
|
2349
|
+
trailingIcon,
|
|
2350
|
+
disabled,
|
|
2351
|
+
className,
|
|
2352
|
+
id,
|
|
2353
|
+
...rest
|
|
2354
|
+
},
|
|
2355
|
+
ref
|
|
2356
|
+
) {
|
|
2357
|
+
const s = SIZE_CLASSES[size];
|
|
2358
|
+
const inputId = id ?? (label ? label.toLowerCase().replace(/\\s+/g, "-") : undefined);
|
|
2359
|
+
|
|
2360
|
+
return (
|
|
2361
|
+
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
2362
|
+
{label && (
|
|
2363
|
+
<label
|
|
2364
|
+
htmlFor={inputId}
|
|
2365
|
+
className={cn(
|
|
2366
|
+
"font-medium text-slate-700 dark:text-slate-300",
|
|
2367
|
+
s.label,
|
|
2368
|
+
disabled && "opacity-50"
|
|
2369
|
+
)}
|
|
2370
|
+
>
|
|
2371
|
+
{label}
|
|
2372
|
+
</label>
|
|
2373
|
+
)}
|
|
2374
|
+
|
|
2375
|
+
<div
|
|
2376
|
+
className={cn(
|
|
2377
|
+
"relative flex items-center rounded-lg transition-all",
|
|
2378
|
+
VARIANT_BASE[variant],
|
|
2379
|
+
error && ERROR_OVERRIDE,
|
|
2380
|
+
disabled && "opacity-50 cursor-not-allowed pointer-events-none"
|
|
2381
|
+
)}
|
|
2382
|
+
>
|
|
2383
|
+
{leadingIcon && (
|
|
2384
|
+
<span className={cn("absolute left-3 flex shrink-0 items-center text-slate-400", s.icon)}>
|
|
2385
|
+
{leadingIcon}
|
|
2386
|
+
</span>
|
|
2387
|
+
)}
|
|
2388
|
+
|
|
2389
|
+
<input
|
|
2390
|
+
ref={ref}
|
|
2391
|
+
id={inputId}
|
|
2392
|
+
disabled={disabled}
|
|
2393
|
+
className={cn(
|
|
2394
|
+
"w-full bg-transparent outline-hidden text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500",
|
|
2395
|
+
s.input,
|
|
2396
|
+
leadingIcon && (size === "sm" ? "pl-8" : size === "lg" ? "pl-10" : "pl-9"),
|
|
2397
|
+
trailingIcon && (size === "sm" ? "pr-8" : size === "lg" ? "pr-10" : "pr-9")
|
|
2398
|
+
)}
|
|
2399
|
+
{...rest}
|
|
2400
|
+
/>
|
|
2401
|
+
|
|
2402
|
+
{trailingIcon && (
|
|
2403
|
+
<span className={cn("absolute right-3 flex shrink-0 items-center text-slate-400", s.icon)}>
|
|
2404
|
+
{trailingIcon}
|
|
2405
|
+
</span>
|
|
2406
|
+
)}
|
|
2407
|
+
</div>
|
|
2408
|
+
|
|
2409
|
+
{description && !error && (
|
|
2410
|
+
<p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>
|
|
2411
|
+
)}
|
|
2412
|
+
{error && (
|
|
2413
|
+
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
|
|
2414
|
+
)}
|
|
2415
|
+
</div>
|
|
2416
|
+
);
|
|
2417
|
+
});`,
|
|
2418
|
+
"Pagination": `import { cn } from "@/lib/utils";
|
|
2419
|
+
|
|
2420
|
+
type PaginationToken = number | "ellipsis";
|
|
2421
|
+
|
|
2422
|
+
export interface PaginationProps {
|
|
2423
|
+
page: number;
|
|
2424
|
+
totalPages: number;
|
|
2425
|
+
onPageChange: (page: number) => void;
|
|
2426
|
+
siblingCount?: number;
|
|
2427
|
+
className?: string;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function buildRange(page: number, totalPages: number, siblingCount: number): PaginationToken[] {
|
|
2431
|
+
const pages: PaginationToken[] = [];
|
|
2432
|
+
const totalButtons = siblingCount * 2 + 5;
|
|
2433
|
+
|
|
2434
|
+
if (totalPages <= totalButtons) {
|
|
2435
|
+
for (let i = 1; i <= totalPages; i += 1) pages.push(i);
|
|
2436
|
+
return pages;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
const leftSibling = Math.max(page - siblingCount, 1);
|
|
2440
|
+
const rightSibling = Math.min(page + siblingCount, totalPages);
|
|
2441
|
+
const showLeftEllipsis = leftSibling > 2;
|
|
2442
|
+
const showRightEllipsis = rightSibling < totalPages - 1;
|
|
2443
|
+
|
|
2444
|
+
pages.push(1);
|
|
2445
|
+
|
|
2446
|
+
if (showLeftEllipsis) {
|
|
2447
|
+
pages.push("ellipsis");
|
|
2448
|
+
} else {
|
|
2449
|
+
for (let i = 2; i < leftSibling; i += 1) pages.push(i);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
for (let i = leftSibling; i <= rightSibling; i += 1) {
|
|
2453
|
+
if (i !== 1 && i !== totalPages) pages.push(i);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
if (showRightEllipsis) {
|
|
2457
|
+
pages.push("ellipsis");
|
|
2458
|
+
} else {
|
|
2459
|
+
for (let i = rightSibling + 1; i < totalPages; i += 1) pages.push(i);
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
if (totalPages > 1) pages.push(totalPages);
|
|
2463
|
+
|
|
2464
|
+
return pages;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
export function Pagination({ page, totalPages, onPageChange, siblingCount = 1, className }: PaginationProps) {
|
|
2468
|
+
const clampedPage = Math.min(Math.max(page, 1), Math.max(totalPages, 1));
|
|
2469
|
+
const range = buildRange(clampedPage, totalPages, siblingCount);
|
|
2470
|
+
|
|
2471
|
+
return (
|
|
2472
|
+
<nav aria-label="Pagination" className={cn("inline-flex items-center gap-1", className)}>
|
|
2473
|
+
<button
|
|
2474
|
+
type="button"
|
|
2475
|
+
onClick={() => onPageChange(clampedPage - 1)}
|
|
2476
|
+
disabled={clampedPage <= 1}
|
|
2477
|
+
className="rounded-md border border-slate-200 px-2.5 py-1.5 text-xs text-slate-700 transition-all hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-[#1f2937] dark:text-slate-200 dark:hover:bg-[#161b22]"
|
|
2478
|
+
>
|
|
2479
|
+
Prev
|
|
2480
|
+
</button>
|
|
2481
|
+
|
|
2482
|
+
{range.map((token, index) =>
|
|
2483
|
+
token === "ellipsis" ? (
|
|
2484
|
+
<span key={\`ellipsis-\${index}\`} className="px-1 text-xs text-slate-400">
|
|
2485
|
+
...
|
|
2486
|
+
</span>
|
|
2487
|
+
) : (
|
|
2488
|
+
<button
|
|
2489
|
+
key={token}
|
|
2490
|
+
type="button"
|
|
2491
|
+
onClick={() => onPageChange(token)}
|
|
2492
|
+
aria-current={token === clampedPage ? "page" : undefined}
|
|
2493
|
+
className={cn(
|
|
2494
|
+
"min-w-8 rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
|
|
2495
|
+
token === clampedPage
|
|
2496
|
+
? "bg-primary text-white"
|
|
2497
|
+
: "border border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-[#1f2937] dark:text-slate-200 dark:hover:bg-[#161b22]"
|
|
2498
|
+
)}
|
|
2499
|
+
>
|
|
2500
|
+
{token}
|
|
2501
|
+
</button>
|
|
2502
|
+
)
|
|
2503
|
+
)}
|
|
2504
|
+
|
|
2505
|
+
<button
|
|
2506
|
+
type="button"
|
|
2507
|
+
onClick={() => onPageChange(clampedPage + 1)}
|
|
2508
|
+
disabled={clampedPage >= totalPages}
|
|
2509
|
+
className="rounded-md border border-slate-200 px-2.5 py-1.5 text-xs text-slate-700 transition-all hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-[#1f2937] dark:text-slate-200 dark:hover:bg-[#161b22]"
|
|
2510
|
+
>
|
|
2511
|
+
Next
|
|
2512
|
+
</button>
|
|
2513
|
+
</nav>
|
|
2514
|
+
);
|
|
2515
|
+
}`,
|
|
2516
|
+
"Popover": `import { createContext, useCallback, useContext, useEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from "react";
|
|
2517
|
+
import { cn } from "@/lib/utils";
|
|
2518
|
+
|
|
2519
|
+
type PopoverSide = "top" | "bottom";
|
|
2520
|
+
type PopoverAlign = "start" | "center" | "end";
|
|
2521
|
+
|
|
2522
|
+
interface PopoverContextValue {
|
|
2523
|
+
open: boolean;
|
|
2524
|
+
setOpen: (open: boolean) => void;
|
|
2525
|
+
side: PopoverSide;
|
|
2526
|
+
align: PopoverAlign;
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
const PopoverContext = createContext<PopoverContextValue | null>(null);
|
|
2530
|
+
|
|
2531
|
+
function usePopoverContext() {
|
|
2532
|
+
const context = useContext(PopoverContext);
|
|
2533
|
+
if (!context) throw new Error("Popover sub-components must be used inside <Popover>");
|
|
2534
|
+
return context;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
export interface PopoverProps {
|
|
2538
|
+
open?: boolean;
|
|
2539
|
+
defaultOpen?: boolean;
|
|
2540
|
+
onOpenChange?: (open: boolean) => void;
|
|
2541
|
+
side?: PopoverSide;
|
|
2542
|
+
align?: PopoverAlign;
|
|
2543
|
+
children: ReactNode;
|
|
2544
|
+
className?: string;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
export interface PopoverTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
2548
|
+
children: ReactNode;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
export interface PopoverContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
2552
|
+
children: ReactNode;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
export interface PopoverCloseProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
2556
|
+
children?: ReactNode;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
export function Popover({
|
|
2560
|
+
open,
|
|
2561
|
+
defaultOpen = false,
|
|
2562
|
+
onOpenChange,
|
|
2563
|
+
side = "bottom",
|
|
2564
|
+
align = "start",
|
|
2565
|
+
children,
|
|
2566
|
+
className,
|
|
2567
|
+
}: PopoverProps) {
|
|
2568
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
2569
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
2570
|
+
const isControlled = open !== undefined;
|
|
2571
|
+
const isOpen = isControlled ? open : internalOpen;
|
|
2572
|
+
|
|
2573
|
+
const setOpen = useCallback((next: boolean) => {
|
|
2574
|
+
if (!isControlled) setInternalOpen(next);
|
|
2575
|
+
onOpenChange?.(next);
|
|
2576
|
+
}, [isControlled, onOpenChange]);
|
|
2577
|
+
|
|
2578
|
+
useEffect(() => {
|
|
2579
|
+
if (!isOpen) return;
|
|
2580
|
+
|
|
2581
|
+
const handleOutside = (event: MouseEvent) => {
|
|
2582
|
+
if (!containerRef.current?.contains(event.target as Node)) {
|
|
2583
|
+
setOpen(false);
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2586
|
+
|
|
2587
|
+
const handleEscape = (event: KeyboardEvent) => {
|
|
2588
|
+
if (event.key === "Escape") setOpen(false);
|
|
2589
|
+
};
|
|
2590
|
+
|
|
2591
|
+
window.addEventListener("mousedown", handleOutside);
|
|
2592
|
+
window.addEventListener("keydown", handleEscape);
|
|
2593
|
+
|
|
2594
|
+
return () => {
|
|
2595
|
+
window.removeEventListener("mousedown", handleOutside);
|
|
2596
|
+
window.removeEventListener("keydown", handleEscape);
|
|
2597
|
+
};
|
|
2598
|
+
}, [isOpen, setOpen]);
|
|
2599
|
+
|
|
2600
|
+
return (
|
|
2601
|
+
<PopoverContext.Provider value={{ open: isOpen, setOpen, side, align }}>
|
|
2602
|
+
<div ref={containerRef} className={cn("relative inline-block", className)}>
|
|
2603
|
+
{children}
|
|
2604
|
+
</div>
|
|
2605
|
+
</PopoverContext.Provider>
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
Popover.Trigger = function PopoverTrigger({ children, className, onClick, ...props }: PopoverTriggerProps) {
|
|
2610
|
+
const { open, setOpen } = usePopoverContext();
|
|
2611
|
+
|
|
2612
|
+
return (
|
|
2613
|
+
<button
|
|
2614
|
+
type="button"
|
|
2615
|
+
onClick={(event) => {
|
|
2616
|
+
onClick?.(event);
|
|
2617
|
+
setOpen(!open);
|
|
2618
|
+
}}
|
|
2619
|
+
className={cn(
|
|
2620
|
+
"inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 transition-all",
|
|
2621
|
+
"hover:bg-slate-50 dark:border-[#1f2937] dark:text-slate-200 dark:hover:bg-[#161b22]",
|
|
2622
|
+
className
|
|
2623
|
+
)}
|
|
2624
|
+
{...props}
|
|
2625
|
+
>
|
|
2626
|
+
{children}
|
|
2627
|
+
</button>
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
const SIDE_CLASS: Record<PopoverSide, string> = {
|
|
2632
|
+
top: "bottom-[calc(100%+8px)]",
|
|
2633
|
+
bottom: "top-[calc(100%+8px)]",
|
|
2634
|
+
};
|
|
2635
|
+
|
|
2636
|
+
const ALIGN_CLASS: Record<PopoverAlign, string> = {
|
|
2637
|
+
start: "left-0",
|
|
2638
|
+
center: "left-1/2 -translate-x-1/2",
|
|
2639
|
+
end: "right-0",
|
|
2640
|
+
};
|
|
2641
|
+
|
|
2642
|
+
Popover.Content = function PopoverContent({ children, className, ...props }: PopoverContentProps) {
|
|
2643
|
+
const { open, side, align } = usePopoverContext();
|
|
2644
|
+
|
|
2645
|
+
if (!open) return null;
|
|
2646
|
+
|
|
2647
|
+
return (
|
|
2648
|
+
<div
|
|
2649
|
+
className={cn(
|
|
2650
|
+
"absolute z-50 w-72 rounded-xl border border-slate-200 bg-white p-4 shadow-xl dark:border-[#1f2937] dark:bg-[#161b22]",
|
|
2651
|
+
SIDE_CLASS[side],
|
|
2652
|
+
ALIGN_CLASS[align],
|
|
2653
|
+
className
|
|
2654
|
+
)}
|
|
2655
|
+
{...props}
|
|
2656
|
+
>
|
|
2657
|
+
{children}
|
|
2658
|
+
</div>
|
|
2659
|
+
);
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
Popover.Close = function PopoverClose({ children = "Close", className, onClick, ...props }: PopoverCloseProps) {
|
|
2663
|
+
const { setOpen } = usePopoverContext();
|
|
2664
|
+
|
|
2665
|
+
return (
|
|
2666
|
+
<button
|
|
2667
|
+
type="button"
|
|
2668
|
+
onClick={(event) => {
|
|
2669
|
+
onClick?.(event);
|
|
2670
|
+
setOpen(false);
|
|
2671
|
+
}}
|
|
2672
|
+
className={cn("text-sm font-medium text-primary hover:underline", className)}
|
|
2673
|
+
{...props}
|
|
2674
|
+
>
|
|
2675
|
+
{children}
|
|
2676
|
+
</button>
|
|
2677
|
+
);
|
|
2678
|
+
}`,
|
|
2679
|
+
"Progress": `import type { HTMLAttributes } from "react";
|
|
2680
|
+
import { cn } from "@/lib/utils";
|
|
2681
|
+
|
|
2682
|
+
export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
|
|
2683
|
+
value: number;
|
|
2684
|
+
max?: number;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
export function Progress({ value, max = 100, className, ...props }: ProgressProps) {
|
|
2688
|
+
const safeMax = max > 0 ? max : 100;
|
|
2689
|
+
const clamped = Math.min(Math.max(value, 0), safeMax);
|
|
2690
|
+
const percentage = (clamped / safeMax) * 100;
|
|
2691
|
+
|
|
2692
|
+
return (
|
|
2693
|
+
<div
|
|
2694
|
+
role="progressbar"
|
|
2695
|
+
aria-valuemin={0}
|
|
2696
|
+
aria-valuemax={safeMax}
|
|
2697
|
+
aria-valuenow={Math.round(clamped)}
|
|
2698
|
+
className={cn("h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-[#1f2937]", className)}
|
|
2699
|
+
{...props}
|
|
2700
|
+
>
|
|
2701
|
+
<div
|
|
2702
|
+
className="h-full rounded-full bg-primary transition-all duration-300"
|
|
2703
|
+
style={{ width: \`\${percentage}%\` }}
|
|
2704
|
+
/>
|
|
2705
|
+
</div>
|
|
2706
|
+
);
|
|
2707
|
+
}`,
|
|
2708
|
+
"ProgressBar": `import { cn } from "@/lib/utils";
|
|
2709
|
+
|
|
2710
|
+
// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2711
|
+
|
|
2712
|
+
export interface ProgressBarProps {
|
|
2713
|
+
/** Current progress 0\u2013100 */
|
|
2714
|
+
progress: number;
|
|
2715
|
+
/** Whether the bar is visible (false triggers fade-out) */
|
|
2716
|
+
visible: boolean;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// \u2500\u2500\u2500 Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2720
|
+
|
|
2721
|
+
/**
|
|
2722
|
+
* Thin top-of-page progress bar for navigation feedback.
|
|
2723
|
+
* Pair with \`useProgressBar\` hook.
|
|
2724
|
+
*/
|
|
2725
|
+
export function ProgressBar({ progress, visible }: ProgressBarProps) {
|
|
2726
|
+
return (
|
|
2727
|
+
<div
|
|
2728
|
+
role="progressbar"
|
|
2729
|
+
aria-valuemin={0}
|
|
2730
|
+
aria-valuemax={100}
|
|
2731
|
+
aria-valuenow={progress}
|
|
2732
|
+
aria-hidden="true"
|
|
2733
|
+
className={cn(
|
|
2734
|
+
"pointer-events-none fixed left-0 top-0 z-200 h-0.5 bg-primary",
|
|
2735
|
+
// Glow effect matching the primary color
|
|
2736
|
+
"shadow-[0_0_8px_1px_rgba(70,40,241,0.7)]",
|
|
2737
|
+
"transition-opacity duration-300",
|
|
2738
|
+
visible ? "opacity-100" : "opacity-0"
|
|
2739
|
+
)}
|
|
2740
|
+
style={{
|
|
2741
|
+
width: \`\${progress}%\`,
|
|
2742
|
+
// Instant reset to 0, ease-out for forward progress
|
|
2743
|
+
transition: \`width \${progress === 0 ? "0ms" : progress === 100 ? "150ms" : "350ms"} ease-out, opacity 300ms\`,
|
|
2744
|
+
}}
|
|
2745
|
+
/>
|
|
2746
|
+
);
|
|
2747
|
+
}`,
|
|
2748
|
+
"RadioGroup": `import { createContext, useContext, useId, useState, type HTMLAttributes, type ReactNode } from "react";
|
|
2749
|
+
import { cn } from "@/lib/utils";
|
|
2750
|
+
|
|
2751
|
+
export interface RadioGroupProps extends HTMLAttributes<HTMLDivElement> {
|
|
2752
|
+
value?: string;
|
|
2753
|
+
defaultValue?: string;
|
|
2754
|
+
onValueChange?: (value: string) => void;
|
|
2755
|
+
name?: string;
|
|
2756
|
+
children: ReactNode;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
export interface RadioGroupItemProps extends HTMLAttributes<HTMLButtonElement> {
|
|
2760
|
+
value: string;
|
|
2761
|
+
disabled?: boolean;
|
|
2762
|
+
label?: string;
|
|
2763
|
+
description?: string;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
interface RadioGroupContextValue {
|
|
2767
|
+
value: string;
|
|
2768
|
+
name: string;
|
|
2769
|
+
setValue: (value: string) => void;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
|
|
2773
|
+
|
|
2774
|
+
function useRadioGroupContext() {
|
|
2775
|
+
const context = useContext(RadioGroupContext);
|
|
2776
|
+
if (!context) {
|
|
2777
|
+
throw new Error("RadioGroup sub-components must be used inside <RadioGroup>");
|
|
2778
|
+
}
|
|
2779
|
+
return context;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
export function RadioGroup({
|
|
2783
|
+
value,
|
|
2784
|
+
defaultValue = "",
|
|
2785
|
+
onValueChange,
|
|
2786
|
+
name,
|
|
2787
|
+
children,
|
|
2788
|
+
className,
|
|
2789
|
+
...props
|
|
2790
|
+
}: RadioGroupProps) {
|
|
2791
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
2792
|
+
const autoName = useId();
|
|
2793
|
+
const isControlled = value !== undefined;
|
|
2794
|
+
const active = isControlled ? value : internalValue;
|
|
2795
|
+
|
|
2796
|
+
const setValue = (next: string) => {
|
|
2797
|
+
if (!isControlled) setInternalValue(next);
|
|
2798
|
+
onValueChange?.(next);
|
|
2799
|
+
};
|
|
2800
|
+
|
|
2801
|
+
return (
|
|
2802
|
+
<RadioGroupContext.Provider value={{ value: active, setValue, name: name ?? autoName }}>
|
|
2803
|
+
<div className={cn("space-y-2", className)} role="radiogroup" {...props}>
|
|
2804
|
+
{children}
|
|
2805
|
+
</div>
|
|
2806
|
+
</RadioGroupContext.Provider>
|
|
2807
|
+
);
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
RadioGroup.Item = function RadioGroupItem({
|
|
2811
|
+
value,
|
|
2812
|
+
disabled = false,
|
|
2813
|
+
label,
|
|
2814
|
+
description,
|
|
2815
|
+
children,
|
|
2816
|
+
className,
|
|
2817
|
+
...props
|
|
2818
|
+
}: RadioGroupItemProps) {
|
|
2819
|
+
const context = useRadioGroupContext();
|
|
2820
|
+
const checked = context.value === value;
|
|
2821
|
+
|
|
2822
|
+
return (
|
|
2823
|
+
<button
|
|
2824
|
+
type="button"
|
|
2825
|
+
role="radio"
|
|
2826
|
+
aria-checked={checked}
|
|
2827
|
+
disabled={disabled}
|
|
2828
|
+
onClick={() => !disabled && context.setValue(value)}
|
|
2829
|
+
className={cn(
|
|
2830
|
+
"flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-all",
|
|
2831
|
+
checked
|
|
2832
|
+
? "border-primary bg-primary/5"
|
|
2833
|
+
: "border-slate-200 bg-white hover:border-slate-300 dark:border-[#1f2937] dark:bg-[#0d1117] dark:hover:border-slate-600",
|
|
2834
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
2835
|
+
className
|
|
2836
|
+
)}
|
|
2837
|
+
{...props}
|
|
2838
|
+
>
|
|
2839
|
+
<span
|
|
2840
|
+
className={cn(
|
|
2841
|
+
"mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
|
|
2842
|
+
checked ? "border-primary" : "border-slate-400 dark:border-slate-500"
|
|
2843
|
+
)}
|
|
2844
|
+
>
|
|
2845
|
+
{checked && <span className="h-2 w-2 rounded-full bg-primary" />}
|
|
2846
|
+
</span>
|
|
2847
|
+
<span className="min-w-0 flex-1">
|
|
2848
|
+
{label && <span className="block text-sm font-medium text-slate-900 dark:text-white">{label}</span>}
|
|
2849
|
+
{description && <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{description}</span>}
|
|
2850
|
+
{children}
|
|
2851
|
+
</span>
|
|
2852
|
+
<input readOnly tabIndex={-1} type="radio" name={context.name} value={value} checked={checked} className="sr-only" />
|
|
2853
|
+
</button>
|
|
2854
|
+
);
|
|
2855
|
+
}`,
|
|
2856
|
+
"SearchDialog": `import { useEffect, useRef, useState } from "react";
|
|
2857
|
+
import { useAnimatedMount } from "@/hooks/use-animated-mount";
|
|
2858
|
+
import { cn } from "@/lib/utils";
|
|
2859
|
+
|
|
2860
|
+
export interface SearchItem {
|
|
2861
|
+
title: string;
|
|
2862
|
+
href: string;
|
|
2863
|
+
description?: string;
|
|
2864
|
+
group: "Docs" | "Cookbook";
|
|
2865
|
+
section?: string;
|
|
2866
|
+
icon?: string;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
interface SearchDialogProps {
|
|
2870
|
+
open: boolean;
|
|
2871
|
+
items: SearchItem[];
|
|
2872
|
+
onClose: () => void;
|
|
2873
|
+
onNavigate: (href: string) => void;
|
|
2874
|
+
savedSlugs?: string[];
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
export function SearchDialog({ open, items, onClose, onNavigate, savedSlugs = [] }: SearchDialogProps) {
|
|
2878
|
+
const [query, setQuery] = useState("");
|
|
2879
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
2880
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
2881
|
+
const { mounted, visible } = useAnimatedMount(open, 150);
|
|
2882
|
+
|
|
2883
|
+
const results = query.trim()
|
|
2884
|
+
? items.filter(
|
|
2885
|
+
(item) =>
|
|
2886
|
+
item.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
2887
|
+
item.description?.toLowerCase().includes(query.toLowerCase()),
|
|
2888
|
+
)
|
|
2889
|
+
: items.slice(0, 8);
|
|
2890
|
+
|
|
2891
|
+
const docsResults = results.filter((r) => r.group === "Docs");
|
|
2892
|
+
const cookbookResults = results.filter((r) => r.group === "Cookbook");
|
|
2893
|
+
|
|
2894
|
+
// Focus input and reset state on open
|
|
2895
|
+
useEffect(() => {
|
|
2896
|
+
if (open) {
|
|
2897
|
+
setQuery("");
|
|
2898
|
+
setActiveIndex(0);
|
|
2899
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
2900
|
+
}
|
|
2901
|
+
}, [open]);
|
|
2902
|
+
|
|
2903
|
+
// Reset active index when results change
|
|
2904
|
+
useEffect(() => {
|
|
2905
|
+
setActiveIndex(0);
|
|
2906
|
+
}, [query]);
|
|
2907
|
+
|
|
2908
|
+
// Keyboard navigation
|
|
2909
|
+
useEffect(() => {
|
|
2910
|
+
if (!open) return;
|
|
2911
|
+
const handler = (e: KeyboardEvent) => {
|
|
2912
|
+
if (e.key === "ArrowDown") {
|
|
2913
|
+
e.preventDefault();
|
|
2914
|
+
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
|
2915
|
+
} else if (e.key === "ArrowUp") {
|
|
2916
|
+
e.preventDefault();
|
|
2917
|
+
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
2918
|
+
} else if (e.key === "Enter") {
|
|
2919
|
+
const item = results[activeIndex];
|
|
2920
|
+
if (item) {
|
|
2921
|
+
onNavigate(item.href);
|
|
2922
|
+
onClose();
|
|
2923
|
+
}
|
|
2924
|
+
} else if (e.key === "Escape") {
|
|
2925
|
+
onClose();
|
|
2926
|
+
}
|
|
2927
|
+
};
|
|
2928
|
+
window.addEventListener("keydown", handler);
|
|
2929
|
+
return () => window.removeEventListener("keydown", handler);
|
|
2930
|
+
}, [open, results, activeIndex, onNavigate, onClose]);
|
|
2931
|
+
|
|
2932
|
+
if (!mounted) return null;
|
|
2933
|
+
|
|
2934
|
+
return (
|
|
2935
|
+
<div
|
|
2936
|
+
className={cn(
|
|
2937
|
+
"fixed inset-0 z-50 flex items-start justify-center pt-[15vh] transition-opacity duration-150",
|
|
2938
|
+
visible ? "opacity-100" : "opacity-0",
|
|
2939
|
+
)}
|
|
2940
|
+
>
|
|
2941
|
+
{/* Backdrop */}
|
|
2942
|
+
<div
|
|
2943
|
+
className="absolute inset-0 bg-black/50 backdrop-blur-xs"
|
|
2944
|
+
onClick={onClose}
|
|
2945
|
+
/>
|
|
2946
|
+
|
|
2947
|
+
{/* Panel */}
|
|
2948
|
+
<div
|
|
2949
|
+
className={cn(
|
|
2950
|
+
"relative z-10 mx-4 w-full max-w-xl rounded-xl border border-slate-200 dark:border-[#1f2937] bg-white dark:bg-[#161b22] shadow-2xl transition-all duration-150",
|
|
2951
|
+
visible ? "scale-100 opacity-100" : "scale-95 opacity-0",
|
|
2952
|
+
)}
|
|
2953
|
+
>
|
|
2954
|
+
{/* Input */}
|
|
2955
|
+
<div className="flex items-center gap-3 border-b border-slate-100 dark:border-[#1f2937] px-4 py-3.5">
|
|
2956
|
+
<span className="material-symbols-outlined text-[20px] text-slate-400">
|
|
2957
|
+
search
|
|
2958
|
+
</span>
|
|
2959
|
+
<input
|
|
2960
|
+
ref={inputRef}
|
|
2961
|
+
value={query}
|
|
2962
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
2963
|
+
placeholder="Search docs, components, patterns..."
|
|
2964
|
+
className="flex-1 bg-transparent text-sm text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-hidden"
|
|
2965
|
+
/>
|
|
2966
|
+
{query ? (
|
|
2967
|
+
<button
|
|
2968
|
+
onClick={() => setQuery("")}
|
|
2969
|
+
className="text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-200"
|
|
2970
|
+
>
|
|
2971
|
+
<span className="material-symbols-outlined text-[18px]">close</span>
|
|
2972
|
+
</button>
|
|
2973
|
+
) : (
|
|
2974
|
+
<kbd className="hidden rounded-sm border border-slate-200 dark:border-[#1f2937] px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-flex">
|
|
2975
|
+
ESC
|
|
2976
|
+
</kbd>
|
|
2977
|
+
)}
|
|
2978
|
+
</div>
|
|
2979
|
+
|
|
2980
|
+
{/* Results */}
|
|
2981
|
+
<div className="max-h-[55vh] overflow-y-auto p-2">
|
|
2982
|
+
{results.length === 0 ? (
|
|
2983
|
+
<div className="flex flex-col items-center py-12 text-slate-400">
|
|
2984
|
+
<span className="material-symbols-outlined mb-2 text-[40px]">
|
|
2985
|
+
search_off
|
|
2986
|
+
</span>
|
|
2987
|
+
<p className="text-sm">No results for “{query}”</p>
|
|
2988
|
+
</div>
|
|
2989
|
+
) : (
|
|
2990
|
+
<>
|
|
2991
|
+
{docsResults.length > 0 && (
|
|
2992
|
+
<ResultGroup
|
|
2993
|
+
title="Docs"
|
|
2994
|
+
items={docsResults}
|
|
2995
|
+
allResults={results}
|
|
2996
|
+
query={query}
|
|
2997
|
+
activeIndex={activeIndex}
|
|
2998
|
+
savedSlugs={savedSlugs}
|
|
2999
|
+
onSelect={(href) => {
|
|
3000
|
+
onNavigate(href);
|
|
3001
|
+
onClose();
|
|
3002
|
+
}}
|
|
3003
|
+
onHover={setActiveIndex}
|
|
3004
|
+
/>
|
|
3005
|
+
)}
|
|
3006
|
+
{cookbookResults.length > 0 && (
|
|
3007
|
+
<ResultGroup
|
|
3008
|
+
title="Cookbook"
|
|
3009
|
+
items={cookbookResults}
|
|
3010
|
+
allResults={results}
|
|
3011
|
+
query={query}
|
|
3012
|
+
activeIndex={activeIndex}
|
|
3013
|
+
savedSlugs={savedSlugs}
|
|
3014
|
+
onSelect={(href) => {
|
|
3015
|
+
onNavigate(href);
|
|
3016
|
+
onClose();
|
|
3017
|
+
}}
|
|
3018
|
+
onHover={setActiveIndex}
|
|
3019
|
+
/>
|
|
3020
|
+
)}
|
|
3021
|
+
</>
|
|
3022
|
+
)}
|
|
3023
|
+
</div>
|
|
3024
|
+
|
|
3025
|
+
{/* Footer */}
|
|
3026
|
+
<div className="flex items-center justify-between border-t border-slate-100 dark:border-[#1f2937] px-4 py-2.5 text-[11px] text-slate-400">
|
|
3027
|
+
<div className="flex items-center gap-3">
|
|
3028
|
+
<span className="flex items-center gap-1">
|
|
3029
|
+
<kbd className="rounded-sm border border-slate-200 dark:border-[#2d3748] px-1 py-0.5 font-mono">
|
|
3030
|
+
\u2191
|
|
3031
|
+
</kbd>
|
|
3032
|
+
<kbd className="rounded-sm border border-slate-200 dark:border-[#2d3748] px-1 py-0.5 font-mono">
|
|
3033
|
+
\u2193
|
|
3034
|
+
</kbd>
|
|
3035
|
+
navigate
|
|
3036
|
+
</span>
|
|
3037
|
+
<span className="flex items-center gap-1">
|
|
3038
|
+
<kbd className="rounded-sm border border-slate-200 dark:border-[#2d3748] px-1 py-0.5 font-mono">
|
|
3039
|
+
\u21B5
|
|
3040
|
+
</kbd>
|
|
3041
|
+
select
|
|
3042
|
+
</span>
|
|
3043
|
+
</div>
|
|
3044
|
+
<span>
|
|
3045
|
+
{results.length} result{results.length !== 1 ? "s" : ""}
|
|
3046
|
+
</span>
|
|
3047
|
+
</div>
|
|
3048
|
+
</div>
|
|
3049
|
+
</div>
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// --- helpers ---
|
|
3054
|
+
|
|
3055
|
+
function highlight(text: string, query: string) {
|
|
3056
|
+
if (!query.trim()) return text;
|
|
3057
|
+
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
3058
|
+
if (idx === -1) return text;
|
|
3059
|
+
return (
|
|
3060
|
+
<>
|
|
3061
|
+
{text.slice(0, idx)}
|
|
3062
|
+
<mark className="rounded-xs bg-primary/20 px-0.5 font-semibold text-primary not-italic">
|
|
3063
|
+
{text.slice(idx, idx + query.length)}
|
|
3064
|
+
</mark>
|
|
3065
|
+
{text.slice(idx + query.length)}
|
|
3066
|
+
</>
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
interface ResultGroupProps {
|
|
3071
|
+
title: string;
|
|
3072
|
+
items: SearchItem[];
|
|
3073
|
+
allResults: SearchItem[];
|
|
3074
|
+
query: string;
|
|
3075
|
+
activeIndex: number;
|
|
3076
|
+
savedSlugs: string[];
|
|
3077
|
+
onSelect: (href: string) => void;
|
|
3078
|
+
onHover: (index: number) => void;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
function ResultGroup({
|
|
3082
|
+
title,
|
|
3083
|
+
items,
|
|
3084
|
+
allResults,
|
|
3085
|
+
query,
|
|
3086
|
+
activeIndex,
|
|
3087
|
+
savedSlugs,
|
|
3088
|
+
onSelect,
|
|
3089
|
+
onHover,
|
|
3090
|
+
}: ResultGroupProps) {
|
|
3091
|
+
return (
|
|
3092
|
+
<div className="mb-1">
|
|
3093
|
+
<p className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-500">
|
|
3094
|
+
{title}
|
|
3095
|
+
</p>
|
|
3096
|
+
{items.map((item) => {
|
|
3097
|
+
const globalIdx = allResults.indexOf(item);
|
|
3098
|
+
const isActive = globalIdx === activeIndex;
|
|
3099
|
+
const cookbookMatch = item.href.match(/\\/(nextjs|vitejs)\\/cookbook\\/(.+)/);
|
|
3100
|
+
const slug = cookbookMatch ? cookbookMatch[2] : null;
|
|
3101
|
+
const isSaved = slug ? savedSlugs.includes(slug) : false;
|
|
3102
|
+
return (
|
|
3103
|
+
<button
|
|
3104
|
+
key={item.href}
|
|
3105
|
+
onMouseEnter={() => onHover(globalIdx)}
|
|
3106
|
+
onClick={() => onSelect(item.href)}
|
|
3107
|
+
className={cn(
|
|
3108
|
+
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
|
|
3109
|
+
isActive
|
|
3110
|
+
? "bg-primary/10 dark:bg-primary/20"
|
|
3111
|
+
: "hover:bg-slate-50 dark:hover:bg-[#0d1117]",
|
|
3112
|
+
)}
|
|
3113
|
+
>
|
|
3114
|
+
<span
|
|
3115
|
+
className={cn(
|
|
3116
|
+
"material-symbols-outlined shrink-0 text-[18px]",
|
|
3117
|
+
isActive ? "text-primary" : "text-slate-400",
|
|
3118
|
+
)}
|
|
3119
|
+
>
|
|
3120
|
+
{item.icon ?? (title === "Docs" ? "article" : "menu_book")}
|
|
3121
|
+
</span>
|
|
3122
|
+
<div className="min-w-0 flex-1">
|
|
3123
|
+
<p
|
|
3124
|
+
className={cn(
|
|
3125
|
+
"flex items-center gap-1.5 truncate text-sm font-medium",
|
|
3126
|
+
isActive ? "text-primary" : "text-slate-900 dark:text-white",
|
|
3127
|
+
)}
|
|
3128
|
+
>
|
|
3129
|
+
{highlight(item.title, query)}
|
|
3130
|
+
{isSaved && (
|
|
3131
|
+
<span
|
|
3132
|
+
className="material-symbols-outlined shrink-0 text-[13px] text-primary"
|
|
3133
|
+
style={{ fontVariationSettings: "'FILL' 1" }}
|
|
3134
|
+
>
|
|
3135
|
+
bookmark
|
|
3136
|
+
</span>
|
|
3137
|
+
)}
|
|
3138
|
+
</p>
|
|
3139
|
+
{item.description && (
|
|
3140
|
+
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
|
|
3141
|
+
{highlight(item.description, query)}
|
|
3142
|
+
</p>
|
|
3143
|
+
)}
|
|
3144
|
+
{item.section && !item.description && (
|
|
3145
|
+
<p className="mt-0.5 truncate text-xs text-slate-400 dark:text-slate-500">
|
|
3146
|
+
{item.section}
|
|
3147
|
+
</p>
|
|
3148
|
+
)}
|
|
3149
|
+
</div>
|
|
3150
|
+
{isActive && (
|
|
3151
|
+
<span className="material-symbols-outlined shrink-0 text-[16px] text-primary">
|
|
3152
|
+
arrow_forward
|
|
3153
|
+
</span>
|
|
3154
|
+
)}
|
|
3155
|
+
</button>
|
|
3156
|
+
);
|
|
3157
|
+
})}
|
|
3158
|
+
</div>
|
|
3159
|
+
);
|
|
3160
|
+
}`,
|
|
3161
|
+
"Select": `import { forwardRef, type SelectHTMLAttributes } from "react";
|
|
3162
|
+
import { cn } from "@/lib/utils";
|
|
3163
|
+
|
|
3164
|
+
export type SelectSize = "sm" | "md" | "lg";
|
|
3165
|
+
|
|
3166
|
+
export interface SelectOption {
|
|
3167
|
+
label: string;
|
|
3168
|
+
value: string;
|
|
3169
|
+
disabled?: boolean;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
|
3173
|
+
label?: string;
|
|
3174
|
+
description?: string;
|
|
3175
|
+
error?: string;
|
|
3176
|
+
size?: SelectSize;
|
|
3177
|
+
options?: SelectOption[];
|
|
3178
|
+
placeholder?: string;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
const SIZE_CLASSES: Record<SelectSize, string> = {
|
|
3182
|
+
sm: "h-8 px-3 pr-9 text-xs",
|
|
3183
|
+
md: "h-10 px-3.5 pr-10 text-sm",
|
|
3184
|
+
lg: "h-12 px-4 pr-11 text-base",
|
|
3185
|
+
};
|
|
3186
|
+
|
|
3187
|
+
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function SelectRoot(
|
|
3188
|
+
{ label, description, error, size = "md", options, placeholder, className, id, children, ...props },
|
|
3189
|
+
ref
|
|
3190
|
+
) {
|
|
3191
|
+
const inputId = id ?? (label ? label.toLowerCase().replace(/\\s+/g, "-") : undefined);
|
|
3192
|
+
|
|
3193
|
+
return (
|
|
3194
|
+
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
3195
|
+
{label && <label htmlFor={inputId} className="text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}
|
|
3196
|
+
|
|
3197
|
+
<div className="relative">
|
|
3198
|
+
<select
|
|
3199
|
+
ref={ref}
|
|
3200
|
+
id={inputId}
|
|
3201
|
+
className={cn(
|
|
3202
|
+
"w-full appearance-none rounded-lg border bg-white outline-hidden transition-all",
|
|
3203
|
+
"border-slate-200 text-slate-900 hover:border-slate-300",
|
|
3204
|
+
"focus:border-primary focus:ring-2 focus:ring-primary/20",
|
|
3205
|
+
"dark:border-[#1f2937] dark:bg-[#0d1117] dark:text-white dark:hover:border-slate-600",
|
|
3206
|
+
error && "border-red-400 focus:border-red-400 focus:ring-red-400/20 dark:border-red-500",
|
|
3207
|
+
SIZE_CLASSES[size]
|
|
3208
|
+
)}
|
|
3209
|
+
{...props}
|
|
3210
|
+
>
|
|
3211
|
+
{placeholder && <option value="">{placeholder}</option>}
|
|
3212
|
+
{options?.map((option) => (
|
|
3213
|
+
<option key={option.value} value={option.value} disabled={option.disabled}>
|
|
3214
|
+
{option.label}
|
|
3215
|
+
</option>
|
|
3216
|
+
))}
|
|
3217
|
+
{children}
|
|
3218
|
+
</select>
|
|
3219
|
+
<span className="material-symbols-outlined pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[18px] text-slate-400">
|
|
3220
|
+
expand_more
|
|
3221
|
+
</span>
|
|
3222
|
+
</div>
|
|
3223
|
+
|
|
3224
|
+
{description && !error && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
|
|
3225
|
+
{error && <p className="text-xs text-red-500 dark:text-red-400">{error}</p>}
|
|
3226
|
+
</div>
|
|
3227
|
+
);
|
|
3228
|
+
});`,
|
|
3229
|
+
"Separator": `import type { HTMLAttributes } from "react";
|
|
3230
|
+
import { cn } from "@/lib/utils";
|
|
3231
|
+
|
|
3232
|
+
export type SeparatorOrientation = "horizontal" | "vertical";
|
|
3233
|
+
|
|
3234
|
+
export interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
|
|
3235
|
+
orientation?: SeparatorOrientation;
|
|
3236
|
+
decorative?: boolean;
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
export function Separator({
|
|
3240
|
+
orientation = "horizontal",
|
|
3241
|
+
decorative = true,
|
|
3242
|
+
className,
|
|
3243
|
+
...props
|
|
3244
|
+
}: SeparatorProps) {
|
|
3245
|
+
return (
|
|
3246
|
+
<div
|
|
3247
|
+
role={decorative ? "presentation" : "separator"}
|
|
3248
|
+
aria-orientation={orientation}
|
|
3249
|
+
className={cn(
|
|
3250
|
+
"shrink-0 bg-slate-200 dark:bg-[#1f2937]",
|
|
3251
|
+
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
|
3252
|
+
className
|
|
3253
|
+
)}
|
|
3254
|
+
{...props}
|
|
3255
|
+
/>
|
|
3256
|
+
);
|
|
3257
|
+
}`,
|
|
3258
|
+
"Skeleton": `import { cn } from "@/lib/utils";
|
|
3259
|
+
|
|
3260
|
+
export type SkeletonVariant = "line" | "rect" | "circle";
|
|
3261
|
+
|
|
3262
|
+
export interface SkeletonProps {
|
|
3263
|
+
variant?: SkeletonVariant;
|
|
3264
|
+
width?: number | string;
|
|
3265
|
+
height?: number | string;
|
|
3266
|
+
className?: string;
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
export function Skeleton({ variant = "line", width, height, className }: SkeletonProps) {
|
|
3270
|
+
return (
|
|
3271
|
+
<span
|
|
3272
|
+
aria-hidden="true"
|
|
3273
|
+
className={cn(
|
|
3274
|
+
"inline-block animate-pulse bg-slate-200 dark:bg-[#1f2937]",
|
|
3275
|
+
variant === "line" && "h-4 w-40 rounded-md",
|
|
3276
|
+
variant === "rect" && "h-24 w-full rounded-xl",
|
|
3277
|
+
variant === "circle" && "h-10 w-10 rounded-full",
|
|
3278
|
+
className
|
|
3279
|
+
)}
|
|
3280
|
+
style={{ width, height }}
|
|
3281
|
+
/>
|
|
3282
|
+
);
|
|
3283
|
+
}`,
|
|
3284
|
+
"Slider": `import { useState } from "react";
|
|
3285
|
+
import { cn } from "@/lib/utils";
|
|
3286
|
+
|
|
3287
|
+
export interface SliderProps {
|
|
3288
|
+
value?: number;
|
|
3289
|
+
defaultValue?: number;
|
|
3290
|
+
min?: number;
|
|
3291
|
+
max?: number;
|
|
3292
|
+
step?: number;
|
|
3293
|
+
onValueChange?: (value: number) => void;
|
|
3294
|
+
label?: string;
|
|
3295
|
+
showValue?: boolean;
|
|
3296
|
+
className?: string;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
export function Slider({
|
|
3300
|
+
value,
|
|
3301
|
+
defaultValue = 50,
|
|
3302
|
+
min = 0,
|
|
3303
|
+
max = 100,
|
|
3304
|
+
step = 1,
|
|
3305
|
+
onValueChange,
|
|
3306
|
+
label,
|
|
3307
|
+
showValue = true,
|
|
3308
|
+
className,
|
|
3309
|
+
}: SliderProps) {
|
|
3310
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
3311
|
+
const isControlled = value !== undefined;
|
|
3312
|
+
const current = isControlled ? value : internalValue;
|
|
3313
|
+
|
|
3314
|
+
return (
|
|
3315
|
+
<div className={cn("w-full", className)}>
|
|
3316
|
+
{(label || showValue) && (
|
|
3317
|
+
<div className="mb-2 flex items-center justify-between gap-3">
|
|
3318
|
+
{label && <label className="text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}
|
|
3319
|
+
{showValue && <span className="text-xs text-slate-500 dark:text-slate-400">{Math.round(current)}</span>}
|
|
3320
|
+
</div>
|
|
3321
|
+
)}
|
|
3322
|
+
|
|
3323
|
+
<input
|
|
3324
|
+
type="range"
|
|
3325
|
+
min={min}
|
|
3326
|
+
max={max}
|
|
3327
|
+
step={step}
|
|
3328
|
+
value={current}
|
|
3329
|
+
onChange={(event) => {
|
|
3330
|
+
const next = Number(event.target.value);
|
|
3331
|
+
if (!isControlled) setInternalValue(next);
|
|
3332
|
+
onValueChange?.(next);
|
|
3333
|
+
}}
|
|
3334
|
+
className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-slate-200 accent-primary dark:bg-[#1f2937]"
|
|
3335
|
+
/>
|
|
3336
|
+
</div>
|
|
3337
|
+
);
|
|
3338
|
+
}`,
|
|
3339
|
+
"Switch": `import { useMemo, useState } from "react";
|
|
3340
|
+
import { cn } from "@/lib/utils";
|
|
3341
|
+
|
|
3342
|
+
export type SwitchSize = "sm" | "md" | "lg";
|
|
3343
|
+
|
|
3344
|
+
export interface SwitchProps {
|
|
3345
|
+
checked?: boolean;
|
|
3346
|
+
defaultChecked?: boolean;
|
|
3347
|
+
onChange?: (checked: boolean) => void;
|
|
3348
|
+
disabled?: boolean;
|
|
3349
|
+
size?: SwitchSize;
|
|
3350
|
+
label?: string;
|
|
3351
|
+
description?: string;
|
|
3352
|
+
className?: string;
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
const TRACK_SIZES: Record<SwitchSize, string> = {
|
|
3356
|
+
sm: "h-5 w-9",
|
|
3357
|
+
md: "h-6 w-11",
|
|
3358
|
+
lg: "h-7 w-14",
|
|
3359
|
+
};
|
|
3360
|
+
|
|
3361
|
+
const THUMB_SIZES: Record<SwitchSize, string> = {
|
|
3362
|
+
sm: "h-4 w-4 data-[checked=true]:translate-x-4",
|
|
3363
|
+
md: "h-5 w-5 data-[checked=true]:translate-x-5",
|
|
3364
|
+
lg: "h-6 w-6 data-[checked=true]:translate-x-7",
|
|
3365
|
+
};
|
|
3366
|
+
|
|
3367
|
+
export function Switch({
|
|
3368
|
+
checked,
|
|
3369
|
+
defaultChecked = false,
|
|
3370
|
+
onChange,
|
|
3371
|
+
disabled = false,
|
|
3372
|
+
size = "md",
|
|
3373
|
+
label,
|
|
3374
|
+
description,
|
|
3375
|
+
className,
|
|
3376
|
+
}: SwitchProps) {
|
|
3377
|
+
const [internal, setInternal] = useState(defaultChecked);
|
|
3378
|
+
const isControlled = checked !== undefined;
|
|
3379
|
+
const isOn = isControlled ? checked : internal;
|
|
3380
|
+
|
|
3381
|
+
const stateLabel = useMemo(() => (isOn ? "enabled" : "disabled"), [isOn]);
|
|
3382
|
+
|
|
3383
|
+
const toggle = () => {
|
|
3384
|
+
if (disabled) return;
|
|
3385
|
+
const next = !isOn;
|
|
3386
|
+
if (!isControlled) setInternal(next);
|
|
3387
|
+
onChange?.(next);
|
|
3388
|
+
};
|
|
3389
|
+
|
|
3390
|
+
return (
|
|
3391
|
+
<div className={cn("inline-flex items-start gap-3", className)}>
|
|
3392
|
+
<button
|
|
3393
|
+
type="button"
|
|
3394
|
+
role="switch"
|
|
3395
|
+
aria-checked={isOn}
|
|
3396
|
+
aria-label={label ?? "Switch"}
|
|
3397
|
+
aria-disabled={disabled}
|
|
3398
|
+
data-state={isOn ? "checked" : "unchecked"}
|
|
3399
|
+
onClick={toggle}
|
|
3400
|
+
className={cn(
|
|
3401
|
+
"relative shrink-0 rounded-full p-0.5 transition-all outline-hidden",
|
|
3402
|
+
"focus-visible:ring-2 focus-visible:ring-primary/40",
|
|
3403
|
+
TRACK_SIZES[size],
|
|
3404
|
+
isOn ? "bg-primary" : "bg-slate-300 dark:bg-slate-700",
|
|
3405
|
+
disabled && "cursor-not-allowed opacity-50"
|
|
3406
|
+
)}
|
|
3407
|
+
>
|
|
3408
|
+
<span
|
|
3409
|
+
data-checked={isOn}
|
|
3410
|
+
className={cn(
|
|
3411
|
+
"block rounded-full bg-white shadow-sm transition-transform duration-200",
|
|
3412
|
+
THUMB_SIZES[size]
|
|
3413
|
+
)}
|
|
3414
|
+
/>
|
|
3415
|
+
</button>
|
|
3416
|
+
|
|
3417
|
+
{(label ?? description) && (
|
|
3418
|
+
<div className="min-w-0">
|
|
3419
|
+
{label && <p className="text-sm font-medium text-slate-900 dark:text-white">{label}</p>}
|
|
3420
|
+
{description && (
|
|
3421
|
+
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
|
|
3422
|
+
{description} ({stateLabel})
|
|
3423
|
+
</p>
|
|
3424
|
+
)}
|
|
3425
|
+
</div>
|
|
3426
|
+
)}
|
|
3427
|
+
</div>
|
|
3428
|
+
);
|
|
3429
|
+
}`,
|
|
3430
|
+
"Tabs": `import {
|
|
3431
|
+
createContext,
|
|
3432
|
+
useContext,
|
|
3433
|
+
useState,
|
|
3434
|
+
type ButtonHTMLAttributes,
|
|
3435
|
+
type HTMLAttributes,
|
|
3436
|
+
type ReactNode,
|
|
3437
|
+
} from "react";
|
|
3438
|
+
import { cn } from "@/lib/utils";
|
|
3439
|
+
|
|
3440
|
+
// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3441
|
+
|
|
3442
|
+
export type TabsVariant = "underline" | "pills";
|
|
3443
|
+
|
|
3444
|
+
export interface TabsProps {
|
|
3445
|
+
value?: string;
|
|
3446
|
+
defaultValue?: string;
|
|
3447
|
+
onChange?: (value: string) => void;
|
|
3448
|
+
variant?: TabsVariant;
|
|
3449
|
+
children: ReactNode;
|
|
3450
|
+
className?: string;
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
export interface TabsListProps extends HTMLAttributes<HTMLDivElement> {
|
|
3454
|
+
children: ReactNode;
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
export interface TabsTriggerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value"> {
|
|
3458
|
+
value: string;
|
|
3459
|
+
children: ReactNode;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
export interface TabsContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
3463
|
+
value: string;
|
|
3464
|
+
children: ReactNode;
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
// \u2500\u2500\u2500 Context \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3468
|
+
|
|
3469
|
+
interface TabsContextValue {
|
|
3470
|
+
active: string;
|
|
3471
|
+
setActive: (value: string) => void;
|
|
3472
|
+
variant: TabsVariant;
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
3476
|
+
|
|
3477
|
+
function useTabsContext() {
|
|
3478
|
+
const ctx = useContext(TabsContext);
|
|
3479
|
+
if (!ctx) throw new Error("Tabs sub-components must be used inside <Tabs> or <Tabs>");
|
|
3480
|
+
return ctx;
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
// \u2500\u2500\u2500 Components \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3484
|
+
|
|
3485
|
+
export function Tabs({
|
|
3486
|
+
value,
|
|
3487
|
+
defaultValue = "",
|
|
3488
|
+
onChange,
|
|
3489
|
+
variant = "underline",
|
|
3490
|
+
children,
|
|
3491
|
+
className,
|
|
3492
|
+
}: TabsProps) {
|
|
3493
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
3494
|
+
const isControlled = value !== undefined;
|
|
3495
|
+
const active = isControlled ? value : internalValue;
|
|
3496
|
+
|
|
3497
|
+
const setActive = (next: string) => {
|
|
3498
|
+
if (!isControlled) setInternalValue(next);
|
|
3499
|
+
onChange?.(next);
|
|
3500
|
+
};
|
|
3501
|
+
|
|
3502
|
+
return (
|
|
3503
|
+
<TabsContext.Provider value={{ active, setActive, variant }}>
|
|
3504
|
+
<div className={cn("w-full", className)}>{children}</div>
|
|
3505
|
+
</TabsContext.Provider>
|
|
3506
|
+
);
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
Tabs.List = function TabsList({ children, className, ...props }: TabsListProps) {
|
|
3510
|
+
const { variant } = useTabsContext();
|
|
3511
|
+
|
|
3512
|
+
return (
|
|
3513
|
+
<div
|
|
3514
|
+
role="tablist"
|
|
3515
|
+
className={cn(
|
|
3516
|
+
"flex",
|
|
3517
|
+
variant === "underline" && "border-b border-slate-200 dark:border-[#1f2937] gap-0",
|
|
3518
|
+
variant === "pills" && "gap-1 p-1 rounded-xl bg-slate-100 dark:bg-[#161b22] w-fit",
|
|
3519
|
+
className
|
|
3520
|
+
)}
|
|
3521
|
+
{...props}
|
|
3522
|
+
>
|
|
3523
|
+
{children}
|
|
3524
|
+
</div>
|
|
3525
|
+
);
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
Tabs.Trigger = function TabsTrigger({ value, children, disabled, className, ...props }: TabsTriggerProps) {
|
|
3529
|
+
const { active, setActive, variant } = useTabsContext();
|
|
3530
|
+
const isActive = active === value;
|
|
3531
|
+
|
|
3532
|
+
return (
|
|
3533
|
+
<button
|
|
3534
|
+
role="tab"
|
|
3535
|
+
aria-selected={isActive}
|
|
3536
|
+
disabled={disabled}
|
|
3537
|
+
onClick={() => setActive(value)}
|
|
3538
|
+
className={cn(
|
|
3539
|
+
"text-sm font-medium transition-all outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40 rounded-sm",
|
|
3540
|
+
disabled && "opacity-40 cursor-not-allowed pointer-events-none",
|
|
3541
|
+
|
|
3542
|
+
// underline variant
|
|
3543
|
+
variant === "underline" && [
|
|
3544
|
+
"px-4 py-2.5 -mb-px border-b-2",
|
|
3545
|
+
isActive
|
|
3546
|
+
? "border-primary text-primary"
|
|
3547
|
+
: "border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 hover:border-slate-300 dark:hover:border-slate-600",
|
|
3548
|
+
],
|
|
3549
|
+
|
|
3550
|
+
// pills variant
|
|
3551
|
+
variant === "pills" && [
|
|
3552
|
+
"px-4 py-1.5 rounded-lg",
|
|
3553
|
+
isActive
|
|
3554
|
+
? "bg-white dark:bg-[#0d1117] text-slate-900 dark:text-white shadow-xs"
|
|
3555
|
+
: "text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200",
|
|
3556
|
+
],
|
|
3557
|
+
|
|
3558
|
+
className
|
|
3559
|
+
)}
|
|
3560
|
+
{...props}
|
|
3561
|
+
>
|
|
3562
|
+
{children}
|
|
3563
|
+
</button>
|
|
3564
|
+
);
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
Tabs.Content = function TabsContent({ value, children, className, ...props }: TabsContentProps) {
|
|
3568
|
+
const { active } = useTabsContext();
|
|
3569
|
+
|
|
3570
|
+
if (active !== value) return null;
|
|
3571
|
+
|
|
3572
|
+
return (
|
|
3573
|
+
<div
|
|
3574
|
+
role="tabpanel"
|
|
3575
|
+
className={cn("mt-4 animate-fade-in", className)}
|
|
3576
|
+
{...props}
|
|
3577
|
+
>
|
|
3578
|
+
{children}
|
|
3579
|
+
</div>
|
|
3580
|
+
);
|
|
3581
|
+
}`,
|
|
3582
|
+
"Textarea": `import { forwardRef, type ReactNode, type TextareaHTMLAttributes } from "react";
|
|
3583
|
+
import { cn } from "@/lib/utils";
|
|
3584
|
+
|
|
3585
|
+
export type TextareaSize = "sm" | "md" | "lg";
|
|
3586
|
+
export type TextareaVariant = "default" | "filled" | "ghost";
|
|
3587
|
+
|
|
3588
|
+
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
|
|
3589
|
+
label?: string;
|
|
3590
|
+
description?: string;
|
|
3591
|
+
error?: string;
|
|
3592
|
+
size?: TextareaSize;
|
|
3593
|
+
variant?: TextareaVariant;
|
|
3594
|
+
children?: ReactNode;
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
const SIZE_CLASSES: Record<TextareaSize, string> = {
|
|
3598
|
+
sm: "min-h-20 px-3 py-2 text-xs",
|
|
3599
|
+
md: "min-h-24 px-3.5 py-2.5 text-sm",
|
|
3600
|
+
lg: "min-h-32 px-4 py-3 text-base",
|
|
3601
|
+
};
|
|
3602
|
+
|
|
3603
|
+
const VARIANT_CLASSES: Record<TextareaVariant, string> = {
|
|
3604
|
+
default:
|
|
3605
|
+
"border border-slate-200 dark:border-[#1f2937] bg-white dark:bg-[#0d1117] hover:border-slate-300 dark:hover:border-slate-600",
|
|
3606
|
+
filled:
|
|
3607
|
+
"border border-transparent bg-slate-100 dark:bg-[#161b22] hover:bg-slate-200/80 dark:hover:bg-[#1f2937]",
|
|
3608
|
+
ghost:
|
|
3609
|
+
"border border-transparent bg-transparent hover:bg-slate-50 dark:hover:bg-[#161b22]",
|
|
3610
|
+
};
|
|
3611
|
+
|
|
3612
|
+
const ERROR_CLASSES =
|
|
3613
|
+
"border-red-400 dark:border-red-500 focus-within:border-red-400 dark:focus-within:border-red-500 focus-within:ring-red-400/20";
|
|
3614
|
+
|
|
3615
|
+
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function TextareaRoot(
|
|
3616
|
+
{
|
|
3617
|
+
label,
|
|
3618
|
+
description,
|
|
3619
|
+
error,
|
|
3620
|
+
size = "md",
|
|
3621
|
+
variant = "default",
|
|
3622
|
+
disabled,
|
|
3623
|
+
className,
|
|
3624
|
+
id,
|
|
3625
|
+
...props
|
|
3626
|
+
},
|
|
3627
|
+
ref
|
|
3628
|
+
) {
|
|
3629
|
+
const inputId = id ?? (label ? label.toLowerCase().replace(/\\s+/g, "-") : undefined);
|
|
3630
|
+
|
|
3631
|
+
return (
|
|
3632
|
+
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
3633
|
+
{label && (
|
|
3634
|
+
<label
|
|
3635
|
+
htmlFor={inputId}
|
|
3636
|
+
className={cn("text-sm font-medium text-slate-700 dark:text-slate-300", disabled && "opacity-50")}
|
|
3637
|
+
>
|
|
3638
|
+
{label}
|
|
3639
|
+
</label>
|
|
3640
|
+
)}
|
|
3641
|
+
|
|
3642
|
+
<div
|
|
3643
|
+
className={cn(
|
|
3644
|
+
"rounded-lg transition-all focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary dark:focus-within:border-primary",
|
|
3645
|
+
VARIANT_CLASSES[variant],
|
|
3646
|
+
error && ERROR_CLASSES,
|
|
3647
|
+
disabled && "opacity-50"
|
|
3648
|
+
)}
|
|
3649
|
+
>
|
|
3650
|
+
<textarea
|
|
3651
|
+
ref={ref}
|
|
3652
|
+
id={inputId}
|
|
3653
|
+
disabled={disabled}
|
|
3654
|
+
className={cn(
|
|
3655
|
+
"w-full resize-y rounded-lg bg-transparent outline-hidden",
|
|
3656
|
+
"text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500",
|
|
3657
|
+
SIZE_CLASSES[size]
|
|
3658
|
+
)}
|
|
3659
|
+
{...props}
|
|
3660
|
+
/>
|
|
3661
|
+
</div>
|
|
3662
|
+
|
|
3663
|
+
{description && !error && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
|
|
3664
|
+
{error && <p className="text-xs text-red-500 dark:text-red-400">{error}</p>}
|
|
3665
|
+
</div>
|
|
3666
|
+
);
|
|
3667
|
+
});`,
|
|
3668
|
+
"Toast": `"use client";
|
|
3669
|
+
|
|
3670
|
+
import { createContext, useContext, useEffect, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from "react";
|
|
3671
|
+
import { createPortal } from "react-dom";
|
|
3672
|
+
import { useAnimatedMount } from "@/hooks/use-animated-mount";
|
|
3673
|
+
import { cn } from "@/lib/utils";
|
|
3674
|
+
|
|
3675
|
+
export type ToastVariant = "default" | "success" | "warning" | "error";
|
|
3676
|
+
export type ToastPosition = "top-right" | "bottom-right" | "top-left" | "bottom-left";
|
|
3677
|
+
|
|
3678
|
+
interface ToastContextValue {
|
|
3679
|
+
onClose: () => void;
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
3683
|
+
|
|
3684
|
+
function useToastContext() {
|
|
3685
|
+
const context = useContext(ToastContext);
|
|
3686
|
+
if (!context) throw new Error("Toast sub-components must be used inside <Toast>");
|
|
3687
|
+
return context;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
export interface ToastProps {
|
|
3691
|
+
open: boolean;
|
|
3692
|
+
onOpenChange: (open: boolean) => void;
|
|
3693
|
+
duration?: number;
|
|
3694
|
+
variant?: ToastVariant;
|
|
3695
|
+
position?: ToastPosition;
|
|
3696
|
+
children: ReactNode;
|
|
3697
|
+
className?: string;
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
|
3701
|
+
default: "border-slate-200 bg-white dark:border-[#1f2937] dark:bg-[#161b22]",
|
|
3702
|
+
success: "border-green-300 bg-green-50 dark:border-green-900 dark:bg-green-950/30",
|
|
3703
|
+
warning: "border-amber-300 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30",
|
|
3704
|
+
error: "border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30",
|
|
3705
|
+
};
|
|
3706
|
+
|
|
3707
|
+
const POSITION_CLASSES: Record<ToastPosition, string> = {
|
|
3708
|
+
"top-right": "right-4 top-4",
|
|
3709
|
+
"bottom-right": "right-4 bottom-4",
|
|
3710
|
+
"top-left": "left-4 top-4",
|
|
3711
|
+
"bottom-left": "left-4 bottom-4",
|
|
3712
|
+
};
|
|
3713
|
+
|
|
3714
|
+
export function Toast({
|
|
3715
|
+
open,
|
|
3716
|
+
onOpenChange,
|
|
3717
|
+
duration = 3000,
|
|
3718
|
+
variant = "default",
|
|
3719
|
+
position = "top-right",
|
|
3720
|
+
children,
|
|
3721
|
+
className,
|
|
3722
|
+
}: ToastProps) {
|
|
3723
|
+
const { mounted, visible } = useAnimatedMount(open, 180);
|
|
3724
|
+
|
|
3725
|
+
useEffect(() => {
|
|
3726
|
+
if (!open || duration <= 0) return;
|
|
3727
|
+
const timeout = setTimeout(() => onOpenChange(false), duration);
|
|
3728
|
+
return () => clearTimeout(timeout);
|
|
3729
|
+
}, [open, duration, onOpenChange]);
|
|
3730
|
+
|
|
3731
|
+
useEffect(() => {
|
|
3732
|
+
if (!open) return;
|
|
3733
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
3734
|
+
if (event.key === "Escape") onOpenChange(false);
|
|
3735
|
+
};
|
|
3736
|
+
window.addEventListener("keydown", onKeyDown);
|
|
3737
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
3738
|
+
}, [open, onOpenChange]);
|
|
3739
|
+
|
|
3740
|
+
if (!mounted) return null;
|
|
3741
|
+
|
|
3742
|
+
return createPortal(
|
|
3743
|
+
<ToastContext.Provider value={{ onClose: () => onOpenChange(false) }}>
|
|
3744
|
+
<div className={cn("fixed z-[70] w-full max-w-sm", POSITION_CLASSES[position])}>
|
|
3745
|
+
<div
|
|
3746
|
+
role="status"
|
|
3747
|
+
className={cn(
|
|
3748
|
+
"rounded-xl border p-4 shadow-xl transition-all",
|
|
3749
|
+
VARIANT_CLASSES[variant],
|
|
3750
|
+
visible ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0",
|
|
3751
|
+
className
|
|
3752
|
+
)}
|
|
3753
|
+
>
|
|
3754
|
+
{children}
|
|
3755
|
+
</div>
|
|
3756
|
+
</div>
|
|
3757
|
+
</ToastContext.Provider>,
|
|
3758
|
+
document.body
|
|
3759
|
+
);
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
Toast.Title = function ToastTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
|
3763
|
+
return <h4 className={cn("text-sm font-semibold text-slate-900 dark:text-white", className)} {...props} />;
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
Toast.Description = function ToastDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
|
3767
|
+
return <p className={cn("mt-1 text-xs text-slate-600 dark:text-slate-400", className)} {...props} />;
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
Toast.Action = function ToastAction({ className, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
3771
|
+
return (
|
|
3772
|
+
<button
|
|
3773
|
+
type="button"
|
|
3774
|
+
className={cn("rounded-md bg-primary px-2.5 py-1.5 text-xs font-medium text-white transition-opacity hover:opacity-90", className)}
|
|
3775
|
+
{...props}
|
|
3776
|
+
/>
|
|
3777
|
+
);
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
Toast.Close = function ToastClose({ className, onClick, children = "Dismiss", ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
3781
|
+
const { onClose } = useToastContext();
|
|
3782
|
+
|
|
3783
|
+
return (
|
|
3784
|
+
<button
|
|
3785
|
+
type="button"
|
|
3786
|
+
onClick={(event) => {
|
|
3787
|
+
onClick?.(event);
|
|
3788
|
+
onClose();
|
|
3789
|
+
}}
|
|
3790
|
+
className={cn("text-xs font-medium text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200", className)}
|
|
3791
|
+
{...props}
|
|
3792
|
+
>
|
|
3793
|
+
{children}
|
|
3794
|
+
</button>
|
|
3795
|
+
);
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
Toast.Footer = function ToastFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
3799
|
+
return <div className={cn("mt-3 flex items-center justify-end gap-2", className)} {...props} />;
|
|
3800
|
+
}`,
|
|
3801
|
+
"Tooltip": `import { createContext, useContext, useState, type HTMLAttributes, type ReactNode } from "react";
|
|
3802
|
+
import { cn } from "@/lib/utils";
|
|
3803
|
+
|
|
3804
|
+
type TooltipSide = "top" | "bottom" | "left" | "right";
|
|
3805
|
+
|
|
3806
|
+
interface TooltipContextValue {
|
|
3807
|
+
open: boolean;
|
|
3808
|
+
setOpen: (open: boolean) => void;
|
|
3809
|
+
side: TooltipSide;
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
const TooltipContext = createContext<TooltipContextValue | null>(null);
|
|
3813
|
+
|
|
3814
|
+
function useTooltipContext() {
|
|
3815
|
+
const context = useContext(TooltipContext);
|
|
3816
|
+
if (!context) throw new Error("Tooltip sub-components must be used inside <Tooltip>");
|
|
3817
|
+
return context;
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
export interface TooltipProps {
|
|
3821
|
+
defaultOpen?: boolean;
|
|
3822
|
+
side?: TooltipSide;
|
|
3823
|
+
children: ReactNode;
|
|
3824
|
+
className?: string;
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
export interface TooltipTriggerProps extends HTMLAttributes<HTMLSpanElement> {
|
|
3828
|
+
children: ReactNode;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
export interface TooltipContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
3832
|
+
children: ReactNode;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
export function Tooltip({ defaultOpen = false, side = "top", children, className }: TooltipProps) {
|
|
3836
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
3837
|
+
|
|
3838
|
+
return (
|
|
3839
|
+
<TooltipContext.Provider value={{ open, setOpen, side }}>
|
|
3840
|
+
<span className={cn("relative inline-flex", className)}>{children}</span>
|
|
3841
|
+
</TooltipContext.Provider>
|
|
3842
|
+
);
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
Tooltip.Trigger = function TooltipTrigger({ children, className, ...props }: TooltipTriggerProps) {
|
|
3846
|
+
const { setOpen } = useTooltipContext();
|
|
3847
|
+
|
|
3848
|
+
return (
|
|
3849
|
+
<span
|
|
3850
|
+
className={cn("inline-flex", className)}
|
|
3851
|
+
onMouseEnter={() => setOpen(true)}
|
|
3852
|
+
onMouseLeave={() => setOpen(false)}
|
|
3853
|
+
onFocus={() => setOpen(true)}
|
|
3854
|
+
onBlur={() => setOpen(false)}
|
|
3855
|
+
{...props}
|
|
3856
|
+
>
|
|
3857
|
+
{children}
|
|
3858
|
+
</span>
|
|
3859
|
+
);
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
const SIDE_CLASSES: Record<TooltipSide, string> = {
|
|
3863
|
+
top: "bottom-[calc(100%+8px)] left-1/2 -translate-x-1/2",
|
|
3864
|
+
bottom: "top-[calc(100%+8px)] left-1/2 -translate-x-1/2",
|
|
3865
|
+
left: "right-[calc(100%+8px)] top-1/2 -translate-y-1/2",
|
|
3866
|
+
right: "left-[calc(100%+8px)] top-1/2 -translate-y-1/2",
|
|
3867
|
+
};
|
|
3868
|
+
|
|
3869
|
+
Tooltip.Content = function TooltipContent({ children, className, ...props }: TooltipContentProps) {
|
|
3870
|
+
const { open, side } = useTooltipContext();
|
|
3871
|
+
|
|
3872
|
+
return (
|
|
3873
|
+
<div
|
|
3874
|
+
role="tooltip"
|
|
3875
|
+
className={cn(
|
|
3876
|
+
"pointer-events-none absolute z-50 rounded-md bg-slate-900 px-2.5 py-1.5 text-xs text-white shadow-lg transition-all",
|
|
3877
|
+
SIDE_CLASSES[side],
|
|
3878
|
+
open ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0",
|
|
3879
|
+
className
|
|
3880
|
+
)}
|
|
3881
|
+
{...props}
|
|
3882
|
+
>
|
|
3883
|
+
{children}
|
|
3884
|
+
</div>
|
|
3885
|
+
);
|
|
3886
|
+
}`
|
|
3887
|
+
};
|
|
3888
|
+
|
|
3889
|
+
// src/registry/index.ts
|
|
3890
|
+
var REGISTRY = [
|
|
3891
|
+
// ── Utilities ────────────────────────────────────────────────────────────
|
|
3892
|
+
{
|
|
3893
|
+
name: "utils",
|
|
3894
|
+
description: "cn() utility for merging Tailwind classes",
|
|
3895
|
+
templateKey: "utils",
|
|
3896
|
+
outputFile: "utils.ts",
|
|
3897
|
+
internalDeps: [],
|
|
3898
|
+
npmDeps: ["clsx", "tailwind-merge"],
|
|
3899
|
+
target: "lib"
|
|
3900
|
+
},
|
|
3901
|
+
{
|
|
3902
|
+
name: "use-animated-mount",
|
|
3903
|
+
description: "Hook for managing animated mount/unmount timing",
|
|
3904
|
+
templateKey: "use-animated-mount",
|
|
3905
|
+
outputFile: "use-animated-mount.ts",
|
|
3906
|
+
internalDeps: [],
|
|
3907
|
+
npmDeps: [],
|
|
3908
|
+
target: "hooks"
|
|
3909
|
+
},
|
|
3910
|
+
// ── Components ───────────────────────────────────────────────────────────
|
|
3911
|
+
{
|
|
3912
|
+
name: "accordion",
|
|
3913
|
+
description: "Collapsible content sections",
|
|
3914
|
+
templateKey: "Accordion",
|
|
3915
|
+
outputFile: "Accordion.tsx",
|
|
3916
|
+
internalDeps: ["utils"],
|
|
3917
|
+
npmDeps: [],
|
|
3918
|
+
target: "components"
|
|
3919
|
+
},
|
|
3920
|
+
{
|
|
3921
|
+
name: "alert",
|
|
3922
|
+
description: "Inline status messages",
|
|
3923
|
+
templateKey: "Alert",
|
|
3924
|
+
outputFile: "Alert.tsx",
|
|
3925
|
+
internalDeps: ["utils"],
|
|
3926
|
+
npmDeps: [],
|
|
3927
|
+
target: "components"
|
|
3928
|
+
},
|
|
3929
|
+
{
|
|
3930
|
+
name: "alert-dialog",
|
|
3931
|
+
description: "Confirmation dialog with destructive actions",
|
|
3932
|
+
templateKey: "AlertDialog",
|
|
3933
|
+
outputFile: "AlertDialog.tsx",
|
|
3934
|
+
internalDeps: ["utils", "use-animated-mount"],
|
|
3935
|
+
npmDeps: [],
|
|
3936
|
+
target: "components"
|
|
3937
|
+
},
|
|
3938
|
+
{
|
|
3939
|
+
name: "avatar",
|
|
3940
|
+
description: "User avatar with size variants",
|
|
3941
|
+
templateKey: "Avatar",
|
|
3942
|
+
outputFile: "Avatar.tsx",
|
|
3943
|
+
internalDeps: ["utils"],
|
|
3944
|
+
npmDeps: [],
|
|
3945
|
+
target: "components"
|
|
3946
|
+
},
|
|
3947
|
+
{
|
|
3948
|
+
name: "badge",
|
|
3949
|
+
description: "Status and label tags",
|
|
3950
|
+
templateKey: "Badge",
|
|
3951
|
+
outputFile: "Badge.tsx",
|
|
3952
|
+
internalDeps: ["utils"],
|
|
3953
|
+
npmDeps: [],
|
|
3954
|
+
target: "components"
|
|
3955
|
+
},
|
|
3956
|
+
{
|
|
3957
|
+
name: "breadcrumb",
|
|
3958
|
+
description: "Navigation breadcrumb trail",
|
|
3959
|
+
templateKey: "Breadcrumb",
|
|
3960
|
+
outputFile: "Breadcrumb.tsx",
|
|
3961
|
+
internalDeps: ["utils"],
|
|
3962
|
+
npmDeps: [],
|
|
3963
|
+
target: "components"
|
|
3964
|
+
},
|
|
3965
|
+
{
|
|
3966
|
+
name: "button",
|
|
3967
|
+
description: "Primary, secondary, ghost, outline, destructive variants",
|
|
3968
|
+
templateKey: "Button",
|
|
3969
|
+
outputFile: "Button.tsx",
|
|
3970
|
+
internalDeps: ["utils"],
|
|
3971
|
+
npmDeps: [],
|
|
3972
|
+
target: "components"
|
|
3973
|
+
},
|
|
3974
|
+
{
|
|
3975
|
+
name: "card",
|
|
3976
|
+
description: "Content container with variants",
|
|
3977
|
+
templateKey: "Card",
|
|
3978
|
+
outputFile: "Card.tsx",
|
|
3979
|
+
internalDeps: ["utils"],
|
|
3980
|
+
npmDeps: [],
|
|
3981
|
+
target: "components"
|
|
3982
|
+
},
|
|
3983
|
+
{
|
|
3984
|
+
name: "checkbox",
|
|
3985
|
+
description: "Controlled checkbox with label",
|
|
3986
|
+
templateKey: "Checkbox",
|
|
3987
|
+
outputFile: "Checkbox.tsx",
|
|
3988
|
+
internalDeps: ["utils"],
|
|
3989
|
+
npmDeps: [],
|
|
3990
|
+
target: "components"
|
|
3991
|
+
},
|
|
3992
|
+
{
|
|
3993
|
+
name: "combobox",
|
|
3994
|
+
description: "Searchable dropdown select",
|
|
3995
|
+
templateKey: "Combobox",
|
|
3996
|
+
outputFile: "Combobox.tsx",
|
|
3997
|
+
internalDeps: ["utils"],
|
|
3998
|
+
npmDeps: [],
|
|
3999
|
+
target: "components"
|
|
4000
|
+
},
|
|
4001
|
+
{
|
|
4002
|
+
name: "command",
|
|
4003
|
+
description: "Command palette container",
|
|
4004
|
+
templateKey: "Command",
|
|
4005
|
+
outputFile: "Command.tsx",
|
|
4006
|
+
internalDeps: ["utils"],
|
|
4007
|
+
npmDeps: [],
|
|
4008
|
+
target: "components"
|
|
4009
|
+
},
|
|
4010
|
+
{
|
|
4011
|
+
name: "date-picker",
|
|
4012
|
+
description: "Date input",
|
|
4013
|
+
templateKey: "DatePicker",
|
|
4014
|
+
outputFile: "DatePicker.tsx",
|
|
4015
|
+
internalDeps: ["utils"],
|
|
4016
|
+
npmDeps: [],
|
|
4017
|
+
target: "components"
|
|
4018
|
+
},
|
|
4019
|
+
{
|
|
4020
|
+
name: "dialog",
|
|
4021
|
+
description: "Modal dialog with compound sub-components",
|
|
4022
|
+
templateKey: "Dialog",
|
|
4023
|
+
outputFile: "Dialog.tsx",
|
|
4024
|
+
internalDeps: ["utils", "use-animated-mount"],
|
|
4025
|
+
npmDeps: [],
|
|
4026
|
+
target: "components"
|
|
4027
|
+
},
|
|
4028
|
+
{
|
|
4029
|
+
name: "drawer",
|
|
4030
|
+
description: "Slide-in panel (left/right)",
|
|
4031
|
+
templateKey: "Drawer",
|
|
4032
|
+
outputFile: "Drawer.tsx",
|
|
4033
|
+
internalDeps: ["utils", "use-animated-mount"],
|
|
4034
|
+
npmDeps: [],
|
|
4035
|
+
target: "components"
|
|
4036
|
+
},
|
|
4037
|
+
{
|
|
4038
|
+
name: "dropdown-menu",
|
|
4039
|
+
description: "Contextual dropdown",
|
|
4040
|
+
templateKey: "DropdownMenu",
|
|
4041
|
+
outputFile: "DropdownMenu.tsx",
|
|
4042
|
+
internalDeps: ["utils"],
|
|
4043
|
+
npmDeps: [],
|
|
4044
|
+
target: "components"
|
|
4045
|
+
},
|
|
4046
|
+
{
|
|
4047
|
+
name: "floating-lines",
|
|
4048
|
+
description: "Decorative animated background",
|
|
4049
|
+
templateKey: "FloatingLines",
|
|
4050
|
+
outputFile: "FloatingLines.tsx",
|
|
4051
|
+
internalDeps: ["utils"],
|
|
4052
|
+
npmDeps: [],
|
|
4053
|
+
target: "components"
|
|
4054
|
+
},
|
|
4055
|
+
{
|
|
4056
|
+
name: "input",
|
|
4057
|
+
description: "Text input with variants",
|
|
4058
|
+
templateKey: "Input",
|
|
4059
|
+
outputFile: "Input.tsx",
|
|
4060
|
+
internalDeps: ["utils"],
|
|
4061
|
+
npmDeps: [],
|
|
4062
|
+
target: "components"
|
|
4063
|
+
},
|
|
4064
|
+
{
|
|
4065
|
+
name: "pagination",
|
|
4066
|
+
description: "Page navigation",
|
|
4067
|
+
templateKey: "Pagination",
|
|
4068
|
+
outputFile: "Pagination.tsx",
|
|
4069
|
+
internalDeps: ["utils"],
|
|
4070
|
+
npmDeps: [],
|
|
4071
|
+
target: "components"
|
|
4072
|
+
},
|
|
4073
|
+
{
|
|
4074
|
+
name: "popover",
|
|
4075
|
+
description: "Floating content anchored to a trigger",
|
|
4076
|
+
templateKey: "Popover",
|
|
4077
|
+
outputFile: "Popover.tsx",
|
|
4078
|
+
internalDeps: ["utils"],
|
|
4079
|
+
npmDeps: [],
|
|
4080
|
+
target: "components"
|
|
4081
|
+
},
|
|
4082
|
+
{
|
|
4083
|
+
name: "progress",
|
|
4084
|
+
description: "Linear progress bar",
|
|
4085
|
+
templateKey: "Progress",
|
|
4086
|
+
outputFile: "Progress.tsx",
|
|
4087
|
+
internalDeps: ["utils"],
|
|
4088
|
+
npmDeps: [],
|
|
4089
|
+
target: "components"
|
|
4090
|
+
},
|
|
4091
|
+
{
|
|
4092
|
+
name: "progress-bar",
|
|
4093
|
+
description: "Animated top progress bar tied to navigation",
|
|
4094
|
+
templateKey: "ProgressBar",
|
|
4095
|
+
outputFile: "ProgressBar.tsx",
|
|
4096
|
+
internalDeps: ["utils"],
|
|
4097
|
+
npmDeps: [],
|
|
4098
|
+
target: "components"
|
|
4099
|
+
},
|
|
4100
|
+
{
|
|
4101
|
+
name: "radio-group",
|
|
4102
|
+
description: "Radio button group",
|
|
4103
|
+
templateKey: "RadioGroup",
|
|
4104
|
+
outputFile: "RadioGroup.tsx",
|
|
4105
|
+
internalDeps: ["utils"],
|
|
4106
|
+
npmDeps: [],
|
|
4107
|
+
target: "components"
|
|
4108
|
+
},
|
|
4109
|
+
{
|
|
4110
|
+
name: "search-dialog",
|
|
4111
|
+
description: "Full-screen search dialog",
|
|
4112
|
+
templateKey: "SearchDialog",
|
|
4113
|
+
outputFile: "SearchDialog.tsx",
|
|
4114
|
+
internalDeps: ["utils", "use-animated-mount"],
|
|
4115
|
+
npmDeps: [],
|
|
4116
|
+
target: "components"
|
|
4117
|
+
},
|
|
4118
|
+
{
|
|
4119
|
+
name: "select",
|
|
4120
|
+
description: "Native select with styling",
|
|
4121
|
+
templateKey: "Select",
|
|
4122
|
+
outputFile: "Select.tsx",
|
|
4123
|
+
internalDeps: ["utils"],
|
|
4124
|
+
npmDeps: [],
|
|
4125
|
+
target: "components"
|
|
4126
|
+
},
|
|
4127
|
+
{
|
|
4128
|
+
name: "separator",
|
|
4129
|
+
description: "Horizontal/vertical divider",
|
|
4130
|
+
templateKey: "Separator",
|
|
4131
|
+
outputFile: "Separator.tsx",
|
|
4132
|
+
internalDeps: ["utils"],
|
|
4133
|
+
npmDeps: [],
|
|
4134
|
+
target: "components"
|
|
4135
|
+
},
|
|
4136
|
+
{
|
|
4137
|
+
name: "skeleton",
|
|
4138
|
+
description: "Loading placeholder shapes",
|
|
4139
|
+
templateKey: "Skeleton",
|
|
4140
|
+
outputFile: "Skeleton.tsx",
|
|
4141
|
+
internalDeps: ["utils"],
|
|
4142
|
+
npmDeps: [],
|
|
4143
|
+
target: "components"
|
|
4144
|
+
},
|
|
4145
|
+
{
|
|
4146
|
+
name: "slider",
|
|
4147
|
+
description: "Range input",
|
|
4148
|
+
templateKey: "Slider",
|
|
4149
|
+
outputFile: "Slider.tsx",
|
|
4150
|
+
internalDeps: ["utils"],
|
|
4151
|
+
npmDeps: [],
|
|
4152
|
+
target: "components"
|
|
4153
|
+
},
|
|
4154
|
+
{
|
|
4155
|
+
name: "switch",
|
|
4156
|
+
description: "Toggle switch",
|
|
4157
|
+
templateKey: "Switch",
|
|
4158
|
+
outputFile: "Switch.tsx",
|
|
4159
|
+
internalDeps: ["utils"],
|
|
4160
|
+
npmDeps: [],
|
|
4161
|
+
target: "components"
|
|
4162
|
+
},
|
|
4163
|
+
{
|
|
4164
|
+
name: "tabs",
|
|
4165
|
+
description: "Tabbed content panels",
|
|
4166
|
+
templateKey: "Tabs",
|
|
4167
|
+
outputFile: "Tabs.tsx",
|
|
4168
|
+
internalDeps: ["utils"],
|
|
4169
|
+
npmDeps: [],
|
|
4170
|
+
target: "components"
|
|
4171
|
+
},
|
|
4172
|
+
{
|
|
4173
|
+
name: "textarea",
|
|
4174
|
+
description: "Multi-line text input",
|
|
4175
|
+
templateKey: "Textarea",
|
|
4176
|
+
outputFile: "Textarea.tsx",
|
|
4177
|
+
internalDeps: ["utils"],
|
|
4178
|
+
npmDeps: [],
|
|
4179
|
+
target: "components"
|
|
4180
|
+
},
|
|
4181
|
+
{
|
|
4182
|
+
name: "toast",
|
|
4183
|
+
description: "Transient notification",
|
|
4184
|
+
templateKey: "Toast",
|
|
4185
|
+
outputFile: "Toast.tsx",
|
|
4186
|
+
internalDeps: ["utils", "use-animated-mount"],
|
|
4187
|
+
npmDeps: [],
|
|
4188
|
+
target: "components"
|
|
4189
|
+
},
|
|
4190
|
+
{
|
|
4191
|
+
name: "tooltip",
|
|
4192
|
+
description: "Hover tooltip with positioning",
|
|
4193
|
+
templateKey: "Tooltip",
|
|
4194
|
+
outputFile: "Tooltip.tsx",
|
|
4195
|
+
internalDeps: ["utils"],
|
|
4196
|
+
npmDeps: [],
|
|
4197
|
+
target: "components"
|
|
4198
|
+
}
|
|
4199
|
+
];
|
|
4200
|
+
var BY_NAME = new Map(REGISTRY.map((e) => [e.name, e]));
|
|
4201
|
+
function getEntry(name) {
|
|
4202
|
+
return BY_NAME.get(name);
|
|
4203
|
+
}
|
|
4204
|
+
function getAll() {
|
|
4205
|
+
return REGISTRY;
|
|
4206
|
+
}
|
|
4207
|
+
function resolve(name) {
|
|
4208
|
+
const entry = BY_NAME.get(name);
|
|
4209
|
+
if (!entry) return [];
|
|
4210
|
+
const visited = /* @__PURE__ */ new Set();
|
|
4211
|
+
const result = [];
|
|
4212
|
+
function walk(e) {
|
|
4213
|
+
if (visited.has(e.name)) return;
|
|
4214
|
+
visited.add(e.name);
|
|
4215
|
+
for (const dep of e.internalDeps) {
|
|
4216
|
+
const d = BY_NAME.get(dep);
|
|
4217
|
+
if (d) walk(d);
|
|
4218
|
+
}
|
|
4219
|
+
result.push(e);
|
|
4220
|
+
}
|
|
4221
|
+
walk(entry);
|
|
4222
|
+
return result;
|
|
4223
|
+
}
|
|
4224
|
+
function getTemplate(key) {
|
|
4225
|
+
return TEMPLATES[key] ?? "";
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
// src/utils/pm.ts
|
|
4229
|
+
var import_child_process = require("child_process");
|
|
4230
|
+
var import_fs2 = require("fs");
|
|
4231
|
+
var import_path2 = require("path");
|
|
4232
|
+
function detectPackageManager(cwd) {
|
|
4233
|
+
if ((0, import_fs2.existsSync)((0, import_path2.join)(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
4234
|
+
if ((0, import_fs2.existsSync)((0, import_path2.join)(cwd, "yarn.lock"))) return "yarn";
|
|
4235
|
+
return "npm";
|
|
4236
|
+
}
|
|
4237
|
+
function installPackages(deps, cwd) {
|
|
4238
|
+
if (deps.length === 0) return;
|
|
4239
|
+
const pm = detectPackageManager(cwd);
|
|
4240
|
+
const cmd = pm === "yarn" ? "yarn add" : `${pm} install`;
|
|
4241
|
+
const list = deps.join(" ");
|
|
4242
|
+
(0, import_child_process.execSync)(`${cmd} ${list}`, { cwd, stdio: "inherit" });
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
// src/utils/css.ts
|
|
4246
|
+
var import_fs3 = require("fs");
|
|
4247
|
+
var import_path3 = require("path");
|
|
4248
|
+
var GLOBALS_CANDIDATES = [
|
|
4249
|
+
"src/app/globals.css",
|
|
4250
|
+
"src/index.css",
|
|
4251
|
+
"src/styles/globals.css",
|
|
4252
|
+
"src/styles/index.css",
|
|
4253
|
+
"app/globals.css"
|
|
4254
|
+
];
|
|
4255
|
+
var TAILWIND_IMPORT = '@import "tailwindcss";';
|
|
4256
|
+
function setupGlobalsCss(cwd, framework) {
|
|
4257
|
+
if (framework === "vite") return null;
|
|
4258
|
+
for (const candidate of GLOBALS_CANDIDATES) {
|
|
4259
|
+
const fullPath = (0, import_path3.join)(cwd, candidate);
|
|
4260
|
+
if (!(0, import_fs3.existsSync)(fullPath)) continue;
|
|
4261
|
+
const content = (0, import_fs3.readFileSync)(fullPath, "utf8");
|
|
4262
|
+
if (content.includes("@import") && content.includes("tailwindcss")) return null;
|
|
4263
|
+
const updated = `${TAILWIND_IMPORT}
|
|
4264
|
+
${content}`.trimEnd() + "\n";
|
|
4265
|
+
(0, import_fs3.writeFileSync)(fullPath, updated, "utf8");
|
|
4266
|
+
return candidate;
|
|
4267
|
+
}
|
|
4268
|
+
return null;
|
|
4269
|
+
}
|
|
4270
|
+
|
|
4271
|
+
// src/commands/init.ts
|
|
4272
|
+
var FRAMEWORK_LABELS = {
|
|
4273
|
+
next: "Next.js",
|
|
4274
|
+
vite: "Vite",
|
|
4275
|
+
remix: "Remix",
|
|
4276
|
+
other: "Other"
|
|
4277
|
+
};
|
|
4278
|
+
async function init(cwd, frameworkFlag) {
|
|
4279
|
+
const existing = readConfig(cwd);
|
|
4280
|
+
if (existing) {
|
|
4281
|
+
console.log(import_picocolors.default.yellow("components.json already exists. Remove it to re-run init."));
|
|
4282
|
+
return;
|
|
4283
|
+
}
|
|
4284
|
+
console.log(import_picocolors.default.bold("\nInitializing react-principles components...\n"));
|
|
4285
|
+
const detectedFramework = frameworkFlag ?? detectFramework(cwd);
|
|
4286
|
+
const detectedAliases = detectTsconfigAliases(cwd);
|
|
4287
|
+
const defaults = getDefaultConfig();
|
|
4288
|
+
if (detectedAliases.components) defaults.aliases.components = detectedAliases.components;
|
|
4289
|
+
if (detectedAliases.hooks) defaults.aliases.hooks = detectedAliases.hooks;
|
|
4290
|
+
if (detectedAliases.lib) defaults.aliases.lib = detectedAliases.lib;
|
|
4291
|
+
const answers = await (0, import_prompts.default)(
|
|
4292
|
+
[
|
|
4293
|
+
{
|
|
4294
|
+
type: "select",
|
|
4295
|
+
name: "framework",
|
|
4296
|
+
message: "Which framework are you using?",
|
|
4297
|
+
choices: [
|
|
4298
|
+
{ title: "Next.js", value: "next" },
|
|
4299
|
+
{ title: "Vite", value: "vite" },
|
|
4300
|
+
{ title: "Remix", value: "remix" },
|
|
4301
|
+
{ title: "Other", value: "other" }
|
|
4302
|
+
],
|
|
4303
|
+
initial: ["next", "vite", "remix", "other"].indexOf(detectedFramework),
|
|
4304
|
+
hint: detectedFramework !== "other" ? `detected: ${FRAMEWORK_LABELS[detectedFramework]}` : void 0
|
|
4305
|
+
},
|
|
4306
|
+
{
|
|
4307
|
+
type: "text",
|
|
4308
|
+
name: "componentsDir",
|
|
4309
|
+
message: "Where should components be installed?",
|
|
4310
|
+
initial: defaults.componentsDir
|
|
4311
|
+
},
|
|
4312
|
+
{
|
|
4313
|
+
type: "text",
|
|
4314
|
+
name: "hooksDir",
|
|
4315
|
+
message: "Where should hooks be installed?",
|
|
4316
|
+
initial: defaults.hooksDir
|
|
4317
|
+
},
|
|
4318
|
+
{
|
|
4319
|
+
type: "text",
|
|
4320
|
+
name: "libDir",
|
|
4321
|
+
message: "Where should utilities be installed?",
|
|
4322
|
+
initial: defaults.libDir
|
|
4323
|
+
}
|
|
4324
|
+
],
|
|
4325
|
+
{
|
|
4326
|
+
onCancel: () => {
|
|
4327
|
+
console.log(import_picocolors.default.red("\nSetup cancelled."));
|
|
4328
|
+
process.exit(0);
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
);
|
|
4332
|
+
const framework = answers.framework;
|
|
4333
|
+
const rsc = framework === "next";
|
|
4334
|
+
const config = {
|
|
4335
|
+
framework,
|
|
4336
|
+
rsc,
|
|
4337
|
+
tsx: true,
|
|
4338
|
+
componentsDir: answers.componentsDir,
|
|
4339
|
+
hooksDir: answers.hooksDir,
|
|
4340
|
+
libDir: answers.libDir,
|
|
4341
|
+
aliases: {
|
|
4342
|
+
components: `@/${answers.componentsDir.replace(/^src\//, "")}`,
|
|
4343
|
+
hooks: `@/${answers.hooksDir.replace(/^src\//, "")}`,
|
|
4344
|
+
lib: `@/${answers.libDir.replace(/^src\//, "")}`
|
|
4345
|
+
}
|
|
4346
|
+
};
|
|
4347
|
+
writeConfig(config, cwd);
|
|
4348
|
+
console.log(import_picocolors.default.green("\n\u2713") + " Created components.json");
|
|
4349
|
+
const utilsEntry = getEntry("utils");
|
|
4350
|
+
if (utilsEntry) {
|
|
4351
|
+
const outputPath = resolveOutputPath(utilsEntry.outputFile, utilsEntry.target, config, cwd);
|
|
4352
|
+
const written = writeFile(getTemplate(utilsEntry.templateKey), outputPath);
|
|
4353
|
+
const rel = (0, import_path4.join)(config.libDir, utilsEntry.outputFile);
|
|
4354
|
+
console.log(
|
|
4355
|
+
written ? import_picocolors.default.green("\u2713") + ` Created ${rel}` : import_picocolors.default.gray("\u2013") + ` Skipped ${rel} (already exists)`
|
|
4356
|
+
);
|
|
4357
|
+
const hasDeps = utilsEntry.npmDeps.some((d) => !(0, import_fs4.existsSync)((0, import_path4.join)(cwd, "node_modules", d)));
|
|
4358
|
+
if (hasDeps) {
|
|
4359
|
+
console.log(import_picocolors.default.cyan("\nInstalling dependencies..."));
|
|
4360
|
+
installPackages([...utilsEntry.npmDeps, "tailwind-animate"], cwd);
|
|
4361
|
+
}
|
|
4362
|
+
}
|
|
4363
|
+
const cssResult = setupGlobalsCss(cwd, framework);
|
|
4364
|
+
if (cssResult) console.log(import_picocolors.default.green("\u2713") + ` Updated ${cssResult}`);
|
|
4365
|
+
console.log(
|
|
4366
|
+
import_picocolors.default.bold(import_picocolors.default.green("\nDone!")) + ` Framework: ${FRAMEWORK_LABELS[framework]} | RSC: ${rsc ? "yes" : "no"}
|
|
4367
|
+
Run ${import_picocolors.default.cyan("npx react-principles-cli add <component>")} to add components.
|
|
4368
|
+
`
|
|
4369
|
+
);
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
// src/commands/add.ts
|
|
4373
|
+
var import_picocolors2 = __toESM(require("picocolors"));
|
|
4374
|
+
async function add(names, cwd) {
|
|
4375
|
+
const config = readConfig(cwd);
|
|
4376
|
+
if (!config) {
|
|
4377
|
+
console.log(import_picocolors2.default.red("No components.json found. Run `npx react-principles-cli init` first."));
|
|
4378
|
+
process.exit(1);
|
|
4379
|
+
}
|
|
4380
|
+
const targets = names.length === 1 && names[0] === "--all" ? getAll().filter((e) => e.target === "components").map((e) => e.name) : names;
|
|
4381
|
+
const unknown = targets.filter((n) => !getEntry(n));
|
|
4382
|
+
if (unknown.length > 0) {
|
|
4383
|
+
console.log(import_picocolors2.default.red(`Unknown component(s): ${unknown.join(", ")}`));
|
|
4384
|
+
console.log(import_picocolors2.default.gray("Run `npx react-principles-cli list` to see available components."));
|
|
4385
|
+
process.exit(1);
|
|
4386
|
+
}
|
|
4387
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4388
|
+
const toInstall = targets.flatMap((n) => resolve(n)).filter((e) => {
|
|
4389
|
+
if (seen.has(e.name)) return false;
|
|
4390
|
+
seen.add(e.name);
|
|
4391
|
+
return true;
|
|
4392
|
+
});
|
|
4393
|
+
const allNpmDeps = /* @__PURE__ */ new Set();
|
|
4394
|
+
const written = [];
|
|
4395
|
+
const skipped = [];
|
|
4396
|
+
for (const entry of toInstall) {
|
|
4397
|
+
const outputPath = resolveOutputPath(entry.outputFile, entry.target, config, cwd);
|
|
4398
|
+
const content = getTemplate(entry.templateKey);
|
|
4399
|
+
if (!content) {
|
|
4400
|
+
console.log(import_picocolors2.default.yellow(`\u26A0 No template found for ${entry.name}, skipping.`));
|
|
4401
|
+
continue;
|
|
4402
|
+
}
|
|
4403
|
+
const didWrite = writeFile(content, outputPath);
|
|
4404
|
+
const label = [config.componentsDir, config.hooksDir, config.libDir].find((d) => outputPath.includes(d))?.concat("/", entry.outputFile) ?? entry.outputFile;
|
|
4405
|
+
if (didWrite) {
|
|
4406
|
+
written.push(label);
|
|
4407
|
+
} else {
|
|
4408
|
+
skipped.push(label);
|
|
4409
|
+
}
|
|
4410
|
+
for (const dep of entry.npmDeps) allNpmDeps.add(dep);
|
|
4411
|
+
}
|
|
4412
|
+
for (const f of written) console.log(import_picocolors2.default.green("\u2713") + ` Created ${f}`);
|
|
4413
|
+
for (const f of skipped) console.log(import_picocolors2.default.gray("\u2013") + ` Skipped ${f} (already exists)`);
|
|
4414
|
+
if (allNpmDeps.size > 0) {
|
|
4415
|
+
console.log(import_picocolors2.default.cyan("\nInstalling dependencies..."));
|
|
4416
|
+
installPackages([...allNpmDeps], cwd);
|
|
4417
|
+
}
|
|
4418
|
+
if (written.length > 0) {
|
|
4419
|
+
console.log(import_picocolors2.default.bold(import_picocolors2.default.green("\nDone!\n")));
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4423
|
+
// src/index.ts
|
|
4424
|
+
var program = new import_commander.Command();
|
|
4425
|
+
program.name("react-principles").description("Add react-principles UI components to your project").version("0.0.1");
|
|
4426
|
+
program.command("init").description("Initialize components.json config and install the cn() utility").option("-t, --template <framework>", "Framework: next | vite | remix | other").action(async (opts) => {
|
|
4427
|
+
await init(process.cwd(), opts.template);
|
|
4428
|
+
});
|
|
4429
|
+
program.command("add <components...>").description("Add one or more components to your project").action(async (components) => {
|
|
4430
|
+
await add(components, process.cwd());
|
|
4431
|
+
});
|
|
4432
|
+
program.command("list").description("List all available components").action(() => {
|
|
4433
|
+
const entries = getAll().filter((e) => e.target === "components");
|
|
4434
|
+
console.log(import_picocolors3.default.bold("\nAvailable components:\n"));
|
|
4435
|
+
for (const e of entries) {
|
|
4436
|
+
console.log(` ${import_picocolors3.default.cyan(e.name.padEnd(20))} ${import_picocolors3.default.gray(e.description)}`);
|
|
4437
|
+
}
|
|
4438
|
+
console.log();
|
|
4439
|
+
});
|
|
4440
|
+
program.parse();
|