From 28c7634c79b565334d50a0ee810e863fbc5b5615 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:20:04 -0500 Subject: [PATCH 01/23] feat(createOtp): scaffold composable with placeholder surface Adds the createOtp directory, barrel export, and maturity entry. The composable returns the documented context shape but the mutation helpers are placeholders that subsequent commits flesh out per the locked design spec. --- .../0/src/composables/createOtp/index.test.ts | 38 +++++ packages/0/src/composables/createOtp/index.ts | 147 ++++++++++++++++++ packages/0/src/composables/index.ts | 1 + packages/0/src/maturity.json | 5 + 4 files changed, 191 insertions(+) create mode 100644 packages/0/src/composables/createOtp/index.test.ts create mode 100644 packages/0/src/composables/createOtp/index.ts 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..5929834d1 --- /dev/null +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createOtp } from './index' + +// Utilities +import { 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.setAt).toBe('function') + expect(typeof otp.paste).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') + }) + }) +}) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts new file mode 100644 index 000000000..0611791df --- /dev/null +++ b/packages/0/src/composables/createOtp/index.ts @@ -0,0 +1,147 @@ +/** + * @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.setAt(0, '4') + * otp.paste('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 } 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 + +export interface OtpOptions extends Omit, 'value'> { + /** 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 + /** Disabled state — mutation helpers no-op. @default false */ + disabled?: MaybeRefOrGetter + /** Readonly state — mutation helpers no-op. @default false */ + readonly?: MaybeRefOrGetter + /** + * Fire when the joined value first reaches `length`. Decisional — + * return / resolve `false` to reject (clears value, sets error). + */ + onComplete?: (value: string) => boolean | void | Promise +} + +export interface OtpContext { + value: Ref + length: Readonly> + input: InputContext + isComplete: Readonly> + setAt: (index: number, char: string) => void + paste: (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)) + + // Placeholders — fleshed out in subsequent tasks. + void logger + void clamp + void watch + void onComplete + void pattern + + const isComplete = shallowRef(false) + + function accepts (_char: string): boolean { + return false + } + function setAt (_index: number, _char: string): void {} + function paste (_text: string, _index = 0): number { + return 0 + } + function clear (): void {} + function fill (_text: string): void {} + + return { + value, + length: lengthRef, + input, + isComplete, + setAt, + paste, + 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..373232c7f 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": "draft", + "since": null, + "category": "forms" + }, "createDataTable": { "level": "preview", "since": "0.1.0", From d0f0489daebf276bc9c1f18b03f33a6567de4b18 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:26:33 -0500 Subject: [PATCH 02/23] docs(createOtp): add @example blocks to OtpOptions and OtpContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Satisfies the "Per-symbol @example (100% enforced)" rule from .claude/rules/composables.md — the interfaces render in and require their own example blocks. --- packages/0/src/composables/createOtp/index.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 0611791df..4e8ae537b 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -53,6 +53,22 @@ import type { MaybeRefOrGetter, Ref } from 'vue' */ export type OtpPattern = 'numeric' | 'alphanumeric' | 'alphabetic' | RegExp +/** + * 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'> { /** Number of characters. @default 6 */ length?: MaybeRefOrGetter @@ -75,6 +91,20 @@ export interface OtpOptions extends Omit, 'value'> { onComplete?: (value: string) => boolean | void | Promise } +/** + * 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.setAt(0, '4') + * otp.paste('12345', 1) // returns 5 + * otp.isComplete.value // true + * otp.clear() + * ``` + */ export interface OtpContext { value: Ref length: Readonly> From 4715e7fb92d0df9b8eb5314883aa8b028059022b Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:30:20 -0500 Subject: [PATCH 03/23] feat(createOtp): implement pattern matching with accepts() Compiles string presets ('numeric', 'alphanumeric', 'alphabetic') lazily and applies arbitrary RegExp patterns per character. Reactive through MaybeRefOrGetter. A dev-mode useLogger warn fires once per RegExp that matches multi-character input. --- .../0/src/composables/createOtp/index.test.ts | 53 +++++++++++++++++++ packages/0/src/composables/createOtp/index.ts | 28 +++++++--- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 5929834d1..0d509d7a2 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -35,4 +35,57 @@ describe('createOtp', () => { 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).toHaveBeenCalled() + spy.mockRestore() + }) + }) }) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 4e8ae537b..2804888a6 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -144,18 +144,34 @@ export function createOtp (options: OtpOptions = {}): OtpContext { const lengthRef = toRef(() => toValue(length)) + const PRESETS: Record, RegExp> = { + numeric: /^[0-9]$/, + alphanumeric: /^[a-zA-Z0-9]$/, + alphabetic: /^[a-zA-Z]$/, + } + + const warned = new WeakSet() + + function compile (resolved: OtpPattern): RegExp { + if (typeof resolved === 'string') 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) + } + // Placeholders — fleshed out in subsequent tasks. - void logger void clamp void watch void onComplete - void pattern const isComplete = shallowRef(false) - - function accepts (_char: string): boolean { - return false - } function setAt (_index: number, _char: string): void {} function paste (_text: string, _index = 0): number { return 0 From ffb7e06be0d8dfb99b50c7ff9ac1316c10a14c84 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:32:46 -0500 Subject: [PATCH 04/23] test(createOtp): tighten warn assertion to lock no-spam contract The WeakSet cache ensures the multi-char warn fires exactly once per pattern instance. Assert toHaveBeenCalledTimes(1) so the no-spam guarantee is locked by the test, not just the implementation. --- packages/0/src/composables/createOtp/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 0d509d7a2..a2c79ee7c 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -84,7 +84,7 @@ describe('createOtp', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) otp.accepts('1') otp.accepts('2') - expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledTimes(1) spy.mockRestore() }) }) From 85ff2b0457a7f986dd2a63c7972dce814f627e4f Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:36:43 -0500 Subject: [PATCH 05/23] refactor(createOtp): use isString guard and assert warn message Replaces typeof resolved === 'string' with isString(resolved) per the project type-guard rule, and tightens the multi-character warn test to assert the message content alongside the call count. --- packages/0/src/composables/createOtp/index.test.ts | 1 + packages/0/src/composables/createOtp/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index a2c79ee7c..7bae9a58f 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -85,6 +85,7 @@ describe('createOtp', () => { otp.accepts('1') otp.accepts('2') expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(expect.stringContaining('multi-character')) spy.mockRestore() }) }) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 2804888a6..5f891644d 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -34,7 +34,7 @@ import { createInput } from '#v0/composables/createInput' import { useLogger } from '#v0/composables/useLogger' // Utilities -import { clamp } from '#v0/utilities' +import { clamp, isString } from '#v0/utilities' import { shallowRef, toRef, toValue, watch } from 'vue' // Types @@ -153,7 +153,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { const warned = new WeakSet() function compile (resolved: OtpPattern): RegExp { - if (typeof resolved === 'string') return PRESETS[resolved] + 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') From 68e2620b60bd5d459022e17f07343913cfd44719 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:39:34 -0500 Subject: [PATCH 06/23] feat(createOtp): implement setAt, paste, clear, fill setAt overwrites a single position, truncates-from-here when char is empty, and takes the first character of multi-char input (consumers use paste for distribution). paste filters through accepts, splices at the index, and clips to length, returning the consumed count. fill replaces the joined value through the same filter+clip path. --- .../0/src/composables/createOtp/index.test.ts | 97 +++++++++++++++++++ packages/0/src/composables/createOtp/index.ts | 50 ++++++++-- 2 files changed, 140 insertions(+), 7 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 7bae9a58f..6f546181e 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -89,4 +89,101 @@ describe('createOtp', () => { spy.mockRestore() }) }) + + describe('setAt', () => { + it('should write a single character at the index', () => { + const otp = setup() + otp.setAt(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.setAt(2, '3') + otp.setAt(3, '4') + expect(otp.value.value).toBe('1234') + }) + + it('should silently drop out-of-range indices', () => { + const otp = setup({ length: 4 }) + otp.setAt(-1, '1') + otp.setAt(4, '1') + expect(otp.value.value).toBe('') + }) + + it('should truncate to-the-end when char is empty', () => { + const otp = setup() + otp.fill('12345') + otp.setAt(2, '') + expect(otp.value.value).toBe('12') + }) + + it('should use the first character when multi-char is passed', () => { + const otp = setup({ pattern: 'alphanumeric' }) + otp.setAt(0, 'ab') + expect(otp.value.value).toBe('a') + }) + + it('should drop characters that fail the pattern', () => { + const otp = setup() // numeric default + otp.setAt(0, 'a') + expect(otp.value.value).toBe('') + }) + }) + + describe('paste', () => { + it('should distribute filtered characters and return the count consumed', () => { + const otp = setup({ length: 6 }) + const count = otp.paste('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.paste('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.paste('34', 2) + expect(count).toBe(2) + expect(otp.value.value).toBe('1234') + }) + + it('should clip to length when paste would overflow', () => { + const otp = setup({ length: 4 }) + const count = otp.paste('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.paste('abc') + expect(count).toBe(0) + expect(otp.value.value).toBe('') + }) + }) + + 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') + }) + }) }) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 5f891644d..e421d089f 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -166,18 +166,54 @@ export function createOtp (options: OtpOptions = {}): OtpContext { 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 + } + + function setAt (index: number, char: string): void { + const max = toValue(lengthRef) + if (index < 0 || index >= max) return + if (char === '') { + value.value = value.value.slice(0, index) + return + } + const first = char[0] + if (!accepts(first)) return + 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 paste (text: string, index = 0): number { + const max = toValue(lengthRef) + const start = clamp(index, 0, max) + const filtered = filterAccepted(text) + if (filtered.length === 0) return 0 + 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 { + value.value = '' + } + + function fill (text: string): void { + const max = toValue(lengthRef) + value.value = filterAccepted(text).slice(0, max) + } + // Placeholders — fleshed out in subsequent tasks. - void clamp void watch void onComplete const isComplete = shallowRef(false) - function setAt (_index: number, _char: string): void {} - function paste (_text: string, _index = 0): number { - return 0 - } - function clear (): void {} - function fill (_text: string): void {} return { value, From 12947cce4dca9eefa7784c0e9c9c593e924b426b Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:45:23 -0500 Subject: [PATCH 07/23] feat(createOtp): wire isComplete and disabled/readonly gating isComplete derives through toRef: true when value length equals the configured length and every character matches the active pattern. Mutation helpers short-circuit when disabled or readonly is true. --- .../0/src/composables/createOtp/index.test.ts | 42 +++++++++++++++++++ packages/0/src/composables/createOtp/index.ts | 19 ++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 6f546181e..10981bd06 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -186,4 +186,46 @@ describe('createOtp', () => { 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.setAt(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.setAt(3, '') + 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.setAt(0, '1') + otp.paste('123') + otp.fill('999') + expect(otp.value.value).toBe('') + disabled.value = false + otp.setAt(0, '1') + expect(otp.value.value).toBe('1') + }) + + it('should no-op mutations when readonly is true', () => { + const otp = setup({ readonly: true }) + otp.setAt(0, '1') + otp.paste('123') + otp.fill('999') + otp.clear() + expect(otp.value.value).toBe('') + }) + }) }) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index e421d089f..57a830137 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -174,7 +174,12 @@ export function createOtp (options: OtpOptions = {}): OtpContext { return out } + function isLocked (): boolean { + return toValue(disabled) || toValue(_readonly) + } + function setAt (index: number, char: string): void { + if (isLocked()) return const max = toValue(lengthRef) if (index < 0 || index >= max) return if (char === '') { @@ -190,6 +195,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { } function paste (text: string, index = 0): number { + if (isLocked()) return 0 const max = toValue(lengthRef) const start = clamp(index, 0, max) const filtered = filterAccepted(text) @@ -201,10 +207,12 @@ export function createOtp (options: OtpOptions = {}): OtpContext { } function clear (): void { + if (isLocked()) return value.value = '' } function fill (text: string): void { + if (isLocked()) return const max = toValue(lengthRef) value.value = filterAccepted(text).slice(0, max) } @@ -213,7 +221,16 @@ export function createOtp (options: OtpOptions = {}): OtpContext { void watch void onComplete - const isComplete = shallowRef(false) + 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 + }) return { value, From adca53752b5bb6f5441c2dfdc9f27c89cc57387f Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:51:09 -0500 Subject: [PATCH 08/23] feat(createOtp): wire onComplete edge with sync + async rejection A watcher on value fires onComplete when isComplete is true on each distinct completed value. Sync false / async resolved false / thrown / rejected promise paths clear the value and set input.error with the v0.otp.rejected message. Async pending state locks mutation helpers until the promise settles. clearRejection() is called synchronously in each mutation helper so error state clears on the next user input. --- .../0/src/composables/createOtp/index.test.ts | 73 +++++++++++++++++++ packages/0/src/composables/createOtp/index.ts | 60 ++++++++++++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 10981bd06..1e6e2b9de 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -228,4 +228,77 @@ describe('createOtp', () => { 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 Promise.resolve() + 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 Promise.resolve() + otp.clear() + otp.fill('5678') + await Promise.resolve() + 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 Promise.resolve() + 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 Promise.resolve() + expect(otp.input.errors.value).toContain('v0.otp.rejected') + otp.setAt(0, '9') + expect(otp.input.errors.value).not.toContain('v0.otp.rejected') + }) + }) + + 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 Promise.resolve() + otp.setAt(0, '9') + expect(otp.value.value).toBe('1234') + resolve(false) + await Promise.resolve() + await Promise.resolve() + 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 Promise.resolve() + await Promise.resolve() + expect(otp.value.value).toBe('1234') + expect(otp.input.errors.value).toEqual([]) + }) + }) }) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 57a830137..32f8633f2 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -174,12 +174,22 @@ export function createOtp (options: OtpOptions = {}): OtpContext { return out } + const isPending = shallowRef(false) + function isLocked (): boolean { - return toValue(disabled) || toValue(_readonly) + return toValue(disabled) || toValue(_readonly) || isPending.value + } + + function clearRejection (): void { + if (errorRef.value || errorMessagesRef.value) { + errorRef.value = false + errorMessagesRef.value = undefined + } } function setAt (index: number, char: string): void { if (isLocked()) return + clearRejection() const max = toValue(lengthRef) if (index < 0 || index >= max) return if (char === '') { @@ -196,6 +206,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { function paste (text: string, index = 0): number { if (isLocked()) return 0 + clearRejection() const max = toValue(lengthRef) const start = clamp(index, 0, max) const filtered = filterAccepted(text) @@ -208,18 +219,22 @@ export function createOtp (options: OtpOptions = {}): OtpContext { function clear (): void { if (isLocked()) return + clearRejection() value.value = '' } function fill (text: string): void { if (isLocked()) return + clearRejection() const max = toValue(lengthRef) value.value = filterAccepted(text).slice(0, max) } - // Placeholders — fleshed out in subsequent tasks. - void watch - void onComplete + function reject (): void { + errorRef.value = true + errorMessagesRef.value = ['v0.otp.rejected'] + value.value = '' + } const isComplete = toRef(() => { const max = toValue(lengthRef) @@ -232,6 +247,43 @@ export function createOtp (options: OtpOptions = {}): OtpContext { return true }) + // 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 = '' + + watch(value, next => { + if (!onComplete) return + if (!isComplete.value) return + if (toValue(disabled) || toValue(_readonly)) return + if (next === lastCompleted) return + lastCompleted = next + let result: ReturnType> + try { + result = onComplete(next) + } catch (error) { + logger.warn(`createOtp: onComplete threw — ${(error as Error).message}`) + reject() + return + } + if (result instanceof Promise) { + isPending.value = true + result.then( + ok => { + isPending.value = false + if (ok === false) reject() + }, + error => { + isPending.value = false + logger.warn(`createOtp: onComplete rejected — ${(error as Error).message}`) + reject() + }, + ) + return + } + if (result === false) reject() + }) + return { value, length: lengthRef, From 8ddb160dc10db543fd088e04c2cf38e138dca18b Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 13:55:41 -0500 Subject: [PATCH 09/23] fix(createOtp): re-fire onComplete after same-value rejection re-entry Resets the lastCompleted edge tracker inside reject() so a user re-entering the same rejected code triggers onComplete on the next cycle. Also routes the watcher's lock check through isLocked() for consistency, and adds a comment marking the input.isValidating follow-up. --- .../0/src/composables/createOtp/index.test.ts | 15 +++++++++++++++ packages/0/src/composables/createOtp/index.ts | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 1e6e2b9de..ce31a9441 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -267,6 +267,21 @@ describe('createOtp', () => { otp.setAt(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 Promise.resolve() + expect(onComplete).toHaveBeenCalledTimes(1) + expect(otp.value.value).toBe('') + allow = true + otp.fill('1234') + await Promise.resolve() + expect(onComplete).toHaveBeenCalledTimes(2) + expect(otp.value.value).toBe('1234') + }) }) describe('onComplete (async)', () => { diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 32f8633f2..6fb31c9ed 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -174,6 +174,10 @@ export function createOtp (options: OtpOptions = {}): OtpContext { 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 { @@ -233,6 +237,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { function reject (): void { errorRef.value = true errorMessagesRef.value = ['v0.otp.rejected'] + lastCompleted = '' value.value = '' } @@ -255,7 +260,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { watch(value, next => { if (!onComplete) return if (!isComplete.value) return - if (toValue(disabled) || toValue(_readonly)) return + if (isLocked()) return if (next === lastCompleted) return lastCompleted = next let result: ReturnType> From fa6fa9aec9a58ed332e80d80138cd83350b486b9 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 14:00:03 -0500 Subject: [PATCH 10/23] test(createOtp): cover onComplete throw and async-reject warn paths Adds spy-based assertions for the two logger.warn paths in the onComplete edge watcher (sync throw, async promise rejection). Also moves the lastCompleted declaration above reject() to remove a latent TDZ forward reference. --- .../0/src/composables/createOtp/index.test.ts | 30 +++++++++++++++++++ packages/0/src/composables/createOtp/index.ts | 10 +++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index ce31a9441..2c40c7ad1 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -282,6 +282,20 @@ describe('createOtp', () => { 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 Promise.resolve() + 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() + }) }) describe('onComplete (async)', () => { @@ -315,5 +329,21 @@ describe('createOtp', () => { 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 Promise.resolve() + await Promise.resolve() + 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() + }) }) }) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 6fb31c9ed..2235b8aa5 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -234,6 +234,11 @@ export function createOtp (options: OtpOptions = {}): OtpContext { value.value = filterAccepted(text).slice(0, max) } + // 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 reject (): void { errorRef.value = true errorMessagesRef.value = ['v0.otp.rejected'] @@ -252,11 +257,6 @@ export function createOtp (options: OtpOptions = {}): OtpContext { return true }) - // 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 = '' - watch(value, next => { if (!onComplete) return if (!isComplete.value) return From 2649965f288a7fb3a70f73873aba2d07912a0e93 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 14:06:32 -0500 Subject: [PATCH 11/23] docs(createOtp): add composable page and basic example Public docs page with the standard composable structure (Usage, Architecture, Patterns, Behavior, Examples, FAQ, DocsApi). The basic example wires six numeric inputs to createOtp, demonstrating setAt with focus advance, Backspace handling, and paste distribution. --- .../examples/composables/create-otp/basic.vue | 60 ++++++++++ .../src/pages/composables/forms/create-otp.md | 111 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 apps/docs/src/examples/composables/create-otp/basic.vue create mode 100644 apps/docs/src/pages/composables/forms/create-otp.md 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..51e189fc3 --- /dev/null +++ b/apps/docs/src/examples/composables/create-otp/basic.vue @@ -0,0 +1,60 @@ + + + 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..777b30668 --- /dev/null +++ b/apps/docs/src/pages/composables/forms/create-otp.md @@ -0,0 +1,111 @@ +--- +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.setAt(0, '4') // single character at a position +otp.paste('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. + +## 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 + +- `setAt(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 `paste` for distribution). +- `paste(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, refs, and focus advance; `createOtp` owns the state. Note how `paste` returns the number of characters consumed, so a single pasted code distributes across the inputs and the next focus target is straightforward to compute. The example also demonstrates Backspace handling and the data-attribute styling pattern used across v0 examples. + +::: + +## 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. + +::: + + From 4a7a3d2ec8ae3a24656f0b2dacc7532e42adecf3 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 14:11:56 -0500 Subject: [PATCH 12/23] docs(createOtp): fix frontmatter, ordering, and section structure Dedents meta list items to column 0 per docs.md, moves DocsPageFeatures between the H1 and intro per the 10-section composable page structure, adds the missing Reactivity section, and expands the Examples prose to multi-paragraph depth covering when to reach for the helper, tradeoffs, and related APIs. --- .../src/pages/composables/forms/create-otp.md | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/pages/composables/forms/create-otp.md b/apps/docs/src/pages/composables/forms/create-otp.md index 777b30668..510791259 100644 --- a/apps/docs/src/pages/composables/forms/create-otp.md +++ b/apps/docs/src/pages/composables/forms/create-otp.md @@ -1,10 +1,10 @@ --- 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 +- 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' @@ -17,10 +17,10 @@ related: # 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. - +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 @@ -60,6 +60,25 @@ flowchart TD 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 | +| - | - | - | +| `setAt` | `(index: number, char: string) => void` | Writes one character at `index`; empty `char` truncates from `index`. | +| `paste` | `(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 | @@ -86,7 +105,13 @@ Layer 2 orchestrator. Aggregates createInput for validation, dirty tracking, and ### Six-Input Numeric OTP -A minimal six-input numeric OTP. The consumer's component owns the inputs, refs, and focus advance; `createOtp` owns the state. Note how `paste` returns the number of characters consumed, so a single pasted code distributes across the inputs and the next focus target is straightforward to compute. The example also demonstrates Backspace handling and the data-attribute styling pattern used across v0 examples. +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 `paste` helper returns the count consumed so the consumer can choose where to land focus after distribution — 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. ::: From 4099072167d99bd44fda6d93b29072545fce79ca Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 14:15:08 -0500 Subject: [PATCH 13/23] docs(createOtp): register in index, READMEs, and skill references Adds createOtp to the docs Forms table, both package and root READMEs, the vuetify0 SKILL.md decision table, and the REFERENCE.md form handling section. --- apps/docs/src/pages/composables/index.md | 1 + packages/0/README.md | 1 + skills/vuetify0/SKILL.md | 1 + skills/vuetify0/references/REFERENCE.md | 16 ++++++++++++++++ 4 files changed, 19 insertions(+) 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/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/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..26d706729 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.setAt(0, '4') +otp.paste('123456') // returns count consumed +otp.value.value // joined string +otp.isComplete.value +``` + ### Context & State Sharing #### createContext — Type-Safe Provide/Inject From dfcf8c7548551c2ef4606911b6fe2632a4b963e1 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 14:18:53 -0500 Subject: [PATCH 14/23] chore(createOtp): update typed-router with createOtp docs route --- apps/docs/src/typed-router.d.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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' From f15fdb56258438b53f8b483c1c9abf18d2dd41f7 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 14:26:03 -0500 Subject: [PATCH 15/23] chore(createOtp): promote maturity to preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementation, tests (35 passing), docs page, and basic example all ship in this branch — meets the preview promotion criteria from the new-feature-checklist. `since` stays null; the maintainer cutting the next release flips it. --- packages/0/src/maturity.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index 373232c7f..b560b7d55 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -102,7 +102,7 @@ "category": "forms" }, "createOtp": { - "level": "draft", + "level": "preview", "since": null, "category": "forms" }, From ebc15529a14113b43e0e640c64352437d2dbf1b0 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 18:45:16 -0500 Subject: [PATCH 16/23] refactor(createOtp): rename setAt to put MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-word verb matches the neighboring put/paste/clear/fill surface and reads naturally ("put '4' at position 0"). setAt was an escape- hatch multi-word name; put fits PHILOSOPHY §3.3 cleanly without the ambiguity carve-out. Safe to flip — createOtp is still preview with no released consumers. --- .../examples/composables/create-otp/basic.vue | 4 +-- .../src/pages/composables/forms/create-otp.md | 6 ++-- .../0/src/composables/createOtp/index.test.ts | 34 +++++++++---------- packages/0/src/composables/createOtp/index.ts | 10 +++--- skills/vuetify0/references/REFERENCE.md | 2 +- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/apps/docs/src/examples/composables/create-otp/basic.vue b/apps/docs/src/examples/composables/create-otp/basic.vue index 51e189fc3..018cb558d 100644 --- a/apps/docs/src/examples/composables/create-otp/basic.vue +++ b/apps/docs/src/examples/composables/create-otp/basic.vue @@ -11,7 +11,7 @@ function onInput (index: number, event: Event) { const target = event.target as HTMLInputElement - otp.setAt(index, target.value) + otp.put(index, target.value) if (otp.value.value.length > index) { const next = inputs.value?.[index + 1] next?.focus() @@ -22,7 +22,7 @@ function onKey (index: number, event: KeyboardEvent) { if (event.key !== 'Backspace' || (event.target as HTMLInputElement).value) return event.preventDefault() - otp.setAt(Math.max(0, index - 1), '') + otp.put(Math.max(0, index - 1), '') inputs.value?.[Math.max(0, index - 1)]?.focus() } diff --git a/apps/docs/src/pages/composables/forms/create-otp.md b/apps/docs/src/pages/composables/forms/create-otp.md index 510791259..377fb7f7b 100644 --- a/apps/docs/src/pages/composables/forms/create-otp.md +++ b/apps/docs/src/pages/composables/forms/create-otp.md @@ -35,7 +35,7 @@ const otp = createOtp({ }, }) -otp.setAt(0, '4') // single character at a position +otp.put(0, '4') // single character at a position otp.paste('123456') // distributes filtered characters otp.value.value // '412345' joined string otp.isComplete.value // true when length reached and all chars valid @@ -71,7 +71,7 @@ Layer 2 orchestrator. Aggregates createInput for validation, dirty tracking, and | Method | Signature | Effect | | - | - | - | -| `setAt` | `(index: number, char: string) => void` | Writes one character at `index`; empty `char` truncates from `index`. | +| `put` | `(index: number, char: string) => void` | Writes one character at `index`; empty `char` truncates from `index`. | | `paste` | `(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). | @@ -92,7 +92,7 @@ Every helper is gated on the configured `disabled` and `readonly` options, and o ## Behavior -- `setAt(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 `paste` for distribution). +- `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 `paste` for distribution). - `paste(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. diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 2c40c7ad1..8fe1eaf9c 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -22,7 +22,7 @@ describe('createOtp', () => { expect(otp.length.value).toBe(6) expect(otp.input).toBeDefined() expect(otp.isComplete.value).toBe(false) - expect(typeof otp.setAt).toBe('function') + expect(typeof otp.put).toBe('function') expect(typeof otp.paste).toBe('function') expect(typeof otp.clear).toBe('function') expect(typeof otp.fill).toBe('function') @@ -90,44 +90,44 @@ describe('createOtp', () => { }) }) - describe('setAt', () => { + describe('put', () => { it('should write a single character at the index', () => { const otp = setup() - otp.setAt(0, '4') + 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.setAt(2, '3') - otp.setAt(3, '4') + 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.setAt(-1, '1') - otp.setAt(4, '1') + 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.setAt(2, '') + 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.setAt(0, 'ab') + otp.put(0, 'ab') expect(otp.value.value).toBe('a') }) it('should drop characters that fail the pattern', () => { const otp = setup() // numeric default - otp.setAt(0, 'a') + otp.put(0, 'a') expect(otp.value.value).toBe('') }) }) @@ -193,7 +193,7 @@ describe('createOtp', () => { expect(otp.isComplete.value).toBe(false) otp.fill('123') expect(otp.isComplete.value).toBe(false) - otp.setAt(3, '4') + otp.put(3, '4') expect(otp.isComplete.value).toBe(true) }) @@ -201,7 +201,7 @@ describe('createOtp', () => { const otp = setup({ length: 4 }) otp.fill('1234') expect(otp.isComplete.value).toBe(true) - otp.setAt(3, '') + otp.put(3, '') expect(otp.isComplete.value).toBe(false) }) }) @@ -210,18 +210,18 @@ describe('createOtp', () => { it('should no-op mutations when disabled is true', () => { const disabled = shallowRef(true) const otp = setup({ disabled }) - otp.setAt(0, '1') + otp.put(0, '1') otp.paste('123') otp.fill('999') expect(otp.value.value).toBe('') disabled.value = false - otp.setAt(0, '1') + 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.setAt(0, '1') + otp.put(0, '1') otp.paste('123') otp.fill('999') otp.clear() @@ -264,7 +264,7 @@ describe('createOtp', () => { otp.fill('1234') await Promise.resolve() expect(otp.input.errors.value).toContain('v0.otp.rejected') - otp.setAt(0, '9') + otp.put(0, '9') expect(otp.input.errors.value).not.toContain('v0.otp.rejected') }) @@ -309,7 +309,7 @@ describe('createOtp', () => { }) otp.fill('1234') await Promise.resolve() - otp.setAt(0, '9') + otp.put(0, '9') expect(otp.value.value).toBe('1234') resolve(false) await Promise.resolve() diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 2235b8aa5..1c76277dc 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -23,7 +23,7 @@ * }, * }) * - * otp.setAt(0, '4') + * otp.put(0, '4') * otp.paste('12345', 1) // returns 5; value becomes '412345' * otp.isComplete.value // true * ``` @@ -99,7 +99,7 @@ export interface OtpOptions extends Omit, 'value'> { * ```ts * const otp = createOtp({ length: 6 }) * - * otp.setAt(0, '4') + * otp.put(0, '4') * otp.paste('12345', 1) // returns 5 * otp.isComplete.value // true * otp.clear() @@ -110,7 +110,7 @@ export interface OtpContext { length: Readonly> input: InputContext isComplete: Readonly> - setAt: (index: number, char: string) => void + put: (index: number, char: string) => void paste: (text: string, index?: number) => number clear: () => void fill: (text: string) => void @@ -191,7 +191,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { } } - function setAt (index: number, char: string): void { + function put (index: number, char: string): void { if (isLocked()) return clearRejection() const max = toValue(lengthRef) @@ -294,7 +294,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { length: lengthRef, input, isComplete, - setAt, + put, paste, clear, fill, diff --git a/skills/vuetify0/references/REFERENCE.md b/skills/vuetify0/references/REFERENCE.md index 26d706729..11324c854 100644 --- a/skills/vuetify0/references/REFERENCE.md +++ b/skills/vuetify0/references/REFERENCE.md @@ -123,7 +123,7 @@ const otp = createOtp({ onComplete: async value => await verify(value), }) -otp.setAt(0, '4') +otp.put(0, '4') otp.paste('123456') // returns count consumed otp.value.value // joined string otp.isComplete.value From 173114e338ac98e3ae1e2b18c3e360ffe8cc0741 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 12 May 2026 19:30:16 -0500 Subject: [PATCH 17/23] refactor(createOtp): rename paste to distribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paste reads as a DOM clipboard-event handler; distribute is a plain string operation with no UI association. The example keeps its onPaste DOM handler (consumer wires the @paste event), now calling otp.distribute(text, index) inside — clarifies the line between component-layer event handlers and composable-layer state ops. --- .../examples/composables/create-otp/basic.vue | 2 +- .../src/pages/composables/forms/create-otp.md | 10 +++++----- .../0/src/composables/createOtp/index.test.ts | 20 +++++++++---------- packages/0/src/composables/createOtp/index.ts | 10 +++++----- skills/vuetify0/references/REFERENCE.md | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/docs/src/examples/composables/create-otp/basic.vue b/apps/docs/src/examples/composables/create-otp/basic.vue index 018cb558d..b99c33575 100644 --- a/apps/docs/src/examples/composables/create-otp/basic.vue +++ b/apps/docs/src/examples/composables/create-otp/basic.vue @@ -29,7 +29,7 @@ function onPaste (index: number, event: ClipboardEvent) { event.preventDefault() const text = event.clipboardData?.getData('text') ?? '' - const consumed = otp.paste(text, index) + const consumed = otp.distribute(text, index) const target = Math.min(index + consumed, otp.length.value - 1) inputs.value?.[target]?.focus() } diff --git a/apps/docs/src/pages/composables/forms/create-otp.md b/apps/docs/src/pages/composables/forms/create-otp.md index 377fb7f7b..032f45626 100644 --- a/apps/docs/src/pages/composables/forms/create-otp.md +++ b/apps/docs/src/pages/composables/forms/create-otp.md @@ -36,7 +36,7 @@ const otp = createOtp({ }) otp.put(0, '4') // single character at a position -otp.paste('123456') // distributes filtered characters +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' @@ -72,7 +72,7 @@ Layer 2 orchestrator. Aggregates createInput for validation, dirty tracking, and | Method | Signature | Effect | | - | - | - | | `put` | `(index: number, char: string) => void` | Writes one character at `index`; empty `char` truncates from `index`. | -| `paste` | `(text: string, index?: number) => number` | Filters and splices, returns the count consumed. | +| `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`. | @@ -92,8 +92,8 @@ Every helper is gated on the configured `disabled` and `readonly` options, and o ## 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 `paste` for distribution). -- `paste(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. +- `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. @@ -109,7 +109,7 @@ A minimal six-input numeric OTP. The consumer's component owns the inputs, the t 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 `paste` helper returns the count consumed so the consumer can choose where to land focus after distribution — 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. +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. diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 8fe1eaf9c..c0a7ec0b6 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -23,7 +23,7 @@ describe('createOtp', () => { expect(otp.input).toBeDefined() expect(otp.isComplete.value).toBe(false) expect(typeof otp.put).toBe('function') - expect(typeof otp.paste).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') @@ -132,17 +132,17 @@ describe('createOtp', () => { }) }) - describe('paste', () => { + describe('distribute', () => { it('should distribute filtered characters and return the count consumed', () => { const otp = setup({ length: 6 }) - const count = otp.paste('123456') + 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.paste('12-34-56') + const count = otp.distribute('12-34-56') expect(count).toBe(6) expect(otp.value.value).toBe('123456') }) @@ -150,21 +150,21 @@ describe('createOtp', () => { it('should splice into the existing value at the given index', () => { const otp = setup({ length: 6 }) otp.fill('12') - const count = otp.paste('34', 2) + const count = otp.distribute('34', 2) expect(count).toBe(2) expect(otp.value.value).toBe('1234') }) - it('should clip to length when paste would overflow', () => { + it('should clip to length when distribute would overflow', () => { const otp = setup({ length: 4 }) - const count = otp.paste('123456') + 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.paste('abc') + const count = otp.distribute('abc') expect(count).toBe(0) expect(otp.value.value).toBe('') }) @@ -211,7 +211,7 @@ describe('createOtp', () => { const disabled = shallowRef(true) const otp = setup({ disabled }) otp.put(0, '1') - otp.paste('123') + otp.distribute('123') otp.fill('999') expect(otp.value.value).toBe('') disabled.value = false @@ -222,7 +222,7 @@ describe('createOtp', () => { it('should no-op mutations when readonly is true', () => { const otp = setup({ readonly: true }) otp.put(0, '1') - otp.paste('123') + otp.distribute('123') otp.fill('999') otp.clear() expect(otp.value.value).toBe('') diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 1c76277dc..f01c01dfc 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -24,7 +24,7 @@ * }) * * otp.put(0, '4') - * otp.paste('12345', 1) // returns 5; value becomes '412345' + * otp.distribute('12345', 1) // returns 5; value becomes '412345' * otp.isComplete.value // true * ``` */ @@ -100,7 +100,7 @@ export interface OtpOptions extends Omit, 'value'> { * const otp = createOtp({ length: 6 }) * * otp.put(0, '4') - * otp.paste('12345', 1) // returns 5 + * otp.distribute('12345', 1) // returns 5 * otp.isComplete.value // true * otp.clear() * ``` @@ -111,7 +111,7 @@ export interface OtpContext { input: InputContext isComplete: Readonly> put: (index: number, char: string) => void - paste: (text: string, index?: number) => number + distribute: (text: string, index?: number) => number clear: () => void fill: (text: string) => void accepts: (char: string) => boolean @@ -208,7 +208,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { value.value = (head + first + tail).slice(0, max) } - function paste (text: string, index = 0): number { + function distribute (text: string, index = 0): number { if (isLocked()) return 0 clearRejection() const max = toValue(lengthRef) @@ -295,7 +295,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { input, isComplete, put, - paste, + distribute, clear, fill, accepts, diff --git a/skills/vuetify0/references/REFERENCE.md b/skills/vuetify0/references/REFERENCE.md index 11324c854..bf8cc4cb8 100644 --- a/skills/vuetify0/references/REFERENCE.md +++ b/skills/vuetify0/references/REFERENCE.md @@ -124,7 +124,7 @@ const otp = createOtp({ }) otp.put(0, '4') -otp.paste('123456') // returns count consumed +otp.distribute('123456') // returns count consumed otp.value.value // joined string otp.isComplete.value ``` From 5aa5809f0f6bcf3a6b095aa3c0df26228b2e60bb Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 13 May 2026 11:15:20 -0500 Subject: [PATCH 18/23] fix(createOtp): inspection-driven hardening pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use isThenable over instanceof Promise so onComplete handles cross-realm or library-typed promises correctly. - Safe error message extraction so Promise.reject(null) or throw null doesn't crash the rejection handler. - Reset lastCompleted whenever the value drops below isComplete, not just on reject — same-value re-entry after acceptance now refires the completion edge per spec. Also reset synchronously in clear() so back-to-back clear()+fill() without an await tick still refires. - Hoist PRESETS to module scope with /* @__PURE__ */ — per-instance allocation eliminated. - Tighten the public type: Omit error/errorMessages from OtpOptions (factory owns them) and drop the redundant disabled/readonly re-declarations that shadow InputOptions. - Move clearRejection() calls after validation in put/distribute/fill so out-of-range or pattern-rejected mutations don't silently clear the error indicator without making a change. - Test additions covering same-value re-entry, null rejection paths, non-native thenables, reactive length, multi-char accepts, distribute boundary indices, rules passthrough, and input.reset. --- .../0/src/composables/createOtp/index.test.ts | 114 ++++++++++++++++++ packages/0/src/composables/createOtp/index.ts | 46 ++++--- 2 files changed, 141 insertions(+), 19 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index c0a7ec0b6..5a44b016c 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -88,6 +88,12 @@ describe('createOtp', () => { 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', () => { @@ -168,6 +174,21 @@ describe('createOtp', () => { 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', () => { @@ -204,6 +225,17 @@ describe('createOtp', () => { 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) + }) }) describe('disabled / readonly gating', () => { @@ -296,6 +328,39 @@ describe('createOtp', () => { 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 Promise.resolve() + 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 Promise.resolve() + expect(onComplete).toHaveBeenCalledTimes(1) + otp.clear() + otp.fill('1234') + await Promise.resolve() + 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 Promise.resolve() + expect(otp.value.value).toBe('') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + expect(spy).toHaveBeenCalledTimes(1) + spy.mockRestore() + }) }) describe('onComplete (async)', () => { @@ -345,5 +410,54 @@ describe('createOtp', () => { 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 Promise.resolve() + await Promise.resolve() + expect(otp.value.value).toBe('') + expect(otp.input.errors.value).toContain('v0.otp.rejected') + expect(spy).toHaveBeenCalledTimes(1) + spy.mockRestore() + }) + + it('should accept a thenable that is not a native Promise', async () => { + const onComplete = vi.fn(() => ({ + then (onfulfilled?: ((v: boolean) => unknown) | null) { + onfulfilled?.(true) + }, + }) as PromiseLike) + const otp = setup({ length: 4, onComplete }) + otp.fill('1234') + await Promise.resolve() + await Promise.resolve() + 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('') + }) }) }) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index f01c01dfc..25cf483ff 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -34,7 +34,7 @@ import { createInput } from '#v0/composables/createInput' import { useLogger } from '#v0/composables/useLogger' // Utilities -import { clamp, isString } from '#v0/utilities' +import { clamp, isString, isThenable } from '#v0/utilities' import { shallowRef, toRef, toValue, watch } from 'vue' // Types @@ -53,6 +53,19 @@ import type { MaybeRefOrGetter, Ref } from 'vue' */ export type OtpPattern = 'numeric' | 'alphanumeric' | 'alphabetic' | RegExp +/* @__PURE__ */ +const PRESETS: Record, RegExp> = { + numeric: /^[0-9]$/, + alphanumeric: /^[a-zA-Z0-9]$/, + alphabetic: /^[a-zA-Z]$/, +} + +function extractMessage (error: unknown): string { + if (error instanceof Error) return error.message + if (isString(error)) return error + return String(error) +} + /** * Options accepted by `createOtp`. * @@ -69,7 +82,7 @@ export type OtpPattern = 'numeric' | 'alphanumeric' | 'alphabetic' | RegExp * }) * ``` */ -export interface OtpOptions extends Omit, 'value'> { +export interface OtpOptions extends Omit, 'value' | 'error' | 'errorMessages'> { /** Number of characters. @default 6 */ length?: MaybeRefOrGetter /** Joined value source. @default shallowRef('') */ @@ -80,15 +93,11 @@ export interface OtpOptions extends Omit, 'value'> { * @default 'numeric' */ pattern?: MaybeRefOrGetter - /** Disabled state — mutation helpers no-op. @default false */ - disabled?: MaybeRefOrGetter - /** Readonly state — mutation helpers no-op. @default false */ - readonly?: MaybeRefOrGetter /** * Fire when the joined value first reaches `length`. Decisional — * return / resolve `false` to reject (clears value, sets error). */ - onComplete?: (value: string) => boolean | void | Promise + onComplete?: (value: string) => boolean | void | PromiseLike } /** @@ -144,12 +153,6 @@ export function createOtp (options: OtpOptions = {}): OtpContext { const lengthRef = toRef(() => toValue(length)) - const PRESETS: Record, RegExp> = { - numeric: /^[0-9]$/, - alphanumeric: /^[a-zA-Z0-9]$/, - alphabetic: /^[a-zA-Z]$/, - } - const warned = new WeakSet() function compile (resolved: OtpPattern): RegExp { @@ -193,15 +196,16 @@ export function createOtp (options: OtpOptions = {}): OtpContext { function put (index: number, char: string): void { if (isLocked()) return - clearRejection() 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) @@ -210,11 +214,11 @@ export function createOtp (options: OtpOptions = {}): OtpContext { function distribute (text: string, index = 0): number { if (isLocked()) return 0 - clearRejection() 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 @@ -224,6 +228,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { function clear (): void { if (isLocked()) return clearRejection() + lastCompleted = '' value.value = '' } @@ -258,8 +263,11 @@ export function createOtp (options: OtpOptions = {}): OtpContext { }) watch(value, next => { + if (!isComplete.value) { + lastCompleted = '' + return + } if (!onComplete) return - if (!isComplete.value) return if (isLocked()) return if (next === lastCompleted) return lastCompleted = next @@ -267,11 +275,11 @@ export function createOtp (options: OtpOptions = {}): OtpContext { try { result = onComplete(next) } catch (error) { - logger.warn(`createOtp: onComplete threw — ${(error as Error).message}`) + logger.warn(`createOtp: onComplete threw — ${extractMessage(error)}`) reject() return } - if (result instanceof Promise) { + if (isThenable(result)) { isPending.value = true result.then( ok => { @@ -280,7 +288,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { }, error => { isPending.value = false - logger.warn(`createOtp: onComplete rejected — ${(error as Error).message}`) + logger.warn(`createOtp: onComplete rejected — ${extractMessage(error)}`) reject() }, ) From ef8a6bde5d73b672c83eaa7ed97836a58252f04a Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 13 May 2026 11:17:32 -0500 Subject: [PATCH 19/23] test(createOtp): disable unicorn/no-thenable for intentional thenable The thenable-not-native-Promise test deliberately creates an object with a then method to exercise the isThenable code path. Lint rule flags it as a footgun, which is correct in general but intended here. --- packages/0/src/composables/createOtp/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 5a44b016c..dd9798074 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -428,6 +428,7 @@ describe('createOtp', () => { 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) }, From 6aedefac5be7555e4d29800ae4db078dd19a3953 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 13 May 2026 11:33:38 -0500 Subject: [PATCH 20/23] fix(createOtp): clear rejection on input.reset and tighten test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit input.reset() previously cleared the value but left the rejection error refs (errorRef, errorMessagesRef) set — users would see stale 'v0.otp.rejected' in input.errors after a reset. Now the wrapped reset calls clearRejection first. Test additions: thenable resolving false, mutations unblocked after async accept, message-content assertions on the non-Error throw and reject paths. Sweep await Promise.resolve() to await nextTick() to match the convention used in sibling test files. --- .../0/src/composables/createOtp/index.test.ts | 99 ++++++++++++++----- packages/0/src/composables/createOtp/index.ts | 10 +- 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index dd9798074..605d81a32 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { createOtp } from './index' // Utilities -import { shallowRef } from 'vue' +import { nextTick, shallowRef } from 'vue' vi.mock('vue', async () => { const actual = await vi.importActual('vue') @@ -266,7 +266,7 @@ describe('createOtp', () => { const onComplete = vi.fn() const otp = setup({ length: 4, onComplete }) otp.fill('1234') - await Promise.resolve() + await nextTick() expect(onComplete).toHaveBeenCalledTimes(1) expect(onComplete).toHaveBeenCalledWith('1234') }) @@ -275,17 +275,17 @@ describe('createOtp', () => { const onComplete = vi.fn() const otp = setup({ length: 4, onComplete }) otp.fill('1234') - await Promise.resolve() + await nextTick() otp.clear() otp.fill('5678') - await Promise.resolve() + 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 Promise.resolve() + await nextTick() expect(otp.value.value).toBe('') expect(otp.input.isValid.value).toBe(false) expect(otp.input.errors.value).toContain('v0.otp.rejected') @@ -294,7 +294,7 @@ describe('createOtp', () => { it('should clear error state on the next mutation', async () => { const otp = setup({ length: 4, onComplete: () => false }) otp.fill('1234') - await Promise.resolve() + 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') @@ -305,12 +305,12 @@ describe('createOtp', () => { const onComplete = vi.fn(() => allow) const otp = setup({ length: 4, onComplete }) otp.fill('1234') - await Promise.resolve() + await nextTick() expect(onComplete).toHaveBeenCalledTimes(1) expect(otp.value.value).toBe('') allow = true otp.fill('1234') - await Promise.resolve() + await nextTick() expect(onComplete).toHaveBeenCalledTimes(2) expect(otp.value.value).toBe('1234') }) @@ -321,7 +321,7 @@ describe('createOtp', () => { throw new Error('boom') } }) otp.fill('1234') - await Promise.resolve() + await nextTick() expect(otp.value.value).toBe('') expect(otp.input.errors.value).toContain('v0.otp.rejected') expect(spy).toHaveBeenCalledTimes(1) @@ -332,7 +332,7 @@ describe('createOtp', () => { it('should leave the value intact when sync onComplete returns undefined', async () => { const otp = setup({ length: 4, onComplete: () => {} }) otp.fill('1234') - await Promise.resolve() + await nextTick() expect(otp.value.value).toBe('1234') expect(otp.input.errors.value).toEqual([]) }) @@ -341,11 +341,11 @@ describe('createOtp', () => { const onComplete = vi.fn(() => true) const otp = setup({ length: 4, onComplete }) otp.fill('1234') - await Promise.resolve() + await nextTick() expect(onComplete).toHaveBeenCalledTimes(1) otp.clear() otp.fill('1234') - await Promise.resolve() + await nextTick() expect(onComplete).toHaveBeenCalledTimes(2) }) @@ -355,10 +355,11 @@ describe('createOtp', () => { throw null } }) otp.fill('1234') - await Promise.resolve() + 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() }) }) @@ -373,12 +374,12 @@ describe('createOtp', () => { }), }) otp.fill('1234') - await Promise.resolve() + await nextTick() otp.put(0, '9') expect(otp.value.value).toBe('1234') resolve(false) - await Promise.resolve() - await Promise.resolve() + await nextTick() + await nextTick() expect(otp.value.value).toBe('') expect(otp.input.errors.value).toContain('v0.otp.rejected') }) @@ -389,8 +390,8 @@ describe('createOtp', () => { onComplete: () => Promise.resolve(true), }) otp.fill('1234') - await Promise.resolve() - await Promise.resolve() + await nextTick() + await nextTick() expect(otp.value.value).toBe('1234') expect(otp.input.errors.value).toEqual([]) }) @@ -402,8 +403,8 @@ describe('createOtp', () => { onComplete: () => Promise.reject(new Error('network error')), }) otp.fill('1234') - await Promise.resolve() - await Promise.resolve() + await nextTick() + await nextTick() expect(otp.value.value).toBe('') expect(otp.input.errors.value).toContain('v0.otp.rejected') expect(spy).toHaveBeenCalledTimes(1) @@ -418,11 +419,12 @@ describe('createOtp', () => { onComplete: () => Promise.reject(null), }) otp.fill('1234') - await Promise.resolve() - await Promise.resolve() + 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() }) @@ -435,11 +437,50 @@ describe('createOtp', () => { }) as PromiseLike) const otp = setup({ length: 4, onComplete }) otp.fill('1234') - await Promise.resolve() - await Promise.resolve() + 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') + }) }) describe('input passthrough', () => { @@ -460,5 +501,15 @@ describe('createOtp', () => { 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('') + }) }) }) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 25cf483ff..5bef92f17 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -297,10 +297,18 @@ export function createOtp (options: OtpOptions = {}): OtpContext { if (result === false) reject() }) + const inputContext: InputContext = { + ...input, + reset () { + clearRejection() + input.reset() + }, + } + return { value, length: lengthRef, - input, + input: inputContext, isComplete, put, distribute, From 7003921827130a9b8ea58cdde9ab06e255a0a11e Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 13 May 2026 19:52:36 -0500 Subject: [PATCH 21/23] =?UTF-8?q?fix(createOtp):=20round=203=20hardening?= =?UTF-8?q?=20=E2=80=94=20readonly=20value,=20fill=20clears,=20reset=20edg?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OtpContext.value typed as Readonly>. The mutation helpers (put/distribute/fill/clear) are the sole supported write path; direct ref writes via the context surface bypass pattern, length, and lock invariants. Type-only change — internal mutation paths unchanged. - fill() now only clears the rejection state when input was empty (user intent to clear) or at least one character was accepted. An all-rejected fill (e.g. fill('----') under numeric) preserves the rejection signal, matching distribute()'s semantic. - inputContext.reset() now resets lastCompleted alongside the rejection clear and the underlying input.reset(). Closes the same-tick reset + refill edge where the value watcher coalesces and skips onComplete for an identical re-entry. - warned WeakSet hoisted to module scope with /* @__PURE__ */ so multi-char-RegExp warns dedup across createOtp instances sharing a pattern object. - lastCompleted declaration moved to top of factory body (removes the forward-reference smell from clear() and the new reset wrapper). - Tests: pattern change invalidates complete value, clear() no-ops while pending, fill all-rejected preserves error, fill('') clears error, input.reset() allows same-value re-fire, length decrease does NOT fire onComplete (contract documentation), async resolving undefined leaves value intact. --- .../0/src/composables/createOtp/index.test.ts | 90 +++++++++++++++++++ packages/0/src/composables/createOtp/index.ts | 38 ++++++-- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.test.ts b/packages/0/src/composables/createOtp/index.test.ts index 605d81a32..20a2bd923 100644 --- a/packages/0/src/composables/createOtp/index.test.ts +++ b/packages/0/src/composables/createOtp/index.test.ts @@ -236,6 +236,16 @@ describe('createOtp', () => { 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', () => { @@ -362,6 +372,42 @@ describe('createOtp', () => { 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)', () => { @@ -481,6 +527,38 @@ describe('createOtp', () => { 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', () => { @@ -511,5 +589,17 @@ describe('createOtp', () => { 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 index 5bef92f17..d1dbb0975 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -60,6 +60,9 @@ const PRESETS: Record, RegExp> = { alphabetic: /^[a-zA-Z]$/, } +/* @__PURE__ */ +const warned = new WeakSet() + function extractMessage (error: unknown): string { if (error instanceof Error) return error.message if (isString(error)) return error @@ -115,8 +118,22 @@ export interface OtpOptions extends Omit, 'value' | 'error' * ``` */ export interface OtpContext { - value: Ref + /** + * 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`. Both expose the same + * underlying ref; consumers may write through either but should prefer the + * mutation helpers (`put`, `distribute`, `fill`, `clear`) to maintain + * pattern, length, and lock invariants. + */ input: InputContext isComplete: Readonly> put: (index: number, char: string) => void @@ -153,7 +170,10 @@ export function createOtp (options: OtpOptions = {}): OtpContext { const lengthRef = toRef(() => toValue(length)) - const warned = new WeakSet() + // 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] @@ -234,16 +254,15 @@ export function createOtp (options: OtpOptions = {}): OtpContext { function fill (text: string): void { if (isLocked()) return - clearRejection() const max = toValue(lengthRef) - value.value = filterAccepted(text).slice(0, max) + 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 } - // 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 reject (): void { errorRef.value = true errorMessagesRef.value = ['v0.otp.rejected'] @@ -301,6 +320,7 @@ export function createOtp (options: OtpOptions = {}): OtpContext { ...input, reset () { clearRejection() + lastCompleted = '' input.reset() }, } From e41e317fa4e62bdfe11f21ee1a2652180de0c5f9 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 13 May 2026 19:57:37 -0500 Subject: [PATCH 22/23] fix(createOtp): make input.value readonly to match value contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 review caught an inconsistency: OtpContext.value was typed Readonly> but OtpContext.input.value was still writable via InputContext, and the JSDoc explicitly condoned writes through either alias. Type-narrow input to also expose value as Readonly>, and tighten the JSDoc to remove the "may write through either" carve-out. Internal mutation paths unchanged — the local value ref inside the factory remains Ref for write access through the helpers. --- packages/0/src/composables/createOtp/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index d1dbb0975..65e8d1313 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -129,12 +129,11 @@ export interface OtpContext { * The underlying `createInput` context — exposes ARIA IDs, validation * state, focus/touched, and the `validate` / `reset` methods. * - * Note: `input.value` aliases `OtpContext.value`. Both expose the same - * underlying ref; consumers may write through either but should prefer the - * mutation helpers (`put`, `distribute`, `fill`, `clear`) to maintain - * pattern, length, and lock invariants. + * 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: InputContext + input: Omit, 'value'> & { value: Readonly> } isComplete: Readonly> put: (index: number, char: string) => void distribute: (text: string, index?: number) => number From ccc69306cbf2db1adb9a2ca55fae9e5423428623 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 13 May 2026 21:05:08 -0500 Subject: [PATCH 23/23] fix(createOtp): inline @__PURE__ on warned WeakSet allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 inspection caught that the standalone-line /* @__PURE__ */ form was non-functional — Rollup and esbuild only honor the annotation when it's inline before the call expression. Verified the project convention via utilities/helpers.ts:311, instance.ts:15, constants/htmlElements.ts:22, etc. — every other use is inline on a constructor or call. Dropped the annotation from PRESETS entirely (plain object literals are inherently pure to bundlers; no annotation needed). Kept it inline on the new WeakSet() constructor where it actually helps tree-shaking. --- packages/0/src/composables/createOtp/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/0/src/composables/createOtp/index.ts b/packages/0/src/composables/createOtp/index.ts index 65e8d1313..3098445d2 100644 --- a/packages/0/src/composables/createOtp/index.ts +++ b/packages/0/src/composables/createOtp/index.ts @@ -53,15 +53,13 @@ import type { MaybeRefOrGetter, Ref } from 'vue' */ export type OtpPattern = 'numeric' | 'alphanumeric' | 'alphabetic' | RegExp -/* @__PURE__ */ const PRESETS: Record, RegExp> = { numeric: /^[0-9]$/, alphanumeric: /^[a-zA-Z0-9]$/, alphabetic: /^[a-zA-Z]$/, } -/* @__PURE__ */ -const warned = new WeakSet() +const warned = /* @__PURE__ */ new WeakSet() function extractMessage (error: unknown): string { if (error instanceof Error) return error.message