diff --git a/packages/common b/packages/common index 5aedfdf7ab..a8fb25270d 160000 --- a/packages/common +++ b/packages/common @@ -1 +1 @@ -Subproject commit 5aedfdf7ab0457141b0a886d2126e22bbdb42cae +Subproject commit a8fb25270d04d84022324fad62578031af3d3c2f diff --git a/packages/components/textarea/__tests__/textarea.test.tsx b/packages/components/textarea/__tests__/textarea.test.tsx index d13cc3e6c8..e19b737e63 100644 --- a/packages/components/textarea/__tests__/textarea.test.tsx +++ b/packages/components/textarea/__tests__/textarea.test.tsx @@ -107,6 +107,54 @@ describe('Textarea', () => { value.value = '123456'; expect(textarea.element.value).toBe('12345'); }); + + it(':clearable', async () => { + const value = ref('text'); + const wrapper = mount(() => ); + const root = wrapper.find('.t-textarea'); + // clearable 为 true 时,根节点应带上 --clearable 修饰类(对应 tdesign-common 里的右侧内边距样式) + expect(root.classes()).toContain('t-textarea--clearable'); + // 未悬浮时不展示清空按钮 + expect(wrapper.find('.t-textarea__clear').exists()).toBeFalsy(); + await root.trigger('mouseenter'); + expect(wrapper.find('.t-textarea__clear').exists()).toBeTruthy(); + await root.trigger('mouseleave'); + expect(wrapper.find('.t-textarea__clear').exists()).toBeFalsy(); + }); + + it(':clearable(default false)', async () => { + // 默认 clearable=false:即使有值且悬浮,也不应该出现清空按钮,防止未来默认值被误改 + const value = ref('text'); + const wrapper = mount(() => ); + const root = wrapper.find('.t-textarea'); + expect(root.classes()).not.toContain('t-textarea--clearable'); + await root.trigger('mouseenter'); + expect(wrapper.find('.t-textarea__clear').exists()).toBeFalsy(); + }); + + it(':clearable(no value)', async () => { + const wrapper = mount(() => ); + const root = wrapper.find('.t-textarea'); + await root.trigger('mouseenter'); + // 没有内容时即使悬浮也不展示清空按钮 + expect(wrapper.find('.t-textarea__clear').exists()).toBeFalsy(); + }); + + it(':clearable(disabled)', async () => { + const wrapper = mount(() => ); + const root = wrapper.find('.t-textarea'); + await root.trigger('mouseenter'); + // 禁用状态下即使悬浮也不展示清空按钮 + expect(wrapper.find('.t-textarea__clear').exists()).toBeFalsy(); + }); + + it(':clearable(readonly)', async () => { + const wrapper = mount(() => ); + const root = wrapper.find('.t-textarea'); + await root.trigger('mouseenter'); + // 只读状态下即使悬浮也不展示清空按钮 + expect(wrapper.find('.t-textarea__clear').exists()).toBeFalsy(); + }); }); describe(':events', () => { @@ -149,5 +197,20 @@ describe('Textarea', () => { await textarea.trigger('keyup'); expect(fn).toBeCalled(); }); + + it(':onClear', async () => { + const fn = vi.fn(); + const value = ref('text'); + const wrapper = mount(() => ); + const root = wrapper.find('.t-textarea'); + await root.trigger('mouseenter'); + const clearBtn = wrapper.find('.t-textarea__clear'); + expect(clearBtn.exists()).toBeTruthy(); + await clearBtn.trigger('click'); + expect(fn).toBeCalled(); + expect(value.value).toBe(''); + const textarea = wrapper.find('textarea'); + expect(textarea.element.value).toBe(''); + }); }); }); diff --git a/packages/components/textarea/_example/clearable.vue b/packages/components/textarea/_example/clearable.vue new file mode 100644 index 0000000000..32ac60565d --- /dev/null +++ b/packages/components/textarea/_example/clearable.vue @@ -0,0 +1,12 @@ + + + + diff --git a/packages/components/textarea/_usage/props.json b/packages/components/textarea/_usage/props.json index 856b190a74..08c70c387a 100644 --- a/packages/components/textarea/_usage/props.json +++ b/packages/components/textarea/_usage/props.json @@ -11,6 +11,12 @@ "defaultValue": false, "options": [] }, + { + "name": "clearable", + "type": "Boolean", + "defaultValue": false, + "options": [] + }, { "name": "disabled", "type": "Boolean", diff --git a/packages/components/textarea/props.ts b/packages/components/textarea/props.ts index 1b8f49e089..2f5503abea 100644 --- a/packages/components/textarea/props.ts +++ b/packages/components/textarea/props.ts @@ -17,6 +17,8 @@ export default { type: [Boolean, Object] as PropType, default: false as TdTextareaProps['autosize'], }, + /** 是否可清空 */ + clearable: Boolean, /** 是否禁用文本框 */ disabled: { type: Boolean, @@ -75,6 +77,8 @@ export default { onBlur: Function as PropType, /** 输入内容变化时触发 */ onChange: Function as PropType, + /** 清空按钮点击时触发 */ + onClear: Function as PropType, /** 获得焦点时触发 */ onFocus: Function as PropType, /** 键盘按下时触发 */ diff --git a/packages/components/textarea/textarea.en-US.md b/packages/components/textarea/textarea.en-US.md index b548191428..bd12fcd446 100644 --- a/packages/components/textarea/textarea.en-US.md +++ b/packages/components/textarea/textarea.en-US.md @@ -9,6 +9,7 @@ name | type | default | description | required allowInputOverMax | Boolean | false | \- | N autofocus | Boolean | false | \- | N autosize | Boolean / Object | false | Typescript:`boolean \| { minRows?: number; maxRows?: number }` | N +clearable | Boolean | false | \- | N disabled | Boolean | undefined | \- | N maxcharacter | Number | - | \- | N maxlength | String / Number | - | Typescript:`string \| number` | N @@ -21,6 +22,7 @@ value | String / Number | - | `v-model` and `v-model:value` is supported。Types defaultValue | String / Number | - | uncontrolled property。Typescript:`TextareaValue` `type TextareaValue = string \| number`。[see more ts definition](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/textarea/type.ts) | N onBlur | Function | | Typescript:`(value: TextareaValue, context: { e: FocusEvent }) => void` | N onChange | Function | | Typescript:`(value: TextareaValue, context?: { e?: InputEvent }) => void` | N +onClear | Function | | Typescript:`(context: { e: MouseEvent }) => void` | N onFocus | Function | | Typescript:`(value: TextareaValue, context : { e: FocusEvent }) => void` | N onKeydown | Function | | Typescript:`(value: TextareaValue, context: { e: KeyboardEvent }) => void` | N onKeypress | Function | | Typescript:`(value: TextareaValue, context: { e: KeyboardEvent }) => void` | N @@ -33,8 +35,7 @@ name | params | description -- | -- | -- blur | `(value: TextareaValue, context: { e: FocusEvent })` | \- change | `(value: TextareaValue, context?: { e?: InputEvent })` | \- +clear | `(context: { e: MouseEvent })` | \- focus | `(value: TextareaValue, context : { e: FocusEvent })` | \- keydown | `(value: TextareaValue, context: { e: KeyboardEvent })` | \- -keypress | `(value: TextareaValue, context: { e: KeyboardEvent })` | \- -keyup | `(value: TextareaValue, context: { e: KeyboardEvent })` | \- -validate | `(context: { error?: 'exceed-maximum' \| 'below-minimum' })` | \- +keypress | `(value: TextareaValue, context: { e: Key \ No newline at end of file diff --git a/packages/components/textarea/textarea.md b/packages/components/textarea/textarea.md index eed8c50352..53c529ee89 100644 --- a/packages/components/textarea/textarea.md +++ b/packages/components/textarea/textarea.md @@ -9,6 +9,7 @@ allowInputOverMax | Boolean | false | 超出maxlength或maxcharacter之后是否还允许输入 | N autofocus | Boolean | false | 自动聚焦,拉起键盘 | N autosize | Boolean / Object | false | 高度自动撑开。 autosize = true 表示组件高度自动撑开,同时,依旧允许手动拖高度。如果设置了 autosize.maxRows 或者 autosize.minRows 则不允许手动调整高度。TS 类型:`boolean \| { minRows?: number; maxRows?: number }` | N +clearable | Boolean | false | 是否可清空 | N disabled | Boolean | undefined | 是否禁用文本框 | N maxcharacter | Number | - | 用户最多可以输入的字符个数,一个中文汉字表示两个字符长度 | N maxlength | String / Number | - | 用户最多可以输入的字符个数。TS 类型:`string \| number` | N @@ -21,6 +22,7 @@ value | String / Number | - | 文本框值。支持语法糖 `v-model` 或 `v-mo defaultValue | String / Number | - | 文本框值。非受控属性。TS 类型:`TextareaValue` `type TextareaValue = string \| number`。[详细类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/textarea/type.ts) | N onBlur | Function | | TS 类型:`(value: TextareaValue, context: { e: FocusEvent }) => void`失去焦点时触发 | N onChange | Function | | TS 类型:`(value: TextareaValue, context?: { e?: InputEvent }) => void`输入内容变化时触发 | N +onClear | Function | | TS 类型:`(context: { e: MouseEvent }) => void`清空按钮点击时触发 | N onFocus | Function | | TS 类型:`(value: TextareaValue, context : { e: FocusEvent }) => void`获得焦点时触发 | N onKeydown | Function | | TS 类型:`(value: TextareaValue, context: { e: KeyboardEvent }) => void`键盘按下时触发 | N onKeypress | Function | | TS 类型:`(value: TextareaValue, context: { e: KeyboardEvent }) => void`按下字符键时触发(keydown -> keypress -> keyup) | N @@ -33,8 +35,7 @@ onValidate | Function | | TS 类型:`(context: { error?: 'exceed-maximum' \| -- | -- | -- blur | `(value: TextareaValue, context: { e: FocusEvent })` | 失去焦点时触发 change | `(value: TextareaValue, context?: { e?: InputEvent })` | 输入内容变化时触发 +clear | `(context: { e: MouseEvent })` | 清空按钮点击时触发 focus | `(value: TextareaValue, context : { e: FocusEvent })` | 获得焦点时触发 keydown | `(value: TextareaValue, context: { e: KeyboardEvent })` | 键盘按下时触发 -keypress | `(value: TextareaValue, context: { e: KeyboardEvent })` | 按下字符键时触发(keydown -> keypress -> keyup) -keyup | `(value: TextareaValue, context: { e: KeyboardEvent })` | 释放键盘时触发 -validate | `(context: { error?: 'exceed-maximum' \| 'below-minimum' })` | 字数超出限制时触发 +keypress | `(value: TextareaValue, context: { e: KeyboardEvent })` | 按下字符键时 \ No newline at end of file diff --git a/packages/components/textarea/textarea.tsx b/packages/components/textarea/textarea.tsx index dd8574c38e..fe83387d86 100644 --- a/packages/components/textarea/textarea.tsx +++ b/packages/components/textarea/textarea.tsx @@ -11,6 +11,7 @@ import { CSSProperties, } from 'vue'; import { isObject, merge, omit } from 'lodash-es'; +import { CloseCircleFilledIcon as TdCloseCircleFilledIcon } from 'tdesign-icons-vue-next'; import { FormItemInjectionKey } from '../form/constants'; import setStyle from '@tdesign/common-js/utils/setStyle'; @@ -24,6 +25,7 @@ import { useTNodeJSX, usePrefixClass, useCommonClassName, + useGlobalIcon, } from '@tdesign/shared-hooks'; import { useLengthLimit } from '../input/hooks/useLengthLimit'; @@ -42,7 +44,9 @@ export default defineComponent({ const name = usePrefixClass('textarea'); const TEXTAREA_TIPS_CLASS = computed(() => `${name.value}__tips`); const TEXTAREA_LIMIT = computed(() => `${name.value}__limit`); - + const { CloseCircleFilledIcon } = useGlobalIcon({ + CloseCircleFilledIcon: TdCloseCircleFilledIcon, + }); const { value, modelValue } = toRefs(props); const [innerValue, setInnerValue] = useVModel(value, modelValue, props.defaultValue, props.onChange); const disabled = useDisabled(); @@ -52,7 +56,7 @@ export default defineComponent({ const refTextareaElem = ref(); const focused = ref(false); const isComposing = ref(false); - + const isHover = ref(false); const focus = () => refTextareaElem.value?.focus(); const blur = () => refTextareaElem.value?.blur(); @@ -142,6 +146,22 @@ export default defineComponent({ props.onBlur?.(innerValue.value, { e }); formItem?.handleBlur(); }; + // 清空按钮点击时触发,同步内部受控值、原生 DOM 值并重新计算高度 + const emitClear = (e: MouseEvent) => { + if (disabled.value || isReadonly.value) return; + // onChange 的 context.e 类型为 InputEvent,而清空按钮触发的是 MouseEvent,这里不透传事件对象,避免类型不匹配 + setInnerValue(''); + nextTick(() => setInputValue('')); + adjustTextareaHeight(); + props.onClear?.({ e }); + }; + + const onMouseenter = () => { + isHover.value = true; + }; + const onMouseleave = () => { + isHover.value = false; + }; // computed const textareaClasses = computed(() => { @@ -150,9 +170,19 @@ export default defineComponent({ { [`${prefix.value}-is-disabled`]: disabled.value, [`${prefix.value}-is-readonly`]: isReadonly.value, + [`${name.value}--clearable`]: props.clearable, }, ]; }); + // 是否展示清空按钮:需可清空、有值、非禁用/只读,且鼠标处于悬浮状态(与 Input 组件交互保持一致) + const showClear = computed( + () => + props.clearable && + !disabled.value && + !isReadonly.value && + isHover.value && + ![undefined, null, ''].includes(innerValue.value as never), + ); const inputAttrs = computed>(() => { return getValidAttrs({ autofocus: props.autofocus, @@ -254,8 +284,19 @@ export default defineComponent({ }`} )); + const clearIcon = showClear.value ? ( + + + + ) : null; + return ( - + + {clearIcon} {textTips || limitText ? ( void; + /** + * 清空按钮点击时触发 + */ + onClear?: (context: { e: MouseEvent }) => void; /** * 获得焦点时触发 */