inogen/app/components/ui/form-field.tsx

389 lines
10 KiB
TypeScript

import React from "react";
import { cn } from "~/lib/utils";
import { Eye, EyeOff, AlertCircle, CheckCircle2 } from "lucide-react";
import { Input } from "./input";
import { Label } from "./label";
interface BaseFieldProps {
label?: string;
error?: string;
helper?: string;
required?: boolean;
className?: string;
containerClassName?: string;
}
interface TextFieldProps extends BaseFieldProps {
id: string;
type?: "text" | "email" | "tel" | "url";
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
autoComplete?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
maxLength?: number;
minLength?: number;
}
export function TextField({
id,
label,
type = "text",
value,
onChange,
placeholder,
error,
helper,
required,
disabled,
autoComplete,
leftIcon,
rightIcon,
maxLength,
minLength,
className,
containerClassName,
}: TextFieldProps) {
const hasError = !!error;
const hasSuccess = !hasError && value.length > 0;
return (
<div className={cn("space-y-2", containerClassName)}>
{label && (
<Label
htmlFor={id}
className={cn(
"block text-sm font-medium font-persian",
hasError ? "text-destructive" : "text-foreground",
required && "after:content-['*'] after:ml-1 after:text-destructive",
)}
>
{label}
</Label>
)}
<div className="relative">
{leftIcon && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground">
{leftIcon}
</div>
)}
<Input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
autoComplete={autoComplete}
maxLength={maxLength}
minLength={minLength}
className={cn(
"w-full h-12 px-4 font-persian text-right transition-all duration-200",
leftIcon && "pr-10",
(rightIcon || hasError || hasSuccess) && "pl-10",
hasError &&
"border-destructive focus:border-destructive focus:ring-destructive/20",
hasSuccess &&
"border-green-500 focus:border-green-500 focus:ring-green-500/20",
className,
)}
/>
{(rightIcon || hasError || hasSuccess) && (
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">
{hasError ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : hasSuccess ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
rightIcon && (
<span className="text-muted-foreground">{rightIcon}</span>
)
)}
</div>
)}
</div>
{error && (
<p className="text-sm text-destructive font-persian flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
)}
{helper && !error && (
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
)}
{maxLength && (
<div className="text-xs text-muted-foreground text-left font-persian">
{value.length}/{maxLength}
</div>
)}
</div>
);
}
interface PasswordFieldProps extends BaseFieldProps {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
autoComplete?: string;
showStrength?: boolean;
minLength?: number;
}
export function PasswordField({
id,
label,
value,
onChange,
placeholder,
error,
helper,
required,
disabled,
autoComplete = "current-password",
showStrength = false,
minLength,
className,
containerClassName,
}: PasswordFieldProps) {
const [showPassword, setShowPassword] = React.useState(false);
const hasError = !!error;
const getPasswordStrength = (
password: string,
): {
score: number;
text: string;
color: string;
} => {
if (!password) return { score: 0, text: "", color: "" };
let score = 0;
if (password.length >= 8) score++;
if (/[a-z]/.test(password)) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
const strength = [
{ text: "بسیار ضعیف", color: "text-red-500" },
{ text: "ضعیف", color: "text-orange-500" },
{ text: "متوسط", color: "text-yellow-500" },
{ text: "قوی", color: "text-blue-500" },
{ text: "بسیار قوی", color: "text-green-500" },
];
return {
score,
text: strength[Math.min(score, 4)].text,
color: strength[Math.min(score, 4)].color,
};
};
const strength = showStrength ? getPasswordStrength(value) : null;
return (
<div className={cn("space-y-2", containerClassName)}>
{label && (
<Label
htmlFor={id}
className={cn(
"block text-sm font-medium font-persian",
hasError ? "text-destructive" : "text-foreground",
required && "after:content-['*'] after:ml-1 after:text-destructive",
)}
>
{label}
</Label>
)}
<div className="relative">
<Input
id={id}
type={showPassword ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
autoComplete={autoComplete}
minLength={minLength}
className={cn(
"w-full h-12 px-4 pl-10 font-persian text-right transition-all duration-200",
hasError &&
"border-destructive focus:border-destructive focus:ring-destructive/20",
className,
)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{showStrength && value && (
<div className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs font-persian text-muted-foreground">
قدرت رمز عبور:
</span>
<span
className={cn(
"text-xs font-persian font-medium",
strength?.color,
)}
>
{strength?.text}
</span>
</div>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((step) => (
<div
key={step}
className={cn(
"h-1 flex-1 rounded-full transition-colors duration-200",
step <= (strength?.score || 0)
? step <= 2
? "bg-red-500"
: step <= 3
? "bg-yellow-500"
: step <= 4
? "bg-blue-500"
: "bg-green-500"
: "bg-muted",
)}
/>
))}
</div>
</div>
)}
{error && (
<p className="text-sm text-destructive font-persian flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
)}
{helper && !error && (
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
)}
</div>
);
}
interface CheckboxFieldProps extends BaseFieldProps {
id: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
size?: "sm" | "md" | "lg";
}
export function CheckboxField({
id,
label,
checked,
onChange,
error,
helper,
required,
disabled,
size = "md",
className,
containerClassName,
}: CheckboxFieldProps) {
const sizes = {
sm: "w-3 h-3",
md: "w-4 h-4",
lg: "w-5 h-5",
};
return (
<div className={cn("space-y-2", containerClassName)}>
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className={cn(
sizes[size],
"text-[var(--color-login-primary)] bg-background border-input rounded focus:ring-[var(--color-login-primary)] focus:ring-2 accent-[var(--color-login-primary)] transition-all duration-200",
disabled && "opacity-50 cursor-not-allowed",
error && "border-destructive focus:ring-destructive",
className,
)}
/>
{label && (
<Label
htmlFor={id}
className={cn(
"text-sm font-persian cursor-pointer",
error ? "text-destructive" : "text-foreground",
disabled && "opacity-50 cursor-not-allowed",
required &&
"after:content-['*'] after:ml-1 after:text-destructive",
)}
>
{label}
</Label>
)}
</div>
{error && (
<p className="text-sm text-destructive font-persian flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{error}
</p>
)}
{helper && !error && (
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
)}
</div>
);
}
interface FieldGroupProps {
children: React.ReactNode;
className?: string;
}
export function FieldGroup({ children, className }: FieldGroupProps) {
return <div className={cn("space-y-4", className)}>{children}</div>;
}
interface FormActionsProps {
children: React.ReactNode;
className?: string;
}
export function FormActions({ children, className }: FormActionsProps) {
return (
<div
className={cn("flex flex-col-reverse sm:flex-row gap-3 pt-4", className)}
>
{children}
</div>
);
}