diff --git a/components/form/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/form/__tests__/__snapshots__/demo-extend.test.ts.snap index 2025e07b09..7a619e250f 100644 --- a/components/form/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/components/form/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -28500,6 +28500,216 @@ exports[`renders components/form/demo/validate-static.tsx extend context correct +
+
+
+ +
+
+
+
+
+ + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + + +
+
+
+
+
+
diff --git a/components/form/__tests__/__snapshots__/demo.test.tsx.snap b/components/form/__tests__/__snapshots__/demo.test.tsx.snap index 2137144877..001aa4ee27 100644 --- a/components/form/__tests__/__snapshots__/demo.test.tsx.snap +++ b/components/form/__tests__/__snapshots__/demo.test.tsx.snap @@ -11508,6 +11508,216 @@ exports[`renders components/form/demo/validate-static.tsx correctly 1`] = `
+
+
+
+ +
+
+
+
+
+ + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + + +
+
+
+
+
+
diff --git a/components/form/demo/validate-static.tsx b/components/form/demo/validate-static.tsx index be14303dc2..452a419eb3 100644 --- a/components/form/demo/validate-static.tsx +++ b/components/form/demo/validate-static.tsx @@ -136,6 +136,17 @@ const App: React.FC = () => ( + + + + + + + + + + + diff --git a/components/input/Input.tsx b/components/input/Input.tsx index e77bdd552e..fe4d3d91ab 100644 --- a/components/input/Input.tsx +++ b/components/input/Input.tsx @@ -264,4 +264,8 @@ const Input = forwardRef((props, ref) => { ); }); +if (process.env.NODE_ENV !== 'production') { + Input.displayName = 'Input'; +} + export default Input; diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx new file mode 100644 index 0000000000..de15ad4ac1 --- /dev/null +++ b/components/input/OTP/OTPInput.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import raf from 'rc-util/lib/raf'; + +import Input, { type InputProps, type InputRef } from '../Input'; + +export interface OTPInputProps extends Omit { + index: number; + onChange: (index: number, value: string) => void; + /** Tell parent to do active offset */ + onActiveChange: (nextIndex: number) => void; +} + +const OTPInput = React.forwardRef((props, ref) => { + const { value, onChange, onActiveChange, index, ...restProps } = props; + + const onInternalChange: React.ChangeEventHandler = (e) => { + onChange(index, e.target.value); + }; + + // ========================== Ref =========================== + const inputRef = React.useRef(null); + React.useImperativeHandle(ref, () => inputRef.current!); + + // ========================= Focus ========================== + const syncSelection = () => { + raf(() => { + const inputEle = inputRef.current?.input; + if (document.activeElement === inputEle && inputEle) { + inputEle.select(); + } + }); + }; + + // ======================== Keyboard ======================== + const onInternalKeyDown: React.KeyboardEventHandler = ({ key }) => { + if (key === 'ArrowLeft') { + onActiveChange(index - 1); + } else if (key === 'ArrowRight') { + onActiveChange(index + 1); + } + + syncSelection(); + }; + + const onInternalKeyUp: React.KeyboardEventHandler = (e) => { + if (e.key === 'Backspace' && !value) { + onActiveChange(index - 1); + } + + syncSelection(); + }; + + // ========================= Render ========================= + return ( + + ); +}); + +export default OTPInput; diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx new file mode 100644 index 0000000000..89cb383e89 --- /dev/null +++ b/components/input/OTP/index.tsx @@ -0,0 +1,244 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { useEvent } from 'rc-util'; +import pickAttrs from 'rc-util/lib/pickAttrs'; + +import { getMergedStatus, type InputStatus } from '../../_util/statusUtils'; +import { ConfigContext } from '../../config-provider'; +import useCSSVarCls from '../../config-provider/hooks/useCSSVarCls'; +import useSize from '../../config-provider/hooks/useSize'; +import { type SizeType } from '../../config-provider/SizeContext'; +import { FormItemInputContext } from '../../form/context'; +import type { Variant } from '../../form/hooks/useVariants'; +import { type InputRef } from '../Input'; +import useStyle from '../style/otp'; +import OTPInput, { type OTPInputProps } from './OTPInput'; + +export interface OTPRef { + focus: VoidFunction; + blur: VoidFunction; + nativeElement: HTMLDivElement; +} + +export interface OTPProps extends Omit, 'onChange'> { + prefixCls?: string; + length?: number; + + // Style + variant?: Variant; + rootClassName?: string; + className?: string; + style?: React.CSSProperties; + size?: SizeType; + + // Values + defaultValue?: string; + value?: string; + onChange?: (value: string) => void; + formatter?: (value: string) => string; + + // Status + disabled?: boolean; + status?: InputStatus; +} + +function strToArr(str: string) { + return str.split(''); +} + +const OTP = React.forwardRef((props, ref) => { + const { + prefixCls: customizePrefixCls, + length = 6, + size: customSize, + defaultValue, + value, + onChange, + formatter, + variant, + disabled, + status: customStatus, + autoFocus, + ...restProps + } = props; + + const { getPrefixCls, direction } = React.useContext(ConfigContext); + const prefixCls = getPrefixCls('otp', customizePrefixCls); + + const domAttrs = pickAttrs(restProps, { + aria: true, + data: true, + attr: true, + }); + + // ========================= Root ========================= + // Style + const rootCls = useCSSVarCls(prefixCls); + const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls, rootCls); + + // ========================= Size ========================= + const mergedSize = useSize((ctx) => customSize ?? ctx); + + // ======================== Status ======================== + const formContext = React.useContext(FormItemInputContext); + const mergedStatus = getMergedStatus(formContext.status, customStatus); + + const proxyFormContext = React.useMemo( + () => ({ + ...formContext, + status: mergedStatus, + hasFeedback: false, + feedbackIcon: null, + }), + [formContext, mergedStatus], + ); + + // ========================= Refs ========================= + const containerRef = React.useRef(null); + + const refs = React.useRef>({}); + + React.useImperativeHandle(ref, () => ({ + focus: () => { + refs.current[0]?.focus(); + }, + blur: () => { + for (let i = 0; i < length; i += 1) { + refs.current[i]?.blur(); + } + }, + nativeElement: containerRef.current!, + })); + + // ======================= Formatter ====================== + const internalFormatter = (txt: string) => (formatter ? formatter(txt) : txt); + + // ======================== Values ======================== + const [valueCells, setValueCells] = React.useState( + strToArr(internalFormatter(defaultValue || '')), + ); + + React.useEffect(() => { + if (value) { + setValueCells(strToArr(value)); + } + }, [value]); + + const triggerValueCellsChange = useEvent((nextValueCells: string[]) => { + setValueCells(nextValueCells); + + // Trigger if all cells are filled + if ( + onChange && + nextValueCells.length === length && + nextValueCells.every((c) => c) && + nextValueCells.some((c, index) => valueCells[index] !== c) + ) { + onChange(nextValueCells.join('')); + } + }); + + const patchValue = useEvent((index: number, txt: string) => { + let nextCells = [...valueCells]; + + // Fill cells till index + for (let i = 0; i < index; i += 1) { + if (!nextCells[i]) { + nextCells[i] = ''; + } + } + + if (txt.length <= 1) { + nextCells[index] = txt; + } else { + nextCells = nextCells.slice(0, index).concat(strToArr(txt)); + } + nextCells = nextCells.slice(0, length); + + // Clean the last empty cell + for (let i = nextCells.length - 1; i >= 0; i -= 1) { + if (nextCells[i]) { + break; + } + nextCells.pop(); + } + + // Format if needed + const formattedValue = internalFormatter(nextCells.map((c) => c || ' ').join('')); + nextCells = strToArr(formattedValue).map((c, i) => { + if (c === ' ' && !nextCells[i]) { + return nextCells[i]; + } + return c; + }); + + return nextCells; + }); + + // ======================== Change ======================== + const onInputChange: OTPInputProps['onChange'] = (index, txt) => { + const nextCells = patchValue(index, txt); + + const nextIndex = Math.min(index + txt.length, length - 1); + if (nextIndex !== index) { + refs.current[nextIndex]?.focus(); + } + + triggerValueCellsChange(nextCells); + }; + + const onInputActiveChange: OTPInputProps['onActiveChange'] = (nextIndex) => { + refs.current[nextIndex]?.focus(); + }; + + // ======================== Render ======================== + const inputSharedProps = { + variant, + disabled, + status: mergedStatus as InputStatus, + }; + + return wrapCSSVar( +
+ + {new Array(length).fill(0).map((_, index) => { + const key = `otp-${index}`; + const singleValue = valueCells[index] || ''; + + return ( + { + refs.current[index] = inputEle; + }} + key={key} + index={index} + size={mergedSize} + htmlSize={1} + className={`${prefixCls}-input`} + onChange={onInputChange} + value={singleValue} + onActiveChange={onInputActiveChange} + autoFocus={index === 0 && autoFocus} + {...inputSharedProps} + /> + ); + })} + +
, + ); +}); + +export default OTP; diff --git a/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap index 7c6e74613f..38dc83ee9f 100644 --- a/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -10114,6 +10114,245 @@ exports[`renders components/input/demo/group.tsx extend context correctly 2`] = ] `; +exports[`renders components/input/demo/otp.tsx extend context correctly 1`] = ` +
+
+
+ With formatter (Upcase) +
+
+
+
+ + + + + + +
+
+
+
+ With Disabled +
+
+
+
+ + + + + + +
+
+
+
+ With Length (8) +
+
+
+
+ + + + + + + + +
+
+
+
+ With variant +
+
+
+
+ + + + + + +
+
+
+`; + +exports[`renders components/input/demo/otp.tsx extend context correctly 2`] = `[]`; + exports[`renders components/input/demo/password-input.tsx extend context correctly 1`] = `
`; +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`;