refactor: 简化 TextInputArea,移除 showMessage 和 MUI 样式,改用 shadcn 样式和 sonner toast
This commit is contained in:
+105
-211
@@ -1,165 +1,68 @@
|
|||||||
/**
|
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||||
* TextInputArea - 多行文本输入组件
|
import { Copy, X } from 'lucide-react';
|
||||||
*
|
|
||||||
* 提供功能丰富的多行文本输入体验,支持受控/非受控模式、验证规则、
|
|
||||||
* 字符计数、工具栏操作、复制/清空等交互能力。
|
|
||||||
*
|
|
||||||
* @module TextInputArea
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 基础用法
|
|
||||||
* <TextInputArea placeholder="请输入内容..." />
|
|
||||||
*
|
|
||||||
* // 受控模式
|
|
||||||
* <TextInputArea value={text} onChange={setText} />
|
|
||||||
*
|
|
||||||
* // 带验证规则
|
|
||||||
* <TextInputArea
|
|
||||||
* rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
|
||||||
* validateTrigger="onBlur"
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* // 带操作按钮
|
|
||||||
* <TextInputArea
|
|
||||||
* actions={[
|
|
||||||
* { key: 'submit', label: '提交', type: 'primary', onClick: handleSubmit },
|
|
||||||
* ]}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useRef, useState, useCallback, forwardRef, RefObject } from 'react';
|
|
||||||
import { X, Copy } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
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 = {
|
export type ValidateRule = {
|
||||||
/** 验证函数,返回 true 表示通过 */
|
|
||||||
validator: (value: string) => boolean;
|
validator: (value: string) => boolean;
|
||||||
/** 验证失败时的提示消息 */
|
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 工具栏操作按钮配置 */
|
|
||||||
export type ToolbarAction = {
|
export type ToolbarAction = {
|
||||||
/** 唯一标识 */
|
|
||||||
key: string;
|
key: string;
|
||||||
/** 按钮显示文本 */
|
|
||||||
label: string;
|
label: string;
|
||||||
/** 按钮图标 */
|
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
/** 按钮位置:顶部或底部,默认顶部 */
|
|
||||||
position?: 'top' | 'bottom';
|
position?: 'top' | 'bottom';
|
||||||
/** 按钮样式类型:主要/默认/危险 */
|
|
||||||
type?: 'primary' | 'default' | 'danger';
|
type?: 'primary' | 'default' | 'danger';
|
||||||
/** 禁用条件,可以是布尔值或根据当前值动态判断的函数 */
|
|
||||||
disabled?: boolean | ((value: string) => boolean);
|
disabled?: boolean | ((value: string) => boolean);
|
||||||
/** 点击回调,接收当前值和操作辅助方法 */
|
|
||||||
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TextInputAreaProps {
|
export interface TextInputAreaProps extends Omit<
|
||||||
/** 受控模式下的当前值 */
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
'onChange'
|
||||||
|
> {
|
||||||
value?: string;
|
value?: string;
|
||||||
/** 非受控模式下的初始值,组件挂载时有效 */
|
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
/** 值变化回调 */
|
|
||||||
|
/** 值变化回调,返回最新的字符串内容 */
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
/** 占位文本 */
|
|
||||||
placeholder?: string;
|
|
||||||
/** 是否禁用 */
|
|
||||||
disabled?: boolean;
|
|
||||||
/** 是否只读 */
|
|
||||||
readOnly?: boolean;
|
|
||||||
/** 是否自动聚焦 */
|
|
||||||
autoFocus?: boolean;
|
|
||||||
|
|
||||||
/** 最小行数(autoResize 为 true 时生效) */
|
|
||||||
minRows?: number;
|
minRows?: number;
|
||||||
/** 最大行数(autoResize 为 true 时生效) */
|
|
||||||
maxRows?: number;
|
maxRows?: number;
|
||||||
/** 最大字符数限制 */
|
|
||||||
maxLength?: number;
|
|
||||||
/** 外层容器类名 */
|
|
||||||
className?: string;
|
|
||||||
/** 外层容器样式 */
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
/** 外层容器 sx */
|
|
||||||
sx?: React.CSSProperties;
|
|
||||||
|
|
||||||
/** 是否显示字符计数 */
|
|
||||||
showCount?: boolean;
|
showCount?: boolean;
|
||||||
/** 是否显示清空按钮,默认 true */
|
|
||||||
showClear?: boolean;
|
showClear?: boolean;
|
||||||
/** 是否允许复制内容 */
|
|
||||||
allowCopy?: boolean;
|
allowCopy?: boolean;
|
||||||
/** 是否启用自动调整高度,默认 true */
|
|
||||||
autoResize?: boolean;
|
|
||||||
|
|
||||||
/** 验证规则列表 */
|
|
||||||
rules?: ValidateRule[];
|
rules?: ValidateRule[];
|
||||||
/** 验证触发时机:失焦(onBlur) / 输入时(onChange) / 操作前(onAction),默认 onAction */
|
|
||||||
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
||||||
|
|
||||||
/** 工具栏操作按钮列表 */
|
|
||||||
actions?: ToolbarAction[];
|
actions?: ToolbarAction[];
|
||||||
/** 顶部栏左侧额外内容 */
|
|
||||||
topExtra?: React.ReactNode;
|
topExtra?: React.ReactNode;
|
||||||
|
|
||||||
/** 顶部栏标题 */
|
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
/** 消息提示回调,用于展示 Toast 通知 */
|
|
||||||
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
|
|
||||||
/** 外部错误消息,由父组件控制,优先于内部验证错误 */
|
|
||||||
externalError?: string;
|
externalError?: string;
|
||||||
|
|
||||||
/** 清空按钮点击后的额外回调 */
|
|
||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ActionButton 内部组件的属性 */
|
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
|
||||||
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(默认):灰色文字 + 悬停灰色背景
|
|
||||||
*/
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
action,
|
action,
|
||||||
value,
|
value,
|
||||||
globalDisabled,
|
globalDisabled,
|
||||||
variant: _variant = 'text',
|
|
||||||
onAction,
|
onAction,
|
||||||
size = 'small',
|
}: {
|
||||||
compact,
|
action: ToolbarAction;
|
||||||
}: ActionButtonProps) {
|
value: string;
|
||||||
|
globalDisabled: boolean;
|
||||||
|
onAction: (action: ToolbarAction) => void;
|
||||||
|
}) {
|
||||||
const isBtnDisabled =
|
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<string, string> = {
|
const variantClasses = {
|
||||||
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
danger: 'text-red-600 hover:bg-red-500/10',
|
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
default: 'text-muted-foreground hover:bg-muted',
|
default:
|
||||||
};
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
small: 'text-xs px-2 py-1',
|
|
||||||
medium: 'text-sm px-3 py-1.5',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,24 +70,19 @@ function ActionButton({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAction(action)}
|
onClick={() => onAction(action)}
|
||||||
disabled={isBtnDisabled || globalDisabled}
|
disabled={isBtnDisabled || globalDisabled}
|
||||||
className={`rounded-md font-semibold transition-colors ${sizeClasses[size]} ${
|
className={cn(
|
||||||
typeClasses[action.type || 'default']
|
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
|
||||||
} ${compact ? 'text-xs px-2 min-w-0' : 'text-sm'} ${
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
isBtnDisabled || globalDisabled ? 'opacity-50 cursor-not-allowed' : ''
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
}`}
|
variantClasses[action.type || 'default'],
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{action.icon && <span className="mr-1">{action.icon}</span>}
|
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
|
||||||
{action.label}
|
{action.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* TextInputArea 组件
|
|
||||||
*
|
|
||||||
* 多行文本输入组件,支持受控/非受控双模式、验证规则、工具栏操作等。
|
|
||||||
* 使用 forwardRef 暴露底层 textarea DOM 节点。
|
|
||||||
*/
|
|
||||||
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
||||||
const {
|
const {
|
||||||
value: controlledValue,
|
value: controlledValue,
|
||||||
@@ -197,41 +95,54 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
minRows = 4,
|
minRows = 4,
|
||||||
maxRows = 12,
|
maxRows = 12,
|
||||||
maxLength,
|
maxLength,
|
||||||
className = '',
|
className,
|
||||||
style,
|
|
||||||
sx: containerSx,
|
|
||||||
showCount = false,
|
showCount = false,
|
||||||
showClear = true,
|
showClear = true,
|
||||||
allowCopy = false,
|
allowCopy = false,
|
||||||
autoResize = true,
|
|
||||||
rules = [],
|
rules = [],
|
||||||
validateTrigger = 'onAction',
|
validateTrigger = 'onAction',
|
||||||
actions = [],
|
actions = [],
|
||||||
topExtra,
|
topExtra,
|
||||||
title,
|
title,
|
||||||
showMessage,
|
|
||||||
externalError,
|
externalError,
|
||||||
onClear,
|
onClear,
|
||||||
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
|
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
|
||||||
|
|
||||||
/** 通过 value prop 是否存在来判断是否为受控模式 */
|
|
||||||
const isControlled = controlledValue !== undefined;
|
const isControlled = controlledValue !== undefined;
|
||||||
const value = isControlled ? controlledValue : internalValue;
|
const value = isControlled ? controlledValue : internalValue;
|
||||||
|
|
||||||
/** 外部错误优先级高于内部验证错误 */
|
|
||||||
const displayError = externalError ?? error;
|
const displayError = externalError ?? error;
|
||||||
|
|
||||||
/**
|
// 双向合并 ref 指针
|
||||||
* 执行所有验证规则
|
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
|
||||||
* @param trigger - 触发验证的事件类型,用于匹配 validateTrigger
|
|
||||||
*/
|
// 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(
|
const validate = useCallback(
|
||||||
(val: string, trigger?: string): boolean => {
|
(val: string, trigger?: string): boolean => {
|
||||||
if (validateTrigger !== trigger && trigger) return true;
|
if (validateTrigger !== trigger && trigger) return true;
|
||||||
@@ -247,13 +158,12 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
[rules, validateTrigger],
|
[rules, validateTrigger],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 输入变化处理:更新值、清空错误、按需触发验证 */
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const newVal = e.target.value;
|
const newVal = e.target.value;
|
||||||
if (maxLength && newVal.length > maxLength) {
|
if (maxLength && newVal.length > maxLength) {
|
||||||
const msg = t('charCount', { count: maxLength });
|
const msg = t('charCount', { count: maxLength });
|
||||||
setError(msg);
|
setError(msg);
|
||||||
showMessage?.(msg, { severity: 'warning' });
|
toast.warning(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,43 +174,36 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 失焦时按需触发验证 */
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 清空输入内容并重新聚焦 */
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
if (!isControlled) setInternalValue('');
|
if (!isControlled) setInternalValue('');
|
||||||
onChange?.('');
|
onChange?.('');
|
||||||
setError('');
|
setError('');
|
||||||
textareaRef.current?.focus();
|
internalRef.current?.focus();
|
||||||
showMessage?.(t('textInputArea.cleared'), { severity: 'success' });
|
toast.success('已清空内容');
|
||||||
onClear?.();
|
onClear?.();
|
||||||
}, [isControlled, onChange, showMessage, t, onClear]);
|
}, [isControlled, onChange, onClear]);
|
||||||
|
|
||||||
/** 复制当前内容到剪贴板 */
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
showMessage?.(t('messages.copySuccess'), { severity: 'success' });
|
toast.success('复制成功');
|
||||||
} catch {
|
} catch {
|
||||||
setError(t('messages.copyError'));
|
setError('复制失败');
|
||||||
showMessage?.(t('messages.copyError'), { severity: 'error' });
|
toast.error('复制失败');
|
||||||
}
|
}
|
||||||
}, [value, showMessage, t]);
|
}, [value]);
|
||||||
|
|
||||||
/** 执行工具栏操作:检查禁用状态、验证、调用 onClick */
|
|
||||||
const handleAction = useCallback(
|
const handleAction = useCallback(
|
||||||
(action: ToolbarAction) => {
|
(action: ToolbarAction) => {
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
|
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
|
||||||
|
|
||||||
if (isDisabled || disabled) return;
|
if (isDisabled || disabled) return;
|
||||||
|
|
||||||
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) {
|
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
action.onClick(value, {
|
action.onClick(value, {
|
||||||
clear: handleClear,
|
clear: handleClear,
|
||||||
@@ -310,34 +213,21 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
[value, disabled, validate, validateTrigger, handleClear],
|
[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<HTMLTextAreaElement | null>).current = node;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[ref],
|
|
||||||
);
|
|
||||||
|
|
||||||
const topActions = actions.filter((a) => a.position !== 'bottom');
|
const topActions = actions.filter((a) => a.position !== 'bottom');
|
||||||
const bottomActions = actions.filter((a) => a.position === 'bottom');
|
const bottomActions = actions.filter((a) => a.position === 'bottom');
|
||||||
|
|
||||||
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
|
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
|
||||||
|
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={{ ...style, ...containerSx }}>
|
<div className={cn('w-full flex flex-col gap-1.5', className)}>
|
||||||
|
{/* 顶部工具栏 */}
|
||||||
{hasTopBar && (
|
{hasTopBar && (
|
||||||
<div className="flex items-center justify-between mb-2 px-1">
|
<div className="flex items-center justify-between px-0.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
{title && <span className="text-sm font-semibold text-muted-foreground">{title}</span>}
|
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
|
||||||
{topExtra}
|
{topExtra}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{topActions.map((action) => (
|
{topActions.map((action) => (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
key={action.key}
|
key={action.key}
|
||||||
@@ -345,11 +235,10 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
value={value}
|
value={value}
|
||||||
globalDisabled={disabled}
|
globalDisabled={disabled}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
compact
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{showCount && (
|
{showCount && (
|
||||||
<span className="text-xs text-muted-foreground tabular-nums ml-1">
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
{value.length}
|
{value.length}
|
||||||
{maxLength ? ` / ${maxLength}` : ''}
|
{maxLength ? ` / ${maxLength}` : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -358,52 +247,50 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
{/* 核心卡片容器:完美适配 shadcn 风格的多行包裹框 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input',
|
||||||
|
displayError &&
|
||||||
|
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={handleInputRef}
|
ref={internalRef}
|
||||||
placeholder={placeholder}
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
rows={autoResize ? undefined : minRows}
|
placeholder={placeholder}
|
||||||
style={{
|
className="w-full bg-transparent px-3 py-2.5 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground focus:outline-none resize-none border-0 block"
|
||||||
minHeight: autoResize ? `${minRows * 1.5}rem` : undefined,
|
{...restProps}
|
||||||
maxHeight: autoResize ? `${maxRows * 1.5}rem` : undefined,
|
|
||||||
}}
|
|
||||||
className={`w-full rounded-lg border ${
|
|
||||||
displayError ? 'border-red-300' : 'border-border'
|
|
||||||
} bg-background px-3 py-3 font-mono text-sm leading-relaxed transition-all resize-y ${
|
|
||||||
showClear || allowCopy || bottomActions.length > 0 ? 'pb-10' : ''
|
|
||||||
} focus:outline-none focus:ring-2 focus:ring-primary focus:bg-background hover:bg-muted ${
|
|
||||||
displayError ? 'focus:ring-red-500' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(showClear || allowCopy || bottomActions.length > 0) && (
|
{/* 底部隔离式功能区:杜绝重叠和塌陷 */}
|
||||||
<div
|
{hasBottomBar && (
|
||||||
className={`absolute right-3 flex items-center gap-1 z-10 ${
|
<div className="flex items-center justify-between px-2 py-1.5 bg-muted/20 border-t border-border/60">
|
||||||
displayError ? 'bottom-8' : 'bottom-2'
|
<div className="flex items-center gap-1.5">
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{bottomActions.map((action) => (
|
{bottomActions.map((action) => (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
key={action.key}
|
key={action.key}
|
||||||
action={action}
|
action={action}
|
||||||
value={value}
|
value={value}
|
||||||
globalDisabled={disabled}
|
globalDisabled={disabled}
|
||||||
variant={action.type === 'primary' ? 'contained' : 'text'}
|
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
{allowCopy && value && (
|
{allowCopy && value && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
|
aria-label={t('textInputArea.copyContent')}
|
||||||
title={t('textInputArea.copyContent')}
|
title={t('textInputArea.copyContent')}
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -412,17 +299,24 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
|
aria-label={t('textInputArea.clear')}
|
||||||
title={t('textInputArea.clear')}
|
title={t('textInputArea.clear')}
|
||||||
className="p-1 rounded-md text-muted-foreground hover:text-red-600 hover:bg-red-500/10 transition-colors"
|
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{displayError && <p className="mt-1 text-xs font-semibold text-red-500">{displayError}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示区域 */}
|
||||||
|
{displayError && (
|
||||||
|
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||||
|
{displayError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,33 +107,28 @@ describe('TextInputArea 组件', () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('复制时调用 showMessage', async () => {
|
it('复制时调用 clipboard writeText', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const showMessage = vi.fn();
|
|
||||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
<TextInputArea value="测试" onChange={() => {}} allowCopy showMessage={showMessage} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||||
|
|
||||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
expect(showMessage).toHaveBeenCalledWith('messages.copySuccess', { severity: 'success' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('复制失败时调用 showMessage 错误提示', async () => {
|
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const showMessage = vi.fn();
|
const writeTextSpy = vi
|
||||||
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('失败'));
|
.spyOn(navigator.clipboard, 'writeText')
|
||||||
|
.mockRejectedValue(new Error('失败'));
|
||||||
|
|
||||||
render(
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
<TextInputArea value="测试" onChange={() => {}} allowCopy showMessage={showMessage} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||||
|
|
||||||
expect(showMessage).toHaveBeenCalledWith('messages.copyError', { severity: 'error' });
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -370,18 +365,15 @@ describe('TextInputArea 组件', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('showMessage prop', () => {
|
describe('复制功能', () => {
|
||||||
it('复制成功时调用 showMessage', async () => {
|
it('复制成功时调用 clipboard writeText', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const showMessage = vi.fn();
|
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||||
vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
render(
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
<TextInputArea value="测试" onChange={() => {}} allowCopy showMessage={showMessage} />,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||||
|
|
||||||
expect(showMessage).toHaveBeenCalledWith('messages.copySuccess', { severity: 'success' });
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,7 +464,7 @@ describe('TextInputArea 组件', () => {
|
|||||||
describe('autoResize', () => {
|
describe('autoResize', () => {
|
||||||
it('autoResize=true 时设置 minRows/maxRows', () => {
|
it('autoResize=true 时设置 minRows/maxRows', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TextInputArea value="" onChange={() => {}} autoResize minRows={3} maxRows={8} />,
|
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
const textarea = container.querySelector('textarea');
|
const textarea = container.querySelector('textarea');
|
||||||
@@ -480,9 +472,7 @@ describe('TextInputArea 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('autoResize=false 时设置固定 rows', () => {
|
it('autoResize=false 时设置固定 rows', () => {
|
||||||
const { container } = render(
|
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
|
||||||
<TextInputArea value="" onChange={() => {}} autoResize={false} minRows={5} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const textarea = container.querySelector('textarea');
|
const textarea = container.querySelector('textarea');
|
||||||
expect(textarea).toBeInTheDocument();
|
expect(textarea).toBeInTheDocument();
|
||||||
@@ -497,21 +487,5 @@ describe('TextInputArea 组件', () => {
|
|||||||
|
|
||||||
expect(container.firstChild).toHaveClass('custom-class');
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应透传 style', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TextInputArea value="" onChange={() => {}} style={{ marginTop: 10 }} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.firstChild).toHaveStyle({ marginTop: '10px' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应透传 sx 样式', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TextInputArea value="" onChange={() => {}} sx={{ marginBottom: '24px' }} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.firstChild).toHaveStyle({ marginBottom: '24px' });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user