`;
+exports[`renders components/input/demo/otp.tsx correctly 1`] = `
+
+
+
+ With formatter (Upcase)
+
+
+
+
+
+ With Disabled
+
+
+
+
+
+ With Length (8)
+
+
+
+
+
+ With variant
+
+
+
+
+`;
+
exports[`renders components/input/demo/password-input.tsx correctly 1`] = `
+
+
+
+
+
+
+
+`;
diff --git a/components/input/__tests__/otp.test.tsx b/components/input/__tests__/otp.test.tsx
new file mode 100644
index 0000000000..8e2849a093
--- /dev/null
+++ b/components/input/__tests__/otp.test.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+
+import Input from '..';
+import focusTest from '../../../tests/shared/focusTest';
+import mountTest from '../../../tests/shared/mountTest';
+import rtlTest from '../../../tests/shared/rtlTest';
+import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
+
+const { OTP } = Input;
+
+describe('Input.OTP', () => {
+ focusTest(Input.OTP, { refFocus: true });
+ mountTest(Input.OTP);
+ rtlTest(Input.OTP);
+
+ function getText(container: HTMLElement) {
+ const inputList = container.querySelectorAll('input');
+ return Array.from(inputList)
+ .map((input) => input.value || ' ')
+ .join('')
+ .replace(/\s*$/, '');
+ }
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ it('paste to fill all', async () => {
+ const onChange = jest.fn();
+ const { container } = render(
);
+
+ fireEvent.input(container.querySelector('input')!, { target: { value: '123456' } });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith('123456');
+ });
+
+ it('fill step by step', () => {
+ const CODE = 'BAMBOO';
+ const onChange = jest.fn();
+ render(
);
+
+ for (let i = 0; i < CODE.length; i += 1) {
+ expect(onChange).not.toHaveBeenCalled();
+ fireEvent.input(document.activeElement!, { target: { value: CODE[i] } });
+ }
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith(CODE);
+ });
+
+ it('backspace to delete', async () => {
+ const CODE = 'LITTLE';
+
+ const onChange = jest.fn();
+ const { container } = render(
);
+ expect(getText(container)).toBe(CODE);
+
+ // Focus on the last cell
+ const inputList = container.querySelectorAll('input');
+ inputList[inputList.length - 1].focus();
+
+ for (let i = 0; i < CODE.length; i += 1) {
+ fireEvent.keyDown(document.activeElement!, { key: 'Backspace' });
+ fireEvent.input(document.activeElement!, { target: { value: '' } });
+ fireEvent.keyUp(document.activeElement!, { key: 'Backspace' });
+ }
+
+ expect(getText(container)).toBe('');
+
+ // We do not trigger change if empty. It's safe to modify this logic if needed.
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('controlled', () => {
+ const { container, rerender } = render(
);
+ expect(getText(container)).toBe('BAMBOO');
+
+ rerender(
);
+ expect(getText(container)).toBe('LITTLE');
+ });
+
+ it('focus to selection', async () => {
+ const { container } = render(
);
+
+ const firstInput = container.querySelector('input')!;
+ const selectSpy = jest.spyOn(firstInput, 'select');
+ expect(selectSpy).not.toHaveBeenCalled();
+
+ // Trigger focus
+ firstInput.focus();
+ await waitFakeTimer();
+
+ expect(selectSpy).toHaveBeenCalled();
+ });
+
+ it('arrow key to switch', () => {
+ const { container } = render(
);
+
+ const inputList = Array.from(container.querySelectorAll('input'));
+ expect(document.activeElement).toEqual(inputList[0]);
+
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+ expect(document.activeElement).toEqual(inputList[1]);
+
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
+ expect(document.activeElement).toEqual(inputList[0]);
+ });
+
+ it('fill last cell', () => {
+ const { container } = render(
);
+ fireEvent.input(container.querySelectorAll('input')[5], { target: { value: '1' } });
+
+ expect(getText(container)).toBe(' 1');
+ });
+
+ it('formatter', () => {
+ const { container } = render(
+
val.toUpperCase()} />,
+ );
+ expect(getText(container)).toBe('BAMBOO');
+
+ // Type to trigger formatter
+ fireEvent.input(container.querySelector('input')!, { target: { value: 'little' } });
+ expect(getText(container)).toBe('LITTLE');
+ });
+});
diff --git a/components/input/demo/otp.md b/components/input/demo/otp.md
new file mode 100644
index 0000000000..c15701bf2c
--- /dev/null
+++ b/components/input/demo/otp.md
@@ -0,0 +1,7 @@
+## zh-CN
+
+一次性密码输入框。
+
+## en-US
+
+One time password input.
diff --git a/components/input/demo/otp.tsx b/components/input/demo/otp.tsx
new file mode 100644
index 0000000000..2da51facc5
--- /dev/null
+++ b/components/input/demo/otp.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { Input, Space, Typography, type GetProp } from 'antd';
+
+const { Title } = Typography;
+
+const App: React.FC = () => {
+ const onChange: GetProp = (text) => {
+ console.log('onChange:', text);
+ };
+
+ const sharedProps = {
+ onChange,
+ };
+
+ return (
+
+ With formatter (Upcase)
+ str.toUpperCase()} {...sharedProps} />
+ With Disabled
+
+ With Length (8)
+
+ With variant
+
+
+ );
+};
+
+export default App;
diff --git a/components/input/index.en-US.md b/components/input/index.en-US.md
index dfa46f103f..4c9931ecd1 100644
--- a/components/input/index.en-US.md
+++ b/components/input/index.en-US.md
@@ -28,6 +28,7 @@ demo:
Search box with loading
TextArea
Autosizing the height to fit the content
+OTP
Format Tooltip Input
prefix and suffix
Password box
@@ -102,7 +103,7 @@ Same as Input, and more:
The rest of the props of `Input.TextArea` are the same as the original [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea).
-#### Input.Search
+### Input.Search
| Property | Description | Type | Default |
| --- | --- | --- | --- |
@@ -112,13 +113,29 @@ The rest of the props of `Input.TextArea` are the same as the original [textarea
Supports all props of `Input`.
-#### Input.Password
+### Input.Password
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| iconRender | Custom toggle button | (visible) => ReactNode | (visible) => (visible ? <EyeOutlined /> : <EyeInvisibleOutlined />) | 4.3.0 |
| visibilityToggle | Whether show toggle button or control password visible | boolean \| [VisibilityToggle](#visibilitytoggle) | true | |
+### Input.OTP
+
+Added in `5.16.0`.
+
+| Property | Description | Type | Default | Version |
+| --- | --- | --- | --- | --- |
+| defaultValue | Default value | string | - | |
+| disabled | Whether the input is disabled | boolean | false | |
+| formatter | Format display, blank fields will be filled with ` ` | (value: string) => string | - | |
+| length | The number of input elements | number | 6 | |
+| status | Set validation status | 'error' \| 'warning' | - | |
+| size | The size of the input box | `small` \| `middle` \| `large` | `middle` | |
+| variant | Variants of Input | `outlined` \| `borderless` \| `filled` | `outlined` | |
+| value | The input content value | string | - | |
+| onChange | Trigger when all the fields are filled | function(value: string) | - | |
+
#### VisibilityToggle
| Property | Description | Type | Default | Version |
diff --git a/components/input/index.ts b/components/input/index.ts
index e0da843930..a9af9aa991 100644
--- a/components/input/index.ts
+++ b/components/input/index.ts
@@ -1,7 +1,9 @@
import type * as React from 'react';
+
import Group from './Group';
import type { InputProps, InputRef } from './Input';
import InternalInput from './Input';
+import OTP from './OTP';
import Password from './Password';
import Search from './Search';
import TextArea from './TextArea';
@@ -19,16 +21,14 @@ type CompoundedComponent = React.ForwardRefExoticComponent<
Search: typeof Search;
TextArea: typeof TextArea;
Password: typeof Password;
+ OTP: typeof OTP;
};
const Input = InternalInput as CompoundedComponent;
-if (process.env.NODE_ENV !== 'production') {
- Input.displayName = 'Input';
-}
-
Input.Group = Group;
Input.Search = Search;
Input.TextArea = TextArea;
Input.Password = Password;
+Input.OTP = OTP;
export default Input;
diff --git a/components/input/index.zh-CN.md b/components/input/index.zh-CN.md
index a264d123ad..3fd03b2641 100644
--- a/components/input/index.zh-CN.md
+++ b/components/input/index.zh-CN.md
@@ -29,6 +29,7 @@ demo:
搜索框 loading
文本域
适应文本高度的文本域
+一次性密码框
输入时格式化展示
前缀和后缀
密码框
@@ -103,7 +104,7 @@ interface CountConfig {
`Input.TextArea` 的其他属性和浏览器自带的 [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) 一致。
-#### Input.Search
+### Input.Search
| 参数 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
@@ -113,13 +114,29 @@ interface CountConfig {
其余属性和 Input 一致。
-#### Input.Password
+### Input.Password
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| iconRender | 自定义切换按钮 | (visible) => ReactNode | (visible) => (visible ? <EyeOutlined /> : <EyeInvisibleOutlined />) | 4.3.0 |
| visibilityToggle | 是否显示切换按钮或者控制密码显隐 | boolean \| [VisibilityToggle](#visibilitytoggle) | true | |
+### Input.OTP
+
+`5.16.0` 新增。
+
+| 参数 | 说明 | 类型 | 默认值 | 版本 |
+| --- | --- | --- | --- | --- |
+| defaultValue | 默认值 | string | - | |
+| disabled | 是否禁用 | boolean | false | |
+| formatter | 格式化展示,留空字段会被 ` ` 填充 | (value: string) => string | - | |
+| length | 输入元素数量 | number | 6 | |
+| status | 设置校验状态 | 'error' \| 'warning' | - | |
+| size | 输入框大小 | `small` \| `middle` \| `large` | `middle` | |
+| variant | 形态变体 | `outlined` \| `borderless` \| `filled` | `outlined` | |
+| value | 输入框内容 | string | - | |
+| onChange | 当输入框内容全部填充时触发回调 | function(value: string) | - | |
+
#### VisibilityToggle
| Property | Description | Type | Default | Version |
diff --git a/components/input/style/otp.ts b/components/input/style/otp.ts
new file mode 100644
index 0000000000..6ebfb9bf74
--- /dev/null
+++ b/components/input/style/otp.ts
@@ -0,0 +1,47 @@
+import type { GenerateStyle } from '../../theme/internal';
+import { genStyleHooks, mergeToken } from '../../theme/internal';
+import type { InputToken } from './token';
+import { initComponentToken, initInputToken } from './token';
+
+// =============================== OTP ================================
+const genOTPStyle: GenerateStyle = (token) => {
+ const { componentCls, paddingXS } = token;
+
+ return {
+ [`${componentCls}`]: {
+ display: 'inline-flex',
+ alignItems: 'center',
+ flexWrap: 'nowrap',
+ columnGap: paddingXS,
+
+ '&-rtl': {
+ direction: 'rtl',
+ },
+
+ [`${componentCls}-input`]: {
+ textAlign: 'center',
+ paddingInline: token.paddingXXS,
+ },
+
+ // ================= Size =================
+ [`&${componentCls}-sm ${componentCls}-input`]: {
+ paddingInline: token.calc(token.paddingXXS).div(2).equal(),
+ },
+
+ [`&${componentCls}-lg ${componentCls}-input`]: {
+ paddingInline: token.paddingXS,
+ },
+ },
+ };
+};
+
+// ============================== Export ==============================
+export default genStyleHooks(
+ ['Input', 'OTP'],
+ (token) => {
+ const inputToken = mergeToken(token, initInputToken(token));
+
+ return [genOTPStyle(inputToken)];
+ },
+ initComponentToken,
+);
diff --git a/package.json b/package.json
index e10605b6c0..94bfabf4a2 100644
--- a/package.json
+++ b/package.json
@@ -349,11 +349,11 @@
"size-limit": [
{
"path": "./dist/antd.min.js",
- "limit": "336 KiB"
+ "limit": "337 KiB"
},
{
"path": "./dist/antd-with-locales.min.js",
- "limit": "383 KiB"
+ "limit": "384 KiB"
}
],
"title": "Ant Design",
diff --git a/scripts/__snapshots__/check-site.ts.snap b/scripts/__snapshots__/check-site.ts.snap
index 41d6502ca3..af06715def 100644
--- a/scripts/__snapshots__/check-site.ts.snap
+++ b/scripts/__snapshots__/check-site.ts.snap
@@ -116,9 +116,9 @@ exports[`site test Component components/image en Page 1`] = `4`;
exports[`site test Component components/image zh Page 1`] = `4`;
-exports[`site test Component components/input en Page 1`] = `6`;
+exports[`site test Component components/input en Page 1`] = `7`;
-exports[`site test Component components/input zh Page 1`] = `6`;
+exports[`site test Component components/input zh Page 1`] = `7`;
exports[`site test Component components/input-number en Page 1`] = `2`;