@quarklab/rad-ui 0.3.1 → 0.3.2

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.
@@ -1,454 +0,0 @@
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 };
@@ -1,202 +0,0 @@
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
- }