diff --git a/components/_util/BaseInput.tsx b/components/_util/BaseInput.tsx index e6bc9d7085..405e6b52c6 100644 --- a/components/_util/BaseInput.tsx +++ b/components/_util/BaseInput.tsx @@ -19,6 +19,7 @@ export interface BaseInputExpose { getSelectionEnd: () => number | null; getScrollTop: () => number | null; setScrollTop: (scrollTop: number) => void; + rootInputForceUpdate: () => void; } const BaseInput = defineComponent({ compatConfig: { MODE: 3 }, @@ -119,12 +120,18 @@ const BaseInput = defineComponent({ const select = () => { inputRef.value?.select(); }; + + const rootInputForceUpdate = () => { + inputRef.value?.rootInputForceUpdate(); + }; + expose({ focus, blur, input: computed(() => inputRef.value?.input), setSelectionRange, select, + rootInputForceUpdate, getSelectionStart: () => inputRef.value?.getSelectionStart(), getSelectionEnd: () => inputRef.value?.getSelectionEnd(), getScrollTop: () => inputRef.value?.getScrollTop(), diff --git a/components/_util/BaseInputInner.tsx b/components/_util/BaseInputInner.tsx index 10423d7a45..6842449a50 100644 --- a/components/_util/BaseInputInner.tsx +++ b/components/_util/BaseInputInner.tsx @@ -1,5 +1,5 @@ import type { PropType } from 'vue'; -import { defineComponent, shallowRef } from 'vue'; +import { defineComponent, getCurrentInstance, shallowRef } from 'vue'; import PropTypes from './vue-types'; export interface BaseInputInnerExpose { @@ -16,6 +16,7 @@ export interface BaseInputInnerExpose { getSelectionEnd: () => number | null; getScrollTop: () => number | null; setScrollTop: (scrollTop: number) => void; + rootInputForceUpdate: () => void; } const BaseInputInner = defineComponent({ compatConfig: { MODE: 3 }, @@ -76,12 +77,18 @@ const BaseInputInner = defineComponent({ const select = () => { inputRef.value?.select(); }; + + const ins = getCurrentInstance(); + const rootInputForceUpdate = () => { + ins.proxy.$forceUpdate(); + }; expose({ focus, blur, input: inputRef, setSelectionRange, select, + rootInputForceUpdate, getSelectionStart: () => inputRef.value?.selectionStart, getSelectionEnd: () => inputRef.value?.selectionEnd, getScrollTop: () => inputRef.value?.scrollTop, diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx new file mode 100644 index 0000000000..8abfdae8bc --- /dev/null +++ b/components/input/OTP/OTPInput.tsx @@ -0,0 +1,102 @@ +import Input from '../Input'; +import { PropType, computed, defineComponent, nextTick, shallowRef } from 'vue'; +import inputProps from '../inputProps'; +import omit from '../../_util/omit'; +import { type ChangeEventHandler } from '../../_util/EventInterface'; +import { type InputStatus } from '../../_util/statusUtils'; + +export default defineComponent({ + compatConfig: { MODE: 3 }, + name: 'AOTPInput', + inheritAttrs: false, + props: { + ...inputProps(), + index: Number, + value: { type: String, default: undefined }, + mask: { type: [Boolean, String], default: false }, + onChange: { type: Function as PropType<(index: number, value: string) => void> }, + onActiveChange: Function as PropType<(nextIndex: number) => void>, + status: { type: String as PropType, default: undefined }, + }, + setup(props, { attrs, expose }) { + const inputRef = shallowRef(); + const internalValue = computed(() => { + const { value, mask } = props; + return value && typeof mask === 'string' ? mask : value; + }); + + const syncSelection = () => { + inputRef.value.select(); + requestAnimationFrame(() => { + inputRef.value.select(); + }); + }; + + const forceUpdate = () => { + inputRef.value.input?.rootInputForceUpdate?.(); + }; + + // ======================= Event handlers ================= + const onInternalChange: ChangeEventHandler = e => { + const value = e.target.value; + props.onChange(props.index, value); + + // Edge: If the value after the formatter is the same as the original value + // the Input component will not be updated, and since the input is not controlled + // it will result in an inconsistent UI with the value. + nextTick(() => { + forceUpdate(); + }); + }; + + const focus = () => { + inputRef.value?.focus(); + syncSelection(); + }; + + const activeSelection = () => { + if (document.activeElement === inputRef.value.input.input) { + syncSelection(); + } + }; + + const onInternalKeydown = ({ key }) => { + if (key === 'ArrowLeft') { + props.onActiveChange(props.index - 1); + } else if (key === 'ArrowRight') { + props.onActiveChange(props.index + 1); + } + + activeSelection(); + }; + + const onInternalKeyUp = ({ key }) => { + if (key === 'Backspace' && !props.value) { + props.onActiveChange(props.index - 1); + } + + activeSelection(); + }; + + expose({ + focus, + }); + + return () => ( + + ); + }, +}); diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx new file mode 100644 index 0000000000..cd2e700a12 --- /dev/null +++ b/components/input/OTP/index.tsx @@ -0,0 +1,154 @@ +import { type PropType, computed, defineComponent, ref, watchEffect } from 'vue'; +import inputProps from '../inputProps'; +import { FormItemInputContext } from '../../form/FormItemContext'; +import useConfigInject from '../../config-provider/hooks/useConfigInject'; +import classNames from '../../_util/classNames'; +import useStyle from '../style/otp'; +import OTPInput from './OTPInput'; +import { type InputStatus, getMergedStatus } from '../../_util/statusUtils'; +import warning from '../../_util/warning'; + +export default defineComponent({ + compatConfig: { MODE: 3 }, + name: 'AOTP', + inheritAttrs: false, + props: { + ...inputProps(), + length: { type: Number, default: 6 }, + onChange: { type: Function as PropType<(value: string) => void>, default: undefined }, + formatter: { type: Function as PropType<(arg: string) => string>, default: undefined }, + defaultValue: { type: String, default: undefined }, + mask: { type: [String, Boolean], default: false }, + status: { type: String as PropType, default: undefined }, + }, + setup(props, { attrs }) { + const { prefixCls, direction, size } = useConfigInject('otp', props); + // Style + const [wrapSSR, hashId] = useStyle(prefixCls); + + // ==================== Provider ========================= + const formItemInputContext = FormItemInputContext.useInject(); + const mergedStatus = computed( + () => getMergedStatus(formItemInputContext.status, props.status) as InputStatus, + ); + + const refs = ref([]); + const strToArr = (str: string) => (str || '').split(''); + // keep reactive + const internalFormatter = (txt: string) => (props.formatter ? props.formatter(txt) : txt); + const valueCells = ref(strToArr(internalFormatter(props.defaultValue || ''))); + + watchEffect(() => { + if (typeof props.value !== 'undefined' && props.value !== null) { + valueCells.value = strToArr(String(props.value)); + } + }); + + const patchValue = (index: number, txt: string) => { + let nextCells = valueCells.value.slice(); + + 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, props.length); + for (let i = nextCells.length - 1; i >= 0; i -= 1) { + if (nextCells[i]) { + break; + } + nextCells.pop(); + } + + 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 handlers ================= + const onInputActiveChange = (nextIndex: number) => { + refs.value[nextIndex]?.focus(); + }; + + const onInputChange = (index: number, value: string) => { + const nextValueCells = patchValue(index, value); + const nextIndex = Math.min(index + value.length, props.length); + if (nextIndex !== index) { + refs.value[nextIndex]?.focus(); + } + + if ( + props.onChange && + nextValueCells.length === props.length && + nextValueCells.every(v => v) && + nextValueCells.some((v, i) => v !== valueCells.value[i]) + ) { + props.onChange(nextValueCells.join('')); + } + valueCells.value = nextValueCells.slice(); + }; + + return () => { + const cls = classNames( + prefixCls.value, + { + [`${prefixCls}-sm`]: size.value === 'small', + [`${prefixCls}-lg`]: size.value === 'large', + [`${prefixCls.value}-rtl`]: direction.value === 'rtl', + }, + attrs.class, + hashId.value, + ); + const { length, autofocus, disabled, mask } = props; + const inputShardProps = { + disabled, + mask, + }; + + if (process.env.NODE_ENV !== 'production') { + warning( + !(typeof mask === 'string' && mask.length > 1), + 'Input.OTP', + '`mask` prop should be a single character.', + ); + } + + return wrapSSR( +
+ {Array.from({ length }).map((_, index) => { + const key = `opt-${index}`; + const singleValue = valueCells.value[index]; + return ( + (refs.value[index] = ref)} + key={key} + index={index} + class={`${prefixCls.value}-input`} + value={singleValue} + htmlSize={1} + onChange={onInputChange} + status={mergedStatus.value} + onActiveChange={onInputActiveChange} + autofocus={index === 0 && autofocus} + {...inputShardProps} + /> + ); + })} +
, + ); + }; + }, +}); diff --git a/components/input/__tests__/OTP.test.js b/components/input/__tests__/OTP.test.js new file mode 100644 index 0000000000..4110451b30 --- /dev/null +++ b/components/input/__tests__/OTP.test.js @@ -0,0 +1,177 @@ +import { mount } from '@vue/test-utils'; +import { asyncExpect } from '../../../tests/utils'; +import Input from '../index'; + +const { OTP } = Input; + +const getInputAllText = inputAll => { + let str = ''; + + inputAll.forEach(input => { + str += input.element.value; + }); + return str; +}; +describe('OTP', () => { + it('paste to fill', async () => { + const onChange = jest.fn(); + + const wrapper = mount(OTP, { props: { onChange }, sync: false }); + await asyncExpect(async () => { + const input = wrapper.find('input'); + + await input.setValue('123456'); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('123456'); + + wrapper.unmount(); + }); + }); + + it('the input preceding the current one does not support paste-to-fill', async () => { + const onChange = jest.fn(); + + const wrapper = mount(OTP, { props: { onChange }, sync: false }); + await asyncExpect(async () => { + const inputAll = wrapper.findAll('input'); + + await inputAll[1].setValue('123456'); + expect(onChange).not.toHaveBeenCalled(); + + await inputAll[0].setValue('0'); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('012345'); + + wrapper.unmount(); + }); + }); + + it('step to fill', async () => { + const internalValue = 'holder'.split(''); + const onChange = jest.fn(); + + const wrapper = mount(OTP, { props: { onChange }, sync: false }); + await asyncExpect(async () => { + const inputAll = wrapper.findAll('input'); + + for (let i = 0; i < internalValue.length; i++) { + expect(onChange).not.toHaveBeenCalled(); + await inputAll[i].setValue(internalValue[i]); + } + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(internalValue.join('')); + + wrapper.unmount(); + }); + }); + + it('set value', async () => { + const wrapper = mount(OTP, { props: { value: 'search' }, sync: false }); + + await asyncExpect(async () => { + const inputAll = wrapper.findAll('input'); + expect(getInputAllText(inputAll)).toBe('search'); + + await wrapper.setProps({ value: '' }); + expect(getInputAllText(inputAll)).toBe(''); + + await wrapper.setProps({ value: 'hello world' }); + expect(getInputAllText(inputAll)).toBe('hello '); + + await wrapper.setProps({ value: '' }); + // null is not valid value + await wrapper.setProps({ value: null }); + expect(getInputAllText(inputAll)).toBe(''); + + wrapper.unmount(); + }); + }); + + it('backspace to step', async () => { + const onChange = jest.fn(); + const wrapper = mount(OTP, { props: { value: 'search', onChange }, sync: false }); + + await asyncExpect(async () => { + const inputAll = wrapper.findAll('input'); + let str = getInputAllText(inputAll); + expect(str).toBe('search'); + + for (let i = inputAll.length - 1; i >= 0; i--) { + inputAll[i].trigger('keydown', { key: 'Backspace' }); + inputAll[i].setValue(''); + inputAll[i].trigger('keyup', { key: 'Backspace' }); + await wrapper.vm.$nextTick(); + } + + str = getInputAllText(inputAll); + expect(onChange).not.toHaveBeenCalled(); + expect(str).toBe(''); + + wrapper.unmount(); + }); + }); + + it('formatter', async () => { + const onChange = jest.fn(); + const wrapper = mount(OTP, { + props: { formatter: txt => txt.toUpperCase(), onChange }, + sync: false, + }); + + await asyncExpect(async () => { + const inputAll = wrapper.findAll('input'); + const internalValue = 'search'.split(''); + for (let i = 0; i < inputAll.length; i++) { + await inputAll[i].setValue(internalValue[i]); + } + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('SEARCH'); + + wrapper.unmount(); + }); + }); + + it('support mask prop', async () => { + const onChange = jest.fn(); + const wrapper = mount(OTP, { props: { mask: '🔒', onChange }, sync: false }); + + await asyncExpect(async () => { + const inputAll = wrapper.findAll('input'); + const internalValue = 'search'.split(''); + for (let i = 0; i < inputAll.length; i++) { + await inputAll[i].setValue(internalValue[i]); + } + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('search'); + expect(getInputAllText(inputAll)).toBe('🔒🔒🔒🔒🔒🔒'); + + wrapper.unmount(); + }); + }); + + it('should throw warning when mask length > 1', async () => { + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const wrapper = mount(OTP, { props: { mask: 'abc' }, sync: true }); + await asyncExpect(async () => { + expect(errSpy).toHaveBeenLastCalledWith( + 'Warning: [ant-design-vue: Input.OTP] `mask` prop should be a single character.', + ); + errSpy.mockRestore(); + wrapper.unmount(); + }); + }); + + it('should not throw warning when mask length <= 1', async () => { + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const wrapper = mount(OTP, { props: { mask: 'a' }, sync: true }); + await asyncExpect(async () => { + expect(errSpy).not.toBeCalled(); + errSpy.mockRestore(); + wrapper.unmount(); + }); + }); +}); diff --git a/components/input/demo/index.vue b/components/input/demo/index.vue index 0f50aba0a7..31d99a5df9 100644 --- a/components/input/demo/index.vue +++ b/components/input/demo/index.vue @@ -5,6 +5,7 @@ + @@ -33,6 +34,7 @@ import ShowCount from './show-count.vue'; import Addon from './addon.vue'; import Tooltip from './tooltip.vue'; import borderlessVue from './borderless.vue'; +import OTP from './otp.vue'; import statusVue from './status.vue'; import CN from '../index.zh-CN.md'; import US from '../index.en-US.md'; @@ -57,6 +59,7 @@ export default defineComponent({ PasswordInput, ShowCount, borderlessVue, + OTP, }, }); diff --git a/components/input/demo/otp.vue b/components/input/demo/otp.vue new file mode 100644 index 0000000000..968397d8d4 --- /dev/null +++ b/components/input/demo/otp.vue @@ -0,0 +1,34 @@ + +--- +order: 5 +title: + zh-CN: 一次性密码框。 + en-US: One time password input. +--- + +## zh-CN + +一次性密码输入框。 + +## en-US + +One time password input. + + + + diff --git a/components/input/index.en-US.md b/components/input/index.en-US.md index a57cd983c2..a0a3ccadc5 100644 --- a/components/input/index.en-US.md +++ b/components/input/index.en-US.md @@ -99,3 +99,22 @@ Supports all props of `Input`. | visible(v-model) | password visibility | boolean | false | | iconRender | Custom toggle button | slot | - | | visibilityToggle | Whether show toggle button or control password visible | boolean | true | + +### Input.OTP + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| defaultValue | Default value | string | - | +| disabled | Whether the input is disabled | string | - | +| formatter | Format display, blank fields will be filled with `` | (value: string) => string | - | +| mask | Custom display, the original value will not be modified | boolean | false | +| 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` | +| value | The input content value | string | - | + +#### Input.OTP Events + +| Events Name | Description | Arguments | Version | | +| ----------- | -------------------------------------- | ----------------------- | ------- | --- | +| change | Trigger when all the fields are filled | function(value: string) | - | | diff --git a/components/input/index.ts b/components/input/index.ts index 2a2228c13f..27fedfc93b 100644 --- a/components/input/index.ts +++ b/components/input/index.ts @@ -4,11 +4,14 @@ import Group from './Group'; import Search from './Search'; import TextArea from './TextArea'; import Password from './Password'; +import OTP from './OTP/index'; + export type { InputProps, TextAreaProps } from './inputProps'; Input.Group = Group; Input.Search = Search; Input.TextArea = TextArea; Input.Password = Password; +Input.OTP = OTP; /* istanbul ignore next */ Input.install = function (app: App) { @@ -17,6 +20,8 @@ Input.install = function (app: App) { app.component(Input.Search.name, Input.Search); app.component(Input.TextArea.name, Input.TextArea); app.component(Input.Password.name, Input.Password); + app.component(OTP.name, Input.OTP); + return app; }; @@ -25,6 +30,7 @@ export { Search as InputSearch, TextArea as Textarea, Password as InputPassword, + OTP as InputOTP, }; export default Input as typeof Input & @@ -33,4 +39,5 @@ export default Input as typeof Input & readonly Search: typeof Search; readonly TextArea: typeof TextArea; readonly Password: typeof Password; + readonly OTP: typeof OTP; }; diff --git a/components/input/index.zh-CN.md b/components/input/index.zh-CN.md index 06489389d4..07d23e7e5d 100644 --- a/components/input/index.zh-CN.md +++ b/components/input/index.zh-CN.md @@ -100,3 +100,22 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*sBqqTatJ-AkAAA | visible(v-model) | 密码是否可见 | boolean | false | | iconRender | 自定义切换按钮 | slot | - | | visibilityToggle | 是否显示切换按钮或者控制密码显隐 | boolean | true | + +### Input.OTP + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| defaultValue | 默认值 | string | - | +| disabled | 是否禁用 | boolean | false | +| formatter | 格式化展示,留空字段会被`` 填充 | (value: string) => string | - | +| mask | 自定义展示,和 `formatter` 的区别是不会修改原始值 | boolean | false | +| length | 输入元素数量 | number | 6 | +| status | 设置校验状态 | `error` \| `warning` | - | +| size | 输入框大小 | `small` \| `middle` \| `large` | `middle` | +| value | 输入框内容 | string | - | + +#### Input.OTP Events + +| Events Name | Description | Arguments | Version | | +| ----------- | ------------------------------ | ----------------------- | ------- | --- | +| change | 当输入框内容全部填充时触发回调 | function(value: string) | - | | diff --git a/components/input/style/otp.ts b/components/input/style/otp.ts new file mode 100644 index 0000000000..79d691a582 --- /dev/null +++ b/components/input/style/otp.ts @@ -0,0 +1,40 @@ +import { FullToken, genComponentStyleHook, type GenerateStyle } from '../../theme/internal'; +import { initInputToken, type InputToken } from './index'; + +const genOTPInputStyle: GenerateStyle = (token: InputToken) => { + 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.paddingXXS / 2, + }, + + [`&${componentCls}-lg ${componentCls}-input`]: { + paddingInline: token.paddingXS, + }, + }, + }; +}; + +// ================ EXPORT ======================= +export default genComponentStyleHook('Input', token => { + const inputToken = initInputToken>(token); + + return [genOTPInputStyle(inputToken)]; +}); diff --git a/components/vc-input/Input.tsx b/components/vc-input/Input.tsx index 55312b310f..a01f223afc 100644 --- a/components/vc-input/Input.tsx +++ b/components/vc-input/Input.tsx @@ -62,10 +62,15 @@ export default defineComponent({ inputRef.value.input?.select(); }; + const rootInputForceUpdate = () => { + inputRef.value?.rootInputForceUpdate(); + }; + expose({ focus, blur, - input: computed(() => (inputRef.value.input as any)?.input), + rootInputForceUpdate, + input: computed(() => inputRef.value.input), stateValue, setSelectionRange, select, @@ -181,7 +186,7 @@ export default defineComponent({ ), ref: inputRef, key: 'ant-input', - size: htmlSize, + size: htmlSize ? String(htmlSize) : undefined, type, lazy: props.lazy, }; @@ -191,7 +196,7 @@ export default defineComponent({ if (!inputProps.autofocus) { delete inputProps.autofocus; } - const inputNode = ; + const inputNode = ; return inputNode; }; const getSuffix = () => { diff --git a/components/vc-input/inputProps.ts b/components/vc-input/inputProps.ts index b56779b3cd..c294463467 100644 --- a/components/vc-input/inputProps.ts +++ b/components/vc-input/inputProps.ts @@ -91,6 +91,8 @@ export const inputProps = () => ({ onPressEnter: Function as PropType, onKeydown: Function as PropType, onKeyup: Function as PropType, + onMousedown: { type: Function as PropType, default: undefined }, + onMouseUp: { type: Function as PropType, default: undefined }, onFocus: Function as PropType, onBlur: Function as PropType, onChange: Function as PropType,