@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 +5 -7
- package/package.json +1 -1
- package/templates/web/input/input.tsx +454 -0
- package/templates/web/input/validation.ts +202 -0
- package/templates/web/input.tsx +0 -103
package/dist/index.js
CHANGED
|
@@ -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(
|
|
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
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { Upload } from "lucide-react";
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
import {
|
|
8
|
+
validateValue,
|
|
9
|
+
validateFile,
|
|
10
|
+
characterPresets,
|
|
11
|
+
isControlKey,
|
|
12
|
+
formatFileSize,
|
|
13
|
+
type ValidationResult,
|
|
14
|
+
} from "./validation";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Variants
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const inputVariants = cva(
|
|
21
|
+
"flex w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
size: {
|
|
25
|
+
sm: "h-9 px-3 text-sm",
|
|
26
|
+
md: "h-10 px-3 py-2 text-sm",
|
|
27
|
+
lg: "h-11 px-4 text-base",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
size: "md",
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Types
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export interface InputProps
|
|
41
|
+
extends
|
|
42
|
+
Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
43
|
+
VariantProps<typeof inputVariants> {
|
|
44
|
+
// --- Validation ---
|
|
45
|
+
/** Enable built-in validation based on `type` (email / tel / number) */
|
|
46
|
+
validate?: boolean;
|
|
47
|
+
/** Custom regex pattern for validation (overrides type-based pattern) */
|
|
48
|
+
validationPattern?: RegExp;
|
|
49
|
+
/** Custom error message (overrides default Farsi message) */
|
|
50
|
+
validationMessage?: string;
|
|
51
|
+
/** Callback fired when validation state changes */
|
|
52
|
+
onValidationChange?: (result: { isValid: boolean; message?: string }) => void;
|
|
53
|
+
|
|
54
|
+
// --- Keyboard Restriction ---
|
|
55
|
+
/** Restrict which characters can be typed */
|
|
56
|
+
allowedCharacters?: RegExp | "digits" | "alpha" | "alphanumeric" | "persian";
|
|
57
|
+
/** Enhanced max length with character count feedback */
|
|
58
|
+
maxInputLength?: number;
|
|
59
|
+
|
|
60
|
+
// --- File Validation (type="file") ---
|
|
61
|
+
/** Maximum file size in bytes */
|
|
62
|
+
maxFileSize?: number;
|
|
63
|
+
/** Accepted file extensions, e.g. [".pdf", ".png"] */
|
|
64
|
+
acceptFormats?: string[];
|
|
65
|
+
|
|
66
|
+
// --- Error Display ---
|
|
67
|
+
/** Show inline error message below the input (default: false) */
|
|
68
|
+
showError?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Input Component
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
76
|
+
(
|
|
77
|
+
{
|
|
78
|
+
className,
|
|
79
|
+
size,
|
|
80
|
+
type,
|
|
81
|
+
validate: shouldValidate,
|
|
82
|
+
validationPattern,
|
|
83
|
+
validationMessage,
|
|
84
|
+
onValidationChange,
|
|
85
|
+
allowedCharacters,
|
|
86
|
+
maxInputLength,
|
|
87
|
+
maxFileSize,
|
|
88
|
+
acceptFormats,
|
|
89
|
+
showError = false,
|
|
90
|
+
...props
|
|
91
|
+
},
|
|
92
|
+
ref
|
|
93
|
+
) => {
|
|
94
|
+
// ---- File Input ----
|
|
95
|
+
if (type === "file") {
|
|
96
|
+
return (
|
|
97
|
+
<FileInput
|
|
98
|
+
className={className}
|
|
99
|
+
size={size}
|
|
100
|
+
maxFileSize={maxFileSize}
|
|
101
|
+
acceptFormats={acceptFormats}
|
|
102
|
+
showError={showError}
|
|
103
|
+
onValidationChange={onValidationChange}
|
|
104
|
+
validationMessage={validationMessage}
|
|
105
|
+
ref={ref}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- Standard Input ----
|
|
112
|
+
return (
|
|
113
|
+
<StandardInput
|
|
114
|
+
className={className}
|
|
115
|
+
size={size}
|
|
116
|
+
type={type}
|
|
117
|
+
shouldValidate={shouldValidate}
|
|
118
|
+
validationPattern={validationPattern}
|
|
119
|
+
validationMessage={validationMessage}
|
|
120
|
+
onValidationChange={onValidationChange}
|
|
121
|
+
allowedCharacters={allowedCharacters}
|
|
122
|
+
maxInputLength={maxInputLength}
|
|
123
|
+
showError={showError}
|
|
124
|
+
ref={ref}
|
|
125
|
+
{...props}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
Input.displayName = "Input";
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// StandardInput (internal)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
interface StandardInputInternalProps extends Omit<
|
|
137
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
138
|
+
"size"
|
|
139
|
+
> {
|
|
140
|
+
size?: InputProps["size"];
|
|
141
|
+
shouldValidate?: boolean;
|
|
142
|
+
validationPattern?: RegExp;
|
|
143
|
+
validationMessage?: string;
|
|
144
|
+
onValidationChange?: InputProps["onValidationChange"];
|
|
145
|
+
allowedCharacters?: InputProps["allowedCharacters"];
|
|
146
|
+
maxInputLength?: number;
|
|
147
|
+
showError?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const StandardInput = React.forwardRef<
|
|
151
|
+
HTMLInputElement,
|
|
152
|
+
StandardInputInternalProps
|
|
153
|
+
>(
|
|
154
|
+
(
|
|
155
|
+
{
|
|
156
|
+
className,
|
|
157
|
+
size,
|
|
158
|
+
type,
|
|
159
|
+
shouldValidate,
|
|
160
|
+
validationPattern,
|
|
161
|
+
validationMessage,
|
|
162
|
+
onValidationChange,
|
|
163
|
+
allowedCharacters,
|
|
164
|
+
maxInputLength,
|
|
165
|
+
showError = false,
|
|
166
|
+
onBlur,
|
|
167
|
+
onKeyDown,
|
|
168
|
+
onChange,
|
|
169
|
+
...props
|
|
170
|
+
},
|
|
171
|
+
ref
|
|
172
|
+
) => {
|
|
173
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
174
|
+
const [charCount, setCharCount] = React.useState(0);
|
|
175
|
+
|
|
176
|
+
// ---- Validation on blur ----
|
|
177
|
+
const handleBlur = React.useCallback(
|
|
178
|
+
(e: React.FocusEvent<HTMLInputElement>) => {
|
|
179
|
+
if (shouldValidate || validationPattern) {
|
|
180
|
+
const result = validateValue(e.target.value, {
|
|
181
|
+
type,
|
|
182
|
+
required: props.required,
|
|
183
|
+
pattern: validationPattern,
|
|
184
|
+
customMessage: validationMessage,
|
|
185
|
+
});
|
|
186
|
+
setError(result.isValid ? null : (result.message ?? null));
|
|
187
|
+
onValidationChange?.(result);
|
|
188
|
+
}
|
|
189
|
+
onBlur?.(e);
|
|
190
|
+
},
|
|
191
|
+
[
|
|
192
|
+
shouldValidate,
|
|
193
|
+
validationPattern,
|
|
194
|
+
validationMessage,
|
|
195
|
+
type,
|
|
196
|
+
props.required,
|
|
197
|
+
onValidationChange,
|
|
198
|
+
onBlur,
|
|
199
|
+
]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// ---- Keyboard filtering ----
|
|
203
|
+
const handleKeyDown = React.useCallback(
|
|
204
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
205
|
+
if (allowedCharacters && !isControlKey(e)) {
|
|
206
|
+
const charPattern =
|
|
207
|
+
typeof allowedCharacters === "string"
|
|
208
|
+
? characterPresets[allowedCharacters]
|
|
209
|
+
: allowedCharacters;
|
|
210
|
+
|
|
211
|
+
if (charPattern && !charPattern.test(e.key)) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
onKeyDown?.(e);
|
|
216
|
+
},
|
|
217
|
+
[allowedCharacters, onKeyDown]
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// ---- Change handler (char count + re-validate if already errored) ----
|
|
221
|
+
const handleChange = React.useCallback(
|
|
222
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
223
|
+
if (maxInputLength !== undefined) {
|
|
224
|
+
setCharCount(e.target.value.length);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Clear error on valid input after previous error
|
|
228
|
+
if (error && (shouldValidate || validationPattern)) {
|
|
229
|
+
const result = validateValue(e.target.value, {
|
|
230
|
+
type,
|
|
231
|
+
required: props.required,
|
|
232
|
+
pattern: validationPattern,
|
|
233
|
+
customMessage: validationMessage,
|
|
234
|
+
});
|
|
235
|
+
if (result.isValid) {
|
|
236
|
+
setError(null);
|
|
237
|
+
onValidationChange?.(result);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
onChange?.(e);
|
|
242
|
+
},
|
|
243
|
+
[
|
|
244
|
+
maxInputLength,
|
|
245
|
+
error,
|
|
246
|
+
shouldValidate,
|
|
247
|
+
validationPattern,
|
|
248
|
+
validationMessage,
|
|
249
|
+
type,
|
|
250
|
+
props.required,
|
|
251
|
+
onValidationChange,
|
|
252
|
+
onChange,
|
|
253
|
+
]
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const hasError = error !== null;
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="w-full">
|
|
260
|
+
<input
|
|
261
|
+
type={type}
|
|
262
|
+
className={cn(
|
|
263
|
+
inputVariants({ size, className }),
|
|
264
|
+
hasError && "border-destructive focus-visible:ring-destructive"
|
|
265
|
+
)}
|
|
266
|
+
ref={ref}
|
|
267
|
+
maxLength={maxInputLength}
|
|
268
|
+
aria-invalid={hasError || undefined}
|
|
269
|
+
onBlur={handleBlur}
|
|
270
|
+
onKeyDown={handleKeyDown}
|
|
271
|
+
onChange={handleChange}
|
|
272
|
+
{...props}
|
|
273
|
+
/>
|
|
274
|
+
{/* Character count */}
|
|
275
|
+
{maxInputLength !== undefined && (
|
|
276
|
+
<div className="flex justify-end mt-1">
|
|
277
|
+
<span
|
|
278
|
+
className={cn(
|
|
279
|
+
"text-xs text-muted-foreground",
|
|
280
|
+
charCount >= maxInputLength && "text-destructive"
|
|
281
|
+
)}
|
|
282
|
+
>
|
|
283
|
+
{charCount}/{maxInputLength}
|
|
284
|
+
</span>
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
{/* Inline error */}
|
|
288
|
+
{showError && hasError && (
|
|
289
|
+
<p className="text-sm text-destructive mt-1" role="alert">
|
|
290
|
+
{error}
|
|
291
|
+
</p>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
StandardInput.displayName = "StandardInput";
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// FileInput (internal)
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
interface FileInputInternalProps extends Omit<
|
|
304
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
305
|
+
"size" | "type"
|
|
306
|
+
> {
|
|
307
|
+
size?: InputProps["size"];
|
|
308
|
+
maxFileSize?: number;
|
|
309
|
+
acceptFormats?: string[];
|
|
310
|
+
showError?: boolean;
|
|
311
|
+
onValidationChange?: InputProps["onValidationChange"];
|
|
312
|
+
validationMessage?: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const FileInput = React.forwardRef<HTMLInputElement, FileInputInternalProps>(
|
|
316
|
+
(
|
|
317
|
+
{
|
|
318
|
+
className,
|
|
319
|
+
size,
|
|
320
|
+
maxFileSize,
|
|
321
|
+
acceptFormats,
|
|
322
|
+
showError = false,
|
|
323
|
+
onValidationChange,
|
|
324
|
+
validationMessage,
|
|
325
|
+
onChange,
|
|
326
|
+
...props
|
|
327
|
+
},
|
|
328
|
+
ref
|
|
329
|
+
) => {
|
|
330
|
+
const [fileName, setFileName] = React.useState<string>("");
|
|
331
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
332
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
333
|
+
|
|
334
|
+
React.useImperativeHandle(ref, () => inputRef.current!);
|
|
335
|
+
|
|
336
|
+
const handleFileChange = React.useCallback(
|
|
337
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
338
|
+
const file = e.target.files?.[0];
|
|
339
|
+
|
|
340
|
+
if (file) {
|
|
341
|
+
// Validate file
|
|
342
|
+
if (maxFileSize || acceptFormats) {
|
|
343
|
+
const result = validateFile(file, { maxFileSize, acceptFormats });
|
|
344
|
+
if (!result.isValid) {
|
|
345
|
+
const msg = validationMessage || result.message || null;
|
|
346
|
+
setError(msg);
|
|
347
|
+
setFileName("");
|
|
348
|
+
onValidationChange?.({
|
|
349
|
+
isValid: false,
|
|
350
|
+
message: msg ?? undefined,
|
|
351
|
+
});
|
|
352
|
+
// Reset the input so the same file can be re-selected
|
|
353
|
+
e.target.value = "";
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
setFileName(file.name);
|
|
359
|
+
setError(null);
|
|
360
|
+
onValidationChange?.({ isValid: true });
|
|
361
|
+
} else {
|
|
362
|
+
setFileName("");
|
|
363
|
+
setError(null);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
onChange?.(e);
|
|
367
|
+
},
|
|
368
|
+
[
|
|
369
|
+
maxFileSize,
|
|
370
|
+
acceptFormats,
|
|
371
|
+
validationMessage,
|
|
372
|
+
onValidationChange,
|
|
373
|
+
onChange,
|
|
374
|
+
]
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const hasError = error !== null;
|
|
378
|
+
|
|
379
|
+
// Build accept string from acceptFormats
|
|
380
|
+
const acceptAttr =
|
|
381
|
+
props.accept || (acceptFormats ? acceptFormats.join(",") : undefined);
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<div className="w-full">
|
|
385
|
+
<div
|
|
386
|
+
className={cn(
|
|
387
|
+
inputVariants({ size, className }),
|
|
388
|
+
"flex items-center gap-2 cursor-pointer",
|
|
389
|
+
hasError && "border-destructive focus-visible:ring-destructive",
|
|
390
|
+
props.disabled && "cursor-not-allowed opacity-50"
|
|
391
|
+
)}
|
|
392
|
+
onClick={() => !props.disabled && inputRef.current?.click()}
|
|
393
|
+
>
|
|
394
|
+
<button
|
|
395
|
+
type="button"
|
|
396
|
+
disabled={props.disabled}
|
|
397
|
+
className={cn(
|
|
398
|
+
"flex items-center gap-2 rounded-sm bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground transition-colors",
|
|
399
|
+
"hover:bg-secondary/80 focus:outline-none",
|
|
400
|
+
props.disabled && "pointer-events-none"
|
|
401
|
+
)}
|
|
402
|
+
tabIndex={-1}
|
|
403
|
+
>
|
|
404
|
+
<Upload className="h-3 w-3" />
|
|
405
|
+
<span>انتخاب فایل</span>
|
|
406
|
+
</button>
|
|
407
|
+
<span
|
|
408
|
+
className={cn(
|
|
409
|
+
"text-muted-foreground truncate flex-1 text-right text-xs",
|
|
410
|
+
!fileName && "opacity-70"
|
|
411
|
+
)}
|
|
412
|
+
dir="rtl"
|
|
413
|
+
>
|
|
414
|
+
{fileName || props.placeholder || "فایلی انتخاب نشده"}
|
|
415
|
+
</span>
|
|
416
|
+
<input
|
|
417
|
+
type="file"
|
|
418
|
+
className="hidden"
|
|
419
|
+
ref={inputRef}
|
|
420
|
+
accept={acceptAttr}
|
|
421
|
+
onChange={handleFileChange}
|
|
422
|
+
{...props}
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
{/* File constraints hint */}
|
|
426
|
+
{(maxFileSize || acceptFormats) && !hasError && (
|
|
427
|
+
<p className="text-xs text-muted-foreground mt-1" dir="rtl">
|
|
428
|
+
{[
|
|
429
|
+
maxFileSize ? `حداکثر حجم: ${formatFileSize(maxFileSize)}` : null,
|
|
430
|
+
acceptFormats
|
|
431
|
+
? `فرمتهای مجاز: ${acceptFormats.join("، ")}`
|
|
432
|
+
: null,
|
|
433
|
+
]
|
|
434
|
+
.filter(Boolean)
|
|
435
|
+
.join(" · ")}
|
|
436
|
+
</p>
|
|
437
|
+
)}
|
|
438
|
+
{/* Inline error */}
|
|
439
|
+
{showError && hasError && (
|
|
440
|
+
<p className="text-sm text-destructive mt-1" role="alert" dir="rtl">
|
|
441
|
+
{error}
|
|
442
|
+
</p>
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
FileInput.displayName = "FileInput";
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Exports
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
export { Input, inputVariants };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Validation Patterns
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export const validationPatterns = {
|
|
6
|
+
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
7
|
+
tel: /^\+?[0-9\s\-()]{7,15}$/,
|
|
8
|
+
iranianTel: /^(\+98|0)?9\d{9}$/,
|
|
9
|
+
number: /^-?\d*\.?\d+$/,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Character Presets (for keyboard filtering)
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const characterPresets: Record<string, RegExp> = {
|
|
17
|
+
digits: /^[0-9]$/,
|
|
18
|
+
alpha: /^[a-zA-Z\u0600-\u06FF\s]$/,
|
|
19
|
+
alphanumeric: /^[a-zA-Z0-9\u0600-\u06FF\s]$/,
|
|
20
|
+
persian: /^[\u0600-\u06FF\u200C\u200F0-9\s.,;:!?()«»؟،؛]$/,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Control Keys (should never be blocked by character filtering)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const CONTROL_KEYS = new Set([
|
|
28
|
+
"Backspace",
|
|
29
|
+
"Delete",
|
|
30
|
+
"Tab",
|
|
31
|
+
"Escape",
|
|
32
|
+
"Enter",
|
|
33
|
+
"ArrowLeft",
|
|
34
|
+
"ArrowRight",
|
|
35
|
+
"ArrowUp",
|
|
36
|
+
"ArrowDown",
|
|
37
|
+
"Home",
|
|
38
|
+
"End",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function isControlKey(e: React.KeyboardEvent): boolean {
|
|
42
|
+
return CONTROL_KEYS.has(e.key) || e.ctrlKey || e.metaKey || e.altKey;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Default Farsi Error Messages
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export const defaultMessages = {
|
|
50
|
+
email: "لطفاً یک آدرس ایمیل معتبر وارد کنید.",
|
|
51
|
+
tel: "لطفاً یک شماره تلفن معتبر وارد کنید.",
|
|
52
|
+
number: "لطفاً یک عدد معتبر وارد کنید.",
|
|
53
|
+
required: "این فیلد الزامی است.",
|
|
54
|
+
patternMismatch: "مقدار وارد شده معتبر نیست.",
|
|
55
|
+
fileTooLarge: (max: string) => `حجم فایل نباید بیشتر از ${max} باشد.`,
|
|
56
|
+
invalidFormat: (formats: string) => `فرمتهای مجاز: ${formats}`,
|
|
57
|
+
minLength: (min: number) => `حداقل ${min} کاراکتر وارد کنید.`,
|
|
58
|
+
maxLength: (max: number) => `حداکثر ${max} کاراکتر مجاز است.`,
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// File Size Formatter (bytes → readable Farsi string)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export function formatFileSize(bytes: number): string {
|
|
66
|
+
if (bytes < 1024) return `${bytes} بایت`;
|
|
67
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} کیلوبایت`;
|
|
68
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
69
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} مگابایت`;
|
|
70
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} گیگابایت`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// validateValue – Pure validation function
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export interface ValidateValueOptions {
|
|
78
|
+
type?: string;
|
|
79
|
+
required?: boolean;
|
|
80
|
+
pattern?: RegExp;
|
|
81
|
+
minLength?: number;
|
|
82
|
+
maxLength?: number;
|
|
83
|
+
customMessage?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ValidationResult {
|
|
87
|
+
isValid: boolean;
|
|
88
|
+
message?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validateValue(
|
|
92
|
+
value: string,
|
|
93
|
+
options: ValidateValueOptions = {}
|
|
94
|
+
): ValidationResult {
|
|
95
|
+
const { type, required, pattern, minLength, maxLength, customMessage } =
|
|
96
|
+
options;
|
|
97
|
+
|
|
98
|
+
// Required check
|
|
99
|
+
if (required && !value.trim()) {
|
|
100
|
+
return { isValid: false, message: defaultMessages.required };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Skip further validation if empty and not required
|
|
104
|
+
if (!value.trim()) {
|
|
105
|
+
return { isValid: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Min length
|
|
109
|
+
if (minLength !== undefined && value.length < minLength) {
|
|
110
|
+
return {
|
|
111
|
+
isValid: false,
|
|
112
|
+
message: defaultMessages.minLength(minLength),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Max length
|
|
117
|
+
if (maxLength !== undefined && value.length > maxLength) {
|
|
118
|
+
return {
|
|
119
|
+
isValid: false,
|
|
120
|
+
message: defaultMessages.maxLength(maxLength),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Custom pattern
|
|
125
|
+
if (pattern) {
|
|
126
|
+
if (!pattern.test(value)) {
|
|
127
|
+
return {
|
|
128
|
+
isValid: false,
|
|
129
|
+
message: customMessage || defaultMessages.patternMismatch,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { isValid: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Type-based validation
|
|
136
|
+
switch (type) {
|
|
137
|
+
case "email":
|
|
138
|
+
if (!validationPatterns.email.test(value)) {
|
|
139
|
+
return {
|
|
140
|
+
isValid: false,
|
|
141
|
+
message: customMessage || defaultMessages.email,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
case "tel":
|
|
146
|
+
if (!validationPatterns.tel.test(value)) {
|
|
147
|
+
return {
|
|
148
|
+
isValid: false,
|
|
149
|
+
message: customMessage || defaultMessages.tel,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
case "number":
|
|
154
|
+
if (!validationPatterns.number.test(value)) {
|
|
155
|
+
return {
|
|
156
|
+
isValid: false,
|
|
157
|
+
message: customMessage || defaultMessages.number,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { isValid: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// File Validation
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export interface FileValidationOptions {
|
|
171
|
+
maxFileSize?: number;
|
|
172
|
+
acceptFormats?: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateFile(
|
|
176
|
+
file: File,
|
|
177
|
+
options: FileValidationOptions
|
|
178
|
+
): ValidationResult {
|
|
179
|
+
const { maxFileSize, acceptFormats } = options;
|
|
180
|
+
|
|
181
|
+
if (maxFileSize && file.size > maxFileSize) {
|
|
182
|
+
return {
|
|
183
|
+
isValid: false,
|
|
184
|
+
message: defaultMessages.fileTooLarge(formatFileSize(maxFileSize)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (acceptFormats && acceptFormats.length > 0) {
|
|
189
|
+
const fileName = file.name.toLowerCase();
|
|
190
|
+
const hasValidFormat = acceptFormats.some((format) =>
|
|
191
|
+
fileName.endsWith(format.toLowerCase())
|
|
192
|
+
);
|
|
193
|
+
if (!hasValidFormat) {
|
|
194
|
+
return {
|
|
195
|
+
isValid: false,
|
|
196
|
+
message: defaultMessages.invalidFormat(acceptFormats.join("، ")),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { isValid: true };
|
|
202
|
+
}
|
package/templates/web/input.tsx
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
-
import { Upload } from "lucide-react";
|
|
4
|
-
import { cn } from "../lib/utils";
|
|
5
|
-
|
|
6
|
-
const inputVariants = cva(
|
|
7
|
-
"flex w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
8
|
-
{
|
|
9
|
-
variants: {
|
|
10
|
-
size: {
|
|
11
|
-
sm: "h-9 px-3 text-sm",
|
|
12
|
-
md: "h-10 px-3 py-2 text-sm",
|
|
13
|
-
lg: "h-11 px-4 text-base",
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
defaultVariants: {
|
|
17
|
-
size: "md",
|
|
18
|
-
},
|
|
19
|
-
}
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
export interface InputProps
|
|
23
|
-
extends
|
|
24
|
-
Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
25
|
-
VariantProps<typeof inputVariants> {}
|
|
26
|
-
|
|
27
|
-
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
28
|
-
({ className, size, type, ...props }, ref) => {
|
|
29
|
-
// Custom logic for file input to support Farsi text
|
|
30
|
-
if (type === "file") {
|
|
31
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
32
|
-
const [fileName, setFileName] = React.useState<string>("");
|
|
33
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
34
|
-
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
35
|
-
|
|
36
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
37
|
-
React.useImperativeHandle(ref, () => inputRef.current!);
|
|
38
|
-
|
|
39
|
-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
40
|
-
const file = e.target.files?.[0];
|
|
41
|
-
if (file) {
|
|
42
|
-
setFileName(file.name);
|
|
43
|
-
} else {
|
|
44
|
-
setFileName("");
|
|
45
|
-
}
|
|
46
|
-
props.onChange?.(e);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<div
|
|
51
|
-
className={cn(
|
|
52
|
-
inputVariants({ size, className }),
|
|
53
|
-
"flex items-center gap-2 cursor-pointer",
|
|
54
|
-
props.disabled && "cursor-not-allowed opacity-50"
|
|
55
|
-
)}
|
|
56
|
-
onClick={() => !props.disabled && inputRef.current?.click()}
|
|
57
|
-
>
|
|
58
|
-
<button
|
|
59
|
-
type="button"
|
|
60
|
-
disabled={props.disabled}
|
|
61
|
-
className={cn(
|
|
62
|
-
"flex items-center gap-2 rounded-sm bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground transition-colors",
|
|
63
|
-
"hover:bg-secondary/80 focus:outline-none",
|
|
64
|
-
props.disabled && "pointer-events-none"
|
|
65
|
-
)}
|
|
66
|
-
tabIndex={-1}
|
|
67
|
-
>
|
|
68
|
-
<Upload className="h-3 w-3" />
|
|
69
|
-
<span>انتخاب فایل</span>
|
|
70
|
-
</button>
|
|
71
|
-
<span
|
|
72
|
-
className={cn(
|
|
73
|
-
"text-muted-foreground truncate flex-1 text-right text-xs",
|
|
74
|
-
!fileName && "opacity-70"
|
|
75
|
-
)}
|
|
76
|
-
dir="rtl"
|
|
77
|
-
>
|
|
78
|
-
{fileName || props.placeholder || "فایلی انتخاب نشده"}
|
|
79
|
-
</span>
|
|
80
|
-
<input
|
|
81
|
-
type="file"
|
|
82
|
-
className="hidden"
|
|
83
|
-
ref={inputRef}
|
|
84
|
-
onChange={handleFileChange}
|
|
85
|
-
{...props}
|
|
86
|
-
/>
|
|
87
|
-
</div>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return (
|
|
92
|
-
<input
|
|
93
|
-
type={type}
|
|
94
|
-
className={cn(inputVariants({ size, className }))}
|
|
95
|
-
ref={ref}
|
|
96
|
-
{...props}
|
|
97
|
-
/>
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
);
|
|
101
|
-
Input.displayName = "Input";
|
|
102
|
-
|
|
103
|
-
export { Input, inputVariants };
|