@quarklab/rad-ui 0.3.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
@@ -141,9 +141,7 @@ async function readConfig(cwd) {
141
141
  const configPath = getConfigPath(cwd);
142
142
  const exists = await fs2.pathExists(configPath);
143
143
  if (!exists) {
144
- throw new Error(
145
- `Configuration file not found. Run \`rad-ui init\` first.`
146
- );
144
+ throw new Error(`Configuration file not found. Run \`rad-ui init\` first.`);
147
145
  }
148
146
  return fs2.readJson(configPath);
149
147
  }
@@ -415,7 +413,9 @@ function generateV4CSS(theme) {
415
413
  if (key === "--radius") return ` ${key}: ${value};`;
416
414
  return ` ${key}: hsl(${value});`;
417
415
  }).join("\n");
418
- const themeColorMappings = COLOR_VARS.map((name) => ` --color-${name}: var(--${name});`).join("\n");
416
+ const themeColorMappings = COLOR_VARS.map(
417
+ (name) => ` --color-${name}: var(--${name});`
418
+ ).join("\n");
419
419
  return `@import "tailwindcss";
420
420
 
421
421
  :root {
@@ -709,9 +709,7 @@ module.exports = {
709
709
  s.stop("Dependencies installed.");
710
710
  } catch {
711
711
  s.stop("Could not auto-install dependencies.");
712
- p.log.warn(
713
- `Please manually install: ${chalk2.cyan(baseDeps.join(" "))}`
714
- );
712
+ p.log.warn(`Please manually install: ${chalk2.cyan(baseDeps.join(" "))}`);
715
713
  }
716
714
  logger.break();
717
715
  p.note(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quarklab/rad-ui",
3
- "version": "0.3.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 };