@srcroot/ui 0.0.56 → 0.0.59

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
@@ -5,11 +5,11 @@ import { Command } from "commander";
5
5
  import chalk3 from "chalk";
6
6
 
7
7
  // src/cli/services/project-initializer.ts
8
- import fs3 from "fs-extra";
9
- import path3 from "path";
8
+ import fs4 from "fs-extra";
9
+ import path4 from "path";
10
10
  import ora from "ora";
11
11
  import prompts from "prompts";
12
- import { fileURLToPath as fileURLToPath2 } from "url";
12
+ import { fileURLToPath as fileURLToPath3 } from "url";
13
13
  import { execa } from "execa";
14
14
 
15
15
  // src/cli/services/theme-service.ts
@@ -167,6 +167,25 @@ function getPackageManager(cwd) {
167
167
  return "npm";
168
168
  }
169
169
 
170
+ // src/cli/utils/get-package-info.ts
171
+ import path3 from "path";
172
+ import fs3 from "fs-extra";
173
+ import { fileURLToPath as fileURLToPath2 } from "url";
174
+ function getPackageInfo() {
175
+ const __filename2 = fileURLToPath2(import.meta.url);
176
+ const __dirname5 = path3.dirname(__filename2);
177
+ const pathsToCheck = [
178
+ path3.resolve(__dirname5, "..", "package.json"),
179
+ path3.resolve(__dirname5, "..", "..", "..", "package.json")
180
+ ];
181
+ for (const pkgPath of pathsToCheck) {
182
+ if (fs3.existsSync(pkgPath)) {
183
+ return fs3.readJSONSync(pkgPath);
184
+ }
185
+ }
186
+ return { version: "0.0.0" };
187
+ }
188
+
170
189
  // src/cli/utils/logger.ts
171
190
  import chalk from "chalk";
172
191
  var logger = {
@@ -185,7 +204,7 @@ var logger = {
185
204
  };
186
205
 
187
206
  // src/cli/services/project-initializer.ts
188
- var __dirname3 = path3.dirname(fileURLToPath2(import.meta.url));
207
+ var __dirname3 = path4.dirname(fileURLToPath3(import.meta.url));
189
208
  var ProjectInitializer = class {
190
209
  options;
191
210
  config = {};
@@ -204,13 +223,13 @@ var ProjectInitializer = class {
204
223
  this.printSuccess();
205
224
  }
206
225
  async validateEnvironment() {
207
- const cwd = path3.resolve(this.options.cwd);
208
- const packageJsonPath = path3.join(cwd, "package.json");
209
- if (!fs3.existsSync(packageJsonPath)) {
226
+ const cwd = path4.resolve(this.options.cwd);
227
+ const packageJsonPath = path4.join(cwd, "package.json");
228
+ if (!fs4.existsSync(packageJsonPath)) {
210
229
  logger.error("Error: No package.json found. Please run this in a project directory.");
211
230
  process.exit(1);
212
231
  }
213
- const pkg = await fs3.readJson(packageJsonPath);
232
+ const pkg = await fs4.readJson(packageJsonPath);
214
233
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
215
234
  if (!allDeps["react"]) {
216
235
  logger.error("Error: React not found in dependencies. Please initialize this in a React project.");
@@ -218,33 +237,33 @@ var ProjectInitializer = class {
218
237
  }
219
238
  }
220
239
  async detectConfiguration() {
221
- const cwd = path3.resolve(this.options.cwd);
240
+ const cwd = path4.resolve(this.options.cwd);
222
241
  const packageManager = getPackageManager(cwd);
223
242
  const installCmd = packageManager === "npm" ? "install" : "add";
224
- const pkg = await fs3.readJson(path3.join(cwd, "package.json"));
243
+ const pkg = await fs4.readJson(path4.join(cwd, "package.json"));
225
244
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
226
245
  const tailwindVersion = allDeps["tailwindcss"] || "";
227
246
  const isTailwind4 = tailwindVersion.includes("^4") || tailwindVersion.startsWith("4") || allDeps["@tailwindcss/postcss"];
228
- const hasSrc = fs3.existsSync(path3.join(cwd, "src"));
229
- const srcPath = hasSrc ? path3.join(cwd, "src") : cwd;
230
- const appPath = path3.join(srcPath, "app");
231
- const pagesPath = path3.join(srcPath, "pages");
232
- const hasAppDir = fs3.existsSync(appPath);
233
- const hasPagesDir = fs3.existsSync(pagesPath);
234
- const libDir = path3.join(srcPath, "lib");
235
- const componentsDir = path3.join(srcPath, "components", "ui");
247
+ const hasSrc = fs4.existsSync(path4.join(cwd, "src"));
248
+ const srcPath = hasSrc ? path4.join(cwd, "src") : cwd;
249
+ const appPath = path4.join(srcPath, "app");
250
+ const pagesPath = path4.join(srcPath, "pages");
251
+ const hasAppDir = fs4.existsSync(appPath);
252
+ const hasPagesDir = fs4.existsSync(pagesPath);
253
+ const libDir = path4.join(srcPath, "lib");
254
+ const componentsDir = path4.join(srcPath, "components", "ui");
236
255
  let globalsPath = "";
237
256
  if (hasAppDir) {
238
- if (fs3.existsSync(path3.join(appPath, "globals.css"))) globalsPath = path3.join(appPath, "globals.css");
239
- else if (fs3.existsSync(path3.join(appPath, "global.css"))) globalsPath = path3.join(appPath, "global.css");
240
- else globalsPath = path3.join(appPath, "globals.css");
257
+ if (fs4.existsSync(path4.join(appPath, "globals.css"))) globalsPath = path4.join(appPath, "globals.css");
258
+ else if (fs4.existsSync(path4.join(appPath, "global.css"))) globalsPath = path4.join(appPath, "global.css");
259
+ else globalsPath = path4.join(appPath, "globals.css");
241
260
  } else if (hasPagesDir) {
242
- const stylesPath = path3.join(srcPath, "styles");
243
- if (fs3.existsSync(path3.join(stylesPath, "globals.css"))) globalsPath = path3.join(stylesPath, "globals.css");
244
- else if (fs3.existsSync(path3.join(stylesPath, "global.css"))) globalsPath = path3.join(stylesPath, "global.css");
245
- else globalsPath = path3.join(stylesPath, "globals.css");
261
+ const stylesPath = path4.join(srcPath, "styles");
262
+ if (fs4.existsSync(path4.join(stylesPath, "globals.css"))) globalsPath = path4.join(stylesPath, "globals.css");
263
+ else if (fs4.existsSync(path4.join(stylesPath, "global.css"))) globalsPath = path4.join(stylesPath, "global.css");
264
+ else globalsPath = path4.join(stylesPath, "globals.css");
246
265
  } else {
247
- globalsPath = path3.join(srcPath, "globals.css");
266
+ globalsPath = path4.join(srcPath, "globals.css");
248
267
  }
249
268
  this.config = {
250
269
  cwd,
@@ -292,13 +311,13 @@ var ProjectInitializer = class {
292
311
  const spinner = ora("Creating project structure...").start();
293
312
  const cfg = this.config;
294
313
  try {
295
- await fs3.ensureDir(cfg.libDir);
296
- await fs3.ensureDir(cfg.componentsDir);
297
- const utilsPath = path3.join(cfg.libDir, "utils.ts");
298
- const registryUtilsPath = path3.resolve(__dirname3, "..", "src", "registry", "lib", "utils.ts");
314
+ await fs4.ensureDir(cfg.libDir);
315
+ await fs4.ensureDir(cfg.componentsDir);
316
+ const utilsPath = path4.join(cfg.libDir, "utils.ts");
317
+ const registryUtilsPath = path4.resolve(__dirname3, "..", "src", "registry", "lib", "utils.ts");
299
318
  let utilsContent = "";
300
- if (fs3.existsSync(registryUtilsPath)) {
301
- utilsContent = await fs3.readFile(registryUtilsPath, "utf-8");
319
+ if (fs4.existsSync(registryUtilsPath)) {
320
+ utilsContent = await fs4.readFile(registryUtilsPath, "utf-8");
302
321
  } else {
303
322
  utilsContent = `import { type ClassValue, clsx } from "clsx"
304
323
  import { twMerge } from "tailwind-merge"
@@ -309,15 +328,15 @@ export function cn(...inputs: ClassValue[]) {
309
328
  `;
310
329
  spinner.warn(`Could not find registry/utils.ts, using fallback content.`);
311
330
  }
312
- await fs3.writeFile(utilsPath, utilsContent);
313
- spinner.succeed(`Created ${path3.relative(cfg.cwd, utilsPath)}`);
331
+ await fs4.writeFile(utilsPath, utilsContent);
332
+ spinner.succeed(`Created ${path4.relative(cfg.cwd, utilsPath)}`);
314
333
  spinner.start(`Setting up ${cfg.selectedTheme} theme...`);
315
- const stylesDir = path3.dirname(cfg.globalsPath);
316
- await fs3.ensureDir(stylesDir);
334
+ const stylesDir = path4.dirname(cfg.globalsPath);
335
+ await fs4.ensureDir(stylesDir);
317
336
  try {
318
337
  const cssContent = await this.themeService.getThemeCss(cfg.selectedTheme, cfg.isTailwind4);
319
- await fs3.writeFile(cfg.globalsPath, cssContent);
320
- spinner.succeed(`Updated ${path3.relative(cfg.cwd, cfg.globalsPath)} with ${cfg.selectedTheme} theme (${cfg.isTailwind4 ? "Tailwind 4" : "Tailwind 3"})`);
338
+ await fs4.writeFile(cfg.globalsPath, cssContent);
339
+ spinner.succeed(`Updated ${path4.relative(cfg.cwd, cfg.globalsPath)} with ${cfg.selectedTheme} theme (${cfg.isTailwind4 ? "Tailwind 4" : "Tailwind 3"})`);
321
340
  } catch (error) {
322
341
  spinner.fail(`Failed to load theme: ${cfg.selectedTheme}`);
323
342
  console.error(error);
@@ -325,10 +344,21 @@ export function cn(...inputs: ClassValue[]) {
325
344
  }
326
345
  if (!cfg.isTailwind4) {
327
346
  spinner.start("Setting up Tailwind config...");
328
- const tailwindConfigPath = path3.join(cfg.cwd, "tailwind.config.ts");
329
- await fs3.writeFile(tailwindConfigPath, TAILWIND_CONFIG);
347
+ const tailwindConfigPath = path4.join(cfg.cwd, "tailwind.config.ts");
348
+ await fs4.writeFile(tailwindConfigPath, TAILWIND_CONFIG);
330
349
  spinner.succeed(`Created tailwind.config.ts`);
331
350
  }
351
+ const packageInfo = getPackageInfo();
352
+ const configObj = {
353
+ version: packageInfo.version || "0.0.0",
354
+ theme: cfg.selectedTheme,
355
+ paths: {
356
+ components: path4.relative(cfg.cwd, cfg.componentsDir),
357
+ utils: path4.relative(cfg.cwd, utilsPath)
358
+ }
359
+ };
360
+ await fs4.writeJSON(path4.join(cfg.cwd, "srcroot.config.json"), configObj, { spaces: 2 });
361
+ spinner.succeed("Created srcroot.config.json");
332
362
  } catch (error) {
333
363
  spinner.fail("Failed to initialize project");
334
364
  console.error(error);
@@ -348,8 +378,8 @@ export function cn(...inputs: ClassValue[]) {
348
378
  deps.push("tailwindcss-animate");
349
379
  }
350
380
  try {
351
- const packageJsonPath = path3.join(cfg.cwd, "package.json");
352
- const pkg = await fs3.readJson(packageJsonPath);
381
+ const packageJsonPath = path4.join(cfg.cwd, "package.json");
382
+ const pkg = await fs4.readJson(packageJsonPath);
353
383
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
354
384
  const missingDeps = deps.filter((dep) => !allDeps[dep]);
355
385
  if (missingDeps.length === 0) {
@@ -384,12 +414,12 @@ async function init(options) {
384
414
  }
385
415
 
386
416
  // src/cli/services/component-adder.ts
387
- import fs4 from "fs-extra";
388
- import path4 from "path";
417
+ import fs5 from "fs-extra";
418
+ import path5 from "path";
389
419
  import ora2 from "ora";
390
420
  import prompts2 from "prompts";
391
421
  import { execa as execa2 } from "execa";
392
- import { fileURLToPath as fileURLToPath3 } from "url";
422
+ import { fileURLToPath as fileURLToPath4 } from "url";
393
423
 
394
424
  // src/cli/registry.ts
395
425
  var REGISTRY = {
@@ -858,7 +888,7 @@ var REGISTRY = {
858
888
  };
859
889
 
860
890
  // src/cli/services/component-adder.ts
861
- var __dirname4 = path4.dirname(fileURLToPath3(import.meta.url));
891
+ var __dirname4 = path5.dirname(fileURLToPath4(import.meta.url));
862
892
  var ComponentAdder = class {
863
893
  cwd;
864
894
  options;
@@ -980,16 +1010,16 @@ Please manually install: ${packages.join(" ")}`);
980
1010
  }
981
1011
  async copyComponents(components) {
982
1012
  const spinner = ora2("Adding components...").start();
983
- const hasSrc = fs4.existsSync(path4.join(this.cwd, "src"));
984
- const srcPath = hasSrc ? path4.join(this.cwd, "src") : this.cwd;
985
- const componentsDir = path4.join(srcPath, "components", "ui");
1013
+ const hasSrc = fs5.existsSync(path5.join(this.cwd, "src"));
1014
+ const srcPath = hasSrc ? path5.join(this.cwd, "src") : this.cwd;
1015
+ const componentsDir = path5.join(srcPath, "components", "ui");
986
1016
  try {
987
- await fs4.ensureDir(componentsDir);
1017
+ await fs5.ensureDir(componentsDir);
988
1018
  for (const name of components) {
989
1019
  const comp = REGISTRY[name];
990
- const fileName = path4.basename(comp.file);
991
- const targetPath = path4.join(componentsDir, fileName);
992
- if (fs4.existsSync(targetPath) && !this.options.overwrite) {
1020
+ const fileName = path5.basename(comp.file);
1021
+ const targetPath = path5.join(componentsDir, fileName);
1022
+ if (fs5.existsSync(targetPath) && !this.options.overwrite) {
993
1023
  spinner.stop();
994
1024
  const { overwrite } = await prompts2({
995
1025
  type: "confirm",
@@ -1004,13 +1034,13 @@ Please manually install: ${packages.join(" ")}`);
1004
1034
  }
1005
1035
  spinner.start("Adding components...");
1006
1036
  }
1007
- const registryPath = path4.resolve(__dirname4, "..", "src", "registry", comp.file);
1008
- if (!fs4.existsSync(registryPath)) {
1037
+ const registryPath = path5.resolve(__dirname4, "..", "src", "registry", comp.file);
1038
+ if (!fs5.existsSync(registryPath)) {
1009
1039
  spinner.warn(`Registry file not found for ${name}: ${registryPath}`);
1010
1040
  continue;
1011
1041
  }
1012
- const content = await fs4.readFile(registryPath, "utf-8");
1013
- await fs4.writeFile(targetPath, content);
1042
+ const content = await fs5.readFile(registryPath, "utf-8");
1043
+ await fs5.writeFile(targetPath, content);
1014
1044
  if (components.length > 10) {
1015
1045
  spinner.text = `Adding ${fileName}...`;
1016
1046
  } else {
@@ -1066,25 +1096,6 @@ async function list() {
1066
1096
  console.log(chalk2.dim("Usage: npx @srcroot/ui add <component>\n"));
1067
1097
  }
1068
1098
 
1069
- // src/cli/utils/get-package-info.ts
1070
- import path5 from "path";
1071
- import fs5 from "fs-extra";
1072
- import { fileURLToPath as fileURLToPath4 } from "url";
1073
- function getPackageInfo() {
1074
- const __filename2 = fileURLToPath4(import.meta.url);
1075
- const __dirname5 = path5.dirname(__filename2);
1076
- const pathsToCheck = [
1077
- path5.resolve(__dirname5, "..", "package.json"),
1078
- path5.resolve(__dirname5, "..", "..", "..", "package.json")
1079
- ];
1080
- for (const pkgPath of pathsToCheck) {
1081
- if (fs5.existsSync(pkgPath)) {
1082
- return fs5.readJSONSync(pkgPath);
1083
- }
1084
- }
1085
- return { version: "0.0.0" };
1086
- }
1087
-
1088
1099
  // src/cli/index.ts
1089
1100
  process.on("SIGINT", () => process.exit(0));
1090
1101
  process.on("SIGTERM", () => process.exit(0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@srcroot/ui",
3
- "version": "0.0.56",
3
+ "version": "0.0.59",
4
4
  "description": "A UI library with polymorphic, accessible React components",
5
5
  "author": "Shifaul Islam",
6
6
  "license": "MIT",
@@ -61,7 +61,7 @@
61
61
  "react": "^18.0.0 || ^19.0.0",
62
62
  "react-dom": "^18.0.0 || ^19.0.0",
63
63
  "react-icons": "^5.5.0",
64
- "react-leaflet": "^4.2.1",
64
+ "react-leaflet": "^5.0.0",
65
65
  "tailwind-merge": "^3.4.0",
66
66
  "tailwindcss": "^3.0.0 || ^4.0.0",
67
67
  "tailwindcss-animate": "^1.0.7"
@@ -1,152 +1,163 @@
1
- import * as React from "react"
2
- import { cn } from "@/lib/utils"
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "@/lib/utils";
3
5
 
4
6
  interface OtpInputProps {
5
- /** Number of OTP digits */
6
- length?: number
7
- /** Callback when OTP is complete */
8
- onComplete?: (otp: string) => void
9
- /** Callback when value changes */
10
- onChange?: (value: string) => void
11
- /** Current value */
12
- value?: string
13
- /** Whether input is disabled */
14
- disabled?: boolean
15
- /** Auto focus first input */
16
- autoFocus?: boolean
17
- /** Input type (number or password for hidden) */
18
- type?: "number" | "password"
19
- className?: string
7
+ /** Number of OTP digits */
8
+ length?: number;
9
+ /** Callback when OTP is complete */
10
+ onComplete?: (otp: string) => void;
11
+ /** Callback when value changes */
12
+ onChange?: (value: string) => void;
13
+ /** Current value */
14
+ value?: string;
15
+ /** Whether input is disabled */
16
+ disabled?: boolean;
17
+ /** Auto focus first input */
18
+ autoFocus?: boolean;
19
+ /** Input type (number or password for hidden) */
20
+ type?: "number" | "password";
21
+ className?: string;
20
22
  }
21
23
 
22
24
  /**
23
25
  * OTP Input component with auto-advance
24
- *
26
+ *
25
27
  * @example
26
28
  * const [otp, setOtp] = useState("")
27
- * <OtpInput
28
- * length={6}
29
- * value={otp}
30
- * onChange={setOtp}
31
- * onComplete={(code) => verifyOtp(code)}
29
+ * <OtpInput
30
+ * length={6}
31
+ * value={otp}
32
+ * onChange={setOtp}
33
+ * onComplete={(code) => verifyOtp(code)}
32
34
  * />
33
35
  */
34
36
  const OtpInput = React.forwardRef<HTMLDivElement, OtpInputProps>(
35
- (
36
- {
37
- length = 6,
38
- onComplete,
39
- onChange,
40
- value = "",
41
- disabled,
42
- autoFocus,
43
- type = "number",
44
- className,
45
- },
46
- ref
37
+ (
38
+ {
39
+ length = 6,
40
+ onComplete,
41
+ onChange,
42
+ value = "",
43
+ disabled,
44
+ autoFocus,
45
+ type = "number",
46
+ className,
47
+ },
48
+ ref,
49
+ ) => {
50
+ const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
51
+ const [values, setValues] = React.useState<string[]>(
52
+ value.split("").concat(Array(length - value.length).fill("")),
53
+ );
54
+
55
+ React.useEffect(() => {
56
+ const newValues = value
57
+ .split("")
58
+ .concat(Array(length - value.length).fill(""));
59
+ setValues(newValues.slice(0, length));
60
+ }, [value, length]);
61
+
62
+ const focusInput = (index: number) => {
63
+ if (index >= 0 && index < length) {
64
+ inputRefs.current[index]?.focus();
65
+ }
66
+ };
67
+
68
+ const handleChange = (index: number, inputValue: string) => {
69
+ if (disabled) return;
70
+
71
+ // Only allow single digit
72
+ const digit = inputValue.slice(-1);
73
+ if (type === "number" && digit && !/^\d$/.test(digit)) return;
74
+
75
+ const newValues = [...values];
76
+ newValues[index] = digit;
77
+ setValues(newValues);
78
+
79
+ const newOtp = newValues.join("");
80
+ onChange?.(newOtp);
81
+
82
+ // Auto-advance to next input
83
+ if (digit && index < length - 1) {
84
+ focusInput(index + 1);
85
+ }
86
+
87
+ // Check if complete
88
+ if (newOtp.length === length && !newOtp.includes("")) {
89
+ onComplete?.(newOtp);
90
+ }
91
+ };
92
+
93
+ const handleKeyDown = (
94
+ index: number,
95
+ e: React.KeyboardEvent<HTMLInputElement>,
47
96
  ) => {
48
- const inputRefs = React.useRef<(HTMLInputElement | null)[]>([])
49
- const [values, setValues] = React.useState<string[]>(
50
- value.split("").concat(Array(length - value.length).fill(""))
51
- )
52
-
53
- React.useEffect(() => {
54
- const newValues = value.split("").concat(Array(length - value.length).fill(""))
55
- setValues(newValues.slice(0, length))
56
- }, [value, length])
57
-
58
- const focusInput = (index: number) => {
59
- if (index >= 0 && index < length) {
60
- inputRefs.current[index]?.focus()
61
- }
62
- }
63
-
64
- const handleChange = (index: number, inputValue: string) => {
65
- if (disabled) return
66
-
67
- // Only allow single digit
68
- const digit = inputValue.slice(-1)
69
- if (type === "number" && digit && !/^\d$/.test(digit)) return
70
-
71
- const newValues = [...values]
72
- newValues[index] = digit
73
- setValues(newValues)
74
-
75
- const newOtp = newValues.join("")
76
- onChange?.(newOtp)
77
-
78
- // Auto-advance to next input
79
- if (digit && index < length - 1) {
80
- focusInput(index + 1)
81
- }
82
-
83
- // Check if complete
84
- if (newOtp.length === length && !newOtp.includes("")) {
85
- onComplete?.(newOtp)
86
- }
87
- }
88
-
89
- const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
90
- if (e.key === "Backspace") {
91
- if (!values[index] && index > 0) {
92
- focusInput(index - 1)
93
- }
94
- } else if (e.key === "ArrowLeft") {
95
- e.preventDefault()
96
- focusInput(index - 1)
97
- } else if (e.key === "ArrowRight") {
98
- e.preventDefault()
99
- focusInput(index + 1)
100
- }
97
+ if (e.key === "Backspace") {
98
+ if (!values[index] && index > 0) {
99
+ focusInput(index - 1);
101
100
  }
102
-
103
- const handlePaste = (e: React.ClipboardEvent) => {
104
- e.preventDefault()
105
- const pastedData = e.clipboardData.getData("text").slice(0, length)
106
-
107
- if (type === "number" && !/^\d+$/.test(pastedData)) return
108
-
109
- const newValues = pastedData.split("").concat(Array(length - pastedData.length).fill(""))
110
- setValues(newValues.slice(0, length))
111
-
112
- const newOtp = newValues.slice(0, length).join("")
113
- onChange?.(newOtp)
114
-
115
- if (newOtp.length === length) {
116
- onComplete?.(newOtp)
117
- }
118
-
119
- focusInput(Math.min(pastedData.length, length - 1))
120
- }
121
-
122
- return (
123
- <div ref={ref} className={cn("flex gap-2", className)}>
124
- {Array.from({ length }).map((_, index) => (
125
- <input
126
- key={index}
127
- ref={(el) => { inputRefs.current[index] = el }}
128
- type={type === "password" ? "password" : "text"}
129
- inputMode="numeric"
130
- maxLength={1}
131
- value={values[index] || ""}
132
- onChange={(e) => handleChange(index, e.target.value)}
133
- onKeyDown={(e) => handleKeyDown(index, e)}
134
- onPaste={handlePaste}
135
- onFocus={(e) => e.target.select()}
136
- disabled={disabled}
137
- autoFocus={autoFocus && index === 0}
138
- className={cn(
139
- "h-12 w-12 rounded-md border border-input bg-transparent text-center text-lg font-semibold shadow-sm transition-colors",
140
- "focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
141
- "disabled:cursor-not-allowed disabled:opacity-50"
142
- )}
143
- aria-label={`Digit ${index + 1} of ${length}`}
144
- />
145
- ))}
146
- </div>
147
- )
148
- }
149
- )
150
- OtpInput.displayName = "OtpInput"
151
-
152
- export { OtpInput }
101
+ } else if (e.key === "ArrowLeft") {
102
+ e.preventDefault();
103
+ focusInput(index - 1);
104
+ } else if (e.key === "ArrowRight") {
105
+ e.preventDefault();
106
+ focusInput(index + 1);
107
+ }
108
+ };
109
+
110
+ const handlePaste = (e: React.ClipboardEvent) => {
111
+ e.preventDefault();
112
+ const pastedData = e.clipboardData.getData("text").slice(0, length);
113
+
114
+ if (type === "number" && !/^\d+$/.test(pastedData)) return;
115
+
116
+ const newValues = pastedData
117
+ .split("")
118
+ .concat(Array(length - pastedData.length).fill(""));
119
+ setValues(newValues.slice(0, length));
120
+
121
+ const newOtp = newValues.slice(0, length).join("");
122
+ onChange?.(newOtp);
123
+
124
+ if (newOtp.length === length) {
125
+ onComplete?.(newOtp);
126
+ }
127
+
128
+ focusInput(Math.min(pastedData.length, length - 1));
129
+ };
130
+
131
+ return (
132
+ <div ref={ref} className={cn("flex gap-2", className)}>
133
+ {Array.from({ length }).map((_, index) => (
134
+ <input
135
+ key={index}
136
+ ref={(el) => {
137
+ inputRefs.current[index] = el;
138
+ }}
139
+ type={type === "password" ? "password" : "text"}
140
+ inputMode="numeric"
141
+ maxLength={1}
142
+ value={values[index] || ""}
143
+ onChange={(e) => handleChange(index, e.target.value)}
144
+ onKeyDown={(e) => handleKeyDown(index, e)}
145
+ onPaste={handlePaste}
146
+ onFocus={(e) => e.target.select()}
147
+ disabled={disabled}
148
+ autoFocus={autoFocus && index === 0}
149
+ className={cn(
150
+ "h-12 w-12 rounded-md border border-input bg-transparent text-center text-lg font-semibold shadow-sm transition-colors",
151
+ "focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
152
+ "disabled:cursor-not-allowed disabled:opacity-50",
153
+ )}
154
+ aria-label={`Digit ${index + 1} of ${length}`}
155
+ />
156
+ ))}
157
+ </div>
158
+ );
159
+ },
160
+ );
161
+ OtpInput.displayName = "OtpInput";
162
+
163
+ export { OtpInput };
@@ -1,147 +1,162 @@
1
- import * as React from "react"
2
- import { cn } from "@/lib/utils"
1
+ "use client";
3
2
 
4
- interface SearchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
5
- /** Callback when search value changes */
6
- onSearch?: (value: string) => void
7
- /** Debounce delay in ms */
8
- debounceMs?: number
9
- /** Show clear button */
10
- showClear?: boolean
11
- /** Loading state */
12
- loading?: boolean
3
+ import * as React from "react";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ interface SearchProps extends Omit<
7
+ React.InputHTMLAttributes<HTMLInputElement>,
8
+ "onChange"
9
+ > {
10
+ /** Callback when search value changes */
11
+ onSearch?: (value: string) => void;
12
+ /** Debounce delay in ms */
13
+ debounceMs?: number;
14
+ /** Show clear button */
15
+ showClear?: boolean;
16
+ /** Loading state */
17
+ loading?: boolean;
13
18
  }
14
19
 
15
20
  /**
16
21
  * Search input with optional debounce and clear button
17
- *
22
+ *
18
23
  * @example
19
- * <Search
20
- * placeholder="Search..."
24
+ * <Search
25
+ * placeholder="Search..."
21
26
  * onSearch={(value) => fetchResults(value)}
22
27
  * debounceMs={300}
23
28
  * />
24
29
  */
25
30
  const Search = React.forwardRef<HTMLInputElement, SearchProps>(
26
- (
27
- {
28
- className,
29
- onSearch,
30
- debounceMs = 0,
31
- showClear = true,
32
- loading,
33
- defaultValue = "",
34
- ...props
35
- },
36
- ref
37
- ) => {
38
- const [value, setValue] = React.useState(String(defaultValue))
39
- const debounceRef = React.useRef<NodeJS.Timeout | null>(null)
31
+ (
32
+ {
33
+ className,
34
+ onSearch,
35
+ debounceMs = 0,
36
+ showClear = true,
37
+ loading,
38
+ defaultValue = "",
39
+ ...props
40
+ },
41
+ ref,
42
+ ) => {
43
+ const [value, setValue] = React.useState(String(defaultValue));
44
+ const debounceRef = React.useRef<NodeJS.Timeout | null>(null);
40
45
 
41
- const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
42
- const newValue = e.target.value
43
- setValue(newValue)
46
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
47
+ const newValue = e.target.value;
48
+ setValue(newValue);
44
49
 
45
- if (debounceMs > 0) {
46
- if (debounceRef.current) clearTimeout(debounceRef.current)
47
- debounceRef.current = setTimeout(() => {
48
- onSearch?.(newValue)
49
- }, debounceMs)
50
- } else {
51
- onSearch?.(newValue)
52
- }
53
- }
50
+ if (debounceMs > 0) {
51
+ if (debounceRef.current) clearTimeout(debounceRef.current);
52
+ debounceRef.current = setTimeout(() => {
53
+ onSearch?.(newValue);
54
+ }, debounceMs);
55
+ } else {
56
+ onSearch?.(newValue);
57
+ }
58
+ };
54
59
 
55
- const handleClear = () => {
56
- setValue("")
57
- onSearch?.("")
58
- }
60
+ const handleClear = () => {
61
+ setValue("");
62
+ onSearch?.("");
63
+ };
59
64
 
60
- const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
61
- if (e.key === "Escape") {
62
- handleClear()
63
- }
64
- }
65
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
66
+ if (e.key === "Escape") {
67
+ handleClear();
68
+ }
69
+ };
65
70
 
66
- React.useEffect(() => {
67
- return () => {
68
- if (debounceRef.current) clearTimeout(debounceRef.current)
69
- }
70
- }, [])
71
+ React.useEffect(() => {
72
+ return () => {
73
+ if (debounceRef.current) clearTimeout(debounceRef.current);
74
+ };
75
+ }, []);
71
76
 
72
- return (
73
- <div className={cn("relative", className)}>
74
- {/* Search Icon */}
75
- <svg
76
- className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
77
- fill="none"
78
- viewBox="0 0 24 24"
79
- stroke="currentColor"
80
- strokeWidth={2}
81
- >
82
- <path
83
- strokeLinecap="round"
84
- strokeLinejoin="round"
85
- d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
86
- />
87
- </svg>
77
+ return (
78
+ <div className={cn("relative", className)}>
79
+ {/* Search Icon */}
80
+ <svg
81
+ className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
82
+ fill="none"
83
+ viewBox="0 0 24 24"
84
+ stroke="currentColor"
85
+ strokeWidth={2}
86
+ >
87
+ <path
88
+ strokeLinecap="round"
89
+ strokeLinejoin="round"
90
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
91
+ />
92
+ </svg>
88
93
 
89
- <input
90
- ref={ref}
91
- type="search"
92
- role="searchbox"
93
- value={value}
94
- onChange={handleChange}
95
- onKeyDown={handleKeyDown}
96
- className={cn(
97
- "flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors",
98
- "placeholder:text-muted-foreground",
99
- "focus:outline-none focus:ring-1 focus:ring-ring",
100
- "disabled:cursor-not-allowed disabled:opacity-50",
101
- "[&::-webkit-search-cancel-button]:appearance-none"
102
- )}
103
- {...props}
104
- />
94
+ <input
95
+ ref={ref}
96
+ type="search"
97
+ role="searchbox"
98
+ value={value}
99
+ onChange={handleChange}
100
+ onKeyDown={handleKeyDown}
101
+ className={cn(
102
+ "flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors",
103
+ "placeholder:text-muted-foreground",
104
+ "focus:outline-none focus:ring-1 focus:ring-ring",
105
+ "disabled:cursor-not-allowed disabled:opacity-50",
106
+ "[&::-webkit-search-cancel-button]:appearance-none",
107
+ )}
108
+ {...props}
109
+ />
105
110
 
106
- {/* Loading or Clear */}
107
- <div className="absolute right-3 top-1/2 -translate-y-1/2">
108
- {loading ? (
109
- <svg
110
- className="h-4 w-4 animate-spin text-muted-foreground"
111
- fill="none"
112
- viewBox="0 0 24 24"
113
- >
114
- <circle
115
- className="opacity-25"
116
- cx="12"
117
- cy="12"
118
- r="10"
119
- stroke="currentColor"
120
- strokeWidth="4"
121
- />
122
- <path
123
- className="opacity-75"
124
- fill="currentColor"
125
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
126
- />
127
- </svg>
128
- ) : showClear && value ? (
129
- <button
130
- type="button"
131
- onClick={handleClear}
132
- className="text-muted-foreground hover:text-foreground"
133
- aria-label="Clear search"
134
- >
135
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
136
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
137
- </svg>
138
- </button>
139
- ) : null}
140
- </div>
141
- </div>
142
- )
143
- }
144
- )
145
- Search.displayName = "Search"
111
+ {/* Loading or Clear */}
112
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
113
+ {loading ? (
114
+ <svg
115
+ className="h-4 w-4 animate-spin text-muted-foreground"
116
+ fill="none"
117
+ viewBox="0 0 24 24"
118
+ >
119
+ <circle
120
+ className="opacity-25"
121
+ cx="12"
122
+ cy="12"
123
+ r="10"
124
+ stroke="currentColor"
125
+ strokeWidth="4"
126
+ />
127
+ <path
128
+ className="opacity-75"
129
+ fill="currentColor"
130
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
131
+ />
132
+ </svg>
133
+ ) : showClear && value ? (
134
+ <button
135
+ type="button"
136
+ onClick={handleClear}
137
+ className="text-muted-foreground hover:text-foreground"
138
+ aria-label="Clear search"
139
+ >
140
+ <svg
141
+ className="h-4 w-4"
142
+ fill="none"
143
+ viewBox="0 0 24 24"
144
+ stroke="currentColor"
145
+ strokeWidth={2}
146
+ >
147
+ <path
148
+ strokeLinecap="round"
149
+ strokeLinejoin="round"
150
+ d="M6 18L18 6M6 6l12 12"
151
+ />
152
+ </svg>
153
+ </button>
154
+ ) : null}
155
+ </div>
156
+ </div>
157
+ );
158
+ },
159
+ );
160
+ Search.displayName = "Search";
146
161
 
147
- export { Search }
162
+ export { Search };
@@ -1,131 +1,146 @@
1
- import * as React from "react"
2
- import { cn } from "@/lib/utils"
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "@/lib/utils";
3
5
 
4
6
  interface StarRatingProps {
5
- /** Current rating value */
6
- value?: number
7
- /** Max number of stars */
8
- max?: number
9
- /** Callback when rating changes */
10
- onValueChange?: (value: number) => void
11
- /** Whether rating is readonly */
12
- readonly?: boolean
13
- /** Size of stars */
14
- size?: "sm" | "default" | "lg"
15
- className?: string
7
+ /** Current rating value */
8
+ value?: number;
9
+ /** Max number of stars */
10
+ max?: number;
11
+ /** Callback when rating changes */
12
+ onValueChange?: (value: number) => void;
13
+ /** Whether rating is readonly */
14
+ readonly?: boolean;
15
+ /** Size of stars */
16
+ size?: "sm" | "default" | "lg";
17
+ className?: string;
16
18
  }
17
19
 
18
20
  const sizeClasses = {
19
- sm: "h-4 w-4",
20
- default: "h-5 w-5",
21
- lg: "h-6 w-6",
22
- }
21
+ sm: "h-4 w-4",
22
+ default: "h-5 w-5",
23
+ lg: "h-6 w-6",
24
+ };
23
25
 
24
26
  /**
25
27
  * Star rating component
26
- *
28
+ *
27
29
  * @example
28
30
  * const [rating, setRating] = useState(0)
29
31
  * <StarRating value={rating} onValueChange={setRating} />
30
- *
32
+ *
31
33
  * @example
32
34
  * <StarRating value={4.5} readonly />
33
35
  */
34
36
  const StarRating = React.forwardRef<HTMLDivElement, StarRatingProps>(
35
- (
36
- {
37
- value = 0,
38
- max = 5,
39
- onValueChange,
40
- readonly = false,
41
- size = "default",
42
- className,
43
- },
44
- ref
45
- ) => {
46
- const [hoverValue, setHoverValue] = React.useState<number | null>(null)
37
+ (
38
+ {
39
+ value = 0,
40
+ max = 5,
41
+ onValueChange,
42
+ readonly = false,
43
+ size = "default",
44
+ className,
45
+ },
46
+ ref,
47
+ ) => {
48
+ const [hoverValue, setHoverValue] = React.useState<number | null>(null);
47
49
 
48
- const displayValue = hoverValue !== null ? hoverValue : value
50
+ const displayValue = hoverValue !== null ? hoverValue : value;
49
51
 
50
- const handleClick = (starValue: number) => {
51
- if (!readonly) {
52
- onValueChange?.(starValue)
53
- }
54
- }
52
+ const handleClick = (starValue: number) => {
53
+ if (!readonly) {
54
+ onValueChange?.(starValue);
55
+ }
56
+ };
55
57
 
56
- const handleKeyDown = (e: React.KeyboardEvent, starValue: number) => {
57
- if (readonly) return
58
+ const handleKeyDown = (e: React.KeyboardEvent, starValue: number) => {
59
+ if (readonly) return;
58
60
 
59
- if (e.key === "Enter" || e.key === " ") {
60
- e.preventDefault()
61
- onValueChange?.(starValue)
62
- } else if (e.key === "ArrowRight") {
63
- e.preventDefault()
64
- onValueChange?.(Math.min(value + 1, max))
65
- } else if (e.key === "ArrowLeft") {
66
- e.preventDefault()
67
- onValueChange?.(Math.max(value - 1, 0))
68
- }
69
- }
61
+ if (e.key === "Enter" || e.key === " ") {
62
+ e.preventDefault();
63
+ onValueChange?.(starValue);
64
+ } else if (e.key === "ArrowRight") {
65
+ e.preventDefault();
66
+ onValueChange?.(Math.min(value + 1, max));
67
+ } else if (e.key === "ArrowLeft") {
68
+ e.preventDefault();
69
+ onValueChange?.(Math.max(value - 1, 0));
70
+ }
71
+ };
70
72
 
71
- return (
72
- <div
73
- ref={ref}
74
- role="radiogroup"
75
- aria-label={`Rating: ${value} out of ${max} stars`}
76
- className={cn("inline-flex gap-0.5", className)}
77
- onMouseLeave={() => setHoverValue(null)}
78
- >
79
- {Array.from({ length: max }).map((_, index) => {
80
- const starValue = index + 1
81
- const isFilled = displayValue >= starValue
82
- const isHalfFilled = !isFilled && displayValue > index && displayValue < starValue
73
+ return (
74
+ <div
75
+ ref={ref}
76
+ role="radiogroup"
77
+ aria-label={`Rating: ${value} out of ${max} stars`}
78
+ className={cn("inline-flex gap-0.5", className)}
79
+ onMouseLeave={() => setHoverValue(null)}
80
+ >
81
+ {Array.from({ length: max }).map((_, index) => {
82
+ const starValue = index + 1;
83
+ const isFilled = displayValue >= starValue;
84
+ const isHalfFilled =
85
+ !isFilled && displayValue > index && displayValue < starValue;
83
86
 
84
- return (
85
- <button
86
- key={index}
87
- type="button"
88
- role="radio"
89
- aria-checked={value >= starValue}
90
- aria-label={`${starValue} star${starValue > 1 ? "s" : ""}`}
91
- disabled={readonly}
92
- tabIndex={readonly ? -1 : starValue === Math.ceil(value) || (value === 0 && starValue === 1) ? 0 : -1}
93
- className={cn(
94
- "relative focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
95
- !readonly && "cursor-pointer hover:scale-110 transition-transform"
96
- )}
97
- onClick={() => handleClick(starValue)}
98
- onMouseEnter={() => !readonly && setHoverValue(starValue)}
99
- onKeyDown={(e) => handleKeyDown(e, starValue)}
100
- >
101
- {/* Empty star */}
102
- <svg
103
- className={cn(sizeClasses[size], "text-muted-foreground/30")}
104
- fill="currentColor"
105
- viewBox="0 0 24 24"
106
- >
107
- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
108
- </svg>
87
+ return (
88
+ <button
89
+ key={index}
90
+ type="button"
91
+ role="radio"
92
+ aria-checked={value >= starValue}
93
+ aria-label={`${starValue} star${starValue > 1 ? "s" : ""}`}
94
+ disabled={readonly}
95
+ tabIndex={
96
+ readonly
97
+ ? -1
98
+ : starValue === Math.ceil(value) ||
99
+ (value === 0 && starValue === 1)
100
+ ? 0
101
+ : -1
102
+ }
103
+ className={cn(
104
+ "relative focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
105
+ !readonly &&
106
+ "cursor-pointer hover:scale-110 transition-transform",
107
+ )}
108
+ onClick={() => handleClick(starValue)}
109
+ onMouseEnter={() => !readonly && setHoverValue(starValue)}
110
+ onKeyDown={(e) => handleKeyDown(e, starValue)}
111
+ >
112
+ {/* Empty star */}
113
+ <svg
114
+ className={cn(sizeClasses[size], "text-muted-foreground/30")}
115
+ fill="currentColor"
116
+ viewBox="0 0 24 24"
117
+ >
118
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
119
+ </svg>
109
120
 
110
- {/* Filled star overlay */}
111
- <svg
112
- className={cn(
113
- sizeClasses[size],
114
- "absolute inset-0 text-yellow-400 transition-opacity",
115
- isFilled ? "opacity-100" : isHalfFilled ? "opacity-50" : "opacity-0"
116
- )}
117
- fill="currentColor"
118
- viewBox="0 0 24 24"
119
- >
120
- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
121
- </svg>
122
- </button>
123
- )
124
- })}
125
- </div>
126
- )
127
- }
128
- )
129
- StarRating.displayName = "StarRating"
121
+ {/* Filled star overlay */}
122
+ <svg
123
+ className={cn(
124
+ sizeClasses[size],
125
+ "absolute inset-0 text-yellow-400 transition-opacity",
126
+ isFilled
127
+ ? "opacity-100"
128
+ : isHalfFilled
129
+ ? "opacity-50"
130
+ : "opacity-0",
131
+ )}
132
+ fill="currentColor"
133
+ viewBox="0 0 24 24"
134
+ >
135
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
136
+ </svg>
137
+ </button>
138
+ );
139
+ })}
140
+ </div>
141
+ );
142
+ },
143
+ );
144
+ StarRating.displayName = "StarRating";
130
145
 
131
- export { StarRating }
146
+ export { StarRating };