@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 +87 -76
- package/package.json +2 -2
- package/src/registry/ui/otp-input.tsx +150 -139
- package/src/registry/ui/search.tsx +142 -127
- package/src/registry/ui/star-rating.tsx +123 -108
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
|
|
9
|
-
import
|
|
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
|
|
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 =
|
|
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 =
|
|
208
|
-
const packageJsonPath =
|
|
209
|
-
if (!
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
229
|
-
const srcPath = hasSrc ?
|
|
230
|
-
const appPath =
|
|
231
|
-
const pagesPath =
|
|
232
|
-
const hasAppDir =
|
|
233
|
-
const hasPagesDir =
|
|
234
|
-
const libDir =
|
|
235
|
-
const componentsDir =
|
|
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 (
|
|
239
|
-
else if (
|
|
240
|
-
else globalsPath =
|
|
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 =
|
|
243
|
-
if (
|
|
244
|
-
else if (
|
|
245
|
-
else globalsPath =
|
|
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 =
|
|
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
|
|
296
|
-
await
|
|
297
|
-
const utilsPath =
|
|
298
|
-
const registryUtilsPath =
|
|
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 (
|
|
301
|
-
utilsContent = await
|
|
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
|
|
313
|
-
spinner.succeed(`Created ${
|
|
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 =
|
|
316
|
-
await
|
|
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
|
|
320
|
-
spinner.succeed(`Updated ${
|
|
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 =
|
|
329
|
-
await
|
|
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 =
|
|
352
|
-
const pkg = await
|
|
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
|
|
388
|
-
import
|
|
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
|
|
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 =
|
|
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 =
|
|
984
|
-
const srcPath = hasSrc ?
|
|
985
|
-
const componentsDir =
|
|
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
|
|
1017
|
+
await fs5.ensureDir(componentsDir);
|
|
988
1018
|
for (const name of components) {
|
|
989
1019
|
const comp = REGISTRY[name];
|
|
990
|
-
const fileName =
|
|
991
|
-
const targetPath =
|
|
992
|
-
if (
|
|
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 =
|
|
1008
|
-
if (!
|
|
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
|
|
1013
|
-
await
|
|
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.
|
|
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": "^
|
|
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
|
-
|
|
2
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
3
5
|
|
|
4
6
|
interface OtpInputProps {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
2
|
-
import { cn } from "@/lib/utils"
|
|
1
|
+
"use client";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
47
|
+
const newValue = e.target.value;
|
|
48
|
+
setValue(newValue);
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
const handleClear = () => {
|
|
61
|
+
setValue("");
|
|
62
|
+
onSearch?.("");
|
|
63
|
+
};
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
66
|
+
if (e.key === "Escape") {
|
|
67
|
+
handleClear();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
return () => {
|
|
73
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
2
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
3
5
|
|
|
4
6
|
interface StarRatingProps {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
+
const displayValue = hoverValue !== null ? hoverValue : value;
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
const handleClick = (starValue: number) => {
|
|
53
|
+
if (!readonly) {
|
|
54
|
+
onValueChange?.(starValue);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
const handleKeyDown = (e: React.KeyboardEvent, starValue: number) => {
|
|
59
|
+
if (readonly) return;
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
)
|
|
129
|
-
|
|
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 };
|