creacion de componentes dinamicos
This commit is contained in:
1 parent
ae415fef4e
commit
d74aa50845
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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
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 */
|
/* Custom modern scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
Reference in new issue
Block a user