Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
28c7634
feat(createOtp): scaffold composable with placeholder surface
johnleider May 12, 2026
d0f0489
docs(createOtp): add @example blocks to OtpOptions and OtpContext
johnleider May 12, 2026
4715e7f
feat(createOtp): implement pattern matching with accepts()
johnleider May 12, 2026
ffb7e06
test(createOtp): tighten warn assertion to lock no-spam contract
johnleider May 12, 2026
85ff2b0
refactor(createOtp): use isString guard and assert warn message
johnleider May 12, 2026
68e2620
feat(createOtp): implement setAt, paste, clear, fill
johnleider May 12, 2026
12947cc
feat(createOtp): wire isComplete and disabled/readonly gating
johnleider May 12, 2026
adca537
feat(createOtp): wire onComplete edge with sync + async rejection
johnleider May 12, 2026
8ddb160
fix(createOtp): re-fire onComplete after same-value rejection re-entry
johnleider May 12, 2026
fa6fa9a
test(createOtp): cover onComplete throw and async-reject warn paths
johnleider May 12, 2026
2649965
docs(createOtp): add composable page and basic example
johnleider May 12, 2026
4a7a3d2
docs(createOtp): fix frontmatter, ordering, and section structure
johnleider May 12, 2026
4099072
docs(createOtp): register in index, READMEs, and skill references
johnleider May 12, 2026
dfcf8c7
chore(createOtp): update typed-router with createOtp docs route
johnleider May 12, 2026
f15fdb5
chore(createOtp): promote maturity to preview
johnleider May 12, 2026
ebc1552
refactor(createOtp): rename setAt to put
johnleider May 12, 2026
173114e
refactor(createOtp): rename paste to distribute
johnleider May 13, 2026
5aa5809
fix(createOtp): inspection-driven hardening pass
johnleider May 13, 2026
ef8a6bd
test(createOtp): disable unicorn/no-thenable for intentional thenable
johnleider May 13, 2026
6aedefa
fix(createOtp): clear rejection on input.reset and tighten test coverage
johnleider May 13, 2026
7003921
fix(createOtp): round 3 hardening — readonly value, fill clears, rese…
johnleider May 14, 2026
e41e317
fix(createOtp): make input.value readonly to match value contract
johnleider May 14, 2026
ccc6930
fix(createOtp): inline @__PURE__ on warned WeakSet allocation
johnleider May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/docs/src/examples/composables/create-otp/basic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { createOtp } from '@vuetify/v0'
import { useTemplateRef } from 'vue'

const otp = createOtp({
length: 6,
pattern: 'numeric',
})

const inputs = useTemplateRef<HTMLInputElement[]>('inputs')

function onInput (index: number, event: Event) {
const target = event.target as HTMLInputElement
otp.put(index, target.value)
if (otp.value.value.length > index) {
const next = inputs.value?.[index + 1]
next?.focus()
}
target.value = otp.value.value[index] ?? ''
}

function onKey (index: number, event: KeyboardEvent) {
if (event.key !== 'Backspace' || (event.target as HTMLInputElement).value) return
event.preventDefault()
otp.put(Math.max(0, index - 1), '')
inputs.value?.[Math.max(0, index - 1)]?.focus()
}

function onPaste (index: number, event: ClipboardEvent) {
event.preventDefault()
const text = event.clipboardData?.getData('text') ?? ''
const consumed = otp.distribute(text, index)
const target = Math.min(index + consumed, otp.length.value - 1)
inputs.value?.[target]?.focus()
}
</script>

<template>
<div class="flex flex-col items-center gap-4">
<div class="flex gap-2">
<input
v-for="i in otp.length.value"
:key="i - 1"
ref="inputs"
class="w-10 h-12 text-center tabular-nums text-lg rounded border border-divider bg-surface text-on-surface focus:outline-none focus:border-primary data-[complete=true]:border-success"
:data-complete="otp.isComplete.value"
inputmode="numeric"
maxlength="1"
:value="otp.value.value[i - 1] ?? ''"
@input="onInput(i - 1, $event)"
@keydown="onKey(i - 1, $event)"
@paste="onPaste(i - 1, $event)"
>
</div>

<div class="text-sm text-on-surface-variant">
Value: {{ otp.value.value || '—' }}
</div>
</div>
</template>
136 changes: 136 additions & 0 deletions apps/docs/src/pages/composables/forms/create-otp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
title: createOtp - One-Time Password Composable
meta:
- name: description
content: Composable for fixed-length one-time-password and verification-code state with pattern-gated entry and decisional completion hook for Vue 3.
- name: keywords
content: createOtp, otp, pin input, verification code, composable, Vue 3, headless
features:
category: Composable
label: 'E: createOtp'
github: /composables/createOtp/
level: 2
related:
- /composables/forms/create-input
- /composables/forms/create-validation
---

# createOtp

<DocsPageFeatures :frontmatter />

Manage a fixed-length one-time-password or verification-code value with pattern-gated entry, length-based completion detection, and a decisional async hook. Headless — your component owns rendering, focus, and event wiring.

## Usage

```ts collapse
import { createOtp } from '@vuetify/v0'

const otp = createOtp({
length: 6,
pattern: 'numeric',
onComplete: async value => {
const ok = await verify(value)
return ok // false clears the value and surfaces an error
},
})

otp.put(0, '4') // single character at a position
otp.distribute('123456') // distributes filtered characters
otp.value.value // '412345' joined string
otp.isComplete.value // true when length reached and all chars valid
otp.accepts('a') // false under 'numeric'
otp.clear()
```

## Architecture

```mermaid "createOtp Architecture"
flowchart TD
Options["OtpOptions"]
COTP["createOtp"]:::primary
CI["createInput&lt;string&gt;"]
Context["OtpContext"]

Options --> COTP
COTP --> CI
CI --> Context
COTP --> Context
```

Layer 2 orchestrator. Aggregates createInput for validation, dirty tracking, and ARIA wiring. No registry, no focus traversal, no observers — rendering, per-element refs, and keyboard wiring are the consumer's responsibility.

## Reactivity

| Property | Type | Reactive |
| - | - | - |
| `value` | `Ref<string>` | Yes |
| `length` | `Readonly<Ref<number>>` | Yes |
| `input` | `InputContext<string>` | Yes (delegated) |
| `isComplete` | `Readonly<Ref<boolean>>` | Yes |

| Method | Signature | Effect |
| - | - | - |
| `put` | `(index: number, char: string) => void` | Writes one character at `index`; empty `char` truncates from `index`. |
| `distribute` | `(text: string, index?: number) => number` | Filters and splices, returns the count consumed. |
| `clear` | `() => void` | Empties the joined value. |
| `fill` | `(text: string) => void` | Replaces the joined value (filtered + clipped). |
| `accepts` | `(char: string) => boolean` | Exposes the pattern test so consumers can guard `beforeinput`. |

Every helper is gated on the configured `disabled` and `readonly` options, and on the internal pending state while an async `onComplete` is in flight.

## Patterns

| Pattern | Matches |
| - | - |
| `'numeric'` | `[0-9]` |
| `'alphanumeric'` | `[a-zA-Z0-9]` |
| `'alphabetic'` | `[a-zA-Z]` |
| `RegExp` | Custom; tested per character |

`accepts(char)` is the single point of truth and is reactive through `MaybeRefOrGetter` — toggle modes at runtime and every helper respects the new pattern on the next call.

## Behavior

- `put(index, char)` writes a single character at a position. Empty `char` truncates the joined value to `value.slice(0, index)` — matching the Backspace mental model. Multi-character `char` is reduced to its first character (use `distribute` for multi-character input).
- `distribute(text, index = 0)` filters `text` through `accepts`, splices the filtered characters in at `index`, clips the result to `length`, and returns the count consumed so consumers can decide where to advance focus.
- `isComplete` is true when the joined value reaches `length` and every character passes `accepts`. A watcher fires `onComplete(value)` exactly once on the false → true edge.
- `onComplete` is decisional. Return / resolve `false` to reject — `createOtp` clears the value and surfaces `v0.otp.rejected` through `input.errors`. The error clears automatically on the next mutation.
- While an async `onComplete` is pending, mutation helpers no-op so the user can't race the verification.

## Examples

::: example
/composables/create-otp/basic

### Six-Input Numeric OTP

A minimal six-input numeric OTP. The consumer's component owns the inputs, the template refs, and the per-element focus advance; `createOtp` owns the state, the pattern contract, and the length contract underneath. This is the headless-contract acid test: every visible behavior is replayable by writing markup against `value`, `length`, and `accepts`, with no slot tickets or focus indices baked into the composable.

When to reach for this over a single wide `<input maxlength="6">`: 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 `<input>`s in `useRovingFocus` without changing the state model. The `distribute` helper returns the count consumed so the consumer can choose where to land focus after the characters land — the example moves to the next still-empty slot, but other strategies (stay put, focus the last input, focus the submit button) are equally valid.

Related: see [createInput](/composables/forms/create-input) for the validation, error, and field-state surface that `createOtp` aggregates underneath, and [createValidation](/composables/forms/create-validation) for the `rules` array that flows through unchanged.

:::

## FAQ

::: faq

??? Why is the value a string and not Ref&lt;string[]&gt;?

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<string>`. 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.

:::

<DocsApi />
1 change: 1 addition & 0 deletions apps/docs/src/pages/composables/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 13 additions & 0 deletions apps/docs/src/typed-router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/composables/forms/create-otp': RouteRecordInfo<
'/composables/forms/create-otp',
'/composables/forms/create-otp',
Record<never, never>,
Record<never, never>,
| never
>,
'/composables/forms/create-rating': RouteRecordInfo<
'/composables/forms/create-rating',
'/composables/forms/create-rating',
Expand Down Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions packages/0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading