diff --git a/apps/docs/src/examples/composables/create-otp/basic.vue b/apps/docs/src/examples/composables/create-otp/basic.vue new file mode 100644 index 000000000..b99c33575 --- /dev/null +++ b/apps/docs/src/examples/composables/create-otp/basic.vue @@ -0,0 +1,60 @@ + + + + + + + + + + Value: {{ otp.value.value || '—' }} + + + diff --git a/apps/docs/src/pages/composables/forms/create-otp.md b/apps/docs/src/pages/composables/forms/create-otp.md new file mode 100644 index 000000000..032f45626 --- /dev/null +++ b/apps/docs/src/pages/composables/forms/create-otp.md @@ -0,0 +1,136 @@ +--- +title: createOtp - One-Time Password Composable +meta: +- name: description + content: Composable for fixed-length one-time-password and verification-code state with pattern-gated entry and decisional completion hook for Vue 3. +- name: keywords + content: createOtp, otp, pin input, verification code, composable, Vue 3, headless +features: + category: Composable + label: 'E: createOtp' + github: /composables/createOtp/ + level: 2 +related: + - /composables/forms/create-input + - /composables/forms/create-validation +--- + +# createOtp + + + +Manage a fixed-length one-time-password or verification-code value with pattern-gated entry, length-based completion detection, and a decisional async hook. Headless — your component owns rendering, focus, and event wiring. + +## Usage + +```ts collapse +import { createOtp } from '@vuetify/v0' + +const otp = createOtp({ + length: 6, + pattern: 'numeric', + onComplete: async value => { + const ok = await verify(value) + return ok // false clears the value and surfaces an error + }, +}) + +otp.put(0, '4') // single character at a position +otp.distribute('123456') // distributes filtered characters +otp.value.value // '412345' joined string +otp.isComplete.value // true when length reached and all chars valid +otp.accepts('a') // false under 'numeric' +otp.clear() +``` + +## Architecture + +```mermaid "createOtp Architecture" +flowchart TD + Options["OtpOptions"] + COTP["createOtp"]:::primary + CI["createInput<string>"] + Context["OtpContext"] + + Options --> COTP + COTP --> CI + CI --> Context + COTP --> Context +``` + +Layer 2 orchestrator. Aggregates createInput for validation, dirty tracking, and ARIA wiring. No registry, no focus traversal, no observers — rendering, per-element refs, and keyboard wiring are the consumer's responsibility. + +## Reactivity + +| Property | Type | Reactive | +| - | - | - | +| `value` | `Ref` | Yes | +| `length` | `Readonly>` | Yes | +| `input` | `InputContext` | Yes (delegated) | +| `isComplete` | `Readonly>` | Yes | + +| Method | Signature | Effect | +| - | - | - | +| `put` | `(index: number, char: string) => void` | Writes one character at `index`; empty `char` truncates from `index`. | +| `distribute` | `(text: string, index?: number) => number` | Filters and splices, returns the count consumed. | +| `clear` | `() => void` | Empties the joined value. | +| `fill` | `(text: string) => void` | Replaces the joined value (filtered + clipped). | +| `accepts` | `(char: string) => boolean` | Exposes the pattern test so consumers can guard `beforeinput`. | + +Every helper is gated on the configured `disabled` and `readonly` options, and on the internal pending state while an async `onComplete` is in flight. + +## Patterns + +| Pattern | Matches | +| - | - | +| `'numeric'` | `[0-9]` | +| `'alphanumeric'` | `[a-zA-Z0-9]` | +| `'alphabetic'` | `[a-zA-Z]` | +| `RegExp` | Custom; tested per character | + +`accepts(char)` is the single point of truth and is reactive through `MaybeRefOrGetter` — toggle modes at runtime and every helper respects the new pattern on the next call. + +## Behavior + +- `put(index, char)` writes a single character at a position. Empty `char` truncates the joined value to `value.slice(0, index)` — matching the Backspace mental model. Multi-character `char` is reduced to its first character (use `distribute` for multi-character input). +- `distribute(text, index = 0)` filters `text` through `accepts`, splices the filtered characters in at `index`, clips the result to `length`, and returns the count consumed so consumers can decide where to advance focus. +- `isComplete` is true when the joined value reaches `length` and every character passes `accepts`. A watcher fires `onComplete(value)` exactly once on the false → true edge. +- `onComplete` is decisional. Return / resolve `false` to reject — `createOtp` clears the value and surfaces `v0.otp.rejected` through `input.errors`. The error clears automatically on the next mutation. +- While an async `onComplete` is pending, mutation helpers no-op so the user can't race the verification. + +## Examples + +::: example +/composables/create-otp/basic + +### Six-Input Numeric OTP + +A minimal six-input numeric OTP. The consumer's component owns the inputs, the template refs, and the per-element focus advance; `createOtp` owns the state, the pattern contract, and the length contract underneath. This is the headless-contract acid test: every visible behavior is replayable by writing markup against `value`, `length`, and `accepts`, with no slot tickets or focus indices baked into the composable. + +When to reach for this over a single wide ``: when the design calls for boxed per-character slots, when the consumer needs to react to per-position events (highlighting the focused position, animating fills), or when paste-handling deserves first-class treatment. For a single-input rendering of the same state, the same `createOtp` underneath works without modification — only the markup changes. + +Tradeoffs to know about. The example wires focus advance manually because focus is rendering territory; consumers preferring roving focus across the inputs can wrap the ``s in `useRovingFocus` without changing the state model. The `distribute` helper returns the count consumed so the consumer can choose where to land focus after the characters land — the example moves to the next still-empty slot, but other strategies (stay put, focus the last input, focus the submit button) are equally valid. + +Related: see [createInput](/composables/forms/create-input) for the validation, error, and field-state surface that `createOtp` aggregates underneath, and [createValidation](/composables/forms/create-validation) for the `rules` array that flows through unchanged. + +::: + +## FAQ + +::: faq + +??? Why is the value a string and not Ref<string[]>? + +Backends and form submissions expect the joined string. Storing as an array would force two derivations on every read and break v-model compatibility with `InputContext`. Per-position access is plain string indexing — `value.value[i] ?? ''` — which the consumer's component does inline when rendering. + +??? Why is onComplete decisional instead of an observational event? + +The dominant flow is "user finished typing → verify → wrong, clear it." Folding that into the completion event collapses a state machine consumers would otherwise hand-roll. Async verification also avoids racing a separate `validate` option for who clears the value first. + +??? Where does focus management live? + +In your component, not in `createOtp`. The composable has no concept of slots, element refs, or "which input is focused" because rendering shape is the component's call. A six-input OTP and a single-input OTP with character overlay use the same `createOtp` underneath. + +::: + + diff --git a/apps/docs/src/pages/composables/index.md b/apps/docs/src/pages/composables/index.md index 7820600f6..f9f00c9d4 100644 --- a/apps/docs/src/pages/composables/index.md +++ b/apps/docs/src/pages/composables/index.md @@ -245,6 +245,7 @@ Form state management and model binding utilities. | [createForm](/composables/forms/create-form) | Form validation coordinator | | [createInput](/composables/forms/create-input) | Shared form field primitive: validation, field state, ARIA IDs | | [createNumberField](/composables/forms/create-number-field) | Numeric input state with formatting, parsing, and validation | +| [createOtp](/composables/forms/create-otp) | OTP / verification code state with pattern-gated entry and decisional completion hook | | [createRating](/composables/forms/create-rating) | Bounded rating value with discrete items and half-step support | | [createSlider](/composables/forms/create-slider) | Slider state with multi-thumb support, step snapping, and value math | | [createValidation](/composables/forms/create-validation) | Per-field validation lifecycle | diff --git a/apps/docs/src/typed-router.d.ts b/apps/docs/src/typed-router.d.ts index f97331bda..f5b16eb23 100644 --- a/apps/docs/src/typed-router.d.ts +++ b/apps/docs/src/typed-router.d.ts @@ -422,6 +422,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/composables/forms/create-otp': RouteRecordInfo< + '/composables/forms/create-otp', + '/composables/forms/create-otp', + Record, + Record, + | never + >, '/composables/forms/create-rating': RouteRecordInfo< '/composables/forms/create-rating', '/composables/forms/create-rating', @@ -1415,6 +1422,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/composables/forms/create-otp.md': { + routes: + | '/composables/forms/create-otp' + views: + | never + } 'src/pages/composables/forms/create-rating.md': { routes: | '/composables/forms/create-rating' diff --git a/packages/0/README.md b/packages/0/README.md index 7269e6e4b..41accb5bd 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -189,6 +189,7 @@ Selection management composables built on `createRegistry`: - [`createForm`](https://0.vuetifyjs.com/composables/forms/create-form) - Form validation and state management with async rules - [`createInput`](https://0.vuetifyjs.com/composables/forms/create-input) - Shared form field state: validation, dirty/pristine, ARIA IDs - [`createNumberField`](https://0.vuetifyjs.com/composables/forms/create-number-field) - Numeric input state with formatting, stepping, and validation +- [`createOtp`](https://0.vuetifyjs.com/composables/forms/create-otp) - OTP / verification code state with pattern-gated entry and decisional completion hook - [`createValidation`](https://0.vuetifyjs.com/composables/forms/create-validation) - Field-level validation with sync/async rules - [`createCombobox`](https://0.vuetifyjs.com/composables/forms/create-combobox) - Combobox state management with filtering and virtual focus - [`createRating`](https://0.vuetifyjs.com/composables/forms/create-rating) - Bounded rating value with discrete items and half-step support diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts new file mode 100644 index 000000000..20a2bd923 --- /dev/null +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -0,0 +1,605 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createOtp } from './index' + +// Utilities +import { nextTick, shallowRef } from 'vue' + +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { ...actual, provide: vi.fn(), inject: vi.fn() } +}) + +function setup (options: Parameters[0] = {}) { + return createOtp(options) +} + +describe('createOtp', () => { + describe('shape', () => { + it('should expose the documented context surface', () => { + const otp = setup() + expect(otp.value).toBeDefined() + expect(otp.length.value).toBe(6) + expect(otp.input).toBeDefined() + expect(otp.isComplete.value).toBe(false) + expect(typeof otp.put).toBe('function') + expect(typeof otp.distribute).toBe('function') + expect(typeof otp.clear).toBe('function') + expect(typeof otp.fill).toBe('function') + expect(typeof otp.accepts).toBe('function') + }) + + it('should accept an external value ref', () => { + const value = shallowRef('123') + const otp = setup({ value }) + expect(otp.value.value).toBe('123') + }) + }) + + describe('accepts', () => { + it('should accept digits with the default numeric pattern', () => { + const otp = setup() + expect(otp.accepts('0')).toBe(true) + expect(otp.accepts('9')).toBe(true) + }) + + it('should reject non-digits with the numeric pattern', () => { + const otp = setup() + expect(otp.accepts('a')).toBe(false) + expect(otp.accepts(' ')).toBe(false) + expect(otp.accepts('')).toBe(false) + }) + + it('should accept letters and digits with the alphanumeric pattern', () => { + const otp = setup({ pattern: 'alphanumeric' }) + expect(otp.accepts('A')).toBe(true) + expect(otp.accepts('z')).toBe(true) + expect(otp.accepts('4')).toBe(true) + expect(otp.accepts('-')).toBe(false) + }) + + it('should accept letters only with the alphabetic pattern', () => { + const otp = setup({ pattern: 'alphabetic' }) + expect(otp.accepts('A')).toBe(true) + expect(otp.accepts('9')).toBe(false) + }) + + it('should apply a custom single-character RegExp', () => { + const otp = setup({ pattern: /^[0-9a-fA-F]$/ }) + expect(otp.accepts('f')).toBe(true) + expect(otp.accepts('G')).toBe(false) + }) + + it('should react to pattern changes through MaybeRefOrGetter', () => { + const mode = shallowRef<'numeric' | 'alphabetic'>('numeric') + const otp = setup({ pattern: () => mode.value }) + expect(otp.accepts('1')).toBe(true) + mode.value = 'alphabetic' + expect(otp.accepts('1')).toBe(false) + expect(otp.accepts('a')).toBe(true) + }) + + it('should warn through useLogger when a RegExp matches multi-character input', () => { + const otp = setup({ pattern: /^[0-9]+$/ }) + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + otp.accepts('1') + otp.accepts('2') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(expect.stringContaining('multi-character')) + spy.mockRestore() + }) + + it('should reject multi-character input', () => { + const otp = setup() + expect(otp.accepts('12')).toBe(false) + expect(otp.accepts('ab')).toBe(false) + }) + }) + + describe('put', () => { + it('should write a single character at the index', () => { + const otp = setup() + otp.put(0, '4') + expect(otp.value.value).toBe('4') + }) + + it('should write at the configured length boundary', () => { + const otp = setup({ length: 4 }) + otp.fill('12') + otp.put(2, '3') + otp.put(3, '4') + expect(otp.value.value).toBe('1234') + }) + + it('should silently drop out-of-range indices', () => { + const otp = setup({ length: 4 }) + otp.put(-1, '1') + otp.put(4, '1') + expect(otp.value.value).toBe('') + }) + + it('should truncate to-the-end when char is empty', () => { + const otp = setup() + otp.fill('12345') + otp.put(2, '') + expect(otp.value.value).toBe('12') + }) + + it('should use the first character when multi-char is passed', () => { + const otp = setup({ pattern: 'alphanumeric' }) + otp.put(0, 'ab') + expect(otp.value.value).toBe('a') + }) + + it('should drop characters that fail the pattern', () => { + const otp = setup() // numeric default + otp.put(0, 'a') + expect(otp.value.value).toBe('') + }) + }) + + describe('distribute', () => { + it('should distribute filtered characters and return the count consumed', () => { + const otp = setup({ length: 6 }) + const count = otp.distribute('123456') + expect(count).toBe(6) + expect(otp.value.value).toBe('123456') + }) + + it('should filter rejected characters before distribution', () => { + const otp = setup({ length: 6 }) + const count = otp.distribute('12-34-56') + expect(count).toBe(6) + expect(otp.value.value).toBe('123456') + }) + + it('should splice into the existing value at the given index', () => { + const otp = setup({ length: 6 }) + otp.fill('12') + const count = otp.distribute('34', 2) + expect(count).toBe(2) + expect(otp.value.value).toBe('1234') + }) + + it('should clip to length when distribute would overflow', () => { + const otp = setup({ length: 4 }) + const count = otp.distribute('123456') + expect(count).toBe(4) + expect(otp.value.value).toBe('1234') + }) + + it('should return 0 when every character is rejected', () => { + const otp = setup({ length: 4 }) + const count = otp.distribute('abc') + expect(count).toBe(0) + expect(otp.value.value).toBe('') + }) + + it('should clamp a negative index to 0', () => { + const otp = setup({ length: 4 }) + const count = otp.distribute('12', -5) + expect(count).toBe(2) + expect(otp.value.value).toBe('12') + }) + + it('should return 0 when distributing at index equal to length', () => { + const otp = setup({ length: 4 }) + otp.fill('1234') + const count = otp.distribute('5', 4) + expect(count).toBe(0) + expect(otp.value.value).toBe('1234') + }) + }) + + describe('clear', () => { + it('should empty the joined value', () => { + const otp = setup() + otp.fill('1234') + otp.clear() + expect(otp.value.value).toBe('') + }) + }) + + describe('fill', () => { + it('should replace the joined value with filtered, length-clipped input', () => { + const otp = setup({ length: 4 }) + otp.fill('1-2-3-4-5') + expect(otp.value.value).toBe('1234') + }) + }) + + describe('isComplete', () => { + it('should be false until the value reaches length', () => { + const otp = setup({ length: 4 }) + expect(otp.isComplete.value).toBe(false) + otp.fill('123') + expect(otp.isComplete.value).toBe(false) + otp.put(3, '4') + expect(otp.isComplete.value).toBe(true) + }) + + it('should drop back to false when the value shrinks', () => { + const otp = setup({ length: 4 }) + otp.fill('1234') + expect(otp.isComplete.value).toBe(true) + otp.put(3, '') + expect(otp.isComplete.value).toBe(false) + }) + + it('should update when reactive length changes', () => { + const length = shallowRef(6) + const otp = setup({ length }) + otp.fill('123456') + expect(otp.isComplete.value).toBe(true) + length.value = 8 + expect(otp.isComplete.value).toBe(false) + otp.fill('12345678') + expect(otp.isComplete.value).toBe(true) + }) + + it('should drop isComplete to false when pattern change invalidates existing characters', async () => { + const mode = shallowRef<'numeric' | 'alphabetic'>('numeric') + const otp = setup({ length: 4, pattern: () => mode.value }) + otp.fill('1234') + expect(otp.isComplete.value).toBe(true) + mode.value = 'alphabetic' + await nextTick() + expect(otp.isComplete.value).toBe(false) + }) + }) + + describe('disabled / readonly gating', () => { + it('should no-op mutations when disabled is true', () => { + const disabled = shallowRef(true) + const otp = setup({ disabled }) + otp.put(0, '1') + otp.distribute('123') + otp.fill('999') + expect(otp.value.value).toBe('') + disabled.value = false + otp.put(0, '1') + expect(otp.value.value).toBe('1') + }) + + it('should no-op mutations when readonly is true', () => { + const otp = setup({ readonly: true }) + otp.put(0, '1') + otp.distribute('123') + otp.fill('999') + otp.clear() + expect(otp.value.value).toBe('') + }) + }) + + describe('onComplete (sync)', () => { + it('should fire once on the false → true edge', async () => { + const onComplete = vi.fn() + const otp = setup({ length: 4, onComplete }) + otp.fill('1234') + await nextTick() + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenCalledWith('1234') + }) + + it('should fire again on a subsequent false → true cycle', async () => { + const onComplete = vi.fn() + const otp = setup({ length: 4, onComplete }) + otp.fill('1234') + await nextTick() + otp.clear() + otp.fill('5678') + await nextTick() + expect(onComplete).toHaveBeenCalledTimes(2) + }) + + it('should clear value and set error when sync onComplete returns false', async () => { + const otp = setup({ length: 4, onComplete: () => false }) + otp.fill('1234') + await nextTick() + expect(otp.value.value).toBe('') + expect(otp.input.isValid.value).toBe(false) + expect(otp.input.errors.value).toContain('v0.otp.rejected') + }) + + it('should clear error state on the next mutation', async () => { + const otp = setup({ length: 4, onComplete: () => false }) + otp.fill('1234') + await nextTick() + expect(otp.input.errors.value).toContain('v0.otp.rejected') + otp.put(0, '9') + expect(otp.input.errors.value).not.toContain('v0.otp.rejected') + }) + + it('should fire onComplete again when the same value is re-entered after rejection', async () => { + let allow = false + const onComplete = vi.fn(() => allow) + const otp = setup({ length: 4, onComplete }) + otp.fill('1234') + await nextTick() + expect(onComplete).toHaveBeenCalledTimes(1) + expect(otp.value.value).toBe('') + allow = true + otp.fill('1234') + await nextTick() + expect(onComplete).toHaveBeenCalledTimes(2) + expect(otp.value.value).toBe('1234') + }) + + it('should clear value and warn when onComplete throws', async () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const otp = setup({ length: 4, onComplete: () => { + throw new Error('boom') + } }) + otp.fill('1234') + await nextTick() + expect(otp.value.value).toBe('') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(expect.stringContaining('onComplete threw')) + spy.mockRestore() + }) + + it('should leave the value intact when sync onComplete returns undefined', async () => { + const otp = setup({ length: 4, onComplete: () => {} }) + otp.fill('1234') + await nextTick() + expect(otp.value.value).toBe('1234') + expect(otp.input.errors.value).toEqual([]) + }) + + it('should fire onComplete again after clear and re-entry of the same accepted value', async () => { + const onComplete = vi.fn(() => true) + const otp = setup({ length: 4, onComplete }) + otp.fill('1234') + await nextTick() + expect(onComplete).toHaveBeenCalledTimes(1) + otp.clear() + otp.fill('1234') + await nextTick() + expect(onComplete).toHaveBeenCalledTimes(2) + }) + + it('should handle onComplete throwing a non-Error value without crashing', async () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const otp = setup({ length: 4, onComplete: () => { + throw null + } }) + otp.fill('1234') + await nextTick() + expect(otp.value.value).toBe('') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(expect.stringContaining('onComplete threw')) + spy.mockRestore() + }) + + it('should preserve rejection error when fill receives only rejected characters', async () => { + const otp = setup({ length: 4, onComplete: () => false }) + otp.fill('1234') + await nextTick() + expect(otp.input.errors.value).toContain('v0.otp.rejected') + otp.fill('----') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + expect(otp.value.value).toBe('') + }) + + it('should clear rejection error when fill receives empty input', async () => { + const otp = setup({ length: 4, onComplete: () => false }) + otp.fill('1234') + await nextTick() + expect(otp.input.errors.value).toContain('v0.otp.rejected') + otp.fill('') + expect(otp.input.errors.value).not.toContain('v0.otp.rejected') + expect(otp.value.value).toBe('') + }) + + it('should not fire onComplete when reactive length decrease makes value complete', async () => { + const length = shallowRef(4) + const onComplete = vi.fn(() => true) + const otp = setup({ length, onComplete }) + otp.fill('12') + await nextTick() + expect(onComplete).not.toHaveBeenCalled() + length.value = 2 + await nextTick() + // The watcher is on `value`, not on `isComplete` — length-driven completion + // does not trigger onComplete. Consumers driving length reactively must watch + // isComplete themselves if they need to react to length-induced completion. + expect(onComplete).not.toHaveBeenCalled() + expect(otp.isComplete.value).toBe(true) + }) + }) + + describe('onComplete (async)', () => { + it('should no-op mutations and clear value on async rejection', async () => { + let resolve!: (ok: boolean) => void + const otp = setup({ + length: 4, + onComplete: () => new Promise(r => { + resolve = r + }), + }) + otp.fill('1234') + await nextTick() + otp.put(0, '9') + expect(otp.value.value).toBe('1234') + resolve(false) + await nextTick() + await nextTick() + expect(otp.value.value).toBe('') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + }) + + it('should leave the value intact on async accept', async () => { + const otp = setup({ + length: 4, + onComplete: () => Promise.resolve(true), + }) + otp.fill('1234') + await nextTick() + await nextTick() + expect(otp.value.value).toBe('1234') + expect(otp.input.errors.value).toEqual([]) + }) + + it('should clear value and warn when onComplete returns a rejected promise', async () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const otp = setup({ + length: 4, + onComplete: () => Promise.reject(new Error('network error')), + }) + otp.fill('1234') + await nextTick() + await nextTick() + expect(otp.value.value).toBe('') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(expect.stringContaining('onComplete rejected')) + spy.mockRestore() + }) + + it('should handle a rejected promise with a non-Error value without crashing', async () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const otp = setup({ + length: 4, + onComplete: () => Promise.reject(null), + }) + otp.fill('1234') + await nextTick() + await nextTick() + expect(otp.value.value).toBe('') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(expect.stringContaining('onComplete rejected')) + spy.mockRestore() + }) + + it('should accept a thenable that is not a native Promise', async () => { + const onComplete = vi.fn(() => ({ + // eslint-disable-next-line unicorn/no-thenable + then (onfulfilled?: ((v: boolean) => unknown) | null) { + onfulfilled?.(true) + }, + }) as PromiseLike) + const otp = setup({ length: 4, onComplete }) + otp.fill('1234') + await nextTick() + await nextTick() + expect(otp.value.value).toBe('1234') + expect(otp.input.errors.value).toEqual([]) + }) + + it('should reject when a non-native thenable resolves false', async () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const onComplete = vi.fn(() => ({ + // eslint-disable-next-line unicorn/no-thenable + then (onfulfilled?: ((v: boolean) => unknown) | null) { + onfulfilled?.(false) + }, + }) as PromiseLike) + const otp = setup({ length: 4, onComplete }) + otp.fill('1234') + await nextTick() + await nextTick() + expect(otp.value.value).toBe('') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + spy.mockRestore() + }) + + it('should unblock mutations after async onComplete resolves true', async () => { + let resolve!: (ok: boolean) => void + const otp = setup({ + length: 4, + onComplete: () => new Promise(r => { + resolve = r + }), + }) + otp.fill('1234') + await nextTick() + // During pending, mutations no-op + otp.put(0, '9') + expect(otp.value.value).toBe('1234') + // Accept the completion + resolve(true) + await nextTick() + await nextTick() + // Now mutations should work again + otp.put(0, '9') + expect(otp.value.value).toBe('9234') + }) + + it('should no-op clear() while async onComplete is pending', async () => { + let resolve!: (ok: boolean) => void + const otp = setup({ + length: 4, + onComplete: () => new Promise(r => { + resolve = r + }), + }) + otp.fill('1234') + await nextTick() + otp.clear() + expect(otp.value.value).toBe('1234') + resolve(true) + await nextTick() + await nextTick() + // Unlocked after accept; clear works now + otp.clear() + expect(otp.value.value).toBe('') + }) + + it('should leave value intact when async onComplete resolves undefined (void)', async () => { + const otp = setup({ + length: 4, + onComplete: async () => { /* void */ }, + }) + otp.fill('1234') + await nextTick() + await nextTick() + expect(otp.value.value).toBe('1234') + expect(otp.input.errors.value).toEqual([]) + }) + }) + + describe('input passthrough', () => { + it('should apply rules to the joined value through createInput', async () => { + const otp = setup({ + length: 4, + rules: [(v: unknown) => v === '1234' || 'Invalid code'], + }) + otp.fill('5678') + await otp.input.validate() + expect(otp.input.errors.value).toContain('Invalid code') + }) + + it('should reset the OTP value via input.reset()', () => { + const otp = setup({ length: 4 }) + otp.fill('1234') + expect(otp.value.value).toBe('1234') + otp.input.reset() + expect(otp.value.value).toBe('') + }) + + it('should clear the rejection error when input.reset() is called', async () => { + const otp = setup({ length: 4, onComplete: () => false }) + otp.fill('1234') + await nextTick() + expect(otp.input.errors.value).toContain('v0.otp.rejected') + otp.input.reset() + expect(otp.input.errors.value).not.toContain('v0.otp.rejected') + expect(otp.value.value).toBe('') + }) + + it('should fire onComplete again after input.reset() and same-value re-entry', async () => { + const onComplete = vi.fn(() => true) + const otp = setup({ length: 4, onComplete }) + otp.fill('1234') + await nextTick() + expect(onComplete).toHaveBeenCalledTimes(1) + otp.input.reset() + otp.fill('1234') + await nextTick() + expect(onComplete).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts new file mode 100644 index 000000000..3098445d2 --- /dev/null +++ b/packages/0/src/composables/createOtp/index.ts @@ -0,0 +1,336 @@ +/** + * @module createOtp + * + * @see https://0.vuetifyjs.com/composables/forms/create-otp + * + * @remarks + * Headless state for a fixed-length one-time-password / verification-code value. + * Owns the joined string, per-character pattern matching, length contract, + * completion edge detection with a decisional async `onComplete` hook, and + * validation through `createInput`. Rendering, focus, and per-element event + * wiring are the consumer's responsibility. + * + * @example + * ```ts + * import { createOtp } from '@vuetify/v0' + * + * const otp = createOtp({ + * length: 6, + * pattern: 'numeric', + * onComplete: async value => { + * const ok = await verify(value) + * return ok + * }, + * }) + * + * otp.put(0, '4') + * otp.distribute('12345', 1) // returns 5; value becomes '412345' + * otp.isComplete.value // true + * ``` + */ + +// Composables +import { createInput } from '#v0/composables/createInput' +import { useLogger } from '#v0/composables/useLogger' + +// Utilities +import { clamp, isString, isThenable } from '#v0/utilities' +import { shallowRef, toRef, toValue, watch } from 'vue' + +// Types +import type { InputContext, InputOptions } from '#v0/composables/createInput' +import type { MaybeRefOrGetter, Ref } from 'vue' + +/** + * Pattern presets — string literals compile to internal RegExps; arbitrary + * RegExp values are applied per character. + * + * @example + * ```ts + * const otp = createOtp({ pattern: 'numeric' }) + * const hex = createOtp({ pattern: /^[0-9a-fA-F]$/ }) + * ``` + */ +export type OtpPattern = 'numeric' | 'alphanumeric' | 'alphabetic' | RegExp + +const PRESETS: Record, RegExp> = { + numeric: /^[0-9]$/, + alphanumeric: /^[a-zA-Z0-9]$/, + alphabetic: /^[a-zA-Z]$/, +} + +const warned = /* @__PURE__ */ new WeakSet() + +function extractMessage (error: unknown): string { + if (error instanceof Error) return error.message + if (isString(error)) return error + return String(error) +} + +/** + * Options accepted by `createOtp`. + * + * @example + * ```ts + * const otp = createOtp({ + * length: 6, + * pattern: 'numeric', + * disabled: false, + * onComplete: async value => { + * const ok = await verify(value) + * return ok + * }, + * }) + * ``` + */ +export interface OtpOptions extends Omit, 'value' | 'error' | 'errorMessages'> { + /** Number of characters. @default 6 */ + length?: MaybeRefOrGetter + /** Joined value source. @default shallowRef('') */ + value?: Ref + /** + * Per-character pattern. String presets compile to internal RegExps; + * custom RegExp must match a single character. + * @default 'numeric' + */ + pattern?: MaybeRefOrGetter + /** + * Fire when the joined value first reaches `length`. Decisional — + * return / resolve `false` to reject (clears value, sets error). + */ + onComplete?: (value: string) => boolean | void | PromiseLike +} + +/** + * Reactive context returned by `createOtp`. Consumers index into `value` + * for per-character rendering and call the mutation helpers to drive state. + * + * @example + * ```ts + * const otp = createOtp({ length: 6 }) + * + * otp.put(0, '4') + * otp.distribute('12345', 1) // returns 5 + * otp.isComplete.value // true + * otp.clear() + * ``` + */ +export interface OtpContext { + /** + * The joined OTP string. Use the mutation helpers (`put`, `distribute`, + * `fill`, `clear`) to update. Writing through this ref directly is + * intentionally prevented to preserve pattern, length, and lock invariants. + */ + value: Readonly> + length: Readonly> + /** + * The underlying `createInput` context — exposes ARIA IDs, validation + * state, focus/touched, and the `validate` / `reset` methods. + * + * Note: `input.value` aliases `OtpContext.value` and is also readonly on + * the public surface. Use the mutation helpers (`put`, `distribute`, + * `fill`, `clear`) to update. + */ + input: Omit, 'value'> & { value: Readonly> } + isComplete: Readonly> + put: (index: number, char: string) => void + distribute: (text: string, index?: number) => number + clear: () => void + fill: (text: string) => void + accepts: (char: string) => boolean +} + +export function createOtp (options: OtpOptions = {}): OtpContext { + const { + value = shallowRef(''), + length = 6, + pattern = 'numeric', + disabled = false, + readonly: _readonly = false, + onComplete, + ...inputOptions + } = options + + const logger = useLogger() + + const errorRef = shallowRef(false) + const errorMessagesRef = shallowRef(undefined) + + const input = createInput({ + ...inputOptions, + value, + disabled, + readonly: _readonly, + error: errorRef, + errorMessages: errorMessagesRef, + }) + + const lengthRef = toRef(() => toValue(length)) + + // Track the last value that triggered onComplete to detect repeated completion edges. + // Watching isComplete directly misses cycles where value clears then refills within + // the same flush tick (old === new === true, Vue skips the callback). + let lastCompleted = '' + + function compile (resolved: OtpPattern): RegExp { + if (isString(resolved)) return PRESETS[resolved] + if (__DEV__ && !warned.has(resolved) && (resolved.test('aa') || resolved.test('00'))) { + warned.add(resolved) + logger.warn('createOtp: pattern matches multi-character input; per-character matching may behave unexpectedly') + } + return resolved + } + + function accepts (char: string): boolean { + if (char.length !== 1) return false + return compile(toValue(pattern)).test(char) + } + + function filterAccepted (text: string): string { + let out = '' + for (const ch of text) { + if (accepts(ch)) out += ch + } + return out + } + + // While an async onComplete is pending, mutation helpers no-op via `isLocked()`. + // The spec also wants `input.isValidating` to reflect this state; today + // createInput doesn't accept an external isValidating source. Tracked as a + // follow-up — wire `isValidating: isPending` once createInput supports it. + const isPending = shallowRef(false) + + function isLocked (): boolean { + return toValue(disabled) || toValue(_readonly) || isPending.value + } + + function clearRejection (): void { + if (errorRef.value || errorMessagesRef.value) { + errorRef.value = false + errorMessagesRef.value = undefined + } + } + + function put (index: number, char: string): void { + if (isLocked()) return + const max = toValue(lengthRef) + if (index < 0 || index >= max) return + if (char === '') { + clearRejection() + value.value = value.value.slice(0, index) + return + } + const first = char[0] + if (!accepts(first)) return + clearRejection() + const current = value.value + const head = current.slice(0, index) + const tail = current.slice(index + 1) + value.value = (head + first + tail).slice(0, max) + } + + function distribute (text: string, index = 0): number { + if (isLocked()) return 0 + const max = toValue(lengthRef) + const start = clamp(index, 0, max) + const filtered = filterAccepted(text) + if (filtered.length === 0) return 0 + clearRejection() + const head = value.value.slice(0, start) + const next = (head + filtered).slice(0, max) + value.value = next + return next.length - head.length + } + + function clear (): void { + if (isLocked()) return + clearRejection() + lastCompleted = '' + value.value = '' + } + + function fill (text: string): void { + if (isLocked()) return + const max = toValue(lengthRef) + const filtered = filterAccepted(text).slice(0, max) + // Clear rejection on deliberate empty (fill('')) or any accepted input. + // Skip clearing if user provided non-empty input that was entirely rejected + // — preserves the rejection signal until the user enters a valid character. + if (text.length === 0 || filtered.length > 0) clearRejection() + value.value = filtered + } + + function reject (): void { + errorRef.value = true + errorMessagesRef.value = ['v0.otp.rejected'] + lastCompleted = '' + value.value = '' + } + + const isComplete = toRef(() => { + const max = toValue(lengthRef) + const v = value.value + if (v.length !== max) return false + const re = compile(toValue(pattern)) + for (const ch of v) { + if (!re.test(ch)) return false + } + return true + }) + + watch(value, next => { + if (!isComplete.value) { + lastCompleted = '' + return + } + if (!onComplete) return + if (isLocked()) return + if (next === lastCompleted) return + lastCompleted = next + let result: ReturnType> + try { + result = onComplete(next) + } catch (error) { + logger.warn(`createOtp: onComplete threw — ${extractMessage(error)}`) + reject() + return + } + if (isThenable(result)) { + isPending.value = true + result.then( + ok => { + isPending.value = false + if (ok === false) reject() + }, + error => { + isPending.value = false + logger.warn(`createOtp: onComplete rejected — ${extractMessage(error)}`) + reject() + }, + ) + return + } + if (result === false) reject() + }) + + const inputContext: InputContext = { + ...input, + reset () { + clearRejection() + lastCompleted = '' + input.reset() + }, + } + + return { + value, + length: lengthRef, + input: inputContext, + isComplete, + put, + distribute, + clear, + fill, + accepts, + } +} diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts index 0f1363371..b24554d27 100644 --- a/packages/0/src/composables/index.ts +++ b/packages/0/src/composables/index.ts @@ -12,6 +12,7 @@ export * from './createModel' export * from './createNested' export * from './createNumberField' export * from './createNumeric' +export * from './createOtp' export * from './createOverflow' export * from './createPagination' export * from './createPlugin' diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index d5cf9b424..b560b7d55 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -101,6 +101,11 @@ "since": "0.2.0", "category": "forms" }, + "createOtp": { + "level": "preview", + "since": null, + "category": "forms" + }, "createDataTable": { "level": "preview", "since": "0.1.0", diff --git a/skills/vuetify0/SKILL.md b/skills/vuetify0/SKILL.md index 75e1fe346..9b43233d0 100644 --- a/skills/vuetify0/SKILL.md +++ b/skills/vuetify0/SKILL.md @@ -38,6 +38,7 @@ Check this table **before writing custom logic**. Match by problem, not by keywo | Slider / range / knob state | `createSlider` | forms | | Autocomplete / combobox | `createCombobox` | forms | | Spin-button numeric input | `createNumberField` / `createNumeric` | forms | +| One-time-password / verification-code value | `createOtp` | forms | | Paginated or virtualized list | `createPagination`, `createVirtual` | data | | Sortable / filterable table | `createDataTable`, `createFilter` | data | | Breadcrumb trail derived from route | `createBreadcrumbs` | utilities | diff --git a/skills/vuetify0/references/REFERENCE.md b/skills/vuetify0/references/REFERENCE.md index e4fb35a09..bf8cc4cb8 100644 --- a/skills/vuetify0/references/REFERENCE.md +++ b/skills/vuetify0/references/REFERENCE.md @@ -113,6 +113,22 @@ email.validate() form.submit() // Validates all fields ``` +#### createOtp — OTP / verification code +```ts +import { createOtp } from '@vuetify/v0' + +const otp = createOtp({ + length: 6, + pattern: 'numeric', + onComplete: async value => await verify(value), +}) + +otp.put(0, '4') +otp.distribute('123456') // returns count consumed +otp.value.value // joined string +otp.isComplete.value +``` + ### Context & State Sharing #### createContext — Type-Safe Provide/Inject