diff --git a/components/TextInputArea.tsx b/components/TextInputArea.tsx index 8200026..cb076da 100644 --- a/components/TextInputArea.tsx +++ b/components/TextInputArea.tsx @@ -1,165 +1,68 @@ -/** - * TextInputArea - 多行文本输入组件 - * - * 提供功能丰富的多行文本输入体验,支持受控/非受控模式、验证规则、 - * 字符计数、工具栏操作、复制/清空等交互能力。 - * - * @module TextInputArea - * - * @example - * ```tsx - * // 基础用法 - * - * - * // 受控模式 - * - * - * // 带验证规则 - * v.length >= 3, message: '至少3个字符' }]} - * validateTrigger="onBlur" - * /> - * - * // 带操作按钮 - * - * ``` - */ - -import { useRef, useState, useCallback, forwardRef, RefObject } from 'react'; -import { X, Copy } from 'lucide-react'; +import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import { Copy, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import type { SnackbarOptions } from '@/components/GlobalSnackbar'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast -/** 文本验证规则 */ export type ValidateRule = { - /** 验证函数,返回 true 表示通过 */ validator: (value: string) => boolean; - /** 验证失败时的提示消息 */ message: string; }; -/** 工具栏操作按钮配置 */ export type ToolbarAction = { - /** 唯一标识 */ key: string; - /** 按钮显示文本 */ label: string; - /** 按钮图标 */ icon?: React.ReactNode; - /** 按钮位置:顶部或底部,默认顶部 */ position?: 'top' | 'bottom'; - /** 按钮样式类型:主要/默认/危险 */ type?: 'primary' | 'default' | 'danger'; - /** 禁用条件,可以是布尔值或根据当前值动态判断的函数 */ disabled?: boolean | ((value: string) => boolean); - /** 点击回调,接收当前值和操作辅助方法 */ onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void; }; -export interface TextInputAreaProps { - /** 受控模式下的当前值 */ +export interface TextInputAreaProps extends Omit< + React.TextareaHTMLAttributes, + 'onChange' +> { value?: string; - /** 非受控模式下的初始值,组件挂载时有效 */ defaultValue?: string; - /** 值变化回调 */ + + /** 值变化回调,返回最新的字符串内容 */ onChange?: (value: string) => void; - /** 占位文本 */ - placeholder?: string; - /** 是否禁用 */ - disabled?: boolean; - /** 是否只读 */ - readOnly?: boolean; - /** 是否自动聚焦 */ - autoFocus?: boolean; - /** 最小行数(autoResize 为 true 时生效) */ minRows?: number; - /** 最大行数(autoResize 为 true 时生效) */ maxRows?: number; - /** 最大字符数限制 */ - maxLength?: number; - /** 外层容器类名 */ - className?: string; - /** 外层容器样式 */ - style?: React.CSSProperties; - /** 外层容器 sx */ - sx?: React.CSSProperties; - - /** 是否显示字符计数 */ showCount?: boolean; - /** 是否显示清空按钮,默认 true */ showClear?: boolean; - /** 是否允许复制内容 */ allowCopy?: boolean; - /** 是否启用自动调整高度,默认 true */ - autoResize?: boolean; - - /** 验证规则列表 */ rules?: ValidateRule[]; - /** 验证触发时机:失焦(onBlur) / 输入时(onChange) / 操作前(onAction),默认 onAction */ validateTrigger?: 'onBlur' | 'onChange' | 'onAction'; - - /** 工具栏操作按钮列表 */ actions?: ToolbarAction[]; - /** 顶部栏左侧额外内容 */ topExtra?: React.ReactNode; - - /** 顶部栏标题 */ title?: string; - - /** 消息提示回调,用于展示 Toast 通知 */ - showMessage?: (message: string, options?: SnackbarOptions) => void; - - /** 外部错误消息,由父组件控制,优先于内部验证错误 */ externalError?: string; - - /** 清空按钮点击后的额外回调 */ onClear?: () => void; } -/** ActionButton 内部组件的属性 */ -interface ActionButtonProps { - action: ToolbarAction; - value: string; - globalDisabled: boolean; - variant?: 'text' | 'contained'; - onAction: (action: ToolbarAction) => void; - size?: 'small' | 'medium'; - compact?: boolean; -} - -/** - * 工具栏操作按钮 - 根据 action.type 自动应用样式 - * - * - primary:填充主色背景 - * - danger:红色文字 + 悬停红色背景 - * - default(默认):灰色文字 + 悬停灰色背景 - */ +// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐 function ActionButton({ action, value, globalDisabled, - variant: _variant = 'text', onAction, - size = 'small', - compact, -}: ActionButtonProps) { +}: { + action: ToolbarAction; + value: string; + globalDisabled: boolean; + onAction: (action: ToolbarAction) => void; +}) { const isBtnDisabled = - typeof action.disabled === 'function' ? action.disabled(value) : action.disabled || !value; + typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false); - const typeClasses: Record = { - primary: 'bg-primary text-primary-foreground hover:bg-primary/90', - danger: 'text-red-600 hover:bg-red-500/10', - default: 'text-muted-foreground hover:bg-muted', - }; - - const sizeClasses = { - small: 'text-xs px-2 py-1', - medium: 'text-sm px-3 py-1.5', + const variantClasses = { + primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + default: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', }; return ( @@ -167,24 +70,19 @@ function ActionButton({ type="button" onClick={() => onAction(action)} disabled={isBtnDisabled || globalDisabled} - className={`rounded-md font-semibold transition-colors ${sizeClasses[size]} ${ - typeClasses[action.type || 'default'] - } ${compact ? 'text-xs px-2 min-w-0' : 'text-sm'} ${ - isBtnDisabled || globalDisabled ? 'opacity-50 cursor-not-allowed' : '' - }`} + className={cn( + 'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5', + 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'disabled:pointer-events-none disabled:opacity-50', + variantClasses[action.type || 'default'], + )} > - {action.icon && {action.icon}} + {action.icon && {action.icon}} {action.label} ); } -/** - * TextInputArea 组件 - * - * 多行文本输入组件,支持受控/非受控双模式、验证规则、工具栏操作等。 - * 使用 forwardRef 暴露底层 textarea DOM 节点。 - */ const TextInputArea = forwardRef((props, ref) => { const { value: controlledValue, @@ -197,41 +95,54 @@ const TextInputArea = forwardRef((props minRows = 4, maxRows = 12, maxLength, - className = '', - style, - sx: containerSx, + className, showCount = false, showClear = true, allowCopy = false, - autoResize = true, rules = [], validateTrigger = 'onAction', actions = [], topExtra, title, - showMessage, externalError, onClear, + ...restProps } = props; - const textareaRef = useRef(null); + const internalRef = useRef(null); const [internalValue, setInternalValue] = useState(defaultValue); const [error, setError] = useState(''); const { t } = useTranslation('common'); const placeholder = placeholderProp ?? t('textInputArea.placeholder'); - /** 通过 value prop 是否存在来判断是否为受控模式 */ const isControlled = controlledValue !== undefined; const value = isControlled ? controlledValue : internalValue; - - /** 外部错误优先级高于内部验证错误 */ const displayError = externalError ?? error; - /** - * 执行所有验证规则 - * @param trigger - 触发验证的事件类型,用于匹配 validateTrigger - */ + // 双向合并 ref 指针 + useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement); + + // 1. 高性能的动态高度自适应计算 + const adjustHeight = useCallback(() => { + const textArea = internalRef.current; + if (!textArea) return; + + // 重置高度计算 + textArea.style.height = 'auto'; + + const computedMin = minRows * 24; // 每行粗略按 24px 计算 + const computedMax = maxRows * 24; + const nextHeight = Math.max(textArea.scrollHeight, computedMin); + + textArea.style.height = `${Math.min(nextHeight, computedMax)}px`; + }, [minRows, maxRows]); + + // 当数值改变时自适应扩展 + React.useEffect(() => { + adjustHeight(); + }, [value, adjustHeight]); + const validate = useCallback( (val: string, trigger?: string): boolean => { if (validateTrigger !== trigger && trigger) return true; @@ -247,13 +158,12 @@ const TextInputArea = forwardRef((props [rules, validateTrigger], ); - /** 输入变化处理:更新值、清空错误、按需触发验证 */ const handleChange = (e: React.ChangeEvent) => { const newVal = e.target.value; if (maxLength && newVal.length > maxLength) { const msg = t('charCount', { count: maxLength }); setError(msg); - showMessage?.(msg, { severity: 'warning' }); + toast.warning(msg); return; } @@ -264,43 +174,36 @@ const TextInputArea = forwardRef((props if (validateTrigger === 'onChange') validate(newVal, 'onChange'); }; - /** 失焦时按需触发验证 */ const handleBlur = () => { if (validateTrigger === 'onBlur') validate(value, 'onBlur'); }; - /** 清空输入内容并重新聚焦 */ const handleClear = useCallback(() => { if (!isControlled) setInternalValue(''); onChange?.(''); setError(''); - textareaRef.current?.focus(); - showMessage?.(t('textInputArea.cleared'), { severity: 'success' }); + internalRef.current?.focus(); + toast.success('已清空内容'); onClear?.(); - }, [isControlled, onChange, showMessage, t, onClear]); + }, [isControlled, onChange, onClear]); - /** 复制当前内容到剪贴板 */ const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(value); - showMessage?.(t('messages.copySuccess'), { severity: 'success' }); + toast.success('复制成功'); } catch { - setError(t('messages.copyError')); - showMessage?.(t('messages.copyError'), { severity: 'error' }); + setError('复制失败'); + toast.error('复制失败'); } - }, [value, showMessage, t]); + }, [value]); - /** 执行工具栏操作:检查禁用状态、验证、调用 onClick */ const handleAction = useCallback( (action: ToolbarAction) => { const isDisabled = typeof action.disabled === 'function' ? action.disabled(value) : action.disabled; - if (isDisabled || disabled) return; - if (validateTrigger === 'onAction' && !validate(value, 'onAction')) { - return; - } + if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return; action.onClick(value, { clear: handleClear, @@ -310,34 +213,21 @@ const TextInputArea = forwardRef((props [value, disabled, validate, validateTrigger, handleClear], ); - /** 合并内部 ref 和外部传入的 forwardRef */ - const handleInputRef = useCallback( - (node: HTMLTextAreaElement | null) => { - textareaRef.current = node; - if (typeof ref === 'function') { - ref(node); - } else if (ref) { - (ref as RefObject).current = node; - } - }, - [ref], - ); - const topActions = actions.filter((a) => a.position !== 'bottom'); const bottomActions = actions.filter((a) => a.position === 'bottom'); - const hasTopBar = title || showCount || topActions.length > 0 || topExtra; + const hasBottomBar = allowCopy || showClear || bottomActions.length > 0; return ( -
+
+ {/* 顶部工具栏 */} {hasTopBar && ( -
-
- {title && {title}} +
+
+ {title && {title}} {topExtra}
- -
+
{topActions.map((action) => ( ((props value={value} globalDisabled={disabled} onAction={handleAction} - compact /> ))} {showCount && ( - + {value.length} {maxLength ? ` / ${maxLength}` : ''} @@ -358,71 +247,76 @@ const TextInputArea = forwardRef((props
)} -
+ {/* 核心卡片容器:完美适配 shadcn 风格的多行包裹框 */} +