Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/components/textarea/__tests__/textarea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => <Textarea v-model={value.value} clearable />);
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(() => <Textarea v-model={value.value} />);
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(() => <Textarea clearable />);
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(() => <Textarea clearable disabled defaultValue="text" />);
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(() => <Textarea clearable readonly defaultValue="text" />);
const root = wrapper.find('.t-textarea');
await root.trigger('mouseenter');
// 只读状态下即使悬浮也不展示清空按钮
expect(wrapper.find('.t-textarea__clear').exists()).toBeFalsy();
});
});

describe(':events', () => {
Expand Down Expand Up @@ -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(() => <Textarea v-model={value.value} clearable onClear={fn} />);
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('');
});
});
});
12 changes: 12 additions & 0 deletions packages/components/textarea/_example/clearable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<template>
<t-textarea v-model="value" placeholder="请输入描述文案" clearable @clear="onClear" />
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import type { TextareaProps } from 'tdesign-vue-next';

const value = ref('Hello TDesign');
const onClear: TextareaProps['onClear'] = () => {
console.log('clear');
};
</script>
6 changes: 6 additions & 0 deletions packages/components/textarea/_usage/props.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
"defaultValue": false,
"options": []
},
{
"name": "clearable",
"type": "Boolean",
"defaultValue": false,
"options": []
},
{
"name": "disabled",
"type": "Boolean",
Expand Down
4 changes: 4 additions & 0 deletions packages/components/textarea/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export default {
type: [Boolean, Object] as PropType<TdTextareaProps['autosize']>,
default: false as TdTextareaProps['autosize'],
},
/** 是否可清空 */
clearable: Boolean,
/** 是否禁用文本框 */
disabled: {
type: Boolean,
Expand Down Expand Up @@ -75,6 +77,8 @@ export default {
onBlur: Function as PropType<TdTextareaProps['onBlur']>,
/** 输入内容变化时触发 */
onChange: Function as PropType<TdTextareaProps['onChange']>,
/** 清空按钮点击时触发 */
onClear: Function as PropType<TdTextareaProps['onClear']>,
/** 获得焦点时触发 */
onFocus: Function as PropType<TdTextareaProps['onFocus']>,
/** 键盘按下时触发 */
Expand Down
7 changes: 4 additions & 3 deletions packages/components/textarea/textarea.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`<br/> | N
onChange | Function | | Typescript:`(value: TextareaValue, context?: { e?: InputEvent }) => void`<br/> | N
onClear | Function | | Typescript:`(context: { e: MouseEvent }) => void`<br/> | N
onFocus | Function | | Typescript:`(value: TextareaValue, context : { e: FocusEvent }) => void`<br/> | N
onKeydown | Function | | Typescript:`(value: TextareaValue, context: { e: KeyboardEvent }) => void`<br/> | N
onKeypress | Function | | Typescript:`(value: TextareaValue, context: { e: KeyboardEvent }) => void`<br/> | N
Expand All @@ -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
7 changes: 4 additions & 3 deletions packages/components/textarea/textarea.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`<br/>失去焦点时触发 | N
onChange | Function | | TS 类型:`(value: TextareaValue, context?: { e?: InputEvent }) => void`<br/>输入内容变化时触发 | N
onClear | Function | | TS 类型:`(context: { e: MouseEvent }) => void`<br/>清空按钮点击时触发 | N
onFocus | Function | | TS 类型:`(value: TextareaValue, context : { e: FocusEvent }) => void`<br/>获得焦点时触发 | N
onKeydown | Function | | TS 类型:`(value: TextareaValue, context: { e: KeyboardEvent }) => void`<br/>键盘按下时触发 | N
onKeypress | Function | | TS 类型:`(value: TextareaValue, context: { e: KeyboardEvent }) => void`<br/>按下字符键时触发(keydown -> keypress -> keyup) | N
Expand All @@ -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 })` | 按下字符键时è
48 changes: 45 additions & 3 deletions packages/components/textarea/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +25,7 @@ import {
useTNodeJSX,
usePrefixClass,
useCommonClassName,
useGlobalIcon,
} from '@tdesign/shared-hooks';

import { useLengthLimit } from '../input/hooks/useLengthLimit';
Expand All @@ -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();
Expand All @@ -52,7 +56,7 @@ export default defineComponent({
const refTextareaElem = ref<HTMLTextAreaElement>();
const focused = ref(false);
const isComposing = ref(false);

const isHover = ref(false);
const focus = () => refTextareaElem.value?.focus();
const blur = () => refTextareaElem.value?.blur();

Expand Down Expand Up @@ -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(() => {
Expand All @@ -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<Record<string, any>>(() => {
return getValidAttrs({
autofocus: props.autofocus,
Expand Down Expand Up @@ -254,8 +284,19 @@ export default defineComponent({
}`}</span>
));

const clearIcon = showClear.value ? (
<span class={`${name.value}__clear`} onClick={emitClear}>
<CloseCircleFilledIcon />
</span>
) : null;

return (
<div class={textareaClasses.value} {...omit(attrs, ['style'])}>
<div
class={textareaClasses.value}
onMouseenter={onMouseenter}
onMouseleave={onMouseleave}
{...omit(attrs, ['style'])}
>
<textarea
onInput={handleInput}
onCompositionstart={onCompositionstart}
Expand All @@ -266,6 +307,7 @@ export default defineComponent({
{...inputEvents}
{...inputAttrs.value}
></textarea>
{clearIcon}
{textTips || limitText ? (
<div
class={[
Expand Down
9 changes: 9 additions & 0 deletions packages/components/textarea/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface TdTextareaProps {
* @default false
*/
autosize?: boolean | { minRows?: number; maxRows?: number };
/**
* 是否可清空
* @default false
*/
clearable?: boolean;
/**
* 是否禁用文本框
*/
Expand Down Expand Up @@ -76,6 +81,10 @@ export interface TdTextareaProps {
* 输入内容变化时触发
*/
onChange?: (value: TextareaValue, context?: { e?: InputEvent }) => void;
/**
* 清空按钮点击时触发
*/
onClear?: (context: { e: MouseEvent }) => void;
/**
* 获得焦点时触发
*/
Expand Down
Loading