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
57 changes: 54 additions & 3 deletions packages/components/textarea/Textarea.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { CloseCircleFilledIcon as TdCloseCircleFilledIcon } from 'tdesign-icons-react';
import calcTextareaHeight from '@tdesign/common-js/utils/calcTextareaHeight';
import { getCharacterLength, getUnicodeLength, limitUnicodeMaxLength } from '@tdesign/common-js/utils/helper';

Expand All @@ -9,6 +10,7 @@ import useConfig from '../hooks/useConfig';
import useControlled from '../hooks/useControlled';
import useDefaultProps from '../hooks/useDefaultProps';
import useEventCallback from '../hooks/useEventCallback';
import useGlobalIcon from '../hooks/useGlobalIcon';
import useIsomorphicLayoutEffect from '../hooks/useLayoutEffect';
import { textareaDefaultProps } from './defaultProps';

Expand Down Expand Up @@ -49,13 +51,16 @@ const Textarea = forwardRef<TextareaRefInterface, TextareaProps>((originalProps,
tips,
allowInputOverMax,
rows,
clearable,
onClear,
...otherProps
} = props;
const hasMaxcharacter = typeof maxcharacter !== 'undefined';

const [value = '', setValue] = useControlled(props, 'value', props.onChange);

const [isFocused, setIsFocused] = useState(false);
const [isHover, setIsHover] = useState(false);
const [isOvermax, setIsOvermax] = useState(false);
const [textareaStyle, setTextareaStyle] = useState<Partial<typeof DEFAULT_TEXTAREA_STYLE>>(DEFAULT_TEXTAREA_STYLE);
const [composingValue, setComposingValue] = useState<string>('');
Expand All @@ -72,6 +77,10 @@ const Textarea = forwardRef<TextareaRefInterface, TextareaProps>((originalProps,
}, [value, allowInputOverMax, maxcharacter]);

const { classPrefix } = useConfig();
const { CloseCircleFilledIcon } = useGlobalIcon({
CloseCircleFilledIcon: TdCloseCircleFilledIcon,
});
const isReadonly = props.readOnly || props.readonly;

const textareaPropsNames = Object.keys(otherProps).filter(
(key) => !/^on[A-Z]/.test(key) && !OMIT_PROPS.includes(key),
Expand Down Expand Up @@ -136,10 +145,32 @@ const Textarea = forwardRef<TextareaRefInterface, TextareaProps>((originalProps,
}
}
setComposingValue(val);
setValue(val, { e });
setValue(val, { e, trigger: 'input' });
}
}

function handleClear(e: React.MouseEvent<SVGSVGElement>) {
setValue('', { e, trigger: 'clear' });
onClear?.({ e });
textareaRef.current?.focus();
adjustTextareaHeight();
}

function handleClearMouseDown(e: React.MouseEvent<SVGSVGElement>) {
e.preventDefault();
}

function handleFocus(e: React.FocusEvent<HTMLTextAreaElement>) {
if (disabled) return;
setIsFocused(true);
props.onFocus?.(e.currentTarget.value, { e });
}

function handleBlur(e: React.FocusEvent<HTMLTextAreaElement>) {
setIsFocused(false);
props.onBlur?.(e.currentTarget.value, { e });
}

function handleCompositionStart() {
composingRef.current = true;
}
Expand Down Expand Up @@ -214,19 +245,30 @@ const Textarea = forwardRef<TextareaRefInterface, TextareaProps>((originalProps,
hasMaxcharacter ? characterLength : currentLength,
hasMaxcharacter ? maxcharacter : maxlength,
);
const showClear = Boolean(clearable && value && !disabled && !isReadonly && isHover);

return (
<div style={style} ref={wrapperRef} className={classNames(`${classPrefix}-textarea`, className)}>
<div
style={style}
ref={wrapperRef}
className={classNames(`${classPrefix}-textarea`, className, {
[`${classPrefix}-textarea--clearable`]: clearable,
})}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
<textarea
{...textareaProps}
{...eventProps}
rows={rows}
value={composingRef.current ? composingValue : value}
style={textareaStyle}
className={textareaClassNames}
readOnly={props.readOnly || props.readonly}
readOnly={isReadonly}
autoFocus={autofocus}
disabled={disabled}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={inputValueChangeHandle}
onKeyDown={(e) => onKeydown(e.currentTarget.value, { e })}
onKeyPress={(e) => onKeypress(e.currentTarget.value, { e })}
Expand All @@ -235,6 +277,15 @@ const Textarea = forwardRef<TextareaRefInterface, TextareaProps>((originalProps,
onCompositionEnd={handleCompositionEnd}
ref={textareaRef}
/>
{clearable && !disabled && !isReadonly ? (
<CloseCircleFilledIcon
className={classNames(`${classPrefix}-textarea__clear`, {
[`${classPrefix}-textarea__clear--visible`]: showClear,
})}
onMouseDown={handleClearMouseDown}
onClick={handleClear}
/>
) : null}
{textTips || limitText ? (
<div
className={classNames(`${classPrefix}-textarea__info_wrapper`, {
Expand Down
70 changes: 65 additions & 5 deletions packages/components/textarea/__tests__/textarea.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, mockDelay, render, vi } from '@test/utils';
import { act, fireEvent, mockDelay, render, vi } from '@test/utils';

import { Textarea } from '..';

Expand All @@ -26,20 +26,78 @@ describe('Textarea 组件测试', () => {
fireEvent.change(document.querySelector('textarea'), { target: { value } });
expect(document.querySelector('textarea').textContent).toBe(value);

fireEvent.change(document.querySelector('textarea'), { target: { value: 'hi,tzmax' } });
fireEvent.change(document.querySelector('textarea'), {
target: { value: 'hi,tzmax' },
});
expect(document.querySelector('textarea').textContent.length).toBe(5);

const onChange = vi.fn();
const { container } = render(<Textarea maxLength={1} onChange={onChange} />);
fireEvent.compositionStart(container.querySelector('textarea'));
fireEvent.change(container.querySelector('textarea'), { target: { value: 'tian' } });
fireEvent.change(container.querySelector('textarea'), {
target: { value: 'tian' },
});
fireEvent.compositionEnd(container.querySelector('textarea'), {
currentTarget: { value: '天' },
});
fireEvent.change(container.querySelector('textarea'), { target: { value: '天' } });
fireEvent.change(container.querySelector('textarea'), {
target: { value: '天' },
});
expect(onChange).toHaveBeenLastCalledWith('天', expect.objectContaining({}));
});

test('clearable', async () => {
const onClear = vi.fn();
const onChange = vi.fn();
const { container } = render(
<Textarea defaultValue="Hello TDesign" clearable onClear={onClear} onChange={onChange} />,
);
const textarea = container.querySelector('textarea');

expect(container.querySelector('.t-textarea__clear')).toBeInTheDocument();
expect(container.querySelector('.t-textarea__clear--visible')).not.toBeInTheDocument();
expect(textarea.value).toBe('Hello TDesign');

fireEvent.mouseEnter(container.querySelector('.t-textarea'));
expect(container.querySelector('.t-textarea__clear--visible')).toBeInTheDocument();

fireEvent.click(container.querySelector('.t-textarea__clear'));
expect(onClear).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('', expect.objectContaining({ trigger: 'clear' }));
expect(textarea.value).toBe('');
});

test('clearable should not show when empty, disabled or readonly', async () => {
const { container: emptyContainer } = render(<Textarea value="" clearable />);
fireEvent.mouseEnter(emptyContainer.querySelector('.t-textarea'));
expect(emptyContainer.querySelector('.t-textarea__clear--visible')).not.toBeInTheDocument();

const { container: disabledContainer } = render(<Textarea value="Hello TDesign" clearable disabled />);
fireEvent.mouseEnter(disabledContainer.querySelector('.t-textarea'));
expect(disabledContainer.querySelector('.t-textarea__clear')).not.toBeInTheDocument();

const { container: readonlyContainer } = render(<Textarea value="Hello TDesign" clearable readOnly />);
fireEvent.mouseEnter(readonlyContainer.querySelector('.t-textarea'));
expect(readonlyContainer.querySelector('.t-textarea__clear')).not.toBeInTheDocument();
});

test('should not lost focus when clear textarea', async () => {
const blurFn = vi.fn();
const { container } = render(<Textarea defaultValue="Hello TDesign" clearable onBlur={blurFn} />);
const textarea = container.querySelector('textarea');

act(() => {
textarea.focus();
});
fireEvent.mouseEnter(container.querySelector('.t-textarea'));
const clearIcon = container.querySelector('.t-textarea__clear');
fireEvent.mouseDown(clearIcon);
fireEvent.mouseUp(clearIcon);
fireEvent.click(clearIcon);

expect(blurFn).toHaveBeenCalledTimes(0);
});

// 测试事件
test('event', async () => {
let changeValue = '';
Expand Down Expand Up @@ -82,7 +140,9 @@ describe('Textarea 组件测试', () => {

event = null;
changeValue = '';
fireEvent.change(document.querySelector('textarea'), { target: { value: 'hi,tzmax' } });
fireEvent.change(document.querySelector('textarea'), {
target: { value: 'hi,tzmax' },
});
expect(changeValue).not.toBeNull();
expect(event).not.toBeNull();
});
Expand Down
21 changes: 21 additions & 0 deletions packages/components/textarea/_example/clearable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React, { useState } from 'react';
import { Textarea } from 'tdesign-react';

export default function TextareaClearableExample() {
const [value, setValue] = useState('Hello TDesign');

return (
<Textarea
value={value}
clearable
placeholder="请输入"
style={{ width: '100%', maxWidth: 500 }}
onChange={(value) => {
setValue(value);
}}
onClear={() => {
console.log('onClear');
}}
/>
);
}
8 changes: 7 additions & 1 deletion 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 All @@ -23,4 +29,4 @@
"defaultValue": false,
"options": []
}
]
]
1 change: 1 addition & 0 deletions packages/components/textarea/defaultProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const textareaDefaultProps: TdTextareaProps = {
allowInputOverMax: false,
autofocus: false,
autosize: false,
clearable: false,
disabled: false,
placeholder: undefined,
readonly: false,
Expand Down
10 changes: 9 additions & 1 deletion packages/components/textarea/textarea.en-US.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
:: BASE_DOC ::

### Clearable Textarea

Textarea with a clear operation can quickly clear entered content.

{{ clearable }}

## API

### Textarea Props
Expand All @@ -12,6 +18,7 @@ allowInputOverMax | Boolean | false | Allow input after exceeding `maxlength` or
autofocus | Boolean | false | \- | N
autosize | Boolean / Object | false | Typescript: `boolean \| { minRows?: number; maxRows?: number }` | N
count | Boolean / Function | - | Character counter. It is enabled by default when `maxLength` or `maxCharacter` is set.。Typescript: `boolean \| ((ctx: { value: string; count: number; maxLength?: number; maxCharacter?: number }) => TNode)`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
clearable | Boolean | false | Whether the content can be cleared | N
disabled | Boolean | false | \- | N
maxcharacter | Number | - | \- | N
maxlength | Number | - | Typescript: `number` | N
Expand All @@ -23,7 +30,8 @@ tips | TNode | - | Typescript: `string \| TNode`。[see more ts definition](http
value | String | - | Typescript: `TextareaValue` `type TextareaValue = string`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/textarea/type.ts) | N
defaultValue | String | - | uncontrolled property。Typescript: `TextareaValue` `type TextareaValue = string`。[see more ts definition](https://github.com/Tencent/tdesign-react/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
onChange | Function | | Typescript: `(value: TextareaValue, context?: { e?: FormEvent<HTMLTextAreaElement> \| MouseEvent<HTMLElement>; trigger?: 'input' \| 'clear' }) => void`<br/> | N
onClear | Function | | Typescript: `(context: { e: MouseEvent<HTMLElement> }) => 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 Down
10 changes: 9 additions & 1 deletion packages/components/textarea/textarea.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
:: BASE_DOC ::

### 可清空的多行文本框

带清空操作的多行文本框,可快捷清空输入过的内容。

{{ clearable }}

## API

### Textarea Props
Expand All @@ -12,6 +18,7 @@ allowInputOverMax | Boolean | false | 超出 `maxlength` 或 `maxcharacter` 之
autofocus | Boolean | false | 自动聚焦,拉起键盘 | N
autosize | Boolean / Object | false | 高度自动撑开。 autosize = true 表示组件高度自动撑开,同时,依旧允许手动拖高度。如果设置了 autosize.maxRows 或者 autosize.minRows 则不允许手动调整高度。TS 类型:`boolean \| { minRows?: number; maxRows?: number }` | N
count | Boolean / Function | - | 文字计数元素。设置 `maxlength` 或 `maxchanacter` 时,默认为 true。TS 类型:`boolean \| ((ctx: { value: string; count: number; maxLength?: number; maxCharacter?: number }) => TNode)`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N
clearable | Boolean | false | 是否可清空 | N
disabled | Boolean | false | 是否禁用文本框 | N
maxcharacter | Number | - | 用户最多可以输入的字符个数,一个中文汉字表示两个字符长度 | N
maxlength | Number | - | 用户最多可以输入的字符个数 | N
Expand All @@ -23,7 +30,8 @@ tips | TNode | - | 输入框下方提示文本,会根据不同的 `status` 呈
value | String | - | 文本框值。TS 类型:`TextareaValue` `type TextareaValue = string`。[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/textarea/type.ts) | N
defaultValue | String | - | 文本框值。非受控属性。TS 类型:`TextareaValue` `type TextareaValue = string`。[详细类型定义](https://github.com/Tencent/tdesign-react/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
onChange | Function | | TS 类型:`(value: TextareaValue, context?: { e?: FormEvent<HTMLTextAreaElement> \| MouseEvent<HTMLElement>; trigger?: 'input' \| 'clear' }) => void`<br/>输入内容变化时触发 | N
onClear | Function | | TS 类型:`(context: { e: MouseEvent<HTMLElement> }) => 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 Down
Loading