From 620746e733527370ca750eb50c117ea7f159038d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 03:42:54 +0200 Subject: [PATCH] feat: otp field initial implementation --- packages/core/src/constants/index.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/useCalendar/useCalendar.ts | 2 +- .../src/useDateTimeField/useDateTimeField.ts | 2 +- packages/core/src/useOtpField/index.ts | 2 + packages/core/src/useOtpField/types.ts | 16 ++ packages/core/src/useOtpField/useOtpField.ts | 270 ++++++++++++++++++ packages/core/src/useOtpField/useOtpSlot.ts | 172 +++++++++++ packages/core/src/useSelect/useSelect.ts | 2 +- packages/core/src/validation/index.ts | 1 + ...alidator.ts => useConstraintsValidator.ts} | 0 .../validation/useContraintsValidator.spec.ts | 2 +- packages/playground/src/App.vue | 8 +- .../playground/src/components/OtpField.vue | 45 +++ 14 files changed, 515 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/useOtpField/index.ts create mode 100644 packages/core/src/useOtpField/types.ts create mode 100644 packages/core/src/useOtpField/useOtpField.ts create mode 100644 packages/core/src/useOtpField/useOtpSlot.ts rename packages/core/src/validation/{useContraintsValidator.ts => useConstraintsValidator.ts} (100%) create mode 100644 packages/playground/src/components/OtpField.vue diff --git a/packages/core/src/constants/index.ts b/packages/core/src/constants/index.ts index a9bd4442..b0034a2d 100644 --- a/packages/core/src/constants/index.ts +++ b/packages/core/src/constants/index.ts @@ -21,6 +21,8 @@ export const FieldTypePrefixes = { DateTimeField: 'dtf', DateTimeSegment: 'dts', Calendar: 'cal', + OTPField: 'otp', + OTPSlot: 'otps', } as const; export const NOOP = () => {}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a65fa1aa..95430042 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from './useSelect'; export * from './useComboBox'; export * from './useHiddenField'; export * from './useCustomField'; +export * from './useOtpField'; export * from './useDateTimeField'; export * from './useCalendar'; export * from './usePicker'; diff --git a/packages/core/src/useCalendar/useCalendar.ts b/packages/core/src/useCalendar/useCalendar.ts index 3295ac84..90bedd44 100644 --- a/packages/core/src/useCalendar/useCalendar.ts +++ b/packages/core/src/useCalendar/useCalendar.ts @@ -16,7 +16,7 @@ import { useInputValidity } from '../validation'; import { fromDateToCalendarZonedDateTime, useTemporalStore } from '../useDateTimeField/useTemporalStore'; import { PickerContextKey } from '../usePicker'; import { registerField } from '@formwerk/devtools'; -import { useConstraintsValidator } from '../validation/useContraintsValidator'; +import { useConstraintsValidator } from '../validation/useConstraintsValidator'; export interface CalendarProps { /** diff --git a/packages/core/src/useDateTimeField/useDateTimeField.ts b/packages/core/src/useDateTimeField/useDateTimeField.ts index 67e84261..7da2b9ee 100644 --- a/packages/core/src/useDateTimeField/useDateTimeField.ts +++ b/packages/core/src/useDateTimeField/useDateTimeField.ts @@ -12,7 +12,7 @@ import { ZonedDateTime, Calendar } from '@internationalized/date'; import { useInputValidity } from '../validation'; import { createDisabledContext } from '../helpers/createDisabledContext'; import { registerField } from '@formwerk/devtools'; -import { useConstraintsValidator } from '../validation/useContraintsValidator'; +import { useConstraintsValidator } from '../validation/useConstraintsValidator'; export interface DateTimeFieldProps { /** diff --git a/packages/core/src/useOtpField/index.ts b/packages/core/src/useOtpField/index.ts new file mode 100644 index 00000000..0f0c1e4a --- /dev/null +++ b/packages/core/src/useOtpField/index.ts @@ -0,0 +1,2 @@ +export * from './useOtpField'; +export * from './useOtpSlot'; diff --git a/packages/core/src/useOtpField/types.ts b/packages/core/src/useOtpField/types.ts new file mode 100644 index 00000000..6631744e --- /dev/null +++ b/packages/core/src/useOtpField/types.ts @@ -0,0 +1,16 @@ +import { InjectionKey } from 'vue'; + +export type OtpSlotAcceptType = 'all' | 'numeric' | 'alphanumeric'; + +export interface OtpSlotRegistration { + id: string; + focusNext(): void; + focusPrevious(): void; + setValue(value: string): void; +} + +export interface OtpContext { + useSlotRegistration(slot: { focus: () => void }): OtpSlotRegistration; +} + +export const OtpContextKey: InjectionKey = Symbol('otp-context'); diff --git a/packages/core/src/useOtpField/useOtpField.ts b/packages/core/src/useOtpField/useOtpField.ts new file mode 100644 index 00000000..41f87104 --- /dev/null +++ b/packages/core/src/useOtpField/useOtpField.ts @@ -0,0 +1,270 @@ +import { computed, provide, ref, toValue } from 'vue'; +import { Reactivify, StandardSchema } from '../types'; +import { OtpContextKey, OtpSlotAcceptType } from './types'; +import { createDescribedByProps, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; +import { FieldTypePrefixes } from '../constants'; +import { useErrorMessage, useLabel } from '../a11y'; +import { exposeField, useFormField } from '../useFormField'; +import { useInputValidity, useConstraintsValidator } from '../validation'; +import { OtpSlotProps } from './useOtpSlot'; +import { createDisabledContext } from '../helpers/createDisabledContext'; +import { registerField } from '@formwerk/devtools'; + +export interface OTPFieldProps { + /** + * The label of the OTP field. + */ + label: string; + + /** + * The name of the OTP field. + */ + name?: string; + + /** + * The model value of the OTP field. + */ + modelValue?: string; + + /** + * The initial value of the OTP field. + */ + value?: string; + + /** + * Whether the OTP field is disabled. + */ + disabled?: boolean; + + /** + * Whether the OTP field is readonly. + */ + readonly?: boolean; + + /** + * Whether the OTP field is required. + */ + required?: boolean; + + /** + * The length of the OTP field characters. + */ + length?: number; + + /** + * The type of the OTP field characters. + */ + accept?: OtpSlotAcceptType; + + /** + * The description of the OTP field. + */ + description?: string; + + /** + * Schema for field validation. + */ + schema?: StandardSchema; + + /** + * Whether to disable HTML validation. + */ + disableHtmlValidation?: boolean; + + /** + * The prefix of the OTP field. If you prefix your codes with a character, you can set it here (e.g "G-"). + */ + prefix?: string; +} + +export function useOtpField(_props: Reactivify) { + const props = normalizeProps(_props, ['schema']); + const controlEl = ref(); + const id = useUniqId(FieldTypePrefixes.OTPField); + const isDisabled = createDisabledContext(props.disabled); + + function withPrefix(value: string | undefined) { + const prefix = toValue(props.prefix); + if (!prefix) { + return value; + } + + value = value ?? ''; + if (value.startsWith(prefix)) { + return value; + } + + return `${prefix}${value}`; + } + + function getRequiredLength() { + const prefix = toValue(props.prefix) || ''; + const length = toValue(props.length) ?? 0; + + return prefix.length + length; + } + + const field = useFormField({ + path: props.name, + initialValue: withPrefix(toValue(props.modelValue) ?? toValue(props.value)), + disabled: props.disabled, + schema: props.schema, + }); + + const inputsState = ref(field.fieldValue.value?.split('') ?? []); + + const { element: inputEl } = useConstraintsValidator({ + type: 'text', + maxLength: getRequiredLength(), + minLength: getRequiredLength(), + required: props.required, + value: () => (field.fieldValue.value?.length === getRequiredLength() ? field.fieldValue.value : ''), + source: controlEl, + }); + + const { validityDetails } = useInputValidity({ + inputEl, + field, + disableHtmlValidation: props.disableHtmlValidation, + }); + + const { descriptionProps, describedByProps } = createDescribedByProps({ + inputId: id, + description: props.description, + }); + + const { labelProps, labelledByProps } = useLabel({ + label: props.label, + targetRef: controlEl, + for: id, + }); + + const { errorMessageProps, accessibleErrorProps } = useErrorMessage({ + inputId: id, + errorMessage: field.errorMessage, + }); + + const controlProps = computed(() => { + return withRefCapture( + { + id, + role: 'group', + ...labelledByProps.value, + ...describedByProps.value, + ...accessibleErrorProps.value, + }, + controlEl, + ); + }); + + function createFocusHandler(direction: 'next' | 'previous') { + return () => { + if (!controlEl.value) { + return; + } + + const slots = Array.from(controlEl.value.querySelectorAll('[data-otp-slot][tabindex="0"]')) as HTMLElement[]; + const currentSlot = controlEl.value.querySelector('[data-otp-slot]:focus') as HTMLElement | null; + if (!currentSlot) { + slots[0]?.focus(); + return; + } + + const currentIndex = slots.indexOf(currentSlot); + if (currentIndex === -1) { + slots[0]?.focus(); + return; + } + + const nextSlot = slots[currentIndex + (direction === 'next' ? 1 : -1)]; + nextSlot?.focus(); + }; + } + + const focusNext = createFocusHandler('next'); + const focusPrevious = createFocusHandler('previous'); + + const fieldSlots = computed(() => { + const prefix = toValue(props.prefix) || ''; + const length = prefix.length + (toValue(props.length) ?? 0); + + return Array.from({ length }, (_, index) => ({ + value: inputsState.value[index] ?? '', + disabled: prefix.length ? prefix.length > index : isDisabled.value, + readonly: toValue(props.readonly), + accept: toValue(props.accept), + })); + }); + + function getActiveSlotIndex() { + const slots = Array.from(controlEl.value?.querySelectorAll('[data-otp-slot]') ?? []) as HTMLElement[]; + const currentSlot = controlEl.value?.querySelector('[data-otp-slot]:focus') as HTMLElement | null; + if (!currentSlot) { + return -1; + } + + return slots.indexOf(currentSlot); + } + + provide(OtpContextKey, { + useSlotRegistration() { + const slotId = useUniqId(FieldTypePrefixes.OTPSlot); + + return { + id: slotId, + focusNext, + focusPrevious, + setValue: (value: string) => { + const index = getActiveSlotIndex(); + if (index === -1) { + return; + } + + inputsState.value[index] = value; + const nextValue = withPrefix(inputsState.value.join('')); + + field.setValue(nextValue?.length === getRequiredLength() ? nextValue : withPrefix('')); + }, + }; + }, + }); + + if (__DEV__) { + registerField(field, 'OTP'); + } + + return exposeField( + { + /** + * The props of the control element. + */ + controlProps, + + /** + * The props of the label element. + */ + labelProps, + + /** + * The props of the description element. + */ + descriptionProps, + + /** + * The validity details of the OTP field. + */ + validityDetails, + + /** + * The slots of the OTP field. Use this as an iterable to render with `v-for`. + */ + fieldSlots, + + /** + * The props of the error message element. + */ + errorMessageProps, + }, + field, + ); +} diff --git a/packages/core/src/useOtpField/useOtpSlot.ts b/packages/core/src/useOtpField/useOtpSlot.ts new file mode 100644 index 00000000..64e3f479 --- /dev/null +++ b/packages/core/src/useOtpField/useOtpSlot.ts @@ -0,0 +1,172 @@ +import { computed, defineComponent, h, inject, ref, toValue } from 'vue'; +import { Reactivify } from '../types'; +import { hasKeyCode, isInputElement, normalizeProps, warn, withRefCapture } from '../utils/common'; +import { isFirefox } from '../utils/platform'; +import { blockEvent } from '../utils/events'; +import { OtpContextKey, OtpSlotAcceptType } from './types'; +import { createDisabledContext } from '../helpers/createDisabledContext'; + +export interface OtpSlotProps { + /** + * The value of the slot. + */ + value: string; + + /** + * Whether the slot is disabled. + */ + disabled?: boolean; + + /** + * Whether the slot is readonly. + */ + readonly?: boolean; + + /** + * The type of the slot. + */ + accept?: OtpSlotAcceptType; +} + +export function useOtpSlot(_props: Reactivify) { + const props = normalizeProps(_props); + const slotEl = ref(); + const isDisabled = createDisabledContext(props.disabled); + + const acceptMapRegex: Record = { + all: /./, + numeric: /^\d+$/, + alphanumeric: /^[a-zA-Z0-9]+$/, + }; + + const context = inject(OtpContextKey, { + useSlotRegistration() { + return { + id: '', + focusNext: () => {}, + focusPrevious: () => {}, + setValue: () => {}, + }; + }, + }); + + const { focusNext, focusPrevious, setValue, id } = context.useSlotRegistration({ + focus() { + slotEl.value?.focus(); + }, + }); + + if (!context) { + if (__DEV__) { + warn('OtpSlot must be used within an OtpField'); + } + } + + function setElementValue(value: string) { + if (!slotEl.value) { + return; + } + + setValue(value); + if (isInputElement(slotEl.value)) { + slotEl.value.value = value; + return; + } + + slotEl.value.textContent = value; + } + + const handlers = { + onKeydown(e: KeyboardEvent) { + if (hasKeyCode(e, 'Backspace') || hasKeyCode(e, 'Delete')) { + blockEvent(e); + setElementValue(''); + focusPrevious(); + return; + } + + if (hasKeyCode(e, 'Enter')) { + blockEvent(e); + focusNext(); + return; + } + + if (hasKeyCode(e, 'ArrowLeft')) { + blockEvent(e); + focusPrevious(); + return; + } + + if (hasKeyCode(e, 'ArrowRight')) { + blockEvent(e); + focusNext(); + return; + } + }, + onBeforeinput(e: InputEvent) { + // Ignores non printable keys + if (!e.data) { + return; + } + + const re = acceptMapRegex[toValue(props.accept) || 'all']; + + blockEvent(e); + if (re.test(e.data)) { + setElementValue(e.data); + focusNext(); + } + }, + }; + + const slotProps = computed(() => { + const isInput = isInputElement(slotEl.value); + + const baseProps: Record = { + [isInput ? 'readonly' : 'aria-readonly']: toValue(props.readonly), + [isInput ? 'disabled' : 'aria-disabled']: toValue(props.disabled), + 'data-otp-slot': true, + spellcheck: false, + tabindex: isDisabled.value ? '-1' : '0', + autocorrect: 'off', + autocapitalize: 'off', + // TODO: Should be either done or next depending on if it's the last slot + enterkeyhint: 'next', + ...handlers, + style: { + caretColor: 'transparent', + }, + }; + + if (toValue(props.accept) === 'numeric') { + baseProps.inputmode = 'numeric'; + } + + if (!isInput) { + baseProps.contenteditable = isDisabled.value ? 'false' : isFirefox() ? 'true' : 'plaintext-only'; + } else { + baseProps.value = toValue(props.value); + } + + return withRefCapture(baseProps, slotEl); + }); + + return { + slotProps, + key: id, + value: computed(() => toValue(props.value)), + }; +} + +/** + * A helper component that renders an OTP slot. You can build your own with `useOtpSlot`. + */ +export const OtpSlot = /*#__PURE__*/ defineComponent({ + name: 'OtpSlot', + props: ['value', 'disabled', 'readonly', 'accept'], + setup(props) { + const { slotProps, value, key } = useOtpSlot(props); + + return () => h('span', { ...slotProps.value, key }, value.value); + }, +}); diff --git a/packages/core/src/useSelect/useSelect.ts b/packages/core/src/useSelect/useSelect.ts index 3eb0fc6a..ee97177e 100644 --- a/packages/core/src/useSelect/useSelect.ts +++ b/packages/core/src/useSelect/useSelect.ts @@ -15,7 +15,7 @@ import { useListBox } from '../useListBox'; import { useLabel, useErrorMessage } from '../a11y'; import { FieldTypePrefixes } from '../constants'; import { registerField } from '@formwerk/devtools'; -import { useConstraintsValidator } from '../validation/useContraintsValidator'; +import { useConstraintsValidator } from '../validation/useConstraintsValidator'; export interface SelectProps { /** diff --git a/packages/core/src/validation/index.ts b/packages/core/src/validation/index.ts index aa92e539..8f02b596 100644 --- a/packages/core/src/validation/index.ts +++ b/packages/core/src/validation/index.ts @@ -1 +1,2 @@ +export * from './useConstraintsValidator'; export * from './useInputValidity'; diff --git a/packages/core/src/validation/useContraintsValidator.ts b/packages/core/src/validation/useConstraintsValidator.ts similarity index 100% rename from packages/core/src/validation/useContraintsValidator.ts rename to packages/core/src/validation/useConstraintsValidator.ts diff --git a/packages/core/src/validation/useContraintsValidator.spec.ts b/packages/core/src/validation/useContraintsValidator.spec.ts index bebb2232..ed84da96 100644 --- a/packages/core/src/validation/useContraintsValidator.spec.ts +++ b/packages/core/src/validation/useContraintsValidator.spec.ts @@ -1,5 +1,5 @@ import { nextTick, onMounted, ref } from 'vue'; -import { useConstraintsValidator } from './useContraintsValidator'; +import { useConstraintsValidator } from './useConstraintsValidator'; import { fireEvent, render, screen } from '@testing-library/vue'; import { renderSetup } from '@test-utils/index'; diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index f5e2911f..5ffc02c4 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,11 +1,7 @@ diff --git a/packages/playground/src/components/OtpField.vue b/packages/playground/src/components/OtpField.vue new file mode 100644 index 00000000..ab86cd23 --- /dev/null +++ b/packages/playground/src/components/OtpField.vue @@ -0,0 +1,45 @@ + + + + +