diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..08b24fd --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -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, { 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 = ({ + children, + variant = 'default', + size = 'md', + dot = false, + dotPulse = false, + icon, + removable = false, + onRemove, + className = '', +}) => { + const currentVariant = variantStyles[variant]; + + return ( + + {/* Optional Dot indicator */} + {dot && ( + + {dotPulse && ( + + )} + + + )} + + {/* Optional Icon */} + {icon && {icon}} + + {/* Content */} + {children} + + {/* Optional Remove Button */} + {removable && ( + + )} + + ); +}; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..5991c2b --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Spinner } from './Spinner'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + 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, 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, 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( + ( + { + variant = 'primary', + size = 'md', + isLoading = false, + loadingText, + leftIcon, + rightIcon, + fullWidth = false, + disabled, + className = '', + children, + ...props + }, + ref + ) => { + const isInteractiveDisabled = disabled || isLoading; + + return ( + + ); + } +); + +Button.displayName = 'Button'; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..e034e08 --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,146 @@ +import React from 'react'; + +export interface CardProps extends React.HTMLAttributes { + variant?: 'default' | 'elevated' | 'glass' | 'outline' | 'interactive'; + padding?: 'none' | 'sm' | 'md' | 'lg'; +} + +const variantStyles: Record, 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, string> = { + none: 'p-0', + sm: 'p-4', + md: 'p-6', + lg: 'p-8', +}; + +export const Card = React.forwardRef( + ( + { + variant = 'default', + padding = 'none', + className = '', + children, + ...props + }, + ref + ) => { + return ( +
+ {children} +
+ ); + } +); + +Card.displayName = 'Card'; + +export interface CardHeaderProps extends React.HTMLAttributes { + bordered?: boolean; +} + +export const CardHeader: React.FC = ({ + bordered = false, + className = '', + children, + ...props +}) => { + return ( +
+ {children} +
+ ); +}; + +export const CardTitle: React.FC> = ({ + className = '', + children, + ...props +}) => { + return ( +

+ {children} +

+ ); +}; + +export const CardDescription: React.FC> = ({ + className = '', + children, + ...props +}) => { + return ( +

+ {children} +

+ ); +}; + +export const CardContent: React.FC> = ({ + className = '', + children, + ...props +}) => { + return ( +
+ {children} +
+ ); +}; + +export interface CardFooterProps extends React.HTMLAttributes { + bordered?: boolean; +} + +export const CardFooter: React.FC = ({ + bordered = false, + className = '', + children, + ...props +}) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..68564c9 --- /dev/null +++ b/src/components/ui/Checkbox.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useRef } from 'react'; +import { FiCheck, FiMinus } from 'react-icons/fi'; + +export interface CheckboxProps extends Omit, 'type'> { + label?: React.ReactNode; + description?: string; + errorMessage?: string; + indeterminate?: boolean; +} + +export const Checkbox = React.forwardRef( + ( + { + label, + description, + errorMessage, + indeterminate = false, + checked, + disabled, + className = '', + id, + name, + ...props + }, + forwardedRef + ) => { + const internalRef = useRef(null); + const resolvedRef = (forwardedRef as React.RefObject) || 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 ( +
+ + + {errorMessage && ( +

{errorMessage}

+ )} +
+ ); + } +); + +Checkbox.displayName = 'Checkbox'; diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..68dc26c --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,188 @@ +import React, { useState } from 'react'; +import { FiEye, FiEyeOff, FiX, FiAlertCircle } from 'react-icons/fi'; + +export interface InputProps extends Omit, '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( + ( + { + 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 ( +
+ {/* Label */} + {label && ( +
+ +
+ )} + + {/* Input Wrapper */} +
+ {/* Left Icon */} + {leftIcon && ( +
+ {leftIcon} +
+ )} + + {/* Core Input Element */} + + + {/* Right Action Icons (Clear, Password Toggle, Custom RightIcon) */} +
+ {isClearable && hasValue && !disabled && ( + + )} + + {isPasswordType && !disabled && ( + + )} + + {!isPasswordType && rightIcon && ( +
+ {rightIcon} +
+ )} +
+
+ + {/* Error or Helper Message */} + {hasError ? ( +

+ + {errorMessage} +

+ ) : helperText ? ( +

{helperText}

+ ) : null} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..5d1d22c --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -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 = ({ + 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 ( +
+ {/* Backdrop */} +
{ + if (closeOnOverlayClick) onClose(); + }} + className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity animate-fadeIn" + /> + + {/* Modal Dialog Card */} +
+ {/* Header */} + {(title || description || showCloseButton) && ( +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ + {showCloseButton && ( + + )} +
+ )} + + {/* Body Content */} +
+ {children} +
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
+ ); +}; diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx new file mode 100644 index 0000000..433127b --- /dev/null +++ b/src/components/ui/Select.tsx @@ -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, '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( + ( + { + 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 ( +
+ {/* Label */} + {label && ( +
+ +
+ )} + + {/* Select Wrapper */} +
+ {/* Left Icon */} + {leftIcon && ( +
+ {leftIcon} +
+ )} + + {/* Core Select Element */} + + + {/* Right Chevron Icon */} +
+ +
+
+ + {/* Error or Helper Message */} + {hasError ? ( +

+ + {errorMessage} +

+ ) : helperText ? ( +

{helperText}

+ ) : null} +
+ ); + } +); + +Select.displayName = 'Select'; diff --git a/src/components/ui/Spinner.tsx b/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..80e2505 --- /dev/null +++ b/src/components/ui/Spinner.tsx @@ -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 = ({ + size = 'md', + color = 'cyan', + className = '', + label = 'Cargando...', +}) => { + return ( +
+
+ {label} +
+ ); +}; diff --git a/src/components/ui/Switch.tsx b/src/components/ui/Switch.tsx new file mode 100644 index 0000000..83f8d05 --- /dev/null +++ b/src/components/ui/Switch.tsx @@ -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 = ({ + 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 ( +
+ {/* Switch Button */} + + + {/* Label and description */} + {(label || description) && ( +
+ {label && ( + + {label} + + )} + {description && ( + + {description} + + )} +
+ )} +
+ ); +}; diff --git a/src/components/ui/Textarea.tsx b/src/components/ui/Textarea.tsx new file mode 100644 index 0000000..a76bd2d --- /dev/null +++ b/src/components/ui/Textarea.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { FiAlertCircle } from 'react-icons/fi'; + +export interface TextareaProps extends React.TextareaHTMLAttributes { + label?: string; + helperText?: string; + errorMessage?: string; + showCharCount?: boolean; + fullWidth?: boolean; +} + +export const Textarea = React.forwardRef( + ( + { + 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 ( +
+ {/* Header (Label & Character Count) */} + {(label || (showCharCount && maxLength)) && ( +
+ {label && ( + + )} + + {showCharCount && maxLength && ( + = maxLength + ? 'text-rose-500 font-bold' + : currentLength >= maxLength * 0.85 + ? 'text-amber-500' + : 'text-slate-400 dark:text-slate-500' + }`} + > + {currentLength}/{maxLength} + + )} +
+ )} + + {/* Core Textarea */} +
+