@quarklab/rad-ui 0.2.0 → 0.3.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.js +220 -97
- package/package.json +1 -1
- package/templates/web/input/input.tsx +454 -0
- package/templates/web/input/validation.ts +202 -0
- package/templates/web/input.tsx +0 -103
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Command } from "commander";
|
|
|
7
7
|
import path3 from "path";
|
|
8
8
|
import fs3 from "fs-extra";
|
|
9
9
|
import * as p from "@clack/prompts";
|
|
10
|
-
import
|
|
10
|
+
import chalk2 from "chalk";
|
|
11
11
|
|
|
12
12
|
// src/utils/detect-project.ts
|
|
13
13
|
import path from "path";
|
|
@@ -34,6 +34,76 @@ async function detectPackageManager(cwd) {
|
|
|
34
34
|
if (await fs.pathExists(path.resolve(cwd, "yarn.lock"))) return "yarn";
|
|
35
35
|
return "npm";
|
|
36
36
|
}
|
|
37
|
+
async function detectTailwindVersion(cwd, cssPath) {
|
|
38
|
+
const pkgPath = path.resolve(cwd, "package.json");
|
|
39
|
+
if (await fs.pathExists(pkgPath)) {
|
|
40
|
+
try {
|
|
41
|
+
const pkg = await fs.readJson(pkgPath);
|
|
42
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
43
|
+
const twVersion = allDeps["tailwindcss"];
|
|
44
|
+
if (twVersion) {
|
|
45
|
+
const cleanVersion = twVersion.replace(/^[\^~>=<]+/, "");
|
|
46
|
+
if (cleanVersion.startsWith("4")) return "v4";
|
|
47
|
+
if (cleanVersion.startsWith("3")) return "v3";
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (cssPath) {
|
|
53
|
+
const fullCssPath = path.resolve(cwd, cssPath);
|
|
54
|
+
if (await fs.pathExists(fullCssPath)) {
|
|
55
|
+
try {
|
|
56
|
+
const cssContent = await fs.readFile(fullCssPath, "utf-8");
|
|
57
|
+
if (cssContent.includes('@import "tailwindcss"') || cssContent.includes("@import 'tailwindcss'")) {
|
|
58
|
+
return "v4";
|
|
59
|
+
}
|
|
60
|
+
if (cssContent.includes("@tailwind base")) {
|
|
61
|
+
return "v3";
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const configFiles = [
|
|
68
|
+
"tailwind.config.ts",
|
|
69
|
+
"tailwind.config.js",
|
|
70
|
+
"tailwind.config.cjs",
|
|
71
|
+
"tailwind.config.mjs"
|
|
72
|
+
];
|
|
73
|
+
for (const file of configFiles) {
|
|
74
|
+
if (await fs.pathExists(path.resolve(cwd, file))) {
|
|
75
|
+
return "v3";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return "v4";
|
|
79
|
+
}
|
|
80
|
+
async function detectSrcDir(cwd) {
|
|
81
|
+
const tsconfigPath = path.resolve(cwd, "tsconfig.json");
|
|
82
|
+
if (await fs.pathExists(tsconfigPath)) {
|
|
83
|
+
try {
|
|
84
|
+
const raw = await fs.readFile(tsconfigPath, "utf-8");
|
|
85
|
+
const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
86
|
+
const tsconfig = JSON.parse(cleaned);
|
|
87
|
+
const paths = tsconfig?.compilerOptions?.paths;
|
|
88
|
+
if (paths) {
|
|
89
|
+
const aliasKey = Object.keys(paths).find(
|
|
90
|
+
(k) => k === "@/*" || k === "~/*"
|
|
91
|
+
);
|
|
92
|
+
if (aliasKey) {
|
|
93
|
+
const aliasTargets = paths[aliasKey];
|
|
94
|
+
if (Array.isArray(aliasTargets) && aliasTargets.length > 0) {
|
|
95
|
+
const target = aliasTargets[0].replace(/^\.\//, "").replace(/\/\*$/, "");
|
|
96
|
+
if (target) return target;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (await fs.pathExists(path.resolve(cwd, "src"))) return "src";
|
|
104
|
+
if (await fs.pathExists(path.resolve(cwd, "app"))) return "app";
|
|
105
|
+
return "src";
|
|
106
|
+
}
|
|
37
107
|
function getDefaultCssPath(projectType) {
|
|
38
108
|
switch (projectType) {
|
|
39
109
|
case "nextjs":
|
|
@@ -71,9 +141,7 @@ async function readConfig(cwd) {
|
|
|
71
141
|
const configPath = getConfigPath(cwd);
|
|
72
142
|
const exists = await fs2.pathExists(configPath);
|
|
73
143
|
if (!exists) {
|
|
74
|
-
throw new Error(
|
|
75
|
-
`Configuration file not found. Run ${chalk("rad-ui init")} first.`
|
|
76
|
-
);
|
|
144
|
+
throw new Error(`Configuration file not found. Run \`rad-ui init\` first.`);
|
|
77
145
|
}
|
|
78
146
|
return fs2.readJson(configPath);
|
|
79
147
|
}
|
|
@@ -81,19 +149,19 @@ async function writeConfig(cwd, config) {
|
|
|
81
149
|
const configPath = getConfigPath(cwd);
|
|
82
150
|
await fs2.writeJson(configPath, config, { spaces: 2 });
|
|
83
151
|
}
|
|
84
|
-
function
|
|
85
|
-
return
|
|
152
|
+
function resolveAlias(alias, srcDir) {
|
|
153
|
+
return alias.replace(/^@\//, srcDir + "/").replace(/^~\//, srcDir + "/");
|
|
86
154
|
}
|
|
87
155
|
function resolveComponentsDir(cwd, config) {
|
|
88
|
-
const
|
|
89
|
-
return path2.resolve(cwd,
|
|
156
|
+
const relPath = resolveAlias(config.aliases.components, config.srcDir);
|
|
157
|
+
return path2.resolve(cwd, relPath);
|
|
90
158
|
}
|
|
91
159
|
|
|
92
160
|
// src/registry/themes.ts
|
|
93
161
|
var themes = [
|
|
94
162
|
{
|
|
95
163
|
name: "kahgel",
|
|
96
|
-
label: "Kahgel
|
|
164
|
+
label: "Kahgel \u2014 Warm Clay",
|
|
97
165
|
cssVars: {
|
|
98
166
|
light: {
|
|
99
167
|
"--background": "40 20% 98%",
|
|
@@ -132,7 +200,7 @@ var themes = [
|
|
|
132
200
|
},
|
|
133
201
|
{
|
|
134
202
|
name: "firouzeh",
|
|
135
|
-
label: "Firouzeh
|
|
203
|
+
label: "Firouzeh \u2014 Persian Turquoise",
|
|
136
204
|
cssVars: {
|
|
137
205
|
light: {
|
|
138
206
|
"--background": "180 20% 98%",
|
|
@@ -171,7 +239,7 @@ var themes = [
|
|
|
171
239
|
},
|
|
172
240
|
{
|
|
173
241
|
name: "lajvard",
|
|
174
|
-
label: "Lajvard
|
|
242
|
+
label: "Lajvard \u2014 Lapis Lazuli",
|
|
175
243
|
cssVars: {
|
|
176
244
|
light: {
|
|
177
245
|
"--background": "225 25% 98%",
|
|
@@ -210,7 +278,7 @@ var themes = [
|
|
|
210
278
|
},
|
|
211
279
|
{
|
|
212
280
|
name: "puste",
|
|
213
|
-
label: "Puste
|
|
281
|
+
label: "Puste \u2014 Pistachio",
|
|
214
282
|
cssVars: {
|
|
215
283
|
light: {
|
|
216
284
|
"--background": "80 20% 98%",
|
|
@@ -249,7 +317,7 @@ var themes = [
|
|
|
249
317
|
},
|
|
250
318
|
{
|
|
251
319
|
name: "anar",
|
|
252
|
-
label: "Anar
|
|
320
|
+
label: "Anar \u2014 Pomegranate",
|
|
253
321
|
cssVars: {
|
|
254
322
|
light: {
|
|
255
323
|
"--background": "0 20% 98%",
|
|
@@ -287,7 +355,29 @@ var themes = [
|
|
|
287
355
|
}
|
|
288
356
|
}
|
|
289
357
|
];
|
|
290
|
-
|
|
358
|
+
var COLOR_VARS = [
|
|
359
|
+
"background",
|
|
360
|
+
"foreground",
|
|
361
|
+
"primary",
|
|
362
|
+
"primary-foreground",
|
|
363
|
+
"secondary",
|
|
364
|
+
"secondary-foreground",
|
|
365
|
+
"destructive",
|
|
366
|
+
"destructive-foreground",
|
|
367
|
+
"card",
|
|
368
|
+
"card-foreground",
|
|
369
|
+
"border",
|
|
370
|
+
"muted",
|
|
371
|
+
"muted-foreground",
|
|
372
|
+
"ring"
|
|
373
|
+
];
|
|
374
|
+
function generateThemeCSS(theme, tailwindVersion = "v3") {
|
|
375
|
+
if (tailwindVersion === "v4") {
|
|
376
|
+
return generateV4CSS(theme);
|
|
377
|
+
}
|
|
378
|
+
return generateV3CSS(theme);
|
|
379
|
+
}
|
|
380
|
+
function generateV3CSS(theme) {
|
|
291
381
|
const lightVars = Object.entries(theme.cssVars.light).map(([key, value]) => ` ${key}: ${value};`).join("\n");
|
|
292
382
|
const darkVars = Object.entries(theme.cssVars.dark).map(([key, value]) => ` ${key}: ${value};`).join("\n");
|
|
293
383
|
return `@tailwind base;
|
|
@@ -314,21 +404,51 @@ ${darkVars}
|
|
|
314
404
|
}
|
|
315
405
|
`;
|
|
316
406
|
}
|
|
407
|
+
function generateV4CSS(theme) {
|
|
408
|
+
const lightVars = Object.entries(theme.cssVars.light).map(([key, value]) => {
|
|
409
|
+
if (key === "--radius") return ` ${key}: ${value};`;
|
|
410
|
+
return ` ${key}: hsl(${value});`;
|
|
411
|
+
}).join("\n");
|
|
412
|
+
const darkVars = Object.entries(theme.cssVars.dark).map(([key, value]) => {
|
|
413
|
+
if (key === "--radius") return ` ${key}: ${value};`;
|
|
414
|
+
return ` ${key}: hsl(${value});`;
|
|
415
|
+
}).join("\n");
|
|
416
|
+
const themeColorMappings = COLOR_VARS.map(
|
|
417
|
+
(name) => ` --color-${name}: var(--${name});`
|
|
418
|
+
).join("\n");
|
|
419
|
+
return `@import "tailwindcss";
|
|
420
|
+
|
|
421
|
+
:root {
|
|
422
|
+
${lightVars}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.dark {
|
|
426
|
+
${darkVars}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
@theme inline {
|
|
430
|
+
${themeColorMappings}
|
|
431
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
432
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
433
|
+
--radius-lg: var(--radius);
|
|
434
|
+
}
|
|
435
|
+
`;
|
|
436
|
+
}
|
|
317
437
|
|
|
318
438
|
// src/utils/logger.ts
|
|
319
|
-
import
|
|
439
|
+
import chalk from "chalk";
|
|
320
440
|
var logger = {
|
|
321
|
-
info: (msg) => console.log(
|
|
322
|
-
success: (msg) => console.log(
|
|
323
|
-
warn: (msg) => console.log(
|
|
324
|
-
error: (msg) => console.log(
|
|
441
|
+
info: (msg) => console.log(chalk.cyan("\u2139"), msg),
|
|
442
|
+
success: (msg) => console.log(chalk.green("\u2713"), msg),
|
|
443
|
+
warn: (msg) => console.log(chalk.yellow("\u26A0"), msg),
|
|
444
|
+
error: (msg) => console.log(chalk.red("\u2717"), msg),
|
|
325
445
|
break: () => console.log("")
|
|
326
446
|
};
|
|
327
447
|
|
|
328
448
|
// src/commands/init.ts
|
|
329
449
|
async function initCommand(opts) {
|
|
330
450
|
const cwd = process.cwd();
|
|
331
|
-
p.intro(
|
|
451
|
+
p.intro(chalk2.bold("Welcome to Rad UI"));
|
|
332
452
|
if (await configExists(cwd)) {
|
|
333
453
|
const shouldContinue = await p.confirm({
|
|
334
454
|
message: "Rad UI is already initialized in this project. Reinitialize?",
|
|
@@ -341,53 +461,52 @@ async function initCommand(opts) {
|
|
|
341
461
|
}
|
|
342
462
|
const projectType = await detectProjectType(cwd);
|
|
343
463
|
const packageManager = await detectPackageManager(cwd);
|
|
464
|
+
const detectedSrcDir = await detectSrcDir(cwd);
|
|
465
|
+
const defaultCssPath = getDefaultCssPath(projectType);
|
|
466
|
+
const tailwindVersion = await detectTailwindVersion(cwd, defaultCssPath);
|
|
344
467
|
if (projectType !== "unknown") {
|
|
345
|
-
p.log.info(`Detected project: ${
|
|
468
|
+
p.log.info(`Detected project: ${chalk2.cyan(projectType)}`);
|
|
346
469
|
}
|
|
347
|
-
p.log.info(`Package manager: ${
|
|
470
|
+
p.log.info(`Package manager: ${chalk2.cyan(packageManager)}`);
|
|
471
|
+
p.log.info(`Tailwind CSS: ${chalk2.cyan(tailwindVersion)}`);
|
|
472
|
+
p.log.info(`Source directory: ${chalk2.cyan(detectedSrcDir + "/")}`);
|
|
348
473
|
const responses = await p.group(
|
|
349
474
|
{
|
|
350
|
-
platform: () => p.select({
|
|
351
|
-
message: "Which platform are you building for?",
|
|
352
|
-
options: [
|
|
353
|
-
{ value: "web", label: "Web", hint: "React + Tailwind CSS" },
|
|
354
|
-
{
|
|
355
|
-
value: "mobile",
|
|
356
|
-
label: "Mobile",
|
|
357
|
-
hint: "React Native + NativeWind (coming soon)"
|
|
358
|
-
},
|
|
359
|
-
{ value: "both", label: "Both", hint: "Web + Mobile" }
|
|
360
|
-
],
|
|
361
|
-
initialValue: "web"
|
|
362
|
-
}),
|
|
363
475
|
theme: () => p.select({
|
|
364
|
-
message: "Choose a theme:",
|
|
476
|
+
message: "Choose a color theme for your project:",
|
|
365
477
|
options: themes.map((t) => ({
|
|
366
478
|
value: t.name,
|
|
367
479
|
label: t.label
|
|
368
480
|
})),
|
|
369
481
|
initialValue: "kahgel"
|
|
370
482
|
}),
|
|
483
|
+
srcDir: () => p.text({
|
|
484
|
+
message: `Base directory that @ resolves to:`,
|
|
485
|
+
placeholder: detectedSrcDir,
|
|
486
|
+
defaultValue: detectedSrcDir
|
|
487
|
+
}),
|
|
371
488
|
componentsPath: () => p.text({
|
|
372
|
-
message:
|
|
489
|
+
message: `Where to add UI components (e.g. ${detectedSrcDir}/components/ui):`,
|
|
373
490
|
placeholder: "@/components/ui",
|
|
374
491
|
defaultValue: "@/components/ui"
|
|
375
492
|
}),
|
|
376
493
|
utilsPath: () => p.text({
|
|
377
|
-
message:
|
|
494
|
+
message: `Where to create the cn() helper (e.g. ${detectedSrcDir}/lib/utils.ts):`,
|
|
378
495
|
placeholder: "@/lib/utils",
|
|
379
496
|
defaultValue: "@/lib/utils"
|
|
380
497
|
}),
|
|
381
498
|
cssPath: () => p.text({
|
|
382
|
-
message:
|
|
383
|
-
placeholder:
|
|
384
|
-
defaultValue:
|
|
499
|
+
message: `Path to your global CSS file:`,
|
|
500
|
+
placeholder: defaultCssPath,
|
|
501
|
+
defaultValue: defaultCssPath
|
|
385
502
|
}),
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
503
|
+
...tailwindVersion === "v3" ? {
|
|
504
|
+
tailwindConfig: () => p.text({
|
|
505
|
+
message: `Path to your Tailwind config file:`,
|
|
506
|
+
placeholder: getDefaultTailwindConfig(projectType),
|
|
507
|
+
defaultValue: getDefaultTailwindConfig(projectType)
|
|
508
|
+
})
|
|
509
|
+
} : {}
|
|
391
510
|
},
|
|
392
511
|
{
|
|
393
512
|
onCancel: () => {
|
|
@@ -396,15 +515,18 @@ async function initCommand(opts) {
|
|
|
396
515
|
}
|
|
397
516
|
}
|
|
398
517
|
);
|
|
518
|
+
const srcDir = responses.srcDir || detectedSrcDir;
|
|
399
519
|
const s = p.spinner();
|
|
400
520
|
s.start("Creating configuration...");
|
|
401
521
|
const config = {
|
|
402
522
|
$schema: "https://www.quarklab.dev/schema.json",
|
|
403
523
|
registry: "https://www.quarklab.dev/r",
|
|
404
|
-
|
|
524
|
+
tailwindVersion,
|
|
525
|
+
srcDir,
|
|
526
|
+
platform: "web",
|
|
405
527
|
theme: responses.theme,
|
|
406
528
|
tailwind: {
|
|
407
|
-
config: responses.tailwindConfig,
|
|
529
|
+
...tailwindVersion === "v3" && responses.tailwindConfig ? { config: responses.tailwindConfig } : {},
|
|
408
530
|
css: responses.cssPath
|
|
409
531
|
},
|
|
410
532
|
aliases: {
|
|
@@ -420,13 +542,13 @@ async function initCommand(opts) {
|
|
|
420
542
|
const cssPath = path3.resolve(cwd, config.tailwind.css);
|
|
421
543
|
const cssDir = path3.dirname(cssPath);
|
|
422
544
|
await fs3.ensureDir(cssDir);
|
|
423
|
-
const cssContent = generateThemeCSS(selectedTheme);
|
|
545
|
+
const cssContent = generateThemeCSS(selectedTheme, tailwindVersion);
|
|
424
546
|
await fs3.writeFile(cssPath, cssContent, "utf-8");
|
|
425
547
|
}
|
|
426
548
|
s.stop("Theme configured.");
|
|
427
549
|
s.start("Setting up utilities...");
|
|
428
550
|
const utilsAlias = config.aliases.utils;
|
|
429
|
-
const utilsRelPath = utilsAlias.replace(/^@\//, "
|
|
551
|
+
const utilsRelPath = utilsAlias.replace(/^@\//, srcDir + "/").replace(/^~\//, srcDir + "/") + ".ts";
|
|
430
552
|
const utilsDestPath = path3.resolve(cwd, utilsRelPath);
|
|
431
553
|
const utilsDir = path3.dirname(utilsDestPath);
|
|
432
554
|
await fs3.ensureDir(utilsDir);
|
|
@@ -439,29 +561,30 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
439
561
|
`;
|
|
440
562
|
await fs3.writeFile(utilsDestPath, utilsContent, "utf-8");
|
|
441
563
|
s.stop("Utilities ready.");
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
564
|
+
if (tailwindVersion === "v3" && config.tailwind.config) {
|
|
565
|
+
s.start("Configuring Tailwind CSS...");
|
|
566
|
+
const tailwindConfigPath = path3.resolve(cwd, config.tailwind.config);
|
|
567
|
+
const componentsRelPath = config.aliases.components.replace(/^@\//, srcDir + "/").replace(/^~\//, srcDir + "/");
|
|
568
|
+
if (await fs3.pathExists(tailwindConfigPath)) {
|
|
569
|
+
let tailwindContent = await fs3.readFile(tailwindConfigPath, "utf-8");
|
|
570
|
+
const contentPath = `./${componentsRelPath}/**/*.{ts,tsx}`;
|
|
571
|
+
if (!tailwindContent.includes(contentPath)) {
|
|
572
|
+
tailwindContent = tailwindContent.replace(
|
|
573
|
+
/(content\s*:\s*\[)/,
|
|
574
|
+
`$1
|
|
452
575
|
"${contentPath}",`
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
576
|
+
);
|
|
577
|
+
await fs3.writeFile(tailwindConfigPath, tailwindContent, "utf-8");
|
|
578
|
+
p.log.info(`Added component path to ${config.tailwind.config}`);
|
|
579
|
+
}
|
|
580
|
+
} else {
|
|
581
|
+
const isTS = config.tailwind.config.endsWith(".ts");
|
|
582
|
+
const newConfig = isTS ? `import type { Config } from "tailwindcss";
|
|
460
583
|
|
|
461
584
|
const config: Config = {
|
|
462
585
|
darkMode: ["class"],
|
|
463
586
|
content: [
|
|
464
|
-
"
|
|
587
|
+
"./${srcDir}/**/*.{js,ts,jsx,tsx,mdx}",
|
|
465
588
|
"./${componentsRelPath}/**/*.{ts,tsx}",
|
|
466
589
|
],
|
|
467
590
|
theme: {
|
|
@@ -516,7 +639,7 @@ export default config;
|
|
|
516
639
|
module.exports = {
|
|
517
640
|
darkMode: ["class"],
|
|
518
641
|
content: [
|
|
519
|
-
"
|
|
642
|
+
"./${srcDir}/**/*.{js,ts,jsx,tsx,mdx}",
|
|
520
643
|
"./${componentsRelPath}/**/*.{ts,tsx}",
|
|
521
644
|
],
|
|
522
645
|
theme: {
|
|
@@ -566,16 +689,17 @@ module.exports = {
|
|
|
566
689
|
plugins: [],
|
|
567
690
|
};
|
|
568
691
|
`;
|
|
569
|
-
|
|
692
|
+
await fs3.writeFile(tailwindConfigPath, newConfig, "utf-8");
|
|
693
|
+
}
|
|
694
|
+
s.stop("Tailwind CSS configured.");
|
|
695
|
+
} else {
|
|
696
|
+
p.log.info(
|
|
697
|
+
`Tailwind v4 detected \u2014 theme variables added directly to ${chalk2.cyan(config.tailwind.css)}`
|
|
698
|
+
);
|
|
570
699
|
}
|
|
571
|
-
s.stop("Tailwind CSS configured.");
|
|
572
700
|
s.start("Installing base dependencies...");
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
"clsx",
|
|
576
|
-
"tailwind-merge",
|
|
577
|
-
"class-variance-authority"
|
|
578
|
-
]);
|
|
701
|
+
const baseDeps = ["clsx", "tailwind-merge", "class-variance-authority"];
|
|
702
|
+
const installCmd = getInstallCommand(packageManager, baseDeps);
|
|
579
703
|
try {
|
|
580
704
|
const { execa } = await import("execa");
|
|
581
705
|
await execa(installCmd.command, installCmd.args, {
|
|
@@ -585,21 +709,20 @@ module.exports = {
|
|
|
585
709
|
s.stop("Dependencies installed.");
|
|
586
710
|
} catch {
|
|
587
711
|
s.stop("Could not auto-install dependencies.");
|
|
588
|
-
p.log.warn(
|
|
589
|
-
`Please manually install: ${chalk3.cyan("tailwindcss clsx tailwind-merge class-variance-authority")}`
|
|
590
|
-
);
|
|
712
|
+
p.log.warn(`Please manually install: ${chalk2.cyan(baseDeps.join(" "))}`);
|
|
591
713
|
}
|
|
592
714
|
logger.break();
|
|
593
715
|
p.note(
|
|
594
716
|
[
|
|
595
|
-
`${
|
|
717
|
+
`${chalk2.green("Rad UI has been initialized!")}`,
|
|
596
718
|
"",
|
|
597
|
-
`Add components with: ${
|
|
598
|
-
`Add all components: ${
|
|
719
|
+
`Add components with: ${chalk2.cyan("npx @quarklab/rad-ui add <component>")}`,
|
|
720
|
+
`Add all components: ${chalk2.cyan("npx @quarklab/rad-ui add --all")}`,
|
|
599
721
|
"",
|
|
600
|
-
`Theme: ${
|
|
601
|
-
`
|
|
602
|
-
`
|
|
722
|
+
`Theme: ${chalk2.cyan(selectedTheme?.label || config.theme)}`,
|
|
723
|
+
`Tailwind: ${chalk2.cyan(tailwindVersion)}`,
|
|
724
|
+
`Components: ${chalk2.cyan(config.aliases.components)} \u2192 ${chalk2.dim(srcDir + "/" + config.aliases.components.replace(/^@\//, "").replace(/^~\//, ""))}`,
|
|
725
|
+
`Utils: ${chalk2.cyan(config.aliases.utils)} \u2192 ${chalk2.dim(srcDir + "/" + config.aliases.utils.replace(/^@\//, "").replace(/^~\//, "") + ".ts")}`
|
|
603
726
|
].join("\n"),
|
|
604
727
|
"Next steps"
|
|
605
728
|
);
|
|
@@ -623,7 +746,7 @@ import path4 from "path";
|
|
|
623
746
|
import { fileURLToPath } from "url";
|
|
624
747
|
import fs4 from "fs-extra";
|
|
625
748
|
import * as p2 from "@clack/prompts";
|
|
626
|
-
import
|
|
749
|
+
import chalk3 from "chalk";
|
|
627
750
|
|
|
628
751
|
// src/registry/index.ts
|
|
629
752
|
var components = [
|
|
@@ -956,10 +1079,10 @@ async function getComponentContent(name, config) {
|
|
|
956
1079
|
}
|
|
957
1080
|
async function addCommand(componentNames, opts) {
|
|
958
1081
|
const cwd = process.cwd();
|
|
959
|
-
p2.intro(
|
|
1082
|
+
p2.intro(chalk3.bold("Add Rad UI Components"));
|
|
960
1083
|
if (!await configExists(cwd)) {
|
|
961
1084
|
p2.log.error(
|
|
962
|
-
`Configuration not found. Run ${
|
|
1085
|
+
`Configuration not found. Run ${chalk3.cyan("npx @quarklab/rad-ui init")} first.`
|
|
963
1086
|
);
|
|
964
1087
|
process.exit(1);
|
|
965
1088
|
}
|
|
@@ -987,10 +1110,10 @@ async function addCommand(componentNames, opts) {
|
|
|
987
1110
|
const invalid = componentNames.filter((n) => !availableNames.includes(n));
|
|
988
1111
|
if (invalid.length > 0) {
|
|
989
1112
|
p2.log.error(
|
|
990
|
-
`Unknown component(s): ${invalid.map((n) =>
|
|
1113
|
+
`Unknown component(s): ${invalid.map((n) => chalk3.red(n)).join(", ")}`
|
|
991
1114
|
);
|
|
992
1115
|
p2.log.info(
|
|
993
|
-
`Available: ${availableNames.map((n) =>
|
|
1116
|
+
`Available: ${availableNames.map((n) => chalk3.cyan(n)).join(", ")}`
|
|
994
1117
|
);
|
|
995
1118
|
process.exit(1);
|
|
996
1119
|
}
|
|
@@ -1015,7 +1138,7 @@ async function addCommand(componentNames, opts) {
|
|
|
1015
1138
|
const extraDeps = allNames.filter((n) => !selectedNames.includes(n));
|
|
1016
1139
|
if (extraDeps.length > 0) {
|
|
1017
1140
|
p2.log.info(
|
|
1018
|
-
`Also adding dependencies: ${extraDeps.map((n) =>
|
|
1141
|
+
`Also adding dependencies: ${extraDeps.map((n) => chalk3.cyan(n)).join(", ")}`
|
|
1019
1142
|
);
|
|
1020
1143
|
}
|
|
1021
1144
|
const componentsDir = opts.path ? path4.resolve(cwd, opts.path) : resolveComponentsDir(cwd, config);
|
|
@@ -1034,7 +1157,7 @@ async function addCommand(componentNames, opts) {
|
|
|
1034
1157
|
}
|
|
1035
1158
|
if (existing.length > 0) {
|
|
1036
1159
|
const shouldOverwrite = await p2.confirm({
|
|
1037
|
-
message: `The following components already exist: ${existing.map((n) =>
|
|
1160
|
+
message: `The following components already exist: ${existing.map((n) => chalk3.yellow(n)).join(", ")}. Overwrite?`,
|
|
1038
1161
|
initialValue: false
|
|
1039
1162
|
});
|
|
1040
1163
|
if (p2.isCancel(shouldOverwrite) || !shouldOverwrite) {
|
|
@@ -1098,19 +1221,19 @@ async function addCommand(componentNames, opts) {
|
|
|
1098
1221
|
s.stop("Could not auto-install dependencies.");
|
|
1099
1222
|
p2.log.warn(
|
|
1100
1223
|
`Please manually install:
|
|
1101
|
-
${
|
|
1224
|
+
${chalk3.cyan(depsToInstall.join(" "))}`
|
|
1102
1225
|
);
|
|
1103
1226
|
}
|
|
1104
1227
|
}
|
|
1105
1228
|
logger.break();
|
|
1106
1229
|
p2.note(
|
|
1107
1230
|
[
|
|
1108
|
-
`Components added to ${
|
|
1231
|
+
`Components added to ${chalk3.cyan(componentsDir.replace(cwd + "/", ""))}:`,
|
|
1109
1232
|
"",
|
|
1110
|
-
...allNames.map((n) => ` ${
|
|
1233
|
+
...allNames.map((n) => ` ${chalk3.green("+")} ${n}`),
|
|
1111
1234
|
"",
|
|
1112
1235
|
"Import example:",
|
|
1113
|
-
|
|
1236
|
+
chalk3.gray(
|
|
1114
1237
|
` import { Button } from "${config.aliases.components}/button";`
|
|
1115
1238
|
)
|
|
1116
1239
|
].join("\n"),
|
package/package.json
CHANGED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { Upload } from "lucide-react";
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
import {
|
|
8
|
+
validateValue,
|
|
9
|
+
validateFile,
|
|
10
|
+
characterPresets,
|
|
11
|
+
isControlKey,
|
|
12
|
+
formatFileSize,
|
|
13
|
+
type ValidationResult,
|
|
14
|
+
} from "./validation";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Variants
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const inputVariants = cva(
|
|
21
|
+
"flex w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
size: {
|
|
25
|
+
sm: "h-9 px-3 text-sm",
|
|
26
|
+
md: "h-10 px-3 py-2 text-sm",
|
|
27
|
+
lg: "h-11 px-4 text-base",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
size: "md",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Types
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export interface InputProps
|
|
41
|
+
extends
|
|
42
|
+
Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
43
|
+
VariantProps<typeof inputVariants> {
|
|
44
|
+
// --- Validation ---
|
|
45
|
+
/** Enable built-in validation based on `type` (email / tel / number) */
|
|
46
|
+
validate?: boolean;
|
|
47
|
+
/** Custom regex pattern for validation (overrides type-based pattern) */
|
|
48
|
+
validationPattern?: RegExp;
|
|
49
|
+
/** Custom error message (overrides default Farsi message) */
|
|
50
|
+
validationMessage?: string;
|
|
51
|
+
/** Callback fired when validation state changes */
|
|
52
|
+
onValidationChange?: (result: { isValid: boolean; message?: string }) => void;
|
|
53
|
+
|
|
54
|
+
// --- Keyboard Restriction ---
|
|
55
|
+
/** Restrict which characters can be typed */
|
|
56
|
+
allowedCharacters?: RegExp | "digits" | "alpha" | "alphanumeric" | "persian";
|
|
57
|
+
/** Enhanced max length with character count feedback */
|
|
58
|
+
maxInputLength?: number;
|
|
59
|
+
|
|
60
|
+
// --- File Validation (type="file") ---
|
|
61
|
+
/** Maximum file size in bytes */
|
|
62
|
+
maxFileSize?: number;
|
|
63
|
+
/** Accepted file extensions, e.g. [".pdf", ".png"] */
|
|
64
|
+
acceptFormats?: string[];
|
|
65
|
+
|
|
66
|
+
// --- Error Display ---
|
|
67
|
+
/** Show inline error message below the input (default: false) */
|
|
68
|
+
showError?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Input Component
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
76
|
+
(
|
|
77
|
+
{
|
|
78
|
+
className,
|
|
79
|
+
size,
|
|
80
|
+
type,
|
|
81
|
+
validate: shouldValidate,
|
|
82
|
+
validationPattern,
|
|
83
|
+
validationMessage,
|
|
84
|
+
onValidationChange,
|
|
85
|
+
allowedCharacters,
|
|
86
|
+
maxInputLength,
|
|
87
|
+
maxFileSize,
|
|
88
|
+
acceptFormats,
|
|
89
|
+
showError = false,
|
|
90
|
+
...props
|
|
91
|
+
},
|
|
92
|
+
ref
|
|
93
|
+
) => {
|
|
94
|
+
// ---- File Input ----
|
|
95
|
+
if (type === "file") {
|
|
96
|
+
return (
|
|
97
|
+
<FileInput
|
|
98
|
+
className={className}
|
|
99
|
+
size={size}
|
|
100
|
+
maxFileSize={maxFileSize}
|
|
101
|
+
acceptFormats={acceptFormats}
|
|
102
|
+
showError={showError}
|
|
103
|
+
onValidationChange={onValidationChange}
|
|
104
|
+
validationMessage={validationMessage}
|
|
105
|
+
ref={ref}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- Standard Input ----
|
|
112
|
+
return (
|
|
113
|
+
<StandardInput
|
|
114
|
+
className={className}
|
|
115
|
+
size={size}
|
|
116
|
+
type={type}
|
|
117
|
+
shouldValidate={shouldValidate}
|
|
118
|
+
validationPattern={validationPattern}
|
|
119
|
+
validationMessage={validationMessage}
|
|
120
|
+
onValidationChange={onValidationChange}
|
|
121
|
+
allowedCharacters={allowedCharacters}
|
|
122
|
+
maxInputLength={maxInputLength}
|
|
123
|
+
showError={showError}
|
|
124
|
+
ref={ref}
|
|
125
|
+
{...props}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
Input.displayName = "Input";
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// StandardInput (internal)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
interface StandardInputInternalProps extends Omit<
|
|
137
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
138
|
+
"size"
|
|
139
|
+
> {
|
|
140
|
+
size?: InputProps["size"];
|
|
141
|
+
shouldValidate?: boolean;
|
|
142
|
+
validationPattern?: RegExp;
|
|
143
|
+
validationMessage?: string;
|
|
144
|
+
onValidationChange?: InputProps["onValidationChange"];
|
|
145
|
+
allowedCharacters?: InputProps["allowedCharacters"];
|
|
146
|
+
maxInputLength?: number;
|
|
147
|
+
showError?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const StandardInput = React.forwardRef<
|
|
151
|
+
HTMLInputElement,
|
|
152
|
+
StandardInputInternalProps
|
|
153
|
+
>(
|
|
154
|
+
(
|
|
155
|
+
{
|
|
156
|
+
className,
|
|
157
|
+
size,
|
|
158
|
+
type,
|
|
159
|
+
shouldValidate,
|
|
160
|
+
validationPattern,
|
|
161
|
+
validationMessage,
|
|
162
|
+
onValidationChange,
|
|
163
|
+
allowedCharacters,
|
|
164
|
+
maxInputLength,
|
|
165
|
+
showError = false,
|
|
166
|
+
onBlur,
|
|
167
|
+
onKeyDown,
|
|
168
|
+
onChange,
|
|
169
|
+
...props
|
|
170
|
+
},
|
|
171
|
+
ref
|
|
172
|
+
) => {
|
|
173
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
174
|
+
const [charCount, setCharCount] = React.useState(0);
|
|
175
|
+
|
|
176
|
+
// ---- Validation on blur ----
|
|
177
|
+
const handleBlur = React.useCallback(
|
|
178
|
+
(e: React.FocusEvent<HTMLInputElement>) => {
|
|
179
|
+
if (shouldValidate || validationPattern) {
|
|
180
|
+
const result = validateValue(e.target.value, {
|
|
181
|
+
type,
|
|
182
|
+
required: props.required,
|
|
183
|
+
pattern: validationPattern,
|
|
184
|
+
customMessage: validationMessage,
|
|
185
|
+
});
|
|
186
|
+
setError(result.isValid ? null : (result.message ?? null));
|
|
187
|
+
onValidationChange?.(result);
|
|
188
|
+
}
|
|
189
|
+
onBlur?.(e);
|
|
190
|
+
},
|
|
191
|
+
[
|
|
192
|
+
shouldValidate,
|
|
193
|
+
validationPattern,
|
|
194
|
+
validationMessage,
|
|
195
|
+
type,
|
|
196
|
+
props.required,
|
|
197
|
+
onValidationChange,
|
|
198
|
+
onBlur,
|
|
199
|
+
]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// ---- Keyboard filtering ----
|
|
203
|
+
const handleKeyDown = React.useCallback(
|
|
204
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
205
|
+
if (allowedCharacters && !isControlKey(e)) {
|
|
206
|
+
const charPattern =
|
|
207
|
+
typeof allowedCharacters === "string"
|
|
208
|
+
? characterPresets[allowedCharacters]
|
|
209
|
+
: allowedCharacters;
|
|
210
|
+
|
|
211
|
+
if (charPattern && !charPattern.test(e.key)) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
onKeyDown?.(e);
|
|
216
|
+
},
|
|
217
|
+
[allowedCharacters, onKeyDown]
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// ---- Change handler (char count + re-validate if already errored) ----
|
|
221
|
+
const handleChange = React.useCallback(
|
|
222
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
223
|
+
if (maxInputLength !== undefined) {
|
|
224
|
+
setCharCount(e.target.value.length);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Clear error on valid input after previous error
|
|
228
|
+
if (error && (shouldValidate || validationPattern)) {
|
|
229
|
+
const result = validateValue(e.target.value, {
|
|
230
|
+
type,
|
|
231
|
+
required: props.required,
|
|
232
|
+
pattern: validationPattern,
|
|
233
|
+
customMessage: validationMessage,
|
|
234
|
+
});
|
|
235
|
+
if (result.isValid) {
|
|
236
|
+
setError(null);
|
|
237
|
+
onValidationChange?.(result);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
onChange?.(e);
|
|
242
|
+
},
|
|
243
|
+
[
|
|
244
|
+
maxInputLength,
|
|
245
|
+
error,
|
|
246
|
+
shouldValidate,
|
|
247
|
+
validationPattern,
|
|
248
|
+
validationMessage,
|
|
249
|
+
type,
|
|
250
|
+
props.required,
|
|
251
|
+
onValidationChange,
|
|
252
|
+
onChange,
|
|
253
|
+
]
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const hasError = error !== null;
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="w-full">
|
|
260
|
+
<input
|
|
261
|
+
type={type}
|
|
262
|
+
className={cn(
|
|
263
|
+
inputVariants({ size, className }),
|
|
264
|
+
hasError && "border-destructive focus-visible:ring-destructive"
|
|
265
|
+
)}
|
|
266
|
+
ref={ref}
|
|
267
|
+
maxLength={maxInputLength}
|
|
268
|
+
aria-invalid={hasError || undefined}
|
|
269
|
+
onBlur={handleBlur}
|
|
270
|
+
onKeyDown={handleKeyDown}
|
|
271
|
+
onChange={handleChange}
|
|
272
|
+
{...props}
|
|
273
|
+
/>
|
|
274
|
+
{/* Character count */}
|
|
275
|
+
{maxInputLength !== undefined && (
|
|
276
|
+
<div className="flex justify-end mt-1">
|
|
277
|
+
<span
|
|
278
|
+
className={cn(
|
|
279
|
+
"text-xs text-muted-foreground",
|
|
280
|
+
charCount >= maxInputLength && "text-destructive"
|
|
281
|
+
)}
|
|
282
|
+
>
|
|
283
|
+
{charCount}/{maxInputLength}
|
|
284
|
+
</span>
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
{/* Inline error */}
|
|
288
|
+
{showError && hasError && (
|
|
289
|
+
<p className="text-sm text-destructive mt-1" role="alert">
|
|
290
|
+
{error}
|
|
291
|
+
</p>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
StandardInput.displayName = "StandardInput";
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// FileInput (internal)
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
interface FileInputInternalProps extends Omit<
|
|
304
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
305
|
+
"size" | "type"
|
|
306
|
+
> {
|
|
307
|
+
size?: InputProps["size"];
|
|
308
|
+
maxFileSize?: number;
|
|
309
|
+
acceptFormats?: string[];
|
|
310
|
+
showError?: boolean;
|
|
311
|
+
onValidationChange?: InputProps["onValidationChange"];
|
|
312
|
+
validationMessage?: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const FileInput = React.forwardRef<HTMLInputElement, FileInputInternalProps>(
|
|
316
|
+
(
|
|
317
|
+
{
|
|
318
|
+
className,
|
|
319
|
+
size,
|
|
320
|
+
maxFileSize,
|
|
321
|
+
acceptFormats,
|
|
322
|
+
showError = false,
|
|
323
|
+
onValidationChange,
|
|
324
|
+
validationMessage,
|
|
325
|
+
onChange,
|
|
326
|
+
...props
|
|
327
|
+
},
|
|
328
|
+
ref
|
|
329
|
+
) => {
|
|
330
|
+
const [fileName, setFileName] = React.useState<string>("");
|
|
331
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
332
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
333
|
+
|
|
334
|
+
React.useImperativeHandle(ref, () => inputRef.current!);
|
|
335
|
+
|
|
336
|
+
const handleFileChange = React.useCallback(
|
|
337
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
338
|
+
const file = e.target.files?.[0];
|
|
339
|
+
|
|
340
|
+
if (file) {
|
|
341
|
+
// Validate file
|
|
342
|
+
if (maxFileSize || acceptFormats) {
|
|
343
|
+
const result = validateFile(file, { maxFileSize, acceptFormats });
|
|
344
|
+
if (!result.isValid) {
|
|
345
|
+
const msg = validationMessage || result.message || null;
|
|
346
|
+
setError(msg);
|
|
347
|
+
setFileName("");
|
|
348
|
+
onValidationChange?.({
|
|
349
|
+
isValid: false,
|
|
350
|
+
message: msg ?? undefined,
|
|
351
|
+
});
|
|
352
|
+
// Reset the input so the same file can be re-selected
|
|
353
|
+
e.target.value = "";
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
setFileName(file.name);
|
|
359
|
+
setError(null);
|
|
360
|
+
onValidationChange?.({ isValid: true });
|
|
361
|
+
} else {
|
|
362
|
+
setFileName("");
|
|
363
|
+
setError(null);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
onChange?.(e);
|
|
367
|
+
},
|
|
368
|
+
[
|
|
369
|
+
maxFileSize,
|
|
370
|
+
acceptFormats,
|
|
371
|
+
validationMessage,
|
|
372
|
+
onValidationChange,
|
|
373
|
+
onChange,
|
|
374
|
+
]
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const hasError = error !== null;
|
|
378
|
+
|
|
379
|
+
// Build accept string from acceptFormats
|
|
380
|
+
const acceptAttr =
|
|
381
|
+
props.accept || (acceptFormats ? acceptFormats.join(",") : undefined);
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<div className="w-full">
|
|
385
|
+
<div
|
|
386
|
+
className={cn(
|
|
387
|
+
inputVariants({ size, className }),
|
|
388
|
+
"flex items-center gap-2 cursor-pointer",
|
|
389
|
+
hasError && "border-destructive focus-visible:ring-destructive",
|
|
390
|
+
props.disabled && "cursor-not-allowed opacity-50"
|
|
391
|
+
)}
|
|
392
|
+
onClick={() => !props.disabled && inputRef.current?.click()}
|
|
393
|
+
>
|
|
394
|
+
<button
|
|
395
|
+
type="button"
|
|
396
|
+
disabled={props.disabled}
|
|
397
|
+
className={cn(
|
|
398
|
+
"flex items-center gap-2 rounded-sm bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground transition-colors",
|
|
399
|
+
"hover:bg-secondary/80 focus:outline-none",
|
|
400
|
+
props.disabled && "pointer-events-none"
|
|
401
|
+
)}
|
|
402
|
+
tabIndex={-1}
|
|
403
|
+
>
|
|
404
|
+
<Upload className="h-3 w-3" />
|
|
405
|
+
<span>انتخاب فایل</span>
|
|
406
|
+
</button>
|
|
407
|
+
<span
|
|
408
|
+
className={cn(
|
|
409
|
+
"text-muted-foreground truncate flex-1 text-right text-xs",
|
|
410
|
+
!fileName && "opacity-70"
|
|
411
|
+
)}
|
|
412
|
+
dir="rtl"
|
|
413
|
+
>
|
|
414
|
+
{fileName || props.placeholder || "فایلی انتخاب نشده"}
|
|
415
|
+
</span>
|
|
416
|
+
<input
|
|
417
|
+
type="file"
|
|
418
|
+
className="hidden"
|
|
419
|
+
ref={inputRef}
|
|
420
|
+
accept={acceptAttr}
|
|
421
|
+
onChange={handleFileChange}
|
|
422
|
+
{...props}
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
{/* File constraints hint */}
|
|
426
|
+
{(maxFileSize || acceptFormats) && !hasError && (
|
|
427
|
+
<p className="text-xs text-muted-foreground mt-1" dir="rtl">
|
|
428
|
+
{[
|
|
429
|
+
maxFileSize ? `حداکثر حجم: ${formatFileSize(maxFileSize)}` : null,
|
|
430
|
+
acceptFormats
|
|
431
|
+
? `فرمتهای مجاز: ${acceptFormats.join("، ")}`
|
|
432
|
+
: null,
|
|
433
|
+
]
|
|
434
|
+
.filter(Boolean)
|
|
435
|
+
.join(" · ")}
|
|
436
|
+
</p>
|
|
437
|
+
)}
|
|
438
|
+
{/* Inline error */}
|
|
439
|
+
{showError && hasError && (
|
|
440
|
+
<p className="text-sm text-destructive mt-1" role="alert" dir="rtl">
|
|
441
|
+
{error}
|
|
442
|
+
</p>
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
FileInput.displayName = "FileInput";
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Exports
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
export { Input, inputVariants };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Validation Patterns
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export const validationPatterns = {
|
|
6
|
+
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
7
|
+
tel: /^\+?[0-9\s\-()]{7,15}$/,
|
|
8
|
+
iranianTel: /^(\+98|0)?9\d{9}$/,
|
|
9
|
+
number: /^-?\d*\.?\d+$/,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Character Presets (for keyboard filtering)
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const characterPresets: Record<string, RegExp> = {
|
|
17
|
+
digits: /^[0-9]$/,
|
|
18
|
+
alpha: /^[a-zA-Z\u0600-\u06FF\s]$/,
|
|
19
|
+
alphanumeric: /^[a-zA-Z0-9\u0600-\u06FF\s]$/,
|
|
20
|
+
persian: /^[\u0600-\u06FF\u200C\u200F0-9\s.,;:!?()«»؟،؛]$/,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Control Keys (should never be blocked by character filtering)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const CONTROL_KEYS = new Set([
|
|
28
|
+
"Backspace",
|
|
29
|
+
"Delete",
|
|
30
|
+
"Tab",
|
|
31
|
+
"Escape",
|
|
32
|
+
"Enter",
|
|
33
|
+
"ArrowLeft",
|
|
34
|
+
"ArrowRight",
|
|
35
|
+
"ArrowUp",
|
|
36
|
+
"ArrowDown",
|
|
37
|
+
"Home",
|
|
38
|
+
"End",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function isControlKey(e: React.KeyboardEvent): boolean {
|
|
42
|
+
return CONTROL_KEYS.has(e.key) || e.ctrlKey || e.metaKey || e.altKey;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Default Farsi Error Messages
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export const defaultMessages = {
|
|
50
|
+
email: "لطفاً یک آدرس ایمیل معتبر وارد کنید.",
|
|
51
|
+
tel: "لطفاً یک شماره تلفن معتبر وارد کنید.",
|
|
52
|
+
number: "لطفاً یک عدد معتبر وارد کنید.",
|
|
53
|
+
required: "این فیلد الزامی است.",
|
|
54
|
+
patternMismatch: "مقدار وارد شده معتبر نیست.",
|
|
55
|
+
fileTooLarge: (max: string) => `حجم فایل نباید بیشتر از ${max} باشد.`,
|
|
56
|
+
invalidFormat: (formats: string) => `فرمتهای مجاز: ${formats}`,
|
|
57
|
+
minLength: (min: number) => `حداقل ${min} کاراکتر وارد کنید.`,
|
|
58
|
+
maxLength: (max: number) => `حداکثر ${max} کاراکتر مجاز است.`,
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// File Size Formatter (bytes → readable Farsi string)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export function formatFileSize(bytes: number): string {
|
|
66
|
+
if (bytes < 1024) return `${bytes} بایت`;
|
|
67
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} کیلوبایت`;
|
|
68
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
69
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} مگابایت`;
|
|
70
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} گیگابایت`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// validateValue – Pure validation function
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export interface ValidateValueOptions {
|
|
78
|
+
type?: string;
|
|
79
|
+
required?: boolean;
|
|
80
|
+
pattern?: RegExp;
|
|
81
|
+
minLength?: number;
|
|
82
|
+
maxLength?: number;
|
|
83
|
+
customMessage?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ValidationResult {
|
|
87
|
+
isValid: boolean;
|
|
88
|
+
message?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validateValue(
|
|
92
|
+
value: string,
|
|
93
|
+
options: ValidateValueOptions = {}
|
|
94
|
+
): ValidationResult {
|
|
95
|
+
const { type, required, pattern, minLength, maxLength, customMessage } =
|
|
96
|
+
options;
|
|
97
|
+
|
|
98
|
+
// Required check
|
|
99
|
+
if (required && !value.trim()) {
|
|
100
|
+
return { isValid: false, message: defaultMessages.required };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Skip further validation if empty and not required
|
|
104
|
+
if (!value.trim()) {
|
|
105
|
+
return { isValid: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Min length
|
|
109
|
+
if (minLength !== undefined && value.length < minLength) {
|
|
110
|
+
return {
|
|
111
|
+
isValid: false,
|
|
112
|
+
message: defaultMessages.minLength(minLength),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Max length
|
|
117
|
+
if (maxLength !== undefined && value.length > maxLength) {
|
|
118
|
+
return {
|
|
119
|
+
isValid: false,
|
|
120
|
+
message: defaultMessages.maxLength(maxLength),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Custom pattern
|
|
125
|
+
if (pattern) {
|
|
126
|
+
if (!pattern.test(value)) {
|
|
127
|
+
return {
|
|
128
|
+
isValid: false,
|
|
129
|
+
message: customMessage || defaultMessages.patternMismatch,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { isValid: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Type-based validation
|
|
136
|
+
switch (type) {
|
|
137
|
+
case "email":
|
|
138
|
+
if (!validationPatterns.email.test(value)) {
|
|
139
|
+
return {
|
|
140
|
+
isValid: false,
|
|
141
|
+
message: customMessage || defaultMessages.email,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
case "tel":
|
|
146
|
+
if (!validationPatterns.tel.test(value)) {
|
|
147
|
+
return {
|
|
148
|
+
isValid: false,
|
|
149
|
+
message: customMessage || defaultMessages.tel,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
case "number":
|
|
154
|
+
if (!validationPatterns.number.test(value)) {
|
|
155
|
+
return {
|
|
156
|
+
isValid: false,
|
|
157
|
+
message: customMessage || defaultMessages.number,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { isValid: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// File Validation
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export interface FileValidationOptions {
|
|
171
|
+
maxFileSize?: number;
|
|
172
|
+
acceptFormats?: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateFile(
|
|
176
|
+
file: File,
|
|
177
|
+
options: FileValidationOptions
|
|
178
|
+
): ValidationResult {
|
|
179
|
+
const { maxFileSize, acceptFormats } = options;
|
|
180
|
+
|
|
181
|
+
if (maxFileSize && file.size > maxFileSize) {
|
|
182
|
+
return {
|
|
183
|
+
isValid: false,
|
|
184
|
+
message: defaultMessages.fileTooLarge(formatFileSize(maxFileSize)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (acceptFormats && acceptFormats.length > 0) {
|
|
189
|
+
const fileName = file.name.toLowerCase();
|
|
190
|
+
const hasValidFormat = acceptFormats.some((format) =>
|
|
191
|
+
fileName.endsWith(format.toLowerCase())
|
|
192
|
+
);
|
|
193
|
+
if (!hasValidFormat) {
|
|
194
|
+
return {
|
|
195
|
+
isValid: false,
|
|
196
|
+
message: defaultMessages.invalidFormat(acceptFormats.join("، ")),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { isValid: true };
|
|
202
|
+
}
|
package/templates/web/input.tsx
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
-
import { Upload } from "lucide-react";
|
|
4
|
-
import { cn } from "../lib/utils";
|
|
5
|
-
|
|
6
|
-
const inputVariants = cva(
|
|
7
|
-
"flex w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
8
|
-
{
|
|
9
|
-
variants: {
|
|
10
|
-
size: {
|
|
11
|
-
sm: "h-9 px-3 text-sm",
|
|
12
|
-
md: "h-10 px-3 py-2 text-sm",
|
|
13
|
-
lg: "h-11 px-4 text-base",
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
defaultVariants: {
|
|
17
|
-
size: "md",
|
|
18
|
-
},
|
|
19
|
-
}
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
export interface InputProps
|
|
23
|
-
extends
|
|
24
|
-
Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
25
|
-
VariantProps<typeof inputVariants> {}
|
|
26
|
-
|
|
27
|
-
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
28
|
-
({ className, size, type, ...props }, ref) => {
|
|
29
|
-
// Custom logic for file input to support Farsi text
|
|
30
|
-
if (type === "file") {
|
|
31
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
32
|
-
const [fileName, setFileName] = React.useState<string>("");
|
|
33
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
34
|
-
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
35
|
-
|
|
36
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
37
|
-
React.useImperativeHandle(ref, () => inputRef.current!);
|
|
38
|
-
|
|
39
|
-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
40
|
-
const file = e.target.files?.[0];
|
|
41
|
-
if (file) {
|
|
42
|
-
setFileName(file.name);
|
|
43
|
-
} else {
|
|
44
|
-
setFileName("");
|
|
45
|
-
}
|
|
46
|
-
props.onChange?.(e);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<div
|
|
51
|
-
className={cn(
|
|
52
|
-
inputVariants({ size, className }),
|
|
53
|
-
"flex items-center gap-2 cursor-pointer",
|
|
54
|
-
props.disabled && "cursor-not-allowed opacity-50"
|
|
55
|
-
)}
|
|
56
|
-
onClick={() => !props.disabled && inputRef.current?.click()}
|
|
57
|
-
>
|
|
58
|
-
<button
|
|
59
|
-
type="button"
|
|
60
|
-
disabled={props.disabled}
|
|
61
|
-
className={cn(
|
|
62
|
-
"flex items-center gap-2 rounded-sm bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground transition-colors",
|
|
63
|
-
"hover:bg-secondary/80 focus:outline-none",
|
|
64
|
-
props.disabled && "pointer-events-none"
|
|
65
|
-
)}
|
|
66
|
-
tabIndex={-1}
|
|
67
|
-
>
|
|
68
|
-
<Upload className="h-3 w-3" />
|
|
69
|
-
<span>انتخاب فایل</span>
|
|
70
|
-
</button>
|
|
71
|
-
<span
|
|
72
|
-
className={cn(
|
|
73
|
-
"text-muted-foreground truncate flex-1 text-right text-xs",
|
|
74
|
-
!fileName && "opacity-70"
|
|
75
|
-
)}
|
|
76
|
-
dir="rtl"
|
|
77
|
-
>
|
|
78
|
-
{fileName || props.placeholder || "فایلی انتخاب نشده"}
|
|
79
|
-
</span>
|
|
80
|
-
<input
|
|
81
|
-
type="file"
|
|
82
|
-
className="hidden"
|
|
83
|
-
ref={inputRef}
|
|
84
|
-
onChange={handleFileChange}
|
|
85
|
-
{...props}
|
|
86
|
-
/>
|
|
87
|
-
</div>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return (
|
|
92
|
-
<input
|
|
93
|
-
type={type}
|
|
94
|
-
className={cn(inputVariants({ size, className }))}
|
|
95
|
-
ref={ref}
|
|
96
|
-
{...props}
|
|
97
|
-
/>
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
);
|
|
101
|
-
Input.displayName = "Input";
|
|
102
|
-
|
|
103
|
-
export { Input, inputVariants };
|