Merge branch 'dev-miguel' into feat-home
This commit is contained in:
commit
09e8738148
13 files changed
+1448
No files matched your search
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { FiX } from 'react-icons/fi';
|
||||
|
||||
export interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?:
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info'
|
||||
| 'outline';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
dot?: boolean;
|
||||
dotPulse?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<NonNullable<BadgeProps['variant']>, { badge: string; dot: string }> = {
|
||||
default: {
|
||||
badge: 'bg-slate-100 text-slate-800 border-slate-200 dark:bg-slate-800/80 dark:text-slate-200 dark:border-slate-700',
|
||||
dot: 'bg-slate-500',
|
||||
},
|
||||
primary: {
|
||||
badge: 'bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-950/60 dark:text-cyan-300 dark:border-cyan-500/30',
|
||||
dot: 'bg-cyan-500',
|
||||
},
|
||||
secondary: {
|
||||
badge: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/60 dark:text-purple-300 dark:border-purple-500/30',
|
||||
dot: 'bg-purple-500',
|
||||
},
|
||||
success: {
|
||||
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/60 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
dot: 'bg-emerald-500',
|
||||
},
|
||||
warning: {
|
||||
badge: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/60 dark:text-amber-300 dark:border-amber-500/30',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
danger: {
|
||||
badge: 'bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950/60 dark:text-rose-300 dark:border-rose-500/30',
|
||||
dot: 'bg-rose-500',
|
||||
},
|
||||
info: {
|
||||
badge: 'bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/60 dark:text-sky-300 dark:border-sky-500/30',
|
||||
dot: 'bg-sky-500',
|
||||
},
|
||||
outline: {
|
||||
badge: 'bg-transparent text-slate-700 border-slate-300 dark:text-slate-300 dark:border-slate-700',
|
||||
dot: 'bg-slate-400',
|
||||
},
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'text-[10px] px-2 py-0.5 gap-1 rounded-md font-semibold',
|
||||
md: 'text-xs px-2.5 py-1 gap-1.5 rounded-lg font-medium',
|
||||
lg: 'text-sm px-3 py-1.5 gap-2 rounded-xl font-medium',
|
||||
};
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
dot = false,
|
||||
dotPulse = false,
|
||||
icon,
|
||||
removable = false,
|
||||
onRemove,
|
||||
className = '',
|
||||
}) => {
|
||||
const currentVariant = variantStyles[variant];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center select-none border transition-colors
|
||||
${currentVariant.badge}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{/* Optional Dot indicator */}
|
||||
{dot && (
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||
{dotPulse && (
|
||||
<span
|
||||
className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${currentVariant.dot}`}
|
||||
/>
|
||||
)}
|
||||
<span className={`relative inline-flex rounded-full h-1.5 w-1.5 ${currentVariant.dot}`} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Optional Icon */}
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
|
||||
{/* Content */}
|
||||
<span>{children}</span>
|
||||
|
||||
{/* Optional Remove Button */}
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.();
|
||||
}}
|
||||
className="ml-0.5 p-0.5 rounded-full hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="Remover etiqueta"
|
||||
>
|
||||
<FiX className="text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'gradient';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'icon';
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const variantStyles: Record<NonNullable<ButtonProps['variant']>, string> = {
|
||||
primary:
|
||||
'bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-semibold shadow-md shadow-cyan-500/20 hover:shadow-cyan-500/30 focus-visible:ring-cyan-500 dark:bg-cyan-500 dark:hover:bg-cyan-400 dark:text-slate-950 dark:shadow-cyan-500/30',
|
||||
secondary:
|
||||
'bg-slate-100 hover:bg-slate-200 text-slate-800 border border-slate-200 shadow-sm focus-visible:ring-slate-400 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-100 dark:border-slate-700 dark:shadow-none dark:focus-visible:ring-slate-500',
|
||||
outline:
|
||||
'bg-transparent border border-slate-300 text-slate-700 hover:bg-slate-100 hover:border-slate-400 focus-visible:ring-slate-400 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800/80 dark:hover:border-slate-600 dark:focus-visible:ring-slate-500',
|
||||
ghost:
|
||||
'bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-slate-400 dark:text-slate-300 dark:hover:bg-slate-800/70 dark:hover:text-white dark:focus-visible:ring-slate-600',
|
||||
danger:
|
||||
'bg-rose-600 hover:bg-rose-500 text-white font-medium shadow-md shadow-rose-600/25 focus-visible:ring-rose-500 dark:bg-rose-600 dark:hover:bg-rose-500 dark:text-white',
|
||||
success:
|
||||
'bg-emerald-600 hover:bg-emerald-500 text-white font-medium shadow-md shadow-emerald-600/25 focus-visible:ring-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 dark:text-slate-950',
|
||||
gradient:
|
||||
'bg-gradient-to-r from-cyan-500 via-sky-500 to-blue-600 hover:from-cyan-400 hover:via-sky-400 hover:to-blue-500 text-white font-semibold shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 focus-visible:ring-cyan-500 dark:from-cyan-500 dark:to-blue-600',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<NonNullable<ButtonProps['size']>, string> = {
|
||||
xs: 'text-[11px] px-2.5 py-1 rounded-lg gap-1.5',
|
||||
sm: 'text-xs px-3 py-1.5 rounded-xl gap-2',
|
||||
md: 'text-sm px-4 py-2.5 rounded-xl gap-2',
|
||||
lg: 'text-base px-6 py-3.5 rounded-2xl gap-2.5',
|
||||
icon: 'p-2.5 rounded-xl justify-center items-center',
|
||||
};
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
loadingText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
disabled,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isInteractiveDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isInteractiveDisabled}
|
||||
className={`
|
||||
relative inline-flex items-center justify-center font-medium select-none
|
||||
transition-all duration-200 ease-out
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
||||
focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950
|
||||
active:scale-[0.98]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none disabled:active:scale-100
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner
|
||||
size={size === 'xs' || size === 'sm' ? 'xs' : 'sm'}
|
||||
color={variant === 'primary' || variant === 'success' ? 'slate' : 'current'}
|
||||
/>
|
||||
{loadingText ? <span>{loadingText}</span> : children}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="shrink-0">{leftIcon}</span>}
|
||||
{children && <span>{children}</span>}
|
||||
{rightIcon && <span className="shrink-0">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'elevated' | 'glass' | 'outline' | 'interactive';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const variantStyles: Record<NonNullable<CardProps['variant']>, string> = {
|
||||
default:
|
||||
'bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800/80 shadow-sm',
|
||||
elevated:
|
||||
'bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl shadow-slate-200/50 dark:shadow-black/40',
|
||||
glass:
|
||||
'bg-white/80 dark:bg-slate-900/60 backdrop-blur-xl border border-slate-200/70 dark:border-slate-800/70 shadow-lg',
|
||||
outline:
|
||||
'bg-transparent border border-slate-300 dark:border-slate-800 shadow-none',
|
||||
interactive:
|
||||
'bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800 shadow-sm hover:shadow-xl hover:shadow-cyan-500/10 hover:border-cyan-500/50 dark:hover:border-cyan-500/40 cursor-pointer transform hover:-translate-y-1 transition-all duration-300 ease-out',
|
||||
};
|
||||
|
||||
const paddingStyles: Record<NonNullable<CardProps['padding']>, string> = {
|
||||
none: 'p-0',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
};
|
||||
|
||||
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
(
|
||||
{
|
||||
variant = 'default',
|
||||
padding = 'none',
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`
|
||||
rounded-2xl overflow-hidden transition-colors
|
||||
${variantStyles[variant]}
|
||||
${paddingStyles[padding]}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
export const CardHeader: React.FC<CardHeaderProps> = ({
|
||||
bordered = false,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
p-5 sm:p-6 flex flex-col space-y-1.5
|
||||
${bordered ? 'border-b border-slate-100 dark:border-slate-800/80' : ''}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<h3
|
||||
className={`text-lg sm:text-xl font-bold tracking-tight text-slate-900 dark:text-white transition-colors ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<p
|
||||
className={`text-xs sm:text-sm text-slate-500 dark:text-slate-400 leading-relaxed ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={`p-5 sm:p-6 ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
export const CardFooter: React.FC<CardFooterProps> = ({
|
||||
bordered = false,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
p-5 sm:p-6 flex items-center justify-between
|
||||
${bordered ? 'border-t border-slate-100 dark:border-slate-800/80' : ''}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FiCheck, FiMinus } from 'react-icons/fi';
|
||||
|
||||
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: React.ReactNode;
|
||||
description?: string;
|
||||
errorMessage?: string;
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
errorMessage,
|
||||
indeterminate = false,
|
||||
checked,
|
||||
disabled,
|
||||
className = '',
|
||||
id,
|
||||
name,
|
||||
...props
|
||||
},
|
||||
forwardedRef
|
||||
) => {
|
||||
const internalRef = useRef<HTMLInputElement>(null);
|
||||
const resolvedRef = (forwardedRef as React.RefObject<HTMLInputElement>) || internalRef;
|
||||
const checkboxId = id || name || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedRef.current) {
|
||||
resolvedRef.current.indeterminate = indeterminate;
|
||||
}
|
||||
}, [indeterminate, resolvedRef]);
|
||||
|
||||
const isChecked = Boolean(checked);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col space-y-1 ${className}`}>
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
className={`inline-flex items-start gap-2.5 select-none cursor-pointer group ${
|
||||
disabled ? 'opacity-50 !cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Custom Styled Box */}
|
||||
<div className="relative flex items-center justify-center mt-0.5 shrink-0">
|
||||
<input
|
||||
ref={resolvedRef}
|
||||
id={checkboxId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
className="peer sr-only"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`
|
||||
w-4 h-4 rounded-md flex items-center justify-center transition-all duration-200
|
||||
border
|
||||
peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2
|
||||
peer-focus-visible:ring-cyan-500 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-950
|
||||
${
|
||||
isChecked || indeterminate
|
||||
? 'bg-cyan-500 border-cyan-500 text-slate-950 shadow-sm shadow-cyan-500/20'
|
||||
: 'bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-transparent group-hover:border-slate-400 dark:group-hover:border-slate-500'
|
||||
}
|
||||
${
|
||||
errorMessage
|
||||
? '!border-rose-500 !bg-rose-50 dark:!bg-rose-950/30'
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<FiMinus className="text-xs stroke-3" />
|
||||
) : isChecked ? (
|
||||
<FiCheck className="text-xs stroke-3" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label and Sub-description */}
|
||||
{(label || description) && (
|
||||
<div className="flex flex-col text-left">
|
||||
{label && (
|
||||
<span className="text-xs sm:text-sm font-medium text-slate-800 dark:text-slate-200 group-hover:text-slate-950 dark:group-hover:text-white transition-colors">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-400 pl-6.5">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FiEye, FiEyeOff, FiX, FiAlertCircle } from 'react-icons/fi';
|
||||
|
||||
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
errorMessage?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
isClearable?: boolean;
|
||||
onClear?: () => void;
|
||||
sizeVariant?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: {
|
||||
input: 'py-2 text-xs rounded-lg',
|
||||
icon: 'text-sm',
|
||||
leftPad: 'pl-8',
|
||||
rightPad: 'pr-8',
|
||||
},
|
||||
md: {
|
||||
input: 'py-2.5 text-sm rounded-xl',
|
||||
icon: 'text-base',
|
||||
leftPad: 'pl-10',
|
||||
rightPad: 'pr-10',
|
||||
},
|
||||
lg: {
|
||||
input: 'py-3.5 text-base rounded-2xl',
|
||||
icon: 'text-lg',
|
||||
leftPad: 'pl-12',
|
||||
rightPad: 'pr-12',
|
||||
},
|
||||
};
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
helperText,
|
||||
errorMessage,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
isClearable = false,
|
||||
onClear,
|
||||
sizeVariant = 'md',
|
||||
fullWidth = true,
|
||||
type = 'text',
|
||||
id,
|
||||
name,
|
||||
disabled,
|
||||
required,
|
||||
className = '',
|
||||
value,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const inputId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const isPasswordType = type === 'password';
|
||||
const computedType = isPasswordType ? (showPassword ? 'text' : 'password') : type;
|
||||
const hasError = Boolean(errorMessage);
|
||||
const hasValue = value !== undefined && value !== '' && value !== null;
|
||||
|
||||
const currentSize = sizeStyles[sizeVariant];
|
||||
|
||||
return (
|
||||
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}>
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`block text-xs font-semibold select-none transition-colors ${
|
||||
hasError
|
||||
? 'text-rose-600 dark:text-rose-400'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Wrapper */}
|
||||
<div className="relative flex items-center">
|
||||
{/* Left Icon */}
|
||||
{leftIcon && (
|
||||
<div
|
||||
className={`absolute left-3.5 pointer-events-none flex items-center justify-center transition-colors ${
|
||||
hasError
|
||||
? 'text-rose-500'
|
||||
: 'text-slate-400 dark:text-slate-500'
|
||||
} ${currentSize.icon}`}
|
||||
>
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Core Input Element */}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
name={name}
|
||||
type={computedType}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className={`
|
||||
w-full px-3.5 font-normal transition-all duration-200 outline-none
|
||||
bg-white text-slate-900 placeholder:text-slate-400
|
||||
border border-slate-300
|
||||
hover:border-slate-400
|
||||
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
|
||||
dark:bg-slate-900/90 dark:text-slate-100 dark:placeholder:text-slate-500
|
||||
dark:border-slate-800 dark:hover:border-slate-700
|
||||
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
|
||||
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed
|
||||
${leftIcon ? currentSize.leftPad : ''}
|
||||
${isPasswordType || isClearable || rightIcon ? currentSize.rightPad : ''}
|
||||
${
|
||||
hasError
|
||||
? '!border-rose-500 !ring-rose-500/20 text-rose-900 dark:text-rose-200 focus:!border-rose-500 focus:!ring-rose-500/20'
|
||||
: ''
|
||||
}
|
||||
${currentSize.input}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* Right Action Icons (Clear, Password Toggle, Custom RightIcon) */}
|
||||
<div className="absolute right-3 flex items-center gap-1.5 text-slate-400 dark:text-slate-500">
|
||||
{isClearable && hasValue && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={onClear}
|
||||
aria-label="Limpiar campo"
|
||||
className="p-1 rounded-md hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<FiX className="text-sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPasswordType && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
aria-label={showPassword ? 'Ocultar contraseña' : 'Ver contraseña'}
|
||||
className="p-1 rounded-md hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<FiEyeOff className={currentSize.icon} />
|
||||
) : (
|
||||
<FiEye className={currentSize.icon} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isPasswordType && rightIcon && (
|
||||
<div className={`pointer-events-none flex items-center justify-center ${currentSize.icon}`}>
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error or Helper Message */}
|
||||
{hasError ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn">
|
||||
<FiAlertCircle className="shrink-0 text-xs" />
|
||||
<span>{errorMessage}</span>
|
||||
</p>
|
||||
) : helperText ? (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FiX } from 'react-icons/fi';
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
closeOnOverlayClick?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
'2xl': 'max-w-6xl',
|
||||
full: 'max-w-[95vw] h-[90vh]',
|
||||
};
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
size = 'md',
|
||||
children,
|
||||
footer,
|
||||
closeOnOverlayClick = true,
|
||||
showCloseButton = true,
|
||||
className = '',
|
||||
}) => {
|
||||
// ESC key handler and body scroll lock
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 overflow-y-auto"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (closeOnOverlayClick) onClose();
|
||||
}}
|
||||
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity animate-fadeIn"
|
||||
/>
|
||||
|
||||
{/* Modal Dialog Card */}
|
||||
<div
|
||||
className={`
|
||||
relative w-full rounded-3xl overflow-hidden shadow-2xl z-10
|
||||
bg-white text-slate-900 border border-slate-200
|
||||
dark:bg-slate-900/95 dark:text-slate-100 dark:border-slate-800
|
||||
transition-all transform animate-scaleUp
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || description || showCloseButton) && (
|
||||
<div className="flex items-start justify-between p-6 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<div className="space-y-1 pr-6">
|
||||
{title && (
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-xs sm:text-sm text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar modal"
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<FiX className="text-lg" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(85vh-160px)]">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="p-6 pt-4 border-t border-slate-100 dark:border-slate-800/80 bg-slate-50/50 dark:bg-slate-950/40 flex items-center justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { FiChevronDown, FiAlertCircle } from 'react-icons/fi';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
errorMessage?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
options?: SelectOption[];
|
||||
sizeVariant?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: {
|
||||
select: 'py-2 text-xs rounded-lg',
|
||||
icon: 'text-sm',
|
||||
leftPad: 'pl-8',
|
||||
rightPad: 'pr-8',
|
||||
},
|
||||
md: {
|
||||
select: 'py-2.5 text-sm rounded-xl',
|
||||
icon: 'text-base',
|
||||
leftPad: 'pl-10',
|
||||
rightPad: 'pr-10',
|
||||
},
|
||||
lg: {
|
||||
select: 'py-3.5 text-base rounded-2xl',
|
||||
icon: 'text-lg',
|
||||
leftPad: 'pl-12',
|
||||
rightPad: 'pr-12',
|
||||
},
|
||||
};
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
helperText,
|
||||
errorMessage,
|
||||
leftIcon,
|
||||
options,
|
||||
sizeVariant = 'md',
|
||||
fullWidth = true,
|
||||
id,
|
||||
name,
|
||||
disabled,
|
||||
required,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const selectId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
const hasError = Boolean(errorMessage);
|
||||
const currentSize = sizeStyles[sizeVariant];
|
||||
|
||||
return (
|
||||
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}>
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className={`block text-xs font-semibold select-none transition-colors ${
|
||||
hasError
|
||||
? 'text-rose-600 dark:text-rose-400'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Select Wrapper */}
|
||||
<div className="relative flex items-center">
|
||||
{/* Left Icon */}
|
||||
{leftIcon && (
|
||||
<div
|
||||
className={`absolute left-3.5 pointer-events-none flex items-center justify-center transition-colors ${
|
||||
hasError
|
||||
? 'text-rose-500'
|
||||
: 'text-slate-400 dark:text-slate-500'
|
||||
} ${currentSize.icon}`}
|
||||
>
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Core Select Element */}
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className={`
|
||||
w-full px-3.5 font-normal transition-all duration-200 outline-none appearance-none cursor-pointer
|
||||
bg-white text-slate-900
|
||||
border border-slate-300
|
||||
hover:border-slate-400
|
||||
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
|
||||
dark:bg-slate-900/90 dark:text-slate-100
|
||||
dark:border-slate-800 dark:hover:border-slate-700
|
||||
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
|
||||
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed
|
||||
${leftIcon ? currentSize.leftPad : ''}
|
||||
${currentSize.rightPad}
|
||||
${
|
||||
hasError
|
||||
? '!border-rose-500 !ring-rose-500/20 text-rose-900 dark:text-rose-200 focus:!border-rose-500 focus:!ring-rose-500/20'
|
||||
: ''
|
||||
}
|
||||
${currentSize.select}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{options
|
||||
? options.map((opt) => (
|
||||
<option
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.disabled}
|
||||
className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{opt.label}
|
||||
</option>
|
||||
))
|
||||
: children}
|
||||
</select>
|
||||
|
||||
{/* Right Chevron Icon */}
|
||||
<div
|
||||
className={`absolute right-3.5 pointer-events-none flex items-center justify-center text-slate-400 dark:text-slate-500 ${currentSize.icon}`}
|
||||
>
|
||||
<FiChevronDown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error or Helper Message */}
|
||||
{hasError ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn">
|
||||
<FiAlertCircle className="shrink-0 text-xs" />
|
||||
<span>{errorMessage}</span>
|
||||
</p>
|
||||
) : helperText ? (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface SpinnerProps {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
color?: 'cyan' | 'blue' | 'white' | 'slate' | 'current';
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-3.5 h-3.5 border-2',
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-6 h-6 border-2',
|
||||
lg: 'w-8 h-8 border-3',
|
||||
xl: 'w-12 h-12 border-4',
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
cyan: 'border-cyan-500/20 border-t-cyan-500 dark:border-cyan-400/20 dark:border-t-cyan-400',
|
||||
blue: 'border-blue-500/20 border-t-blue-500 dark:border-blue-400/20 dark:border-t-blue-400',
|
||||
white: 'border-white/30 border-t-white',
|
||||
slate: 'border-slate-300 border-t-slate-700 dark:border-slate-700 dark:border-t-slate-200',
|
||||
current: 'border-current/20 border-t-current',
|
||||
};
|
||||
|
||||
export const Spinner: React.FC<SpinnerProps> = ({
|
||||
size = 'md',
|
||||
color = 'cyan',
|
||||
className = '',
|
||||
label = 'Cargando...',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={`inline-flex items-center justify-center ${className}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-full animate-spin ${sizeClasses[size]} ${colorClasses[color]}`}
|
||||
/>
|
||||
<span className="sr-only">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface SwitchProps {
|
||||
label?: React.ReactNode;
|
||||
description?: string;
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
checkedIcon?: React.ReactNode;
|
||||
uncheckedIcon?: React.ReactNode;
|
||||
id?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const trackSizes = {
|
||||
sm: 'w-8 h-4.5',
|
||||
md: 'w-11 h-6',
|
||||
lg: 'w-14 h-7.5',
|
||||
};
|
||||
|
||||
const thumbSizes = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6.5 h-6.5',
|
||||
};
|
||||
|
||||
const translateSizes = {
|
||||
sm: 'translate-x-3.5',
|
||||
md: 'translate-x-5',
|
||||
lg: 'translate-x-6.5',
|
||||
};
|
||||
|
||||
export const Switch: React.FC<SwitchProps> = ({
|
||||
label,
|
||||
description,
|
||||
checked = false,
|
||||
onChange,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
checkedIcon,
|
||||
uncheckedIcon,
|
||||
id,
|
||||
name,
|
||||
className = '',
|
||||
}) => {
|
||||
const switchId = id || name || (typeof label === 'string' ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(!checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-start gap-3 select-none ${className}`}>
|
||||
{/* Switch Button */}
|
||||
<button
|
||||
id={switchId}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`
|
||||
relative shrink-0 inline-flex items-center rounded-full p-0.5 cursor-pointer transition-colors duration-200 ease-in-out
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
||||
focus-visible:ring-cyan-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${trackSizes[size]}
|
||||
${
|
||||
checked
|
||||
? 'bg-cyan-500 dark:bg-cyan-500'
|
||||
: 'bg-slate-300 dark:bg-slate-700 hover:bg-slate-400 dark:hover:bg-slate-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Thumb */}
|
||||
<span
|
||||
className={`
|
||||
pointer-events-none flex items-center justify-center rounded-full bg-white shadow-md transform transition-transform duration-200 ease-in-out text-slate-800
|
||||
${thumbSizes[size]}
|
||||
${checked ? translateSizes[size] : 'translate-x-0'}
|
||||
`}
|
||||
>
|
||||
{checked ? checkedIcon : uncheckedIcon}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Label and description */}
|
||||
{(label || description) && (
|
||||
<div className="flex flex-col text-left cursor-pointer" onClick={handleClick}>
|
||||
{label && (
|
||||
<span
|
||||
className={`text-xs sm:text-sm font-medium transition-colors ${
|
||||
disabled
|
||||
? 'text-slate-400 dark:text-slate-600'
|
||||
: 'text-slate-800 dark:text-slate-200 hover:text-slate-950 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { FiAlertCircle } from 'react-icons/fi';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
errorMessage?: string;
|
||||
showCharCount?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
helperText,
|
||||
errorMessage,
|
||||
showCharCount = false,
|
||||
fullWidth = true,
|
||||
id,
|
||||
name,
|
||||
disabled,
|
||||
required,
|
||||
maxLength,
|
||||
className = '',
|
||||
value,
|
||||
rows = 4,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const textareaId = id || name || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
const hasError = Boolean(errorMessage);
|
||||
const currentLength = typeof value === 'string' ? value.length : 0;
|
||||
|
||||
return (
|
||||
<div className={`${fullWidth ? 'w-full' : 'inline-block'} space-y-1.5`}>
|
||||
{/* Header (Label & Character Count) */}
|
||||
{(label || (showCharCount && maxLength)) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className={`block text-xs font-semibold select-none transition-colors ${
|
||||
hasError
|
||||
? 'text-rose-600 dark:text-rose-400'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-rose-500 font-bold">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{showCharCount && maxLength && (
|
||||
<span
|
||||
className={`text-[11px] font-medium transition-colors ${
|
||||
currentLength >= maxLength
|
||||
? 'text-rose-500 font-bold'
|
||||
: currentLength >= maxLength * 0.85
|
||||
? 'text-amber-500'
|
||||
: 'text-slate-400 dark:text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{currentLength}/{maxLength}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Core Textarea */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={textareaId}
|
||||
name={name}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
rows={rows}
|
||||
className={`
|
||||
w-full p-3.5 text-sm font-normal rounded-xl transition-all duration-200 outline-none resize-y
|
||||
bg-white text-slate-900 placeholder:text-slate-400
|
||||
border border-slate-300
|
||||
hover:border-slate-400
|
||||
focus:border-cyan-500 focus:ring-4 focus:ring-cyan-500/15
|
||||
dark:bg-slate-900/90 dark:text-slate-100 dark:placeholder:text-slate-500
|
||||
dark:border-slate-800 dark:hover:border-slate-700
|
||||
dark:focus:border-cyan-500 dark:focus:ring-cyan-500/25
|
||||
disabled:opacity-60 disabled:bg-slate-100 disabled:dark:bg-slate-950 disabled:cursor-not-allowed
|
||||
${
|
||||
hasError
|
||||
? '!border-rose-500 !ring-rose-500/20 text-rose-900 dark:text-rose-200 focus:!border-rose-500 focus:!ring-rose-500/20'
|
||||
: ''
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error or Helper Message */}
|
||||
{hasError ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 animate-fadeIn">
|
||||
<FiAlertCircle className="shrink-0 text-xs" />
|
||||
<span>{errorMessage}</span>
|
||||
</p>
|
||||
) : helperText ? (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FiSun, FiMoon } from 'react-icons/fi';
|
||||
|
||||
export interface ThemeToggleProps {
|
||||
variant?: 'icon' | 'pill' | 'switch';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
|
||||
variant = 'icon',
|
||||
size = 'md',
|
||||
className = '',
|
||||
}) => {
|
||||
const [isDark, setIsDark] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return true;
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored) return stored === 'dark';
|
||||
return (
|
||||
document.documentElement.classList.contains('dark') ||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isDark) {
|
||||
root.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDark((prev) => !prev);
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
};
|
||||
|
||||
const paddingSizes = {
|
||||
sm: 'p-1.5',
|
||||
md: 'p-2.5',
|
||||
lg: 'p-3',
|
||||
};
|
||||
|
||||
if (variant === 'pill') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
|
||||
className={`
|
||||
inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold
|
||||
bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200
|
||||
border border-slate-200 dark:border-slate-700
|
||||
hover:border-cyan-500/50 dark:hover:border-cyan-500/50
|
||||
transition-all duration-200 shadow-sm ${className}
|
||||
`}
|
||||
>
|
||||
{isDark ? (
|
||||
<>
|
||||
<FiMoon className="text-cyan-400" />
|
||||
<span>Modo Oscuro</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FiSun className="text-amber-500" />
|
||||
<span>Modo Claro</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'switch') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
onClick={toggleTheme}
|
||||
aria-label="Alternar tema de color"
|
||||
className={`
|
||||
relative inline-flex items-center w-14 h-8 rounded-full p-1 transition-colors duration-300
|
||||
${isDark ? 'bg-slate-800 border border-slate-700' : 'bg-slate-200 border border-slate-300'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-slate-900 shadow-md transform transition-transform duration-300
|
||||
${isDark ? 'translate-x-6 text-cyan-400' : 'translate-x-0 text-amber-500'}
|
||||
`}
|
||||
>
|
||||
{isDark ? <FiMoon className="text-xs" /> : <FiSun className="text-xs" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Default 'icon' variant
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
|
||||
title={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
|
||||
className={`
|
||||
relative inline-flex items-center justify-center rounded-xl transition-all duration-200
|
||||
bg-slate-100 dark:bg-slate-800/80 text-slate-600 dark:text-slate-300
|
||||
border border-slate-200 dark:border-slate-700/80
|
||||
hover:text-cyan-600 dark:hover:text-cyan-400
|
||||
hover:border-cyan-500/40 dark:hover:border-cyan-500/40
|
||||
hover:bg-slate-200/60 dark:hover:bg-slate-800
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500
|
||||
${paddingSizes[size]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{isDark ? (
|
||||
<FiSun className={`${iconSizes[size]} text-amber-400 transition-transform hover:rotate-45`} />
|
||||
) : (
|
||||
<FiMoon className={`${iconSizes[size]} text-slate-700 transition-transform hover:-rotate-12`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
// Button & Actions
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps } from './Button';
|
||||
|
||||
// Form Fields & Inputs
|
||||
export { Input } from './Input';
|
||||
export type { InputProps } from './Input';
|
||||
|
||||
export { Textarea } from './Textarea';
|
||||
export type { TextareaProps } from './Textarea';
|
||||
|
||||
export { Select } from './Select';
|
||||
export type { SelectProps, SelectOption } from './Select';
|
||||
|
||||
export { Checkbox } from './Checkbox';
|
||||
export type { CheckboxProps } from './Checkbox';
|
||||
|
||||
export { Switch } from './Switch';
|
||||
export type { SwitchProps } from './Switch';
|
||||
|
||||
// Feedback & Data Display
|
||||
export { Badge } from './Badge';
|
||||
export type { BadgeProps } from './Badge';
|
||||
|
||||
export { Spinner } from './Spinner';
|
||||
export type { SpinnerProps } from './Spinner';
|
||||
|
||||
// Layout & Overlays
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from './Card';
|
||||
export type {
|
||||
CardProps,
|
||||
CardHeaderProps,
|
||||
CardFooterProps,
|
||||
} from './Card';
|
||||
|
||||
export { Modal } from './Modal';
|
||||
export type { ModalProps } from './Modal';
|
||||
|
||||
// Theme Control
|
||||
export { ThemeToggle } from './ThemeToggle';
|
||||
export type { ThemeToggleProps } from './ThemeToggle';
|
||||
@@ -1,5 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
@@ -7,6 +9,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-scaleUp {
|
||||
animation: scaleUp 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
/* Custom modern scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
Reference in new issue
Block a user