Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Input.OTP #7660

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
7 changes: 7 additions & 0 deletions components/_util/BaseInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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(),
Expand Down
9 changes: 8 additions & 1 deletion components/_util/BaseInputInner.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down
102 changes: 102 additions & 0 deletions components/input/OTP/OTPInput.tsx
Original file line number Diff line number Diff line change
@@ -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<InputStatus>, 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 () => (
<Input
ref={inputRef}
{...attrs}
{...omit(props, ['index', 'value', 'mask', 'onChange'])}
class={attrs.class}
value={internalValue.value}
onInput={onInternalChange}
onFocus={syncSelection}
onMousedown={syncSelection}
onMouseUp={syncSelection}
onKeydown={onInternalKeydown}
onKeyup={onInternalKeyUp}
type={props.mask === true ? 'password' : 'text'}
/>
);
},
});
154 changes: 154 additions & 0 deletions components/input/OTP/index.tsx
Original file line number Diff line number Diff line change
@@ -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<InputStatus>, 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<string[]>(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(
<div class={cls}>
{Array.from({ length }).map((_, index) => {
const key = `opt-${index}`;
const singleValue = valueCells.value[index];
return (
<OTPInput
ref={ref => (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}
/>
);
})}
</div>,
);
};
},
});
Loading
Loading