@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 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 chalk3 from "chalk";
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 chalk(text2) {
85
- return `\`${text2}\``;
152
+ function resolveAlias(alias, srcDir) {
153
+ return alias.replace(/^@\//, srcDir + "/").replace(/^~\//, srcDir + "/");
86
154
  }
87
155
  function resolveComponentsDir(cwd, config) {
88
- const aliasPath = config.aliases.components.replace(/^@\//, "src/");
89
- return path2.resolve(cwd, aliasPath);
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 (\u06A9\u0627\u0647\u06AF\u0644) \u2014 Warm Clay",
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 (\u0641\u06CC\u0631\u0648\u0632\u0647) \u2014 Persian Turquoise",
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 (\u0644\u0627\u062C\u0648\u0631\u062F) \u2014 Lapis Lazuli",
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 (\u067E\u0633\u062A\u0647) \u2014 Pistachio",
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 (\u0627\u0646\u0627\u0631) \u2014 Pomegranate",
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
- function generateThemeCSS(theme) {
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 chalk2 from "chalk";
439
+ import chalk from "chalk";
320
440
  var logger = {
321
- info: (msg) => console.log(chalk2.cyan("\u2139"), msg),
322
- success: (msg) => console.log(chalk2.green("\u2713"), msg),
323
- warn: (msg) => console.log(chalk2.yellow("\u26A0"), msg),
324
- error: (msg) => console.log(chalk2.red("\u2717"), msg),
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(chalk3.bold("Welcome to Rad UI"));
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: ${chalk3.cyan(projectType)}`);
468
+ p.log.info(`Detected project: ${chalk2.cyan(projectType)}`);
346
469
  }
347
- p.log.info(`Package manager: ${chalk3.cyan(packageManager)}`);
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: "Where would you like to store components?",
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: "Where is your utils file?",
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: "Where is your global CSS file?",
383
- placeholder: getDefaultCssPath(projectType),
384
- defaultValue: getDefaultCssPath(projectType)
499
+ message: `Path to your global CSS file:`,
500
+ placeholder: defaultCssPath,
501
+ defaultValue: defaultCssPath
385
502
  }),
386
- tailwindConfig: () => p.text({
387
- message: "Where is your Tailwind config file?",
388
- placeholder: getDefaultTailwindConfig(projectType),
389
- defaultValue: getDefaultTailwindConfig(projectType)
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
- platform: responses.platform,
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(/^@\//, "src/") + ".ts";
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
- s.start("Configuring Tailwind CSS...");
443
- const tailwindConfigPath = path3.resolve(cwd, config.tailwind.config);
444
- const componentsRelPath = config.aliases.components.replace(/^@\//, "src/");
445
- if (await fs3.pathExists(tailwindConfigPath)) {
446
- let tailwindContent = await fs3.readFile(tailwindConfigPath, "utf-8");
447
- const contentPath = `./${componentsRelPath}/**/*.{ts,tsx}`;
448
- if (!tailwindContent.includes(contentPath)) {
449
- tailwindContent = tailwindContent.replace(
450
- /(content\s*:\s*\[)/,
451
- `$1
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
- await fs3.writeFile(tailwindConfigPath, tailwindContent, "utf-8");
455
- p.log.info(`Added component path to ${config.tailwind.config}`);
456
- }
457
- } else {
458
- const isTS = config.tailwind.config.endsWith(".ts");
459
- const newConfig = isTS ? `import type { Config } from "tailwindcss";
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
- "./src/**/*.{js,ts,jsx,tsx,mdx}",
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
- "./src/**/*.{js,ts,jsx,tsx,mdx}",
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
- await fs3.writeFile(tailwindConfigPath, newConfig, "utf-8");
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 installCmd = getInstallCommand(packageManager, [
574
- "tailwindcss",
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
- `${chalk3.green("Rad UI has been initialized!")}`,
717
+ `${chalk2.green("Rad UI has been initialized!")}`,
596
718
  "",
597
- `Add components with: ${chalk3.cyan("npx @quarklab/rad-ui add <component>")}`,
598
- `Add all components: ${chalk3.cyan("npx @quarklab/rad-ui add --all")}`,
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: ${chalk3.cyan(selectedTheme?.label || config.theme)}`,
601
- `Components: ${chalk3.cyan(config.aliases.components)}`,
602
- `Utils: ${chalk3.cyan(config.aliases.utils)}`
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 chalk4 from "chalk";
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(chalk4.bold("Add Rad UI Components"));
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 ${chalk4.cyan("npx @quarklab/rad-ui init")} first.`
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) => chalk4.red(n)).join(", ")}`
1113
+ `Unknown component(s): ${invalid.map((n) => chalk3.red(n)).join(", ")}`
991
1114
  );
992
1115
  p2.log.info(
993
- `Available: ${availableNames.map((n) => chalk4.cyan(n)).join(", ")}`
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) => chalk4.cyan(n)).join(", ")}`
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) => chalk4.yellow(n)).join(", ")}. Overwrite?`,
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
- ${chalk4.cyan(depsToInstall.join(" "))}`
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 ${chalk4.cyan(componentsDir.replace(cwd + "/", ""))}:`,
1231
+ `Components added to ${chalk3.cyan(componentsDir.replace(cwd + "/", ""))}:`,
1109
1232
  "",
1110
- ...allNames.map((n) => ` ${chalk4.green("+")} ${n}`),
1233
+ ...allNames.map((n) => ` ${chalk3.green("+")} ${n}`),
1111
1234
  "",
1112
1235
  "Import example:",
1113
- chalk4.gray(
1236
+ chalk3.gray(
1114
1237
  ` import { Button } from "${config.aliases.components}/button";`
1115
1238
  )
1116
1239
  ].join("\n"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quarklab/rad-ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "A CLI for adding Rad UI components to your project. Beautiful Persian-themed React components built on Radix UI and Tailwind CSS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
+ }
@@ -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 };