feat: Input.OTP component support (#48076)

* chore: basic control

* chore: input instad

* docs: update demo

* chore: adjust operation interactive

* chore: lock selection

* chore: fix patch logic

* chore: merge logic

* chore: patch autoFocus

* test: update snapshot

* test: add test case

* test: coverage

* chore: update size limit

* docs: update docs

* test: fix test case

* chore: update comment

* refactor: change to length

* chore: blur all

* chore: size limit
This commit is contained in:
二货爱吃白萝卜
2024-03-28 14:05:58 +08:00
committed by GitHub
parent 405394a8e4
commit 100fa29bef
18 changed files with 1528 additions and 12 deletions

View File

@@ -28500,6 +28500,216 @@ exports[`renders components/form/demo/validate-static.tsx extend context correct
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-success"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Success"
>
Success
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-warning"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Warning"
>
Warning
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-error"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Error"
>
Error
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-error"
>

View File

@@ -11508,6 +11508,216 @@ exports[`renders components/form/demo/validate-static.tsx correctly 1`] = `
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-success"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Success"
>
Success
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-warning"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Warning"
>
Warning
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-error"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Error"
>
Error
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-error"
>

View File

@@ -136,6 +136,17 @@ const App: React.FC = () => (
<Input.Password allowClear placeholder="with input password and allowClear" />
</Form.Item>
<Form.Item label="Success" hasFeedback validateStatus="success">
<Input.OTP />
</Form.Item>
<Form.Item label="Warning" hasFeedback validateStatus="warning">
<Input.OTP />
</Form.Item>
<Form.Item label="Error" hasFeedback validateStatus="error">
<Input.OTP />
</Form.Item>
<Form.Item label="Fail" validateStatus="error" hasFeedback>
<Mentions />
</Form.Item>

View File

@@ -264,4 +264,8 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
);
});
if (process.env.NODE_ENV !== 'production') {
Input.displayName = 'Input';
}
export default Input;

View File

@@ -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<InputProps, 'onChange'> {
index: number;
onChange: (index: number, value: string) => void;
/** Tell parent to do active offset */
onActiveChange: (nextIndex: number) => void;
}
const OTPInput = React.forwardRef<InputRef, OTPInputProps>((props, ref) => {
const { value, onChange, onActiveChange, index, ...restProps } = props;
const onInternalChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
onChange(index, e.target.value);
};
// ========================== Ref ===========================
const inputRef = React.useRef<InputRef>(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<HTMLInputElement> = ({ key }) => {
if (key === 'ArrowLeft') {
onActiveChange(index - 1);
} else if (key === 'ArrowRight') {
onActiveChange(index + 1);
}
syncSelection();
};
const onInternalKeyUp: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === 'Backspace' && !value) {
onActiveChange(index - 1);
}
syncSelection();
};
// ========================= Render =========================
return (
<Input
{...restProps}
ref={inputRef}
value={value}
onInput={onInternalChange}
onFocus={syncSelection}
onKeyDown={onInternalKeyDown}
onKeyUp={onInternalKeyUp}
onMouseDown={syncSelection}
onMouseUp={syncSelection}
/>
);
});
export default OTPInput;

View File

@@ -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<React.HTMLAttributes<HTMLDivElement>, '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<OTPRef, OTPProps>((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<HTMLDivElement>(null);
const refs = React.useRef<Record<number, InputRef | null>>({});
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<string[]>(
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(
<div
{...domAttrs}
ref={containerRef}
className={classNames(
prefixCls,
{
[`${prefixCls}-sm`]: mergedSize === 'small',
[`${prefixCls}-lg`]: mergedSize === 'large',
[`${prefixCls}-rtl`]: direction === 'rtl',
},
cssVarCls,
hashId,
)}
>
<FormItemInputContext.Provider value={proxyFormContext}>
{new Array(length).fill(0).map((_, index) => {
const key = `otp-${index}`;
const singleValue = valueCells[index] || '';
return (
<OTPInput
ref={(inputEle) => {
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}
/>
);
})}
</FormItemInputContext.Provider>
</div>,
);
});
export default OTP;

View File

@@ -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`] = `
<div
class="ant-space ant-space-vertical ant-space-gap-row-small ant-space-gap-col-small"
>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With formatter (Upcase)
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With Disabled
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With Length (8)
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With variant
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
`;
exports[`renders components/input/demo/otp.tsx extend context correctly 2`] = `[]`;
exports[`renders components/input/demo/password-input.tsx extend context correctly 1`] = `
<div
class="ant-space ant-space-vertical ant-space-gap-row-small ant-space-gap-col-small"

View File

@@ -3511,6 +3511,243 @@ exports[`renders components/input/demo/group.tsx correctly 1`] = `
</div>
`;
exports[`renders components/input/demo/otp.tsx correctly 1`] = `
<div
class="ant-space ant-space-vertical ant-space-gap-row-small ant-space-gap-col-small"
>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With formatter (Upcase)
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With Disabled
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With Length (8)
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With variant
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
`;
exports[`renders components/input/demo/password-input.tsx correctly 1`] = `
<div
class="ant-space ant-space-vertical ant-space-gap-row-small ant-space-gap-col-small"

View File

@@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Input.OTP rtl render component should be rendered correctly in RTL direction 1`] = `
<div
class="ant-otp ant-otp-rtl"
>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
`;

View File

@@ -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(<OTP onChange={onChange} />);
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(<OTP onChange={onChange} autoFocus />);
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(<OTP defaultValue={CODE} onChange={onChange} />);
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(<OTP value="BAMBOO" />);
expect(getText(container)).toBe('BAMBOO');
rerender(<OTP value="LITTLE" />);
expect(getText(container)).toBe('LITTLE');
});
it('focus to selection', async () => {
const { container } = render(<OTP defaultValue="BAMBOO" />);
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(<OTP autoFocus />);
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(<OTP />);
fireEvent.input(container.querySelectorAll('input')[5], { target: { value: '1' } });
expect(getText(container)).toBe(' 1');
});
it('formatter', () => {
const { container } = render(
<OTP defaultValue="bamboo" formatter={(val) => val.toUpperCase()} />,
);
expect(getText(container)).toBe('BAMBOO');
// Type to trigger formatter
fireEvent.input(container.querySelector('input')!, { target: { value: 'little' } });
expect(getText(container)).toBe('LITTLE');
});
});

View File

@@ -0,0 +1,7 @@
## zh-CN
一次性密码输入框。
## en-US
One time password input.

View File

@@ -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<typeof Input.OTP, 'onChange'> = (text) => {
console.log('onChange:', text);
};
const sharedProps = {
onChange,
};
return (
<Space direction="vertical">
<Title level={5}>With formatter (Upcase)</Title>
<Input.OTP formatter={(str) => str.toUpperCase()} {...sharedProps} />
<Title level={5}>With Disabled</Title>
<Input.OTP disabled {...sharedProps} />
<Title level={5}>With Length (8)</Title>
<Input.OTP length={8} {...sharedProps} />
<Title level={5}>With variant</Title>
<Input.OTP variant="filled" {...sharedProps} />
</Space>
);
};
export default App;

View File

@@ -28,6 +28,7 @@ demo:
<code src="./demo/search-input-loading.tsx">Search box with loading</code>
<code src="./demo/textarea.tsx">TextArea</code>
<code src="./demo/autosize-textarea.tsx">Autosizing the height to fit the content</code>
<code src="./demo/otp.tsx" version="5.16.0">OTP</code>
<code src="./demo/tooltip.tsx">Format Tooltip Input</code>
<code src="./demo/presuffix.tsx">prefix and suffix</code>
<code src="./demo/password-input.tsx">Password box</code>
@@ -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 ? &lt;EyeOutlined /> : &lt;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 |

View File

@@ -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;

View File

@@ -29,6 +29,7 @@ demo:
<code src="./demo/search-input-loading.tsx">搜索框 loading</code>
<code src="./demo/textarea.tsx">文本域</code>
<code src="./demo/autosize-textarea.tsx">适应文本高度的文本域</code>
<code src="./demo/otp.tsx" version="5.16.0">一次性密码框</code>
<code src="./demo/tooltip.tsx">输入时格式化展示</code>
<code src="./demo/presuffix.tsx">前缀和后缀</code>
<code src="./demo/password-input.tsx">密码框</code>
@@ -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 ? &lt;EyeOutlined /> : &lt;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 |

View File

@@ -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<InputToken> = (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<InputToken>(token, initInputToken(token));
return [genOTPStyle(inputToken)];
},
initComponentToken,
);

View File

@@ -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",

View File

@@ -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`;