UNPKG

@gambito-corp/mbs-library

Version:

Librería de componentes React reutilizables - Sistema de diseño modular y escalable

356 lines (301 loc) 12 kB
import React, { useState, useRef, useEffect } from 'react'; import PropTypes from 'prop-types'; import Icon from '../Icon/Icon.jsx'; import Text from '../Text/Text.jsx'; const TextArea = ({ // Contenido value, defaultValue, placeholder = '', // Variante y tamaño variant = 'default', size = 'medium', // Estados disabled = false, readOnly = false, required = false, error, success, helperText, // Iconos con callbacks (heredado del Input) iconLeft, iconRight, onIconLeftClick, onIconRightClick, // ✅ CARACTERÍSTICAS ESPECÍFICAS DE TEXTAREA rows = 3, // Filas por defecto cols, // Columnas (opcional) resize = 'vertical', // none, horizontal, vertical, both autoGrow = false, // Crecimiento automático maxRows = 10, // Máximo de filas para autoGrow minRows = 2, // Mínimo de filas para autoGrow // Label label, // Layout fullWidth = true, // Eventos onChange, onBlur, onFocus, onKeyDown, // HTML attributes id, name, autoComplete, autoFocus, maxLength, minLength, // Estilos className = '', ...props }) => { // ✅ Estados para autoGrow const [currentRows, setCurrentRows] = useState(rows); const textareaRef = useRef(null); // Clases de tamaño const sizeClasses = { small: 'px-3 py-1.5 text-sm', medium: 'px-3 py-2 text-base', large: 'px-4 py-3 text-lg' }; // Clases de variante/estado (heredadas del Input) const variantClasses = { default: 'border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100', success: 'border-green-500 bg-green-50 focus:border-green-600 focus:ring-2 focus:ring-green-100', error: 'border-red-500 bg-red-50 focus:border-red-600 focus:ring-2 focus:ring-red-100', warning: 'border-yellow-500 bg-yellow-50 focus:border-yellow-600 focus:ring-2 focus:ring-yellow-100' }; // ✅ Clases de resize const resizeClasses = { none: 'resize-none', horizontal: 'resize-x', vertical: 'resize-y', both: 'resize' }; // Determinar variante basada en props const currentVariant = error ? 'error' : success ? 'success' : variant; // ✅ LÓGICA DE AUTO-GROW const adjustHeight = () => { if (!autoGrow || !textareaRef.current) return; const textarea = textareaRef.current; textarea.style.height = 'auto'; const scrollHeight = textarea.scrollHeight; const lineHeight = parseInt(getComputedStyle(textarea).lineHeight); const newRows = Math.min(Math.max(Math.ceil(scrollHeight / lineHeight), minRows), maxRows); setCurrentRows(newRows); textarea.style.height = `${scrollHeight}px`; }; // Efecto para autoGrow useEffect(() => { if (autoGrow) { adjustHeight(); } }, [value, autoGrow]); // Calcular padding para iconos const getPaddingClasses = () => { const basePadding = sizeClasses[size]; let leftPadding = ''; let rightPadding = ''; if (iconLeft) { leftPadding = size === 'small' ? 'pl-8' : size === 'large' ? 'pl-12' : 'pl-10'; } if (iconRight) { rightPadding = size === 'small' ? 'pr-8' : size === 'large' ? 'pr-12' : 'pr-10'; } if (iconLeft && iconRight) { const bothPadding = size === 'small' ? 'px-8' : size === 'large' ? 'px-12' : 'px-10'; return `py-${basePadding.split(' ')[1].split('-')[1]} ${bothPadding}`; } if (leftPadding || rightPadding) { const verticalPadding = basePadding.split(' ').find(c => c.startsWith('py-')); return `${verticalPadding} ${leftPadding} ${rightPadding}`.trim(); } return basePadding; }; // Renderizar label (heredado del Input) const renderLabel = () => { if (!label) return null; return ( <Text as="label" htmlFor={id} size="small" variant="bold" className="block mb-1" > {label} {required && <span className="text-red-500 ml-1">*</span>} </Text> ); }; // Renderizar iconos (heredado del Input) const renderIcon = (iconName, position, onClick) => { if (!iconName) return null; const iconSize = size === 'small' ? 'xs' : size === 'large' ? 'medium' : 'small'; const positionClass = position === 'left' ? 'left-3 top-4' : 'right-3 top-4'; return ( <Icon name={iconName} size={iconSize} onClick={onClick} className={` absolute ${positionClass} ${onClick ? 'text-gray-600 cursor-pointer hover:text-blue-500' : 'text-gray-400 pointer-events-none'} transition-colors duration-200 `} /> ); }; // Renderizar mensaje (heredado del Input) const renderMessage = () => { const message = error || success || helperText; if (!message) return null; const messageColor = error ? 'text-red-500' : success ? 'text-green-500' : 'text-gray-500'; return ( <Text size="xs" className={`mt-1 block ${messageColor}`} > {message} </Text> ); }; // ✅ Renderizar contador de caracteres const renderCharacterCount = () => { if (!maxLength) return null; const currentLength = value ? value.length : 0; const isNearLimit = currentLength > maxLength * 0.8; const isOverLimit = currentLength > maxLength; return ( <Text size="xs" className={`mt-1 text-right block ${ isOverLimit ? 'text-red-500' : isNearLimit ? 'text-yellow-500' : 'text-gray-400' }`} > {currentLength}{maxLength && `/${maxLength}`} </Text> ); }; // Manejar cambios con autoGrow const handleChange = (e) => { if (onChange) { onChange(e); } if (autoGrow) { // Usar setTimeout para que el DOM se actualice primero setTimeout(adjustHeight, 0); } }; return ( <div className={`textarea-container ${fullWidth ? 'w-full' : 'inline-block'}`}> {renderLabel()} <div className="relative"> <textarea ref={textareaRef} id={id} name={name} value={value} defaultValue={defaultValue} placeholder={placeholder} disabled={disabled} readOnly={readOnly} required={required} autoComplete={autoComplete} autoFocus={autoFocus} maxLength={maxLength} minLength={minLength} rows={autoGrow ? currentRows : rows} cols={cols} onChange={handleChange} onBlur={onBlur} onFocus={onFocus} onKeyDown={onKeyDown} className={` textarea-field w-full border rounded-lg transition-all duration-200 focus:outline-none ${getPaddingClasses()} ${sizeClasses[size].split(' ').find(c => c.startsWith('text-'))} ${variantClasses[currentVariant]} ${resizeClasses[resize]} ${disabled ? 'opacity-50 cursor-not-allowed bg-gray-100' : 'bg-white'} ${readOnly ? 'bg-gray-50 cursor-default' : ''} ${autoGrow ? 'overflow-hidden' : ''} ${className} `} style={{ minHeight: autoGrow ? `${minRows * 1.5}rem` : undefined, maxHeight: autoGrow ? `${maxRows * 1.5}rem` : undefined, lineHeight: '1.5' }} aria-invalid={!!error} aria-describedby={ error ? `${id}-error` : helperText ? `${id}-helper` : undefined } {...props} /> {/* Renderizar iconos */} {renderIcon(iconLeft, 'left', onIconLeftClick)} {renderIcon(iconRight, 'right', onIconRightClick)} </div> {/* Mensajes y contador */} <div className="flex justify-between items-start mt-1"> <div className="flex-1"> {renderMessage()} </div> <div className="flex-shrink-0 ml-2"> {renderCharacterCount()} </div> </div> </div> ); }; TextArea.propTypes = { // Contenido value: PropTypes.string, defaultValue: PropTypes.string, placeholder: PropTypes.string, // Variante y tamaño variant: PropTypes.oneOf(['default', 'success', 'error', 'warning']), size: PropTypes.oneOf(['small', 'medium', 'large']), // Estados disabled: PropTypes.bool, readOnly: PropTypes.bool, required: PropTypes.bool, error: PropTypes.string, success: PropTypes.string, helperText: PropTypes.string, // Iconos iconLeft: PropTypes.string, iconRight: PropTypes.string, onIconLeftClick: PropTypes.func, onIconRightClick: PropTypes.func, // ✅ ESPECÍFICOS DE TEXTAREA rows: PropTypes.number, cols: PropTypes.number, resize: PropTypes.oneOf(['none', 'horizontal', 'vertical', 'both']), autoGrow: PropTypes.bool, maxRows: PropTypes.number, minRows: PropTypes.number, // Label label: PropTypes.string, // Layout fullWidth: PropTypes.bool, // Eventos onChange: PropTypes.func, onBlur: PropTypes.func, onFocus: PropTypes.func, onKeyDown: PropTypes.func, // HTML attributes id: PropTypes.string, name: PropTypes.string, autoComplete: PropTypes.string, autoFocus: PropTypes.bool, maxLength: PropTypes.number, minLength: PropTypes.number, // Estilos className: PropTypes.string }; export default TextArea;