From 421df2f44a66d8ceafb8aed20721a97c3d7bf51d Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Sun, 16 Jun 2024 00:16:26 +0800 Subject: [PATCH 01/17] wip: input otp --- components/input/OTP/OTPInput.tsx | 22 ++++++++++ components/input/OTP/index.tsx | 67 +++++++++++++++++++++++++++++++ components/input/index.ts | 7 ++++ components/input/style/otp.ts | 42 +++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 components/input/OTP/OTPInput.tsx create mode 100644 components/input/OTP/index.tsx create mode 100644 components/input/style/otp.ts diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx new file mode 100644 index 0000000000..7102b0d263 --- /dev/null +++ b/components/input/OTP/OTPInput.tsx @@ -0,0 +1,22 @@ +import Input from '../Input'; +import { computed, defineComponent, shallowRef } from 'vue'; + +export default defineComponent({ + compatConfig: { MODE: 3 }, + name: 'OTPInput', + inheritAttrs: false, + props: { + index: Number, + value: { type: String, default: undefined }, + mask: { type: [Boolean, String], default: false }, + }, + setup(props, { attrs }) { + const inputRef = shallowRef(); + const internalValue = computed(() => { + const { value, mask, ...resetProps } = props; + return value && typeof mask === 'string' ? mask : value; + }); + + return () => ; + }, +}); diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx new file mode 100644 index 0000000000..88832e4474 --- /dev/null +++ b/components/input/OTP/index.tsx @@ -0,0 +1,67 @@ +import { PropType, defineComponent, reactive, ref } from 'vue'; +import inputProps from '../inputProps'; +import { FormItemInputContext } from 'ant-design-vue/es/form/FormItemContext'; +import useConfigInject from '../../config-provider/hooks/useConfigInject'; +import classNames from 'ant-design-vue/es/_util/classNames'; +import useStyle from '../style/otp'; +import OTPInput from './OTPInput'; + +export default defineComponent({ + compatConfig: { MODE: 3 }, + name: 'OTP', + inheritAttrs: false, + props: { + ...inputProps(), + length: { type: Number, default: 6 }, + formatter: { type: Function as PropType<(arg: string) => string>, default: undefined }, + defaultValue: { type: String, default: undefined }, + }, + setup(props, { attrs }) { + const { prefixCls, direction, size } = useConfigInject('otp', props); + // Style + const [wrapSSR, hashId] = useStyle(prefixCls); + + const proxyFormContext = reactive({ + // TODO: + }); + FormItemInputContext.useProvide(proxyFormContext); + + const { defaultValue } = props; + const strToArr = (str: string) => (str || '').split(''); + // keep reactive + const internalFormatter = (txt: string) => (props.formatter ? props.formatter(txt) : txt); + + const valueCells = ref(strToArr(internalFormatter(defaultValue || ''))); + + 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 } = props; + return wrapSSR( +
+ {Array.from({ length }).map((_, index) => { + const key = `opt-${index}`; + const singleValue = valueCells.value[index]; + + return ( + + ); + })} +
, + ); + }; + }, +}); 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/style/otp.ts b/components/input/style/otp.ts new file mode 100644 index 0000000000..b9d5b21ff5 --- /dev/null +++ b/components/input/style/otp.ts @@ -0,0 +1,42 @@ +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, + padding: 0, + // border: 'none', + + '&-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)]; +}); From fb659915b1151133941f512cf6e95cbf6f1c6067 Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Sun, 16 Jun 2024 16:47:51 +0800 Subject: [PATCH 02/17] wip: add event --- components/input/OTP/OTPInput.tsx | 49 ++++++++++++++++++++-- components/input/OTP/index.tsx | 67 ++++++++++++++++++++++++++++--- components/input/style/otp.ts | 2 - components/vc-input/Input.tsx | 4 +- components/vc-input/inputProps.ts | 2 + 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx index 7102b0d263..638737844d 100644 --- a/components/input/OTP/OTPInput.tsx +++ b/components/input/OTP/OTPInput.tsx @@ -1,22 +1,63 @@ import Input from '../Input'; -import { computed, defineComponent, shallowRef } from 'vue'; +import { PropType, computed, defineComponent, nextTick, shallowRef } from 'vue'; +import inputProps from '../inputProps'; +import omit from '../../_util/omit'; +import { type ChangeEventHandler } from '../../_util/EventInterface'; export default defineComponent({ compatConfig: { MODE: 3 }, name: 'OTPInput', 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> }, }, - setup(props, { attrs }) { + setup(props, { attrs, expose }) { const inputRef = shallowRef(); const internalValue = computed(() => { - const { value, mask, ...resetProps } = props; + const { value, mask } = props; return value && typeof mask === 'string' ? mask : value; }); - return () => ; + const syncSelection = () => { + requestAnimationFrame(() => { + inputRef.value.select(); + }); + }; + const handleSyncMouseDown = e => { + e.preventDefault(); + syncSelection(); + }; + // ======================= Event handlers ================= + const onInternalChange: ChangeEventHandler = e => { + props.onChange(props.index, e.target.value); + }; + + const focus = () => { + inputRef.value?.focus(); + syncSelection(); + }; + + expose({ + focus, + }); + + return () => ( + + ); }, }); diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index 88832e4474..bb32a2d9ac 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -1,4 +1,4 @@ -import { PropType, defineComponent, reactive, ref } from 'vue'; +import { PropType, defineComponent, nextTick, reactive, ref } from 'vue'; import inputProps from '../inputProps'; import { FormItemInputContext } from 'ant-design-vue/es/form/FormItemContext'; import useConfigInject from '../../config-provider/hooks/useConfigInject'; @@ -13,6 +13,7 @@ export default defineComponent({ 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 }, }, @@ -20,18 +21,69 @@ export default defineComponent({ const { prefixCls, direction, size } = useConfigInject('otp', props); // Style const [wrapSSR, hashId] = useStyle(prefixCls); - + // ==================== Provider ========================= const proxyFormContext = reactive({ // TODO: }); FormItemInputContext.useProvide(proxyFormContext); - const { defaultValue } = props; + 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 || ''))); + 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; + }); - const valueCells = ref(strToArr(internalFormatter(defaultValue || ''))); + return nextCells; + }; + + // ======================= Change handlers ================= + 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( @@ -44,19 +96,22 @@ export default defineComponent({ attrs.class, hashId.value, ); - const { length } = props; + const { length, autofocus } = props; 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} + autofocus={index === 0 && autofocus} /> ); })} diff --git a/components/input/style/otp.ts b/components/input/style/otp.ts index b9d5b21ff5..79d691a582 100644 --- a/components/input/style/otp.ts +++ b/components/input/style/otp.ts @@ -10,8 +10,6 @@ const genOTPInputStyle: GenerateStyle = (token: InputToken) => { alignItems: 'center', flexWrap: 'nowrap', columnGap: paddingXS, - padding: 0, - // border: 'none', '&-rtl': { direction: 'rtl', diff --git a/components/vc-input/Input.tsx b/components/vc-input/Input.tsx index 55312b310f..d1c31f07a9 100644 --- a/components/vc-input/Input.tsx +++ b/components/vc-input/Input.tsx @@ -181,7 +181,7 @@ export default defineComponent({ ), ref: inputRef, key: 'ant-input', - size: htmlSize, + size: htmlSize ? String(htmlSize) : undefined, type, lazy: props.lazy, }; @@ -191,7 +191,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, From 99c134c0e0473bbede4711634ce0db5b06d12bd5 Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Sun, 16 Jun 2024 17:20:56 +0800 Subject: [PATCH 03/17] chore: remove unless var --- components/input/OTP/OTPInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx index 638737844d..5ea37b19cf 100644 --- a/components/input/OTP/OTPInput.tsx +++ b/components/input/OTP/OTPInput.tsx @@ -1,5 +1,5 @@ import Input from '../Input'; -import { PropType, computed, defineComponent, nextTick, shallowRef } from 'vue'; +import { PropType, computed, defineComponent, shallowRef } from 'vue'; import inputProps from '../inputProps'; import omit from '../../_util/omit'; import { type ChangeEventHandler } from '../../_util/EventInterface'; From 73404c9da27a7d8300057f4ae0dc66906fabd81b Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Sun, 16 Jun 2024 22:19:15 +0800 Subject: [PATCH 04/17] fix: replace key event --- components/input/OTP/OTPInput.tsx | 32 +++++++++++++++++++++++++++---- components/input/OTP/index.tsx | 5 +++++ components/vc-input/Input.tsx | 2 +- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx index 5ea37b19cf..71d3ad0d23 100644 --- a/components/input/OTP/OTPInput.tsx +++ b/components/input/OTP/OTPInput.tsx @@ -14,6 +14,7 @@ export default defineComponent({ 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>, }, setup(props, { attrs, expose }) { const inputRef = shallowRef(); @@ -27,8 +28,7 @@ export default defineComponent({ inputRef.value.select(); }); }; - const handleSyncMouseDown = e => { - e.preventDefault(); + const handleSyncMouseDown = () => { syncSelection(); }; // ======================= Event handlers ================= @@ -41,6 +41,30 @@ export default defineComponent({ 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, }); @@ -55,8 +79,8 @@ export default defineComponent({ onInput={onInternalChange} onMousedown={handleSyncMouseDown} onMouseUp={handleSyncMouseDown} - onKeydown={syncSelection} - onKeyup={syncSelection} + onKeydown={onInternalKeydown} + onKeyup={onInternalKeyUp} /> ); }, diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index bb32a2d9ac..5c5b9b8ab2 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -67,6 +67,10 @@ export default defineComponent({ }; // ======================= 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); @@ -111,6 +115,7 @@ export default defineComponent({ value={singleValue} htmlSize={1} onChange={onInputChange} + onActiveChange={onInputActiveChange} autofocus={index === 0 && autofocus} /> ); diff --git a/components/vc-input/Input.tsx b/components/vc-input/Input.tsx index d1c31f07a9..e005e6f4b0 100644 --- a/components/vc-input/Input.tsx +++ b/components/vc-input/Input.tsx @@ -65,7 +65,7 @@ export default defineComponent({ expose({ focus, blur, - input: computed(() => (inputRef.value.input as any)?.input), + input: computed(() => inputRef.value.input), stateValue, setSelectionRange, select, From ce33cc6d6a11713d55d8460fc51ef5781b8dc7e5 Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Sun, 16 Jun 2024 22:45:16 +0800 Subject: [PATCH 05/17] fix: fix path & fix component name --- components/input/OTP/OTPInput.tsx | 2 +- components/input/OTP/index.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx index 71d3ad0d23..85ba90a607 100644 --- a/components/input/OTP/OTPInput.tsx +++ b/components/input/OTP/OTPInput.tsx @@ -6,7 +6,7 @@ import { type ChangeEventHandler } from '../../_util/EventInterface'; export default defineComponent({ compatConfig: { MODE: 3 }, - name: 'OTPInput', + name: 'AOTPInput', inheritAttrs: false, props: { ...inputProps(), diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index 5c5b9b8ab2..26210fbb04 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -1,14 +1,14 @@ -import { PropType, defineComponent, nextTick, reactive, ref } from 'vue'; +import { PropType, defineComponent, reactive, ref } from 'vue'; import inputProps from '../inputProps'; -import { FormItemInputContext } from 'ant-design-vue/es/form/FormItemContext'; +import { FormItemInputContext } from '../../form/FormItemContext'; import useConfigInject from '../../config-provider/hooks/useConfigInject'; -import classNames from 'ant-design-vue/es/_util/classNames'; +import classNames from '../../_util/classNames'; import useStyle from '../style/otp'; import OTPInput from './OTPInput'; export default defineComponent({ compatConfig: { MODE: 3 }, - name: 'OTP', + name: 'AOTP', inheritAttrs: false, props: { ...inputProps(), From c50189e4c02609e4c72ef78ec9a1db20e1efc310 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Mon, 17 Jun 2024 13:22:57 +0800 Subject: [PATCH 06/17] feat: support disabled --- components/input/OTP/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index 26210fbb04..f043dbd2b0 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -100,7 +100,11 @@ export default defineComponent({ attrs.class, hashId.value, ); - const { length, autofocus } = props; + const { length, autofocus, disabled } = props; + const inputShardProps = { + disabled, + }; + return wrapSSR(
{Array.from({ length }).map((_, index) => { @@ -117,6 +121,7 @@ export default defineComponent({ onChange={onInputChange} onActiveChange={onInputActiveChange} autofocus={index === 0 && autofocus} + {...inputShardProps} /> ); })} From 20d444f9b6bb1fe67cdc010c61e2b0431af4e871 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Mon, 17 Jun 2024 13:57:46 +0800 Subject: [PATCH 07/17] feat: support mask attr --- components/input/OTP/OTPInput.tsx | 17 +++++++++++------ components/input/OTP/index.tsx | 4 +++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx index 85ba90a607..230ce7e7ac 100644 --- a/components/input/OTP/OTPInput.tsx +++ b/components/input/OTP/OTPInput.tsx @@ -28,12 +28,16 @@ export default defineComponent({ inputRef.value.select(); }); }; - const handleSyncMouseDown = () => { - syncSelection(); - }; + // ======================= Event handlers ================= const onInternalChange: ChangeEventHandler = e => { - props.onChange(props.index, e.target.value); + const value = e.target.value; + props.onChange(props.index, value); + + if (typeof props.mask === 'string' && value) { + // force update input value + e.target.value = props.mask; + } }; const focus = () => { @@ -77,10 +81,11 @@ export default defineComponent({ class={attrs.class} value={internalValue.value} onInput={onInternalChange} - onMousedown={handleSyncMouseDown} - onMouseUp={handleSyncMouseDown} + onMousedown={syncSelection} + onMouseUp={syncSelection} onKeydown={onInternalKeydown} onKeyup={onInternalKeyUp} + type={props.mask === true ? 'password' : 'text'} /> ); }, diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index f043dbd2b0..a6c3bc2e7d 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -16,6 +16,7 @@ export default defineComponent({ 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 }, }, setup(props, { attrs }) { const { prefixCls, direction, size } = useConfigInject('otp', props); @@ -100,9 +101,10 @@ export default defineComponent({ attrs.class, hashId.value, ); - const { length, autofocus, disabled } = props; + const { length, autofocus, disabled, mask } = props; const inputShardProps = { disabled, + mask, }; return wrapSSR( From e86f580ad999dabb97140c8c047191f33f262013 Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Mon, 17 Jun 2024 23:35:04 +0800 Subject: [PATCH 08/17] feat: support status attr --- components/input/OTP/OTPInput.tsx | 2 ++ components/input/OTP/index.tsx | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/components/input/OTP/OTPInput.tsx b/components/input/OTP/OTPInput.tsx index 230ce7e7ac..6f3c09f9da 100644 --- a/components/input/OTP/OTPInput.tsx +++ b/components/input/OTP/OTPInput.tsx @@ -3,6 +3,7 @@ import { PropType, computed, defineComponent, 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 }, @@ -15,6 +16,7 @@ export default defineComponent({ 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(); diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index a6c3bc2e7d..06514bbbfa 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -1,10 +1,11 @@ -import { PropType, defineComponent, reactive, ref } from 'vue'; +import { PropType, computed, defineComponent, ref } 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'; export default defineComponent({ compatConfig: { MODE: 3 }, @@ -17,16 +18,18 @@ export default defineComponent({ 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 proxyFormContext = reactive({ - // TODO: - }); - FormItemInputContext.useProvide(proxyFormContext); + const formItemInputContext = FormItemInputContext.useInject(); + const mergedStatus = computed( + () => getMergedStatus(formItemInputContext.status, props.status) as InputStatus, + ); const refs = ref([]); const strToArr = (str: string) => (str || '').split(''); @@ -121,6 +124,7 @@ export default defineComponent({ value={singleValue} htmlSize={1} onChange={onInputChange} + status={mergedStatus.value} onActiveChange={onInputActiveChange} autofocus={index === 0 && autofocus} {...inputShardProps} From 11f85fb5a759a9f842ae100be6d1131dce673ec1 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Tue, 18 Jun 2024 13:35:36 +0800 Subject: [PATCH 09/17] wip[docs]: add docs --- components/input/demo/index.vue | 3 +++ components/input/demo/otp.vue | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 components/input/demo/otp.vue 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. + + + + From 3a63eea284d1bcfb52d618bcd2cfd7feebe97e60 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Tue, 18 Jun 2024 14:37:06 +0800 Subject: [PATCH 10/17] fix: formatter result error --- components/_util/BaseInput.tsx | 7 +++++++ components/_util/BaseInputInner.tsx | 9 ++++++++- components/input/OTP/OTPInput.tsx | 18 +++++++++++++----- components/vc-input/Input.tsx | 5 +++++ 4 files changed, 33 insertions(+), 6 deletions(-) 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 index 6f3c09f9da..8abfdae8bc 100644 --- a/components/input/OTP/OTPInput.tsx +++ b/components/input/OTP/OTPInput.tsx @@ -1,5 +1,5 @@ import Input from '../Input'; -import { PropType, computed, defineComponent, shallowRef } from 'vue'; +import { PropType, computed, defineComponent, nextTick, shallowRef } from 'vue'; import inputProps from '../inputProps'; import omit from '../../_util/omit'; import { type ChangeEventHandler } from '../../_util/EventInterface'; @@ -26,20 +26,27 @@ export default defineComponent({ }); 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); - if (typeof props.mask === 'string' && value) { - // force update input value - e.target.value = props.mask; - } + // 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 = () => { @@ -83,6 +90,7 @@ export default defineComponent({ class={attrs.class} value={internalValue.value} onInput={onInternalChange} + onFocus={syncSelection} onMousedown={syncSelection} onMouseUp={syncSelection} onKeydown={onInternalKeydown} diff --git a/components/vc-input/Input.tsx b/components/vc-input/Input.tsx index e005e6f4b0..a01f223afc 100644 --- a/components/vc-input/Input.tsx +++ b/components/vc-input/Input.tsx @@ -62,9 +62,14 @@ export default defineComponent({ inputRef.value.input?.select(); }; + const rootInputForceUpdate = () => { + inputRef.value?.rootInputForceUpdate(); + }; + expose({ focus, blur, + rootInputForceUpdate, input: computed(() => inputRef.value.input), stateValue, setSelectionRange, From 7289671ba516617f3eadc91575b86a85e17faa31 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Tue, 18 Jun 2024 14:59:06 +0800 Subject: [PATCH 11/17] feat(docs): add api description --- components/input/index.en-US.md | 19 +++++++++++++++++++ components/input/index.zh-CN.md | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) 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.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) | - | | From 2fd6bd4d63cb5ac79dd4341d284be0e7247ca985 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Wed, 19 Jun 2024 23:08:40 +0800 Subject: [PATCH 12/17] test: add unit test --- components/input/OTP/index.tsx | 9 +- components/input/__tests__/OTP.test.js | 116 +++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 components/input/__tests__/OTP.test.js diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index 06514bbbfa..318fdcfcf0 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -1,4 +1,4 @@ -import { PropType, computed, defineComponent, ref } from 'vue'; +import { PropType, computed, defineComponent, ref, watchEffect } from 'vue'; import inputProps from '../inputProps'; import { FormItemInputContext } from '../../form/FormItemContext'; import useConfigInject from '../../config-provider/hooks/useConfigInject'; @@ -36,6 +36,13 @@ export default defineComponent({ // 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(); diff --git a/components/input/__tests__/OTP.test.js b/components/input/__tests__/OTP.test.js new file mode 100644 index 0000000000..8d8dc6df27 --- /dev/null +++ b/components/input/__tests__/OTP.test.js @@ -0,0 +1,116 @@ +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(); + }); + }); +}); From 3743d8c1b759565275a889ab2b4974a9ba2b340f Mon Sep 17 00:00:00 2001 From: carl-chen Date: Wed, 19 Jun 2024 23:27:07 +0800 Subject: [PATCH 13/17] test: add mask attr test --- components/input/__tests__/OTP.test.js | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/components/input/__tests__/OTP.test.js b/components/input/__tests__/OTP.test.js index 8d8dc6df27..3204e794e9 100644 --- a/components/input/__tests__/OTP.test.js +++ b/components/input/__tests__/OTP.test.js @@ -113,4 +113,42 @@ describe('OTP', () => { 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 internalValue = 'search'.split(''); + 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('🔒🔒🔒🔒🔒🔒'); + }); + }); }); From 070055f5dfa261b75f3dc54a037d2f2e507539b5 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Wed, 19 Jun 2024 23:27:37 +0800 Subject: [PATCH 14/17] perf: optimize code --- components/input/__tests__/OTP.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/input/__tests__/OTP.test.js b/components/input/__tests__/OTP.test.js index 3204e794e9..12f53aad84 100644 --- a/components/input/__tests__/OTP.test.js +++ b/components/input/__tests__/OTP.test.js @@ -137,7 +137,6 @@ describe('OTP', () => { it('support mask prop', async () => { const onChange = jest.fn(); - const internalValue = 'search'.split(''); const wrapper = mount(OTP, { props: { mask: '🔒', onChange }, sync: false }); await asyncExpect(async () => { @@ -149,6 +148,8 @@ describe('OTP', () => { expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith('search'); expect(getInputAllText(inputAll)).toBe('🔒🔒🔒🔒🔒🔒'); + + wrapper.unmount(); }); }); }); From 525c510d22c0523ba995b437498590dc061734f9 Mon Sep 17 00:00:00 2001 From: carl-chen Date: Fri, 21 Jun 2024 13:07:22 +0800 Subject: [PATCH 15/17] type: fix --- components/input/OTP/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index 318fdcfcf0..dcc89bed5f 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -1,4 +1,4 @@ -import { PropType, computed, defineComponent, ref, watchEffect } from 'vue'; +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'; From b492c244844fb070ffbc3d28569a25285bf76f26 Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Thu, 4 Jul 2024 21:01:45 +0800 Subject: [PATCH 16/17] feat: throw warning when mask length > 1 --- components/input/OTP/index.tsx | 9 +++++++++ components/input/__tests__/OTP.test.js | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/components/input/OTP/index.tsx b/components/input/OTP/index.tsx index dcc89bed5f..cd2e700a12 100644 --- a/components/input/OTP/index.tsx +++ b/components/input/OTP/index.tsx @@ -6,6 +6,7 @@ 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 }, @@ -117,6 +118,14 @@ export default defineComponent({ 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) => { diff --git a/components/input/__tests__/OTP.test.js b/components/input/__tests__/OTP.test.js index 12f53aad84..b60a08341d 100644 --- a/components/input/__tests__/OTP.test.js +++ b/components/input/__tests__/OTP.test.js @@ -152,4 +152,15 @@ describe('OTP', () => { 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.', + ); + wrapper.unmount(); + }); + }); }); From c11e4d2e405f23f74b8509827ee362911a2d205a Mon Sep 17 00:00:00 2001 From: Carl Chen Date: Thu, 4 Jul 2024 21:05:09 +0800 Subject: [PATCH 17/17] test: completion test --- components/input/__tests__/OTP.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/components/input/__tests__/OTP.test.js b/components/input/__tests__/OTP.test.js index b60a08341d..4110451b30 100644 --- a/components/input/__tests__/OTP.test.js +++ b/components/input/__tests__/OTP.test.js @@ -160,6 +160,17 @@ describe('OTP', () => { 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(); }); });