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
3 changes: 3 additions & 0 deletions src/components/config-provider/config-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ type Config = {
searchBar?: {
searchIcon?: ReactNode
}
textArea?: {
clearIcon?: ReactNode
}
}

export const defaultConfigRef: {
Expand Down
30 changes: 28 additions & 2 deletions src/components/text-area/demos/demo1.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { TextArea } from 'antd-mobile'
import { TextArea, Toast } from 'antd-mobile'
import { DeleteOutline } from 'antd-mobile-icons'
import { DemoBlock } from 'demos'
import React, { useState } from 'react'

export default () => {
const [value, setValue] = useState('')
Expand All @@ -20,13 +21,38 @@ export default () => {
<TextArea placeholder='请输入内容' rows={5} />
</DemoBlock>

<DemoBlock title='带清除按钮'>
<TextArea placeholder='请输入内容' clearable />
</DemoBlock>

<DemoBlock title='带清除按钮(自定义图标和回调)'>
<TextArea
placeholder='请输入内容'
defaultValue='试试点击清除'
clearable={{
clearIcon: <DeleteOutline />,
onClear: () => {
Toast.show('已清除')
},
}}
/>
</DemoBlock>

<DemoBlock title='根据内容自动调整高度'>
<TextArea
placeholder='请输入内容'
autoSize={{ minRows: 3, maxRows: 5 }}
/>
</DemoBlock>

<DemoBlock title='带清除按钮的自动调整高度'>
<TextArea
placeholder='请输入内容'
autoSize={{ minRows: 3, maxRows: 5 }}
clearable
/>
</DemoBlock>

<DemoBlock title='字数统计'>
<TextArea defaultValue={'北极星垂地,\n东山月满川。'} showCount />
</DemoBlock>
Expand Down
18 changes: 17 additions & 1 deletion src/components/text-area/index.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Long text input that requires wrapping.
| Name | Description | Type | Default |
| --- | --- | --- | --- |
| autoSize | Adaptive content height | `boolean \| { minRows?: number, maxRows?: number }` | `false` |
| clearable | Whether to enable the clear feature, supports passing an object for detailed configuration | `boolean \| ClearableConfig` | `false` |
| defaultValue | Input value by default | `string` | - |
| id | `id` of `textarea` element, often used in conjunction with `label` | `string` | - |
| maxLength | Maximum number of characters | `number` | - |
Expand All @@ -26,7 +27,21 @@ Long text input that requires wrapping.
| showCount | Display the number of words, supports custom render | `boolean \| ((length: number, maxLength?: number) => ReactNode)` | `false` |
| value | Input value | `string` | - |

In addition, the following native attributes are supported: `autoComplete` `autoFocus` `disabled` `readOnly` `onFocus` `onBlur` `onCompositionStart` `onCompositionEnd` `onClick`
#### ClearableConfig

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| clearIcon | Custom clear icon | `ReactNode` | `<CloseCircleFill />` |
| onClear | Triggered after clicking the clear button | `() => void` | - |

In addition, the following native attributes are supported: `autoComplete` `autoFocus` `disabled` `readOnly` `onFocus` `onBlur` `onCompositionStart` `onCompositionEnd` `onClick` `onKeyDown` `enterKeyHint`

#### Why is there no onlyShowClearWhenFocus property?

The clearable feature of the TextArea component has already implemented similar behavior:

- In non-autoSize scenario, the clear icon only shows when focused and the value is not empty;
- In autoSize scenario, to prevent input area width changes caused by the appearance/disappearance of the clear icon which could trigger height jitters, the space for the clear icon is always reserved.

### CSS Variables

Expand All @@ -38,6 +53,7 @@ In addition, the following native attributes are supported: `autoComplete` `auto
| --font-size | font size. | `17px` |
| --placeholder-color | `placeholder` font color. | `var(--adm-color-light)` |
| --text-align | The alignment of text. | `left` |
| --clear-icon-padding | The reserved padding for the clear icon area. | `28px` |

### Ref

Expand Down
2 changes: 1 addition & 1 deletion src/components/text-area/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import './text-area.less'
import { TextArea } from './text-area'
import './text-area.less'

export type { TextAreaProps, TextAreaRef } from './text-area'

Expand Down
34 changes: 25 additions & 9 deletions src/components/text-area/index.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
| 属性 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| autoSize | 自适应内容高度 | `boolean \| { minRows?: number, maxRows?: number }` | `false` |
| clearable | 是否启用清除功能,支持传入对象进行详细配置 | `boolean \| ClearableConfig` | `false` |
| defaultValue | 默认值 | `string` | - |
| id | `textarea` 元素的 `id`,常用来配合 `label` 使用 | `string` | - |
| maxLength | 最大字符数 | `number` | - |
Expand All @@ -26,18 +27,33 @@
| showCount | 显示字数,支持自定义渲染 | `boolean \| ((length: number, maxLength?: number) => ReactNode)` | `false` |
| value | 输入值 | `string` | - |

此外还支持以下原生属性:`autoComplete` `autoFocus` `disabled` `readOnly` `onFocus` `onBlur` `onCompositionStart` `onCompositionEnd` `onClick`
#### ClearableConfig

| 属性 | 说明 | 类型 | 默认值 |
| --------- | ------------------ | ------------ | --------------------- |
| clearIcon | 自定义清除图标 | `ReactNode` | `<CloseCircleFill />` |
| onClear | 点击清除按钮后触发 | `() => void` | - |

此外还支持以下原生属性:`autoComplete` `autoFocus` `disabled` `readOnly` `onFocus` `onBlur` `onCompositionStart` `onCompositionEnd` `onClick` `onKeyDown` `enterKeyHint`

#### 为什么没有 onlyShowClearWhenFocus 属性?

TextArea 组件的 clearable 功能已实现类似行为:

- 在非 autoSize 场景下,输入框有内容且聚焦时,清除图标才会显示;
- 在 autoSize 场景下,为了防止清除图标出现/消失导致输入区域宽度变化进而引发高度抖动,会始终预留清除图标的空间。

### CSS 变量

| 属性 | 说明 | 默认值 |
| ------------------- | ---------------------- | ------------------------ |
| --color | 文字颜色 | `var(--adm-color-text)` |
| --count-text-align | 统计文字对齐方式 | `right` |
| --disabled-color | 禁用状态下的文字颜色 | `var(--adm-color-weak)` |
| --font-size | 字号 | `17px` |
| --placeholder-color | `placeholder` 文字颜色 | `var(--adm-color-light)` |
| --text-align | 文字对齐方式 | `left` |
| 属性 | 说明 | 默认值 |
| -------------------- | ------------------------ | ------------------------ |
| --color | 文字颜色 | `var(--adm-color-text)` |
| --count-text-align | 统计文字对齐方式 | `right` |
| --disabled-color | 禁用状态下的文字颜色 | `var(--adm-color-weak)` |
| --font-size | 字号 | `17px` |
| --placeholder-color | `placeholder` 文字颜色 | `var(--adm-color-light)` |
| --text-align | 文字对齐方式 | `left` |
| --clear-icon-padding | 清除图标区域的预留内边距 | `28px` |

### Ref

Expand Down
213 changes: 212 additions & 1 deletion src/components/text-area/tests/text-area.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { createRef } from 'react'
import { render, fireEvent, act } from 'testing'
import { act, fireEvent, render, screen } from 'testing'
import TextArea, { TextAreaRef } from '..'
import * as validate from '../../../utils/validate'
import ConfigProvider from '../../config-provider'

const classPrefix = 'adm-text-area'
const lineHeight = 25
Expand Down Expand Up @@ -165,4 +167,213 @@ describe('TextArea', () => {
expect(onEnterPress).toBeCalledTimes(2)
fireEvent.keyUp(textarea, { keyCode: 14 })
})

test('the clear button should works', async () => {
const { container } = render(
<TextArea clearable defaultValue={'testValue'} />
)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement

// For non-autoSize, clear button only shows when focused
let clearBtn = container.querySelector(`.${classPrefix}-clear`)
expect(clearBtn).not.toBeInTheDocument()

// Focus the textarea to show clear button
fireEvent.focus(textarea)
clearBtn = container.querySelector(`.${classPrefix}-clear`)
expect(clearBtn).toBeInTheDocument()

fireEvent.click(clearBtn as HTMLElement)
expect(textarea.value).toBe('')
})

test('clear button should preventDefault on mouseDown', () => {
const { container } = render(
<TextArea clearable defaultValue={'testValue'} />
)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.focus(textarea)
const clearBtn = container.querySelector(
`.${classPrefix}-clear`
) as HTMLElement
expect(clearBtn).toBeInTheDocument()
// onMouseDown calls e.preventDefault() to keep textarea focused
const prevented = fireEvent.mouseDown(clearBtn)
expect(prevented).toBe(false)
})

test('should works with composition on iOS', async () => {
const spy = jest.spyOn(validate, 'isIOS').mockReturnValue(true)
const onCompositionStart = jest.fn()
const onCompositionEnd = jest.fn()
const { container } = render(
<TextArea
clearable
defaultValue={'testValue'}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
act(() => {
textarea.focus()
})
expect(textarea).toHaveFocus()
fireEvent.compositionStart(textarea)
expect(onCompositionStart).toBeCalledTimes(1)
fireEvent.compositionEnd(textarea)
expect(onCompositionEnd).toBeCalledTimes(1)
fireEvent.compositionStart(textarea)
expect(onCompositionStart).toBeCalledTimes(2)

const clearBtn = container.querySelector(
`.${classPrefix}-clear`
) as HTMLElement
expect(clearBtn).toBeInTheDocument()
fireEvent.click(clearBtn)
expect(textarea).not.toHaveFocus()
spy.mockRestore()
})

test('should not blur on non-iOS when click clear button', async () => {
const spy = jest.spyOn(validate, 'isIOS').mockReturnValue(false)
const { container } = render(
<TextArea clearable defaultValue={'testValue'} />
)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
act(() => {
textarea.focus()
})
fireEvent.compositionStart(textarea)
expect(textarea).toHaveFocus()

const clearBtn = container.querySelector(
`.${classPrefix}-clear`
) as HTMLElement
fireEvent.click(clearBtn)
// On non-iOS, the textarea should keep focus even during composition
expect(textarea).toHaveFocus()
expect(textarea.value).toBe('')
spy.mockRestore()
})

test('onClear callback', () => {
const onClear = jest.fn()
const { container } = render(
<TextArea clearable={{ onClear }} defaultValue={'testValue'} />
)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.focus(textarea)
const clearBtn = container.querySelector(`.${classPrefix}-clear`)
fireEvent.click(clearBtn as HTMLElement)
expect(onClear).toBeCalledTimes(1)
expect(textarea.value).toBe('')
})

describe('clearIcon', () => {
it('default', () => {
const { baseElement } = render(
<TextArea value='foobar' clearable autoSize />
)
expect(baseElement.querySelector('.antd-mobile-icon')).toBeTruthy()
})

it('props', () => {
render(
<TextArea value='foobar' clearable={{ clearIcon: 'bamboo' }} autoSize />
)
fireEvent.focus(screen.getByRole('textbox'))
expect(screen.getByText('bamboo')).toBeVisible()
})

it('context', () => {
render(
<ConfigProvider textArea={{ clearIcon: 'little' }}>
<TextArea value='foobar' clearable autoSize />
</ConfigProvider>
)
fireEvent.focus(screen.getByRole('textbox'))
expect(screen.getByText('little')).toBeVisible()
})

it('props override context', () => {
render(
<ConfigProvider textArea={{ clearIcon: 'little' }}>
<TextArea
value='foobar'
clearable={{ clearIcon: 'bamboo' }}
autoSize
/>
</ConfigProvider>
)
fireEvent.focus(screen.getByRole('textbox'))
expect(screen.getByText('bamboo')).toBeVisible()
})
})

describe('clearable with focus behavior', () => {
test('non-autoSize: clear button only shows when focused and has value', () => {
const { container, rerender } = render(
<TextArea clearable value='test' />
)
let clearBtn = container.querySelector(`.${classPrefix}-clear`)
// Initially not focused, clear button should not be visible
expect(clearBtn).not.toBeInTheDocument()

// Focus but no value
rerender(<TextArea clearable value='' />)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.focus(textarea)
clearBtn = container.querySelector(`.${classPrefix}-clear`)
expect(clearBtn).not.toBeInTheDocument()

// Focus and has value
rerender(<TextArea clearable value='test' />)
fireEvent.focus(textarea)
clearBtn = container.querySelector(`.${classPrefix}-clear`)
expect(clearBtn).toBeInTheDocument()

// Blur after focus
fireEvent.blur(textarea)
clearBtn = container.querySelector(`.${classPrefix}-clear`)
expect(clearBtn).not.toBeInTheDocument()
})

test('autoSize: clear container exists but hidden when not focused', () => {
const { container } = render(<TextArea clearable value='test' autoSize />)
let clearBtn = container.querySelector(`.${classPrefix}-clear`)
// Initially not focused, clear container should exist but be hidden
expect(clearBtn).toBeInTheDocument()
expect(clearBtn).toHaveClass(`${classPrefix}-clear-hidden`)
expect(clearBtn).toHaveAttribute('aria-hidden', 'true')

// Focus and has value - clear should be visible
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.focus(textarea)
clearBtn = container.querySelector(`.${classPrefix}-clear`)
expect(clearBtn).toBeInTheDocument()
expect(clearBtn).not.toHaveClass(`${classPrefix}-clear-hidden`)
expect(clearBtn).not.toHaveAttribute('aria-hidden', 'true')

// Blur after focus - clear should be hidden again
fireEvent.blur(textarea)
clearBtn = container.querySelector(`.${classPrefix}-clear`)
expect(clearBtn).toBeInTheDocument()
expect(clearBtn).toHaveClass(`${classPrefix}-clear-hidden`)
expect(clearBtn).toHaveAttribute('aria-hidden', 'true')
})

test('disabled or readOnly should not show clear', () => {
const { container } = render(<TextArea clearable value='test' disabled />)
const clearBtn = container.querySelector(`.${classPrefix}-clear`)
expect(clearBtn).not.toBeInTheDocument()

// Test readOnly
const { container: container2 } = render(
<TextArea clearable value='test' readOnly />
)
const clearBtn2 = container2.querySelector(`.${classPrefix}-clear`)
expect(clearBtn2).not.toBeInTheDocument()
})
})
})
Loading