From 53cbceb7dbf1d10323a097dc8b089c7e2c712de0 Mon Sep 17 00:00:00 2001 From: lijianan <574980606@qq.com> Date: Tue, 9 Apr 2024 14:47:58 +0800 Subject: [PATCH] feat: Input.OTP support mask prop (#48257) * feat: Input.OTP support mask prop * fix: fix * fix: fix * test: add test case * test: add test case * chore: fix * chore: update * chore: remove * chore: rename useOTPSingleValue * fix: fix * fix: fix * chore: rm 3 lib * chore: add 3 lib * fix: fix * fix: fix * test: fix test case * test: fix test case * fix: fix * fix: fix --------- Signed-off-by: lijianan <574980606@qq.com> --- components/input/OTP/OTPInput.tsx | 9 +- components/input/OTP/index.tsx | 22 +- components/input/Password.tsx | 10 +- .../__snapshots__/demo-extend.test.ts.snap | 457 +++++++++--------- .../__snapshots__/demo.test.tsx.snap | 451 ++++++++--------- components/input/__tests__/otp.test.tsx | 36 +- components/input/demo/otp.tsx | 11 +- components/input/index.en-US.md | 5 + components/input/index.zh-CN.md | 5 + 9 files changed, 551 insertions(+), 455 deletions(-) diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx index a89d730dee..a910841f08 100644 --- a/components/input/OTP/OTPInput.tsx +++ b/components/input/OTP/OTPInput.tsx @@ -9,10 +9,14 @@ export interface OTPInputProps extends Omit { onChange: (index: number, value: string) => void; /** Tell parent to do active offset */ onActiveChange: (nextIndex: number) => void; + + mask?: boolean | string; } const OTPInput = React.forwardRef((props, ref) => { - const { value, onChange, onActiveChange, index, ...restProps } = props; + const { value, onChange, onActiveChange, index, mask, ...restProps } = props; + + const internalValue = value && typeof mask === 'string' ? mask : value; const onInternalChange: React.ChangeEventHandler = (e) => { onChange(index, e.target.value); @@ -56,13 +60,14 @@ const OTPInput = React.forwardRef((props, ref) => { ); }); diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index d268431380..b42dc9ee0c 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -5,11 +5,13 @@ import pickAttrs from 'rc-util/lib/pickAttrs'; import { getMergedStatus } from '../../_util/statusUtils'; import type { InputStatus } from '../../_util/statusUtils'; +import { devUseWarning } from '../../_util/warning'; 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 { FormItemStatusContextProps } from '../../form/context'; import type { Variant } from '../../form/hooks/useVariants'; import type { InputRef } from '../Input'; import useStyle from '../style/otp'; @@ -42,6 +44,8 @@ export interface OTPProps extends Omit, 'on // Status disabled?: boolean; status?: InputStatus; + + mask?: boolean | string; } function strToArr(str: string) { @@ -61,9 +65,19 @@ const OTP = React.forwardRef((props, ref) => { disabled, status: customStatus, autoFocus, + mask, ...restProps } = props; + if (process.env.NODE_ENV !== 'production') { + const warning = devUseWarning('Input.OTP'); + warning( + !(typeof mask === 'string' && mask.length > 1), + 'usage', + '`mask` prop should be a single character.', + ); + } + const { getPrefixCls, direction } = React.useContext(ConfigContext); const prefixCls = getPrefixCls('otp', customizePrefixCls); @@ -85,7 +99,7 @@ const OTP = React.forwardRef((props, ref) => { const formContext = React.useContext(FormItemInputContext); const mergedStatus = getMergedStatus(formContext.status, customStatus); - const proxyFormContext = React.useMemo( + const proxyFormContext = React.useMemo( () => ({ ...formContext, status: mergedStatus, @@ -194,10 +208,11 @@ const OTP = React.forwardRef((props, ref) => { }; // ======================== Render ======================== - const inputSharedProps = { + const inputSharedProps: Partial = { variant, disabled, status: mergedStatus as InputStatus, + mask, }; return wrapCSSVar( @@ -216,10 +231,9 @@ const OTP = React.forwardRef((props, ref) => { )} > - {new Array(length).fill(0).map((_, index) => { + {Array.from({ length }).map((_, index) => { const key = `otp-${index}`; const singleValue = valueCells[index] || ''; - return ( { diff --git a/components/input/Password.tsx b/components/input/Password.tsx index 4ac551b29e..787a8ca46a 100644 --- a/components/input/Password.tsx +++ b/components/input/Password.tsx @@ -35,7 +35,13 @@ const actionMap: Record> type IconPropsType = React.HTMLAttributes & React.Attributes; const Password = React.forwardRef((props, ref) => { - const { visibilityToggle = true } = props; + const { + disabled, + action = 'click', + visibilityToggle = true, + iconRender = defaultIconRender, + } = props; + const visibilityControlled = typeof visibilityToggle === 'object' && visibilityToggle.visible !== undefined; const [visible, setVisible] = useState(() => @@ -53,7 +59,6 @@ const Password = React.forwardRef((props, ref) => { const removePasswordTimeout = useRemovePasswordTimeout(inputRef); const onVisibleChange = () => { - const { disabled } = props; if (disabled) { return; } @@ -70,7 +75,6 @@ const Password = React.forwardRef((props, ref) => { }; const getIcon = (prefixCls: string) => { - const { action = 'click', iconRender = defaultIconRender } = props; const iconTrigger = actionMap[action] || ''; const icon = iconRender(visible); const iconProps: IconPropsType = { diff --git a/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap index e0e725077e..8ce331e0d5 100644 --- a/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/components/input/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -10116,242 +10116,259 @@ 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 formatter (Upcase) + +
+ + + + + +
-
-
- - - - - - -
+ With Disabled + +
+ + + + + +
-
-
- With Disabled -
+ With Length (8) + +
+ + + + + + + +
-
-
- - - - - - -
+ With variant + +
+ + + + + +
-
-
- With Length (8) -
-
+ With custom display character +
-
- - - - - - - - -
-
-
-
- With variant -
-
-
-
- - - - - - -
+ + + + + +
`; -exports[`renders components/input/demo/otp.tsx extend context correctly 2`] = `[]`; +exports[`renders components/input/demo/otp.tsx extend context correctly 2`] = ` +[ + "Warning: [antd: Input.OTP] \`mask\` prop should be a single character.", +] +`; exports[`renders components/input/demo/password-input.tsx extend context correctly 1`] = `
-
-
- With formatter (Upcase) -
+ With formatter (Upcase) + +
+ + + + + +
-
-
- - - - - - -
+ With Disabled + +
+ + + + + +
-
-
- With Disabled -
+ With Length (8) + +
+ + + + + + + +
-
-
- - - - - - -
+ With variant + +
+ + + + + +
-
-
- With Length (8) -
-
+ With custom display character +
-
- - - - - - - - -
-
-
-
- With variant -
-
-
-
- - - - - - -
+ + + + + +
`; diff --git a/components/input/__tests__/otp.test.tsx b/components/input/__tests__/otp.test.tsx index 8e2849a093..854b1d9b7e 100644 --- a/components/input/__tests__/otp.test.tsx +++ b/components/input/__tests__/otp.test.tsx @@ -13,13 +13,13 @@ describe('Input.OTP', () => { mountTest(Input.OTP); rtlTest(Input.OTP); - function getText(container: HTMLElement) { - const inputList = container.querySelectorAll('input'); + const getText = (container: HTMLElement) => { + const inputList = container.querySelectorAll('input'); return Array.from(inputList) .map((input) => input.value || ' ') .join('') .replace(/\s*$/, ''); - } + }; beforeEach(() => { jest.useFakeTimers(); @@ -128,4 +128,34 @@ describe('Input.OTP', () => { fireEvent.input(container.querySelector('input')!, { target: { value: 'little' } }); expect(getText(container)).toBe('LITTLE'); }); + + it('support mask prop', () => { + // default + const { container, rerender } = render(); + expect(getText(container)).toBe('bamboo'); + + // support string + rerender(); + expect(getText(container)).toBe('******'); + + // support emoji + rerender(); + expect(getText(container)).toBe('🔒🔒🔒🔒🔒🔒'); + }); + + it('should throw Error when mask.length > 1', () => { + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + expect(errSpy).toHaveBeenCalledWith( + 'Warning: [antd: Input.OTP] `mask` prop should be a single character.', + ); + errSpy.mockRestore(); + }); + + it('should not throw Error when mask.length <= 1', () => { + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + expect(errSpy).not.toHaveBeenCalled(); + errSpy.mockRestore(); + }); }); diff --git a/components/input/demo/otp.tsx b/components/input/demo/otp.tsx index 9b82e3d24d..9e70d36b12 100644 --- a/components/input/demo/otp.tsx +++ b/components/input/demo/otp.tsx @@ -1,6 +1,7 @@ import React from 'react'; +import { Flex, Input, Typography } from 'antd'; import type { GetProp } from 'antd'; -import { Input, Space, Typography } from 'antd'; +import type { OTPProps } from 'antd/es/input/OTP'; const { Title } = Typography; @@ -9,12 +10,12 @@ const App: React.FC = () => { console.log('onChange:', text); }; - const sharedProps = { + const sharedProps: OTPProps = { onChange, }; return ( - + With formatter (Upcase) str.toUpperCase()} {...sharedProps} /> With Disabled @@ -23,7 +24,9 @@ const App: React.FC = () => { With variant - + With custom display character + + ); }; diff --git a/components/input/index.en-US.md b/components/input/index.en-US.md index 4c9931ecd1..d24fcfc4a5 100644 --- a/components/input/index.en-US.md +++ b/components/input/index.en-US.md @@ -124,11 +124,16 @@ Supports all props of `Input`. Added in `5.16.0`. +> Notes for developers +> +> When the `mask` prop is string, we recommend receiving a single character or a single emoji. If multiple characters or multiple emoji are passed, a warning will be thrown. + | 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 | - | | +| mask | Custom display, the original value will not be modified | boolean \| string | `false` | `5.17.0` | | 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` | | diff --git a/components/input/index.zh-CN.md b/components/input/index.zh-CN.md index 3fd03b2641..7ccd116396 100644 --- a/components/input/index.zh-CN.md +++ b/components/input/index.zh-CN.md @@ -125,11 +125,16 @@ interface CountConfig { `5.16.0` 新增。 +> 开发者注意事项: +> +> 当 `mask` 属性的类型为 string 时,我们强烈推荐接收单个字符或单个 emoji,如果传入多个字符或多个 emoji,则会在控制台抛出警告。 + | 参数 | 说明 | 类型 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | defaultValue | 默认值 | string | - | | | disabled | 是否禁用 | boolean | false | | | formatter | 格式化展示,留空字段会被 ` ` 填充 | (value: string) => string | - | | +| mask | 自定义展示,和 `formatter` 的区别是不会修改原始值 | boolean \| string | `false` | `5.17.0` | | length | 输入元素数量 | number | 6 | | | status | 设置校验状态 | 'error' \| 'warning' | - | | | size | 输入框大小 | `small` \| `middle` \| `large` | `middle` | |