Merge branch 'dev-miguel' into feat-home

This commit is contained in:
Blxckbxll24 committed 2026-08-26 16:08:28 -05:00
commit 09e8738148
13 files changed
+1448

No files matched your search

+121
View File
@@ -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>
);
};
+96
View File
@@ -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';
+146
View File
@@ -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>
);
};
+111
View File
@@ -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';
+188
View File
@@ -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';
+128
View File
@@ -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>
);
};
+164
View File
@@ -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';
+44
View File
@@ -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>
);
};
+122
View File
@@ -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>
);
};
+117
View File
@@ -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';
+133
View File
@@ -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>
);
};
+48
View File
@@ -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';
+30
View File
@@ -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;