Skip to content

feat(createOtp): add headless OTP / verification-code composable#238

Open
johnleider wants to merge 23 commits into
masterfrom
feat/createOtp
Open

feat(createOtp): add headless OTP / verification-code composable#238
johnleider wants to merge 23 commits into
masterfrom
feat/createOtp

Conversation

@johnleider
Copy link
Copy Markdown
Member

Summary

  • Adds createOtp, a Layer 2 forms composable wrapping createInput<string> with fixed-length OTP/verification-code state.
  • Pattern-gated entry via 'numeric' | 'alphanumeric' | 'alphabetic' | RegExp, length-based completion detection, and a decisional sync/async onComplete hook that clears the value and surfaces v0.otp.rejected on rejection.
  • Mutation helpers (setAt, paste, clear, fill) no-op when disabled, readonly, or an async onComplete is pending. paste returns the count consumed so the consumer can advance focus.
  • No registry, no roving focus, no observers — rendering and focus management live entirely in the consumer's component.

What's included

  • packages/0/src/composables/createOtp/ — implementation + 35 colocated tests
  • Maturity entry: preview / forms / since: null
  • Barrel export, both READMEs, <DocsApi /> whitelist
  • Docs page at composables/forms/create-otp with Architecture, Reactivity, Patterns, Behavior, Examples, and FAQ sections
  • Basic example: six-input numeric OTP with focus advance, Backspace truncate, and paste distribution
  • SKILL.md decision-table row and REFERENCE.md Form Handling subsection

Known follow-up

  • input.isValidating is not yet wired to the internal isPending ref — documented inline in source. Will land alongside an isValidating option on createInput.

johnleider added 15 commits May 12, 2026 13:20
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.
Satisfies the "Per-symbol @example (100% enforced)" rule from
.claude/rules/composables.md — the interfaces render in <DocsApi />
and require their own example blocks.
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.
The WeakSet<RegExp> 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

commit: ccc6930

@johnleider johnleider self-assigned this May 12, 2026
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.
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.
@johnleider johnleider added this to the v0.2.x milestone May 13, 2026
- 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.
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.
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.
…t edges

- OtpContext.value typed as Readonly<Ref<string>>. 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.
Round 4 review caught an inconsistency: OtpContext.value was typed
Readonly<Ref<string>> but OtpContext.input.value was still writable
via InputContext<string>, and the JSDoc explicitly condoned writes
through either alias. Type-narrow input to also expose value as
Readonly<Ref<string>>, 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<string> for write access through the helpers.
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<RegExp>() constructor where it actually
helps tree-shaking.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant