From 2ce7a8aa10a200b87c48f55883028ba9afe61426 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 03:42:54 +0200 Subject: [PATCH 01/17] 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 @@ + + + + + From b0552bc6be8a1a75d433d708afaa352d02228097 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 03:53:02 +0200 Subject: [PATCH 02/17] feat: implement masked prop --- packages/core/src/useOtpField/useOtpField.ts | 6 ++++++ packages/core/src/useOtpField/useOtpSlot.ts | 20 +++++++++++++++++--- packages/playground/src/App.vue | 2 +- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/core/src/useOtpField/useOtpField.ts b/packages/core/src/useOtpField/useOtpField.ts index 41f87104..341db0e4 100644 --- a/packages/core/src/useOtpField/useOtpField.ts +++ b/packages/core/src/useOtpField/useOtpField.ts @@ -36,6 +36,11 @@ export interface OTPFieldProps { */ disabled?: boolean; + /** + * Whether the OTP field is masked. + */ + masked?: boolean; + /** * Whether the OTP field is readonly. */ @@ -193,6 +198,7 @@ export function useOtpField(_props: Reactivify) { disabled: prefix.length ? prefix.length > index : isDisabled.value, readonly: toValue(props.readonly), accept: toValue(props.accept), + masked: prefix.length <= index && toValue(props.masked), })); }); diff --git a/packages/core/src/useOtpField/useOtpSlot.ts b/packages/core/src/useOtpField/useOtpSlot.ts index 64e3f479..dc55407a 100644 --- a/packages/core/src/useOtpField/useOtpSlot.ts +++ b/packages/core/src/useOtpField/useOtpSlot.ts @@ -22,6 +22,11 @@ export interface OtpSlotProps { */ readonly?: boolean; + /** + * Whether the slot is masked. + */ + masked?: boolean; + /** * The type of the slot. */ @@ -62,6 +67,14 @@ export function useOtpSlot(_props: Reactivify) { } } + function withMask(value: string | undefined) { + if (!toValue(props.masked) || !value) { + return value ?? ''; + } + + return '•'.repeat(value.length); + } + function setElementValue(value: string) { if (!slotEl.value) { return; @@ -73,7 +86,7 @@ export function useOtpSlot(_props: Reactivify) { return; } - slotEl.value.textContent = value; + slotEl.value.textContent = withMask(value); } const handlers = { @@ -146,6 +159,7 @@ export function useOtpSlot(_props: Reactivify) { baseProps.contenteditable = isDisabled.value ? 'false' : isFirefox() ? 'true' : 'plaintext-only'; } else { baseProps.value = toValue(props.value); + baseProps.type = toValue(props.masked) ? 'password' : 'text'; } return withRefCapture(baseProps, slotEl); @@ -154,7 +168,7 @@ export function useOtpSlot(_props: Reactivify) { return { slotProps, key: id, - value: computed(() => toValue(props.value)), + value: computed(() => withMask(toValue(props.value))), }; } @@ -163,7 +177,7 @@ export function useOtpSlot(_props: Reactivify) { */ export const OtpSlot = /*#__PURE__*/ defineComponent({ name: 'OtpSlot', - props: ['value', 'disabled', 'readonly', 'accept'], + props: ['value', 'disabled', 'readonly', 'accept', 'masked'], setup(props) { const { slotProps, value, key } = useOtpSlot(props); diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 5ffc02c4..bb836a49 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -3,5 +3,5 @@ import OtpField from '@/components/OtpField.vue'; From 42fa4b0478e581029fc949433897aacf653df9aa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 04:01:04 +0200 Subject: [PATCH 03/17] feat: added onCompleted callback --- packages/core/src/useOtpField/useOtpField.ts | 40 +++++++++++++++----- packages/playground/src/App.vue | 9 ++++- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/core/src/useOtpField/useOtpField.ts b/packages/core/src/useOtpField/useOtpField.ts index 341db0e4..5e3a207e 100644 --- a/packages/core/src/useOtpField/useOtpField.ts +++ b/packages/core/src/useOtpField/useOtpField.ts @@ -1,5 +1,5 @@ -import { computed, provide, ref, toValue } from 'vue'; -import { Reactivify, StandardSchema } from '../types'; +import { computed, provide, ref, toValue, watch } from 'vue'; +import { MaybeAsync, Reactivify, StandardSchema } from '../types'; import { OtpContextKey, OtpSlotAcceptType } from './types'; import { createDescribedByProps, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; import { FieldTypePrefixes } from '../constants'; @@ -80,10 +80,15 @@ export interface OTPFieldProps { * The prefix of the OTP field. If you prefix your codes with a character, you can set it here (e.g "G-"). */ prefix?: string; + + /** + * The callback function that is called when the OTP field is completed. + */ + onCompleted?: (value: string) => MaybeAsync; } -export function useOtpField(_props: Reactivify) { - const props = normalizeProps(_props, ['schema']); +export function useOtpField(_props: Reactivify) { + const props = normalizeProps(_props, ['schema', 'onCompleted']); const controlEl = ref(); const id = useUniqId(FieldTypePrefixes.OTPField); const isDisabled = createDisabledContext(props.disabled); @@ -91,7 +96,7 @@ export function useOtpField(_props: Reactivify) { function withPrefix(value: string | undefined) { const prefix = toValue(props.prefix); if (!prefix) { - return value; + return value ?? ''; } value = value ?? ''; @@ -116,7 +121,7 @@ export function useOtpField(_props: Reactivify) { schema: props.schema, }); - const inputsState = ref(field.fieldValue.value?.split('') ?? []); + const inputsState = ref(withPrefix(toValue(props.modelValue) ?? toValue(props.value)).split('')); const { element: inputEl } = useConstraintsValidator({ type: 'text', @@ -212,6 +217,20 @@ export function useOtpField(_props: Reactivify) { return slots.indexOf(currentSlot); } + watch(field.fieldValue, value => { + if (!value) { + inputsState.value = withPrefix('').split(''); + return; + } + + const expected = withPrefix(inputsState.value.join('')); + if (expected === value) { + return; + } + + inputsState.value = value.split(''); + }); + provide(OtpContextKey, { useSlotRegistration() { const slotId = useUniqId(FieldTypePrefixes.OTPSlot); @@ -227,9 +246,12 @@ export function useOtpField(_props: Reactivify) { } inputsState.value[index] = value; - const nextValue = withPrefix(inputsState.value.join('')); - - field.setValue(nextValue?.length === getRequiredLength() ? nextValue : withPrefix('')); + const nextValue = inputsState.value.join(''); + const isCompleted = nextValue?.length === getRequiredLength(); + field.setValue(nextValue); + if (isCompleted) { + props.onCompleted?.(nextValue); + } }, }; }, diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index bb836a49..c7f0e88b 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,7 +1,14 @@ From b9b75cacbbd61dfc46a4897ef7f2e40bfaa80df8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 15:52:22 +0200 Subject: [PATCH 04/17] feat: handle paste --- packages/core/src/useOtpField/types.ts | 1 + packages/core/src/useOtpField/useOtpField.ts | 64 ++++++++++++++++++-- packages/core/src/useOtpField/useOtpSlot.ts | 17 +++--- packages/core/src/useOtpField/utils.ts | 13 ++++ 4 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/useOtpField/utils.ts diff --git a/packages/core/src/useOtpField/types.ts b/packages/core/src/useOtpField/types.ts index 6631744e..7492f58b 100644 --- a/packages/core/src/useOtpField/types.ts +++ b/packages/core/src/useOtpField/types.ts @@ -7,6 +7,7 @@ export interface OtpSlotRegistration { focusNext(): void; focusPrevious(): void; setValue(value: string): void; + handlePaste(event: ClipboardEvent): void; } export interface OtpContext { diff --git a/packages/core/src/useOtpField/useOtpField.ts b/packages/core/src/useOtpField/useOtpField.ts index 5e3a207e..8848fa5d 100644 --- a/packages/core/src/useOtpField/useOtpField.ts +++ b/packages/core/src/useOtpField/useOtpField.ts @@ -9,6 +9,8 @@ import { useInputValidity, useConstraintsValidator } from '../validation'; import { OtpSlotProps } from './useOtpSlot'; import { createDisabledContext } from '../helpers/createDisabledContext'; import { registerField } from '@formwerk/devtools'; +import { isValueAccepted } from './utils'; +import { blockEvent } from '../utils/events'; export interface OTPFieldProps { /** @@ -231,6 +233,60 @@ export function useOtpField(_props: Reactivify { + inputsState.value[index] = value; + }); + + updateFieldValue(); + return; + } + + const currentIndex = getActiveSlotIndex(); + if (currentIndex === -1) { + return; + } + + // Fill input states starting from the active index + const prefixLength = (toValue(props.prefix) || '').length; + const maxLength = getRequiredLength(); + const availableSlots = maxLength - currentIndex; + + // Only take characters that can fit in the remaining slots + const textToFill = text.slice(0, availableSlots); + + // Skip prefix slots if we're pasting into a position after the prefix + if (currentIndex >= prefixLength) { + for (let i = 0; i < textToFill.length; i++) { + const char = textToFill[i]; + inputsState.value[currentIndex + i] = char; + } + } + + updateFieldValue(); + } + + function updateFieldValue() { + const nextValue = inputsState.value.join(''); + const isCompleted = nextValue?.length === getRequiredLength(); + field.setValue(nextValue); + if (isCompleted) { + props.onCompleted?.(nextValue); + } + } + provide(OtpContextKey, { useSlotRegistration() { const slotId = useUniqId(FieldTypePrefixes.OTPSlot); @@ -239,6 +295,7 @@ export function useOtpField(_props: Reactivify { const index = getActiveSlotIndex(); if (index === -1) { @@ -246,12 +303,7 @@ export function useOtpField(_props: Reactivify) { 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 { @@ -51,11 +46,12 @@ export function useOtpSlot(_props: Reactivify) { focusNext: () => {}, focusPrevious: () => {}, setValue: () => {}, + handlePaste: () => {}, }; }, }); - const { focusNext, focusPrevious, setValue, id } = context.useSlotRegistration({ + const { focusNext, focusPrevious, setValue, id, handlePaste } = context.useSlotRegistration({ focus() { slotEl.value?.focus(); }, @@ -90,6 +86,9 @@ export function useOtpSlot(_props: Reactivify) { } const handlers = { + onPaste(e: ClipboardEvent) { + handlePaste(e); + }, onKeydown(e: KeyboardEvent) { if (hasKeyCode(e, 'Backspace') || hasKeyCode(e, 'Delete')) { blockEvent(e); @@ -122,10 +121,8 @@ export function useOtpSlot(_props: Reactivify) { return; } - const re = acceptMapRegex[toValue(props.accept) || 'all']; - blockEvent(e); - if (re.test(e.data)) { + if (isValueAccepted(e.data, toValue(props.accept) || 'all')) { setElementValue(e.data); focusNext(); } diff --git a/packages/core/src/useOtpField/utils.ts b/packages/core/src/useOtpField/utils.ts new file mode 100644 index 00000000..8e80a56d --- /dev/null +++ b/packages/core/src/useOtpField/utils.ts @@ -0,0 +1,13 @@ +import { OtpSlotAcceptType } from './types'; + +const acceptMapRegex: Record = { + all: /./, + numeric: /^\d+$/, + alphanumeric: /^[a-zA-Z0-9]+$/, +}; + +export function isValueAccepted(value: string, accept: OtpSlotAcceptType) { + const regex = acceptMapRegex[accept]; + + return regex.test(value); +} From c8265ed6d020521793c818f4668986f49aed62d3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 16:44:41 +0200 Subject: [PATCH 05/17] fix: add autocomplete off --- packages/core/src/useDateTimeField/useDateTimeSegment.ts | 2 ++ packages/core/src/useOtpField/useOtpSlot.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/core/src/useDateTimeField/useDateTimeSegment.ts b/packages/core/src/useDateTimeField/useDateTimeSegment.ts index de3b502f..89b2be12 100644 --- a/packages/core/src/useDateTimeField/useDateTimeSegment.ts +++ b/packages/core/src/useDateTimeField/useDateTimeSegment.ts @@ -45,6 +45,7 @@ interface DateTimeSegmentDomProps { inputmode?: string; autocorrect?: string; enterkeyhint?: string; + autocomplete?: string; 'aria-valuemin'?: number; 'aria-valuemax'?: number; 'aria-valuenow'?: number; @@ -207,6 +208,7 @@ export function useDateTimeSegment(_props: Reactivify) { 'aria-label': isNonEditable() ? undefined : toValue(props.type), 'aria-readonly': toValue(props.readonly) ? true : undefined, autocorrect: isNonEditable() ? undefined : 'off', + autocomplete: isNonEditable() ? undefined : 'off', spellcheck: isNonEditable() ? undefined : false, enterkeyhint: isNonEditable() ? undefined : isLast() ? 'done' : 'next', inputmode: 'none', diff --git a/packages/core/src/useOtpField/useOtpSlot.ts b/packages/core/src/useOtpField/useOtpSlot.ts index d31c73ed..bda902a2 100644 --- a/packages/core/src/useOtpField/useOtpSlot.ts +++ b/packages/core/src/useOtpField/useOtpSlot.ts @@ -139,6 +139,7 @@ export function useOtpSlot(_props: Reactivify) { spellcheck: false, tabindex: isDisabled.value ? '-1' : '0', autocorrect: 'off', + autocomplete: 'off', autocapitalize: 'off', // TODO: Should be either done or next depending on if it's the last slot enterkeyhint: 'next', @@ -153,6 +154,9 @@ export function useOtpSlot(_props: Reactivify) { } if (!isInput) { + baseProps['aria-role'] = 'textbox'; + baseProps['aria-label'] = toValue(props.value) || 'Enter a character'; + baseProps['aria-multiline'] = 'false'; baseProps.contenteditable = isDisabled.value ? 'false' : isFirefox() ? 'true' : 'plaintext-only'; } else { baseProps.value = toValue(props.value); From 60719f2d1872531b664b5e17522b853403fb6142 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 16:45:54 +0200 Subject: [PATCH 06/17] fix: emit complete event on next tick --- packages/core/src/useOtpField/useOtpField.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/useOtpField/useOtpField.ts b/packages/core/src/useOtpField/useOtpField.ts index 8848fa5d..529618b2 100644 --- a/packages/core/src/useOtpField/useOtpField.ts +++ b/packages/core/src/useOtpField/useOtpField.ts @@ -1,4 +1,4 @@ -import { computed, provide, ref, toValue, watch } from 'vue'; +import { computed, nextTick, provide, ref, toValue, watch } from 'vue'; import { MaybeAsync, Reactivify, StandardSchema } from '../types'; import { OtpContextKey, OtpSlotAcceptType } from './types'; import { createDescribedByProps, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; @@ -283,7 +283,9 @@ export function useOtpField(_props: Reactivify { + props.onCompleted?.(nextValue); + }); } } From 8639550a2d1f2d6869f47b52fac3e7d0d14057e8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 16:46:12 +0200 Subject: [PATCH 07/17] chore: playground stuff --- packages/playground/src/App.vue | 9 +++++--- .../playground/src/components/OtpField.vue | 4 ++-- .../playground/src/components/OtpSlot.vue | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 packages/playground/src/components/OtpSlot.vue diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index c7f0e88b..2fdbdaff 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -4,11 +4,14 @@ import { useForm } from '@formwerk/core'; const form = useForm(); function onCompleted(value: string) { - console.log('onCompleted', value); + alert(`onCompleted: ${value}`); } diff --git a/packages/playground/src/components/OtpField.vue b/packages/playground/src/components/OtpField.vue index ab86cd23..efbd4afe 100644 --- a/packages/playground/src/components/OtpField.vue +++ b/packages/playground/src/components/OtpField.vue @@ -13,8 +13,8 @@ + + + + From c531be86f1d3e9e173ee155dfc776e42d5da16ae Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 16:48:12 +0200 Subject: [PATCH 08/17] chore: changeset --- .changeset/major-groups-rescue.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/major-groups-rescue.md diff --git a/.changeset/major-groups-rescue.md b/.changeset/major-groups-rescue.md new file mode 100644 index 00000000..29a61409 --- /dev/null +++ b/.changeset/major-groups-rescue.md @@ -0,0 +1,5 @@ +--- +'@formwerk/core': minor +--- + +feat: add OTP Field From 41f9d3acf6fcce04271d14a51d80aa04e2bc4fcd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Mar 2025 16:56:53 +0200 Subject: [PATCH 09/17] feat: remove cursor color transparency --- packages/core/src/useOtpField/useOtpSlot.ts | 3 --- packages/playground/src/App.vue | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/useOtpField/useOtpSlot.ts b/packages/core/src/useOtpField/useOtpSlot.ts index bda902a2..168d8368 100644 --- a/packages/core/src/useOtpField/useOtpSlot.ts +++ b/packages/core/src/useOtpField/useOtpSlot.ts @@ -144,9 +144,6 @@ export function useOtpSlot(_props: Reactivify) { // 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') { diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 2fdbdaff..7d97148f 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -9,7 +9,7 @@ function onCompleted(value: string) {