digitojs 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +753 -0
  4. package/dist/adapters/alpine.d.ts +71 -0
  5. package/dist/adapters/alpine.d.ts.map +1 -0
  6. package/dist/adapters/alpine.js +560 -0
  7. package/dist/adapters/alpine.js.map +1 -0
  8. package/dist/adapters/react.d.ts +223 -0
  9. package/dist/adapters/react.d.ts.map +1 -0
  10. package/dist/adapters/react.js +337 -0
  11. package/dist/adapters/react.js.map +1 -0
  12. package/dist/adapters/svelte.d.ts +139 -0
  13. package/dist/adapters/svelte.d.ts.map +1 -0
  14. package/dist/adapters/svelte.js +295 -0
  15. package/dist/adapters/svelte.js.map +1 -0
  16. package/dist/adapters/vanilla.d.ts +110 -0
  17. package/dist/adapters/vanilla.d.ts.map +1 -0
  18. package/dist/adapters/vanilla.js +650 -0
  19. package/dist/adapters/vanilla.js.map +1 -0
  20. package/dist/adapters/vue.d.ts +163 -0
  21. package/dist/adapters/vue.d.ts.map +1 -0
  22. package/dist/adapters/vue.js +298 -0
  23. package/dist/adapters/vue.js.map +1 -0
  24. package/dist/adapters/web-component.d.ts +192 -0
  25. package/dist/adapters/web-component.d.ts.map +1 -0
  26. package/dist/adapters/web-component.js +832 -0
  27. package/dist/adapters/web-component.js.map +1 -0
  28. package/dist/core/feedback.d.ts +26 -0
  29. package/dist/core/feedback.d.ts.map +1 -0
  30. package/dist/core/feedback.js +47 -0
  31. package/dist/core/feedback.js.map +1 -0
  32. package/dist/core/filter.d.ts +24 -0
  33. package/dist/core/filter.d.ts.map +1 -0
  34. package/dist/core/filter.js +47 -0
  35. package/dist/core/filter.js.map +1 -0
  36. package/dist/core/index.d.ts +16 -0
  37. package/dist/core/index.d.ts.map +1 -0
  38. package/dist/core/index.js +15 -0
  39. package/dist/core/index.js.map +1 -0
  40. package/dist/core/machine.d.ts +67 -0
  41. package/dist/core/machine.d.ts.map +1 -0
  42. package/dist/core/machine.js +328 -0
  43. package/dist/core/machine.js.map +1 -0
  44. package/dist/core/timer.d.ts +24 -0
  45. package/dist/core/timer.d.ts.map +1 -0
  46. package/dist/core/timer.js +67 -0
  47. package/dist/core/timer.js.map +1 -0
  48. package/dist/core/types.d.ts +162 -0
  49. package/dist/core/types.d.ts.map +1 -0
  50. package/dist/core/types.js +10 -0
  51. package/dist/core/types.js.map +1 -0
  52. package/dist/digito-wc.min.js +254 -0
  53. package/dist/digito-wc.min.js.map +7 -0
  54. package/dist/digito.min.js +91 -0
  55. package/dist/digito.min.js.map +7 -0
  56. package/dist/index.d.ts +18 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +25 -0
  59. package/dist/index.js.map +1 -0
  60. package/package.json +109 -0
  61. package/src/adapters/alpine.ts +666 -0
  62. package/src/adapters/react.tsx +603 -0
  63. package/src/adapters/svelte.ts +444 -0
  64. package/src/adapters/vanilla.ts +810 -0
  65. package/src/adapters/vue.ts +462 -0
  66. package/src/adapters/web-component.ts +858 -0
  67. package/src/core/feedback.ts +44 -0
  68. package/src/core/filter.ts +48 -0
  69. package/src/core/index.ts +16 -0
  70. package/src/core/machine.ts +373 -0
  71. package/src/core/timer.ts +75 -0
  72. package/src/core/types.ts +167 -0
  73. package/src/index.ts +51 -0
package/README.md ADDED
@@ -0,0 +1,753 @@
1
+ <a href="https://usedigito.vercel.app" target="_blank">
2
+ <img src="https://raw.githubusercontent.com/theolawalemi/digito/refs/heads/main/assets/banner.png" alt="Digito — Live Demo" width="100%" />
3
+ </a>
4
+
5
+ <h1 align="center">digito</h1>
6
+
7
+ <h3 align="center">
8
+ The only framework-agnostic OTP input state machine powering React, Vue, Svelte, Alpine, Vanilla JS, and Web Components from a single core.
9
+ </h3>
10
+
11
+ <p align="center">
12
+ By <a href="https://x.com/theolawalemi">@Olawale Balo</a> — Product Designer + Design Engineer
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="https://usedigito.vercel.app"><img src="https://img.shields.io/badge/usedigito.vercel.app-0A0A0A?style=flat-square&logo=vercel&logoColor=white" alt="Live demo" /></a>
17
+ <a href="https://www.npmjs.com/package/digitojs"><img src="https://img.shields.io/npm/v/digitojs?style=flat-square&color=0A0A0A" alt="npm" /></a>
18
+ <a href="https://bundlephobia.com/package/digitojs"><img src="https://img.shields.io/bundlephobia/minzip/digitojs?style=flat-square&color=0A0A0A&label=gzip" /></a>
19
+ <img src="https://img.shields.io/badge/zero_dependencies-0A0A0A?style=flat-square" />
20
+ <img src="https://img.shields.io/badge/TypeScript-0A0A0A?style=flat-square&logo=typescript&logoColor=white" />
21
+ </p>
22
+
23
+ ---
24
+
25
+ ## Overview
26
+
27
+ Digito is a fully-featured, zero-dependency OTP input library for the web. It handles SMS autofill, password managers, smart paste, screen readers, countdown timers, and resend flows — without requiring you to patch or workaround any of them.
28
+
29
+ Most OTP libraries render one `<input>` per slot. This breaks native autofill, confuses screen readers, and creates complex focus-juggling logic. Digito instead renders **one transparent input** that captures all keyboard and paste events, with purely visual `<div>` elements mirroring the state. The browser sees a single real field — everything works as expected.
30
+
31
+ The core is a **pure state machine** with no DOM or framework dependencies, wrapped by six independent adapters: Vanilla JS, React, Vue, Svelte, Alpine.js, and Web Component.
32
+
33
+ ---
34
+
35
+ ## Features
36
+
37
+ - **Single hidden-input architecture** — full SMS autofill, password manager, and screen reader support out of the box
38
+ - **Six framework adapters** — Vanilla, React, Vue 3, Svelte, Alpine.js, and Web Component (`<digito-input>`)
39
+ - **Zero dependencies** — framework peer deps are all optional
40
+ - **Pure TypeScript** — fully typed, ships with declaration maps
41
+ - **Built-in timer + resend UI** — countdown badge and resend button injected automatically, or drive your own with `onTick`
42
+ - **Smart paste** — distributes valid characters from cursor slot forward, wrapping around if needed
43
+ - **Password manager guard** — detects badges from LastPass, 1Password, Dashlane, Bitwarden, and Keeper; prevents visual overlap automatically
44
+ - **Web OTP API** — intercepts incoming SMS codes on Android Chrome automatically
45
+ - **Fake caret** — blinking caret rendered on the active empty slot for native feel
46
+ - **Masked mode** — `masked: true` renders `●` glyphs and sets `type="password"` on the hidden input
47
+ - **Visual separators** — `separatorAfter` groups slots visually (e.g. `XXX — XXX`) without affecting the value
48
+ - **Custom charset** — `pattern: RegExp` overrides `type` for any per-character validation rule
49
+ - **Haptic + sound feedback** — `navigator.vibrate` and Web Audio API on completion and error
50
+ - **`onComplete` deferral** — fires after DOM sync; cancellable without clearing slot values
51
+ - **Native form support** — `name` option wires the hidden input into `<form>` / `FormData`
52
+ - **Fully accessible** — single ARIA-labelled input, `inputMode`, `autocomplete="one-time-code"`, all visual elements `aria-hidden`
53
+ - **CDN-ready** — two IIFE bundles for no-build usage
54
+
55
+ ---
56
+
57
+ ## How digito compares
58
+
59
+ | Feature | digito | input-otp | react-otp-input |
60
+ |---|---|---|---|
61
+ | Pure headless state machine | ✅ | ✗ | ✗ |
62
+ | Web OTP API (SMS intercept) | ✅ | ✗ | ✗ |
63
+ | Built-in styles | ✅ | ✗ | ✗ |
64
+ | Built-in timer + resend | ✅ | ✗ | ✗ |
65
+ | Masked mode | ✅ | ✗ | ✗ |
66
+ | Visual separators | ✅ | ✗ | ✗ |
67
+ | Programmatic API (`setError`, `setSuccess`, `reset`, `focus`) | ✅ | ✗ | ✗ |
68
+ | Haptic + sound feedback | ✅ | ✗ | ✗ |
69
+ | `blurOnComplete` (auto-advance) | ✅ | ✗ | ✗ |
70
+ | `onInvalidChar` callback | ✅ | ✗ | ✗ |
71
+ | Vanilla JS | ✅ | ✗ | ✗ |
72
+ | Vue | ✅ | ✗ | ✗ |
73
+ | Svelte | ✅ | ✗ | ✗ |
74
+ | Alpine.js | ✅ | ✗ | ✗ |
75
+ | Web Component | ✅ | ✗ | ✗ |
76
+ | Single hidden input | ✅ | ✅ | ✗ |
77
+ | Fake caret | ✅ | ✅ | ✗ |
78
+ | Password manager guard | ✅ | ✅ | ✗ |
79
+ | React | ✅ | ✅ | ✅ |
80
+ | Zero dependencies | ✅ | ✅ | ✗ |
81
+ | TypeScript | ✅ | ✅ | ✅ |
82
+
83
+ ---
84
+
85
+ ## Installation
86
+
87
+ ```bash
88
+ npm i digitojs
89
+ # or
90
+ pnpm add digitojs
91
+ # or
92
+ yarn add digitojs
93
+ ```
94
+
95
+ **CDN (no build step):**
96
+
97
+ ```html
98
+ <!-- Vanilla JS — window.Digito global -->
99
+ <script src="https://unpkg.com/digitojs/dist/digito.min.js"></script>
100
+
101
+ <!-- Web Component — auto-registers <digito-input> -->
102
+ <script src="https://unpkg.com/digitojs/dist/digito-wc.min.js"></script>
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Quick Start
108
+
109
+ **Vanilla JS — add a `<div>` and call `initDigito()`:**
110
+
111
+ ```html
112
+ <div class="digito-wrapper" data-length="6" data-timer="60"></div>
113
+
114
+ <script type="module">
115
+ import { initDigito } from 'digitojs'
116
+
117
+ const [otp] = initDigito('.digito-wrapper', {
118
+ onComplete: (code) => console.log('Code:', code),
119
+ onResend: () => sendOTP(),
120
+ })
121
+ </script>
122
+ ```
123
+
124
+ Digito injects the slot inputs, styles, countdown badge, and resend button automatically. Nothing else to configure.
125
+
126
+ ---
127
+
128
+ ## Usage
129
+
130
+ ### Common Patterns
131
+
132
+ | Pattern | Key options |
133
+ |---|---|
134
+ | SMS / email OTP (6-digit numeric) | `type: 'numeric'`, `timer: 60`, `onResend` |
135
+ | 2FA / TOTP with grouping | `separatorAfter: 3` |
136
+ | PIN entry (hidden) | `masked: true`, `blurOnComplete: true` |
137
+ | Alphanumeric verification code | `type: 'alphanumeric'`, `pasteTransformer` |
138
+ | Invite / referral code (grouped) | `separatorAfter: [3, 6]`, `pattern: /^[A-Z0-9]$/` |
139
+ | Activation key (hex charset) | `pattern: /^[0-9A-F]$/`, `separatorAfter: [5, 10, 15]` |
140
+ | Native form submission | `name: 'otp_code'` |
141
+ | Async verification with lock | `setDisabled(true/false)` around API call |
142
+ | Auto-advance after entry | `blurOnComplete: true` |
143
+
144
+ ---
145
+
146
+ ### Vanilla JS
147
+
148
+ ```html
149
+ <div
150
+ class="digito-wrapper"
151
+ data-length="6"
152
+ data-type="numeric"
153
+ data-timer="60"
154
+ data-resend="30"
155
+ ></div>
156
+
157
+ <script type="module">
158
+ import { initDigito } from 'digitojs'
159
+
160
+ const [otp] = initDigito('.digito-wrapper', {
161
+ onComplete: (code) => verify(code),
162
+ onResend: () => sendOTP(),
163
+ })
164
+
165
+ // Instance API
166
+ otp.getCode() // → "123456"
167
+ otp.reset() // clear all slots, restart timer, re-focus
168
+ otp.setError(true) // red ring on all slots
169
+ otp.setSuccess(true) // green ring on all slots
170
+ otp.setDisabled(true) // lock input during async verification
171
+ otp.destroy() // clean up all event listeners
172
+ </script>
173
+ ```
174
+
175
+ **Custom timer UI** — pass `onTick` to suppress the built-in footer and drive your own:
176
+
177
+ ```js
178
+ const [otp] = initDigito('.digito-wrapper', {
179
+ timer: 60,
180
+ onTick: (remaining) => (timerEl.textContent = `0:${String(remaining).padStart(2, '0')}`),
181
+ onExpire: () => showResendButton(),
182
+ onResend: () => { otp.resend(); hideResendButton() },
183
+ })
184
+ ```
185
+
186
+ ---
187
+
188
+ ### React
189
+
190
+ ```tsx
191
+ import { useOTP, HiddenOTPInput } from 'digitojs/react'
192
+
193
+ export function OTPInput() {
194
+ const otp = useOTP({
195
+ length: 6,
196
+ onComplete: (code) => verify(code),
197
+ })
198
+
199
+ return (
200
+ <div style={{ position: 'relative', display: 'inline-flex', gap: 10 }}>
201
+ <HiddenOTPInput {...otp.hiddenInputProps} />
202
+
203
+ {otp.slotValues.map((_, i) => {
204
+ const { char, isActive, isFilled, isError, hasFakeCaret } = otp.getSlotProps(i)
205
+ return (
206
+ <div
207
+ key={i}
208
+ className={[
209
+ 'slot',
210
+ isActive ? 'is-active' : '',
211
+ isFilled ? 'is-filled' : '',
212
+ isError ? 'is-error' : '',
213
+ ].filter(Boolean).join(' ')}
214
+ >
215
+ {hasFakeCaret && <span className="caret" />}
216
+ {char}
217
+ </div>
218
+ )
219
+ })}
220
+ </div>
221
+ )
222
+ }
223
+ ```
224
+
225
+ **Controlled / react-hook-form:**
226
+
227
+ ```tsx
228
+ const [code, setCode] = useState('')
229
+ const otp = useOTP({ length: 6, value: code, onChange: setCode })
230
+ ```
231
+
232
+ ---
233
+
234
+ ### Vue 3
235
+
236
+ ```vue
237
+ <script setup lang="ts">
238
+ import { useOTP } from 'digitojs/vue'
239
+
240
+ const otp = useOTP({ length: 6, onComplete: (code) => verify(code) })
241
+ </script>
242
+
243
+ <template>
244
+ <div style="position: relative; display: inline-flex; gap: 10px">
245
+ <input
246
+ :ref="(el) => (otp.inputRef.value = el as HTMLInputElement)"
247
+ v-bind="otp.hiddenInputAttrs"
248
+ style="position: absolute; inset: 0; opacity: 0; z-index: 1"
249
+ @keydown="otp.onKeydown"
250
+ @input="otp.onChange"
251
+ @paste="otp.onPaste"
252
+ @focus="otp.onFocus"
253
+ @blur="otp.onBlur"
254
+ />
255
+ <div
256
+ v-for="(char, i) in otp.slotValues.value"
257
+ :key="i"
258
+ class="slot"
259
+ :class="{
260
+ 'is-active': i === otp.activeSlot.value && otp.isFocused.value,
261
+ 'is-filled': !!char,
262
+ 'is-error': otp.hasError.value,
263
+ }"
264
+ >
265
+ {{ char }}
266
+ </div>
267
+ </div>
268
+ </template>
269
+ ```
270
+
271
+ **Reactive controlled value:**
272
+
273
+ ```ts
274
+ const code = ref('')
275
+ const otp = useOTP({ length: 6, value: code })
276
+ code.value = '' // resets the field reactively
277
+ ```
278
+
279
+ ---
280
+
281
+ ### Svelte
282
+
283
+ ```svelte
284
+ <script>
285
+ import { useOTP } from 'digitojs/svelte'
286
+
287
+ const otp = useOTP({ length: 6, onComplete: (code) => verify(code) })
288
+ </script>
289
+
290
+ <div style="position: relative; display: inline-flex; gap: 10px">
291
+ <input
292
+ use:otp.action
293
+ style="position: absolute; inset: 0; opacity: 0; z-index: 1"
294
+ />
295
+
296
+ {#each $otp.slotValues as char, i}
297
+ <div
298
+ class="slot"
299
+ class:is-active={i === $otp.activeSlot}
300
+ class:is-filled={!!char}
301
+ class:is-error={$otp.hasError}
302
+ >
303
+ {char}
304
+ </div>
305
+ {/each}
306
+ </div>
307
+ ```
308
+
309
+ ---
310
+
311
+ ### Alpine.js
312
+
313
+ ```js
314
+ import Alpine from 'alpinejs'
315
+ import { DigitoAlpine } from 'digitojs/alpine'
316
+
317
+ Alpine.plugin(DigitoAlpine)
318
+ Alpine.start()
319
+ ```
320
+
321
+ ```html
322
+ <div x-digito="{
323
+ length: 6,
324
+ timer: 60,
325
+ onComplete(code) { verify(code) },
326
+ onResend() { sendOTP() },
327
+ }"></div>
328
+ ```
329
+
330
+ Access the instance API via `el._digito`:
331
+
332
+ ```js
333
+ const el = document.querySelector('[x-digito]')
334
+ el._digito.getCode()
335
+ el._digito.setError(true)
336
+ el._digito.reset()
337
+ ```
338
+
339
+ ---
340
+
341
+ ### Web Component
342
+
343
+ ```js
344
+ import 'digitojs/web-component'
345
+ ```
346
+
347
+ ```html
348
+ <digito-input
349
+ length="6"
350
+ type="numeric"
351
+ timer="60"
352
+ placeholder="·"
353
+ separator-after="3"
354
+ name="otp_code"
355
+ ></digito-input>
356
+
357
+ <script>
358
+ const el = document.querySelector('digito-input')
359
+
360
+ // JS-only options (cannot be HTML attributes)
361
+ el.pattern = /^[A-Z0-9]$/
362
+ el.pasteTransformer = s => s.toUpperCase()
363
+ el.onComplete = code => verify(code)
364
+ el.onResend = () => sendOTP()
365
+
366
+ // DOM events (bubbles + composed)
367
+ el.addEventListener('complete', e => console.log(e.detail.code))
368
+ el.addEventListener('expire', () => showResendButton())
369
+ el.addEventListener('change', e => console.log(e.detail.code))
370
+
371
+ // DOM API
372
+ el.reset()
373
+ el.setError(true)
374
+ el.getCode()
375
+ </script>
376
+ ```
377
+
378
+ ---
379
+
380
+ ## API Reference
381
+
382
+ ### `initDigito(target?, options?)` — Vanilla
383
+
384
+ Mounts Digito on one or more wrapper elements. Returns an array of `DigitoInstance`.
385
+
386
+ ```ts
387
+ initDigito(target?: string | HTMLElement | HTMLElement[], options?: VanillaOptions): DigitoInstance[]
388
+ ```
389
+
390
+ **`DigitoInstance` methods:**
391
+
392
+ | Method | Description |
393
+ |---|---|
394
+ | `getCode()` | Returns the current joined code string |
395
+ | `reset()` | Clears all slots, restarts timer, re-focuses |
396
+ | `resend()` | `reset()` + fires `onResend` |
397
+ | `setError(bool)` | Applies or clears error state on all slots |
398
+ | `setSuccess(bool)` | Applies or clears success state on all slots |
399
+ | `setDisabled(bool)` | Disables or enables all input; navigation always allowed |
400
+ | `focus(slotIndex)` | Programmatically moves focus to a slot |
401
+ | `destroy()` | Removes all event listeners, stops timer, aborts Web OTP request |
402
+
403
+ ---
404
+
405
+ ### `useOTP(options)` — React
406
+
407
+ ```ts
408
+ import { useOTP, HiddenOTPInput } from 'digitojs/react'
409
+ const otp = useOTP(options)
410
+ ```
411
+
412
+ **Returns:**
413
+
414
+ | Property | Type | Description |
415
+ |---|---|---|
416
+ | `hiddenInputProps` | `object` | Spread onto the `<input>` or use `<HiddenOTPInput>` |
417
+ | `slotValues` | `string[]` | Current character per slot (`''` = empty) |
418
+ | `activeSlot` | `number` | Zero-based index of the focused slot |
419
+ | `isComplete` | `boolean` | All slots filled |
420
+ | `hasError` | `boolean` | Error state active |
421
+ | `isDisabled` | `boolean` | Disabled state active |
422
+ | `isFocused` | `boolean` | Hidden input has browser focus |
423
+ | `timerSeconds` | `number` | Remaining countdown seconds |
424
+ | `getSlotProps(i)` | `(number) => SlotRenderProps` | Full render metadata for slot `i` |
425
+ | `getCode()` | `() => string` | Joined code string |
426
+ | `reset()` | `() => void` | Clear all slots, restart timer |
427
+ | `setError(bool)` | `(boolean) => void` | Toggle error state |
428
+ | `setDisabled(bool)` | `(boolean) => void` | Toggle disabled state |
429
+ | `focus(i)` | `(number) => void` | Move focus to slot |
430
+
431
+ **`SlotRenderProps`** (from `getSlotProps(i)`):
432
+
433
+ | Prop | Type | Description |
434
+ |---|---|---|
435
+ | `char` | `string` | Slot character, `''` when unfilled |
436
+ | `index` | `number` | Zero-based slot index |
437
+ | `isActive` | `boolean` | This slot has visual focus |
438
+ | `isFilled` | `boolean` | Slot contains a character |
439
+ | `isError` | `boolean` | Error state active |
440
+ | `isComplete` | `boolean` | All slots filled |
441
+ | `isDisabled` | `boolean` | Input is disabled |
442
+ | `isFocused` | `boolean` | Hidden input has browser focus |
443
+ | `hasFakeCaret` | `boolean` | `isActive && !isFilled && isFocused` |
444
+ | `masked` | `boolean` | Masked mode active |
445
+ | `maskChar` | `string` | Configured mask glyph |
446
+ | `placeholder` | `string` | Configured placeholder character |
447
+
448
+ **`HiddenOTPInput`** — `forwardRef` wrapper that applies absolute-positioning styles automatically.
449
+
450
+ ---
451
+
452
+ ### `useOTP(options)` — Vue 3
453
+
454
+ ```ts
455
+ import { useOTP } from 'digitojs/vue'
456
+ const otp = useOTP(options)
457
+ ```
458
+
459
+ **Returns:**
460
+
461
+ | Property | Type | Description |
462
+ |---|---|---|
463
+ | `hiddenInputAttrs` | `object` | Bind with `v-bind` |
464
+ | `inputRef` | `Ref<HTMLInputElement \| null>` | Bind with `:ref` |
465
+ | `slotValues` | `Ref<string[]>` | Current slot values |
466
+ | `activeSlot` | `Ref<number>` | Focused slot index |
467
+ | `value` | `Ref<string>` | Computed joined code |
468
+ | `isComplete` | `Ref<boolean>` | All slots filled |
469
+ | `hasError` | `Ref<boolean>` | Error state |
470
+ | `isFocused` | `Ref<boolean>` | Hidden input focused |
471
+ | `timerSeconds` | `Ref<number>` | Remaining countdown |
472
+ | `isDisabled` | `Ref<boolean>` | Disabled state active |
473
+ | `masked` | `Ref<boolean>` | Masked mode active |
474
+ | `maskChar` | `Ref<string>` | Configured mask glyph |
475
+ | `onKeydown` | handler | Bind with `@keydown` |
476
+ | `onChange` | handler | Bind with `@input` |
477
+ | `onPaste` | handler | Bind with `@paste` |
478
+ | `onFocus` | handler | Bind with `@focus` |
479
+ | `onBlur` | handler | Bind with `@blur` |
480
+ | `getCode()` | `() => string` | Joined code |
481
+ | `reset()` | `() => void` | Clear and reset |
482
+ | `setError(bool)` | `(boolean) => void` | Toggle error |
483
+ | `focus(i)` | `(number) => void` | Move focus |
484
+
485
+ `value` also accepts `Ref<string>` — assigning it resets the field reactively without firing `onComplete`.
486
+
487
+ ---
488
+
489
+ ### `useOTP(options)` — Svelte
490
+
491
+ ```ts
492
+ import { useOTP } from 'digitojs/svelte'
493
+ const otp = useOTP(options)
494
+ ```
495
+
496
+ **Returns:**
497
+
498
+ | Property | Type | Description |
499
+ |---|---|---|
500
+ | `subscribe` | Store | Subscribe to full OTP state |
501
+ | `action` | Svelte action | Use with `use:otp.action` on the hidden `<input>` |
502
+ | `value` | Derived store | Joined code string |
503
+ | `isComplete` | Derived store | All slots filled |
504
+ | `hasError` | Derived store | Error state |
505
+ | `activeSlot` | Derived store | Focused slot index |
506
+ | `timerSeconds` | Writable store | Remaining countdown |
507
+ | `masked` | Writable store | Masked mode |
508
+ | `getCode()` | `() => string` | Joined code |
509
+ | `reset()` | `() => void` | Clear and reset |
510
+ | `setError(bool)` | `(boolean) => void` | Toggle error |
511
+ | `setDisabled(bool)` | `(boolean) => void` | Toggle disabled state |
512
+ | `setValue(v)` | `(string) => void` | Programmatic fill without triggering `onComplete` |
513
+ | `focus(i)` | `(number) => void` | Move focus |
514
+
515
+ ---
516
+
517
+ ### `createDigito(options)` — Core (headless)
518
+
519
+ Pure state machine with no DOM or framework dependency.
520
+
521
+ ```ts
522
+ import { createDigito } from 'digitojs'
523
+
524
+ const otp = createDigito({ length: 6, type: 'numeric' })
525
+
526
+ // Input actions
527
+ otp.inputChar(slotIndex, char)
528
+ otp.deleteChar(slotIndex)
529
+ otp.pasteString(cursorSlot, rawText)
530
+ otp.moveFocusLeft(pos)
531
+ otp.moveFocusRight(pos)
532
+ otp.moveFocusTo(index)
533
+
534
+ // State control
535
+ otp.setError(bool)
536
+ otp.resetState()
537
+ otp.setDisabled(bool)
538
+ otp.cancelPendingComplete() // cancel onComplete without clearing slots
539
+
540
+ // Query
541
+ otp.state // DigitoState snapshot
542
+ otp.getCode()
543
+ otp.getSnapshot()
544
+ otp.getState() // alias for getSnapshot() — Zustand-style
545
+
546
+ // Subscription (XState/Zustand-style)
547
+ const unsub = otp.subscribe(state => render(state))
548
+ unsub()
549
+ ```
550
+
551
+ ---
552
+
553
+ ### `createTimer(options)` — Standalone
554
+
555
+ ```ts
556
+ import { createTimer } from 'digitojs'
557
+
558
+ const timer = createTimer({
559
+ totalSeconds: 60,
560
+ onTick: (remaining) => updateUI(remaining),
561
+ onExpire: () => showResendButton(),
562
+ })
563
+
564
+ timer.start() // begin countdown
565
+ timer.stop() // pause
566
+ timer.reset() // restore to totalSeconds without restarting
567
+ timer.restart() // reset + start
568
+ ```
569
+
570
+ If `totalSeconds <= 0`, `onExpire` fires immediately on `start()`. `start()` is idempotent — calling it twice never double-ticks.
571
+
572
+ ---
573
+
574
+ ### `filterChar` / `filterString` — Utilities
575
+
576
+ ```ts
577
+ import { filterChar, filterString } from 'digitojs'
578
+
579
+ filterChar('A', 'numeric') // → '' (rejected)
580
+ filterChar('5', 'numeric') // → '5'
581
+ filterChar('A', 'alphanumeric') // → 'A'
582
+ filterChar('Z', 'any', /^[A-Z]$/) // → 'Z' (pattern overrides type)
583
+
584
+ filterString('84AB91', 'numeric') // → '8491'
585
+ ```
586
+
587
+ ---
588
+
589
+ ## Configuration Options
590
+
591
+ All options are accepted by every adapter unless otherwise noted.
592
+
593
+ | Option | Type | Default | Description |
594
+ |---|---|---|---|
595
+ | `length` | `number` | `6` | Number of input slots |
596
+ | `type` | `'numeric' \| 'alphabet' \| 'alphanumeric' \| 'any'` | `'numeric'` | Character class |
597
+ | `pattern` | `RegExp` | — | Per-character regex; overrides `type` for validation |
598
+ | `pasteTransformer` | `(raw: string) => string` | — | Transforms clipboard text before filtering |
599
+ | `onComplete` | `(code: string) => void` | — | Fired when all slots are filled |
600
+ | `onExpire` | `() => void` | — | Fired when countdown reaches zero |
601
+ | `onResend` | `() => void` | — | Fired when resend is triggered |
602
+ | `onTick` | `(remaining: number) => void` | — | Fired every second; suppresses built-in footer (vanilla) |
603
+ | `onInvalidChar` | `(char: string, index: number) => void` | — | Fired when a typed character is rejected |
604
+ | `onChange` | `(code: string) => void` | — | Fired on every user interaction |
605
+ | `onFocus` | `() => void` | — | Fired when hidden input gains focus |
606
+ | `onBlur` | `() => void` | — | Fired when hidden input loses focus |
607
+ | `timer` | `number` | `0` | Countdown duration in seconds (`0` = disabled) |
608
+ | `resendAfter` | `number` | `30` | Resend button cooldown in seconds (vanilla) |
609
+ | `autoFocus` | `boolean` | `true` | Focus the hidden input on mount |
610
+ | `blurOnComplete` | `boolean` | `false` | Blur on completion (auto-advance to next field) |
611
+ | `selectOnFocus` | `boolean` | `false` | Select-and-replace behavior on focused filled slot |
612
+ | `placeholder` | `string` | `''` | Character shown in empty slots (e.g. `'○'`, `'_'`) |
613
+ | `masked` | `boolean` | `false` | Render `maskChar` in slots; `type="password"` on hidden input |
614
+ | `maskChar` | `string` | `'●'` | Glyph used in masked mode |
615
+ | `name` | `string` | — | Hidden input `name` for `<form>` / `FormData` |
616
+ | `separatorAfter` | `number \| number[]` | — | 1-based slot index/indices to insert a visual separator after |
617
+ | `separator` | `string` | `'—'` | Separator character to render |
618
+ | `disabled` | `boolean` | `false` | Disable all input on mount |
619
+ | `haptic` | `boolean` | `true` | `navigator.vibrate(10)` on completion and error |
620
+ | `sound` | `boolean` | `false` | Play 880 Hz tone via Web Audio on completion |
621
+
622
+ ---
623
+
624
+ ## Styling & Customization
625
+
626
+ ### CSS Custom Properties
627
+
628
+ Set on `.digito-wrapper` (vanilla) or `digito-input` (web component) to theme the entire component:
629
+
630
+ ```css
631
+ .digito-wrapper {
632
+ /* Dimensions */
633
+ --digito-size: 56px; /* slot width + height */
634
+ --digito-gap: 12px; /* gap between slots */
635
+ --digito-radius: 10px; /* slot border radius */
636
+ --digito-font-size: 24px; /* digit font size */
637
+
638
+ /* Colors */
639
+ --digito-color: #0A0A0A; /* digit text color */
640
+ --digito-bg: #FAFAFA; /* empty slot background */
641
+ --digito-bg-filled: #FFFFFF; /* filled slot background */
642
+ --digito-border-color: #E5E5E5; /* default slot border */
643
+ --digito-active-color: #3D3D3D; /* active border + ring */
644
+ --digito-error-color: #FB2C36; /* error border + ring */
645
+ --digito-success-color: #00C950; /* success border + ring */
646
+ --digito-caret-color: #3D3D3D; /* fake caret color */
647
+ --digito-timer-color: #5C5C5C; /* footer text */
648
+
649
+ /* Placeholder & separator */
650
+ --digito-placeholder-color: #D3D3D3;
651
+ --digito-placeholder-size: 16px;
652
+ --digito-separator-color: #A1A1A1;
653
+ --digito-separator-size: 18px;
654
+ }
655
+ ```
656
+
657
+ ### CSS Classes (Vanilla & Web Component)
658
+
659
+ | Class | Applied when |
660
+ |---|---|
661
+ | `.digito-slot` | Always — on every visual slot div |
662
+ | `.digito-slot.is-active` | Slot is the currently focused position |
663
+ | `.digito-slot.is-filled` | Slot contains a character |
664
+ | `.digito-slot.is-error` | Error state is active |
665
+ | `.digito-slot.is-success` | Success state is active |
666
+ | `.digito-caret` | The blinking caret inside the active empty slot |
667
+ | `.digito-timer` | The "Code expires in…" countdown row |
668
+ | `.digito-timer-badge` | The red pill countdown badge |
669
+ | `.digito-resend` | The "Didn't receive the code?" resend row |
670
+ | `.digito-resend-btn` | The resend chip button |
671
+ | `.digito-separator` | The visual separator between slot groups |
672
+
673
+ ---
674
+
675
+ ## Accessibility
676
+
677
+ Digito is built with accessibility as a first-class concern:
678
+
679
+ - **Single ARIA-labelled input** — the hidden input carries `aria-label="Enter your N-digit code"` (or `N-character code` for non-numeric types). Screen readers announce one field, not six.
680
+ - **All visual elements `aria-hidden`** — slot divs, separators, caret, and timer footer are hidden from the accessibility tree.
681
+ - **`inputMode`** — set to `"numeric"` or `"text"` based on `type`, triggering the correct mobile keyboard.
682
+ - **`autocomplete="one-time-code"`** — enables native SMS autofill on iOS and Android.
683
+ - **Anti-interference** — `spellcheck="false"`, `autocorrect="off"`, `autocapitalize="off"` prevent browser UI from interfering.
684
+ - **`maxLength`** — constrains native input to `length`.
685
+ - **`type="password"` in masked mode** — triggers the OS password keyboard on mobile.
686
+ - **Native form integration** — the `name` option wires the hidden input into `<form>` and `FormData`, compatible with any form submission approach.
687
+ - **Keyboard navigation** — full keyboard support (`←`, `→`, `Backspace`, `Tab`). No mouse required.
688
+
689
+ ---
690
+
691
+ ## Keyboard Navigation
692
+
693
+ | Key | Action |
694
+ |---|---|
695
+ | `0–9` / `a–z` / `A–Z` | Fill current slot and advance focus |
696
+ | `Backspace` | Clear current slot; step back if already empty |
697
+ | `←` | Move focus one slot left |
698
+ | `→` | Move focus one slot right |
699
+ | `Cmd/Ctrl+V` | Smart paste from cursor slot, wrapping if needed |
700
+ | `Tab` | Standard browser tab order |
701
+
702
+ ---
703
+
704
+ ## Browser & Environment Support
705
+
706
+ **Browsers:**
707
+
708
+ | Browser | Support |
709
+ |---|---|
710
+ | Chrome / Edge | ✅ Full support including Web OTP API |
711
+ | Firefox | ✅ Full support |
712
+ | Safari / iOS Safari | ✅ Full support including SMS autofill |
713
+ | Android Chrome | ✅ Full support including Web OTP API |
714
+
715
+ **Frameworks (peer deps, all optional):**
716
+
717
+ | Framework | Version |
718
+ |---|---|
719
+ | React | `>= 17` |
720
+ | Vue | `>= 3` |
721
+ | Svelte | `>= 4` |
722
+ | Alpine.js | `>= 3` |
723
+
724
+ **Runtimes:**
725
+ - Node.js (core state machine — no DOM required)
726
+ - All modern browsers
727
+ - CDN / no-build via IIFE bundles (ES2017 target)
728
+
729
+ ---
730
+
731
+ ## Package Exports
732
+
733
+ ```
734
+ digitojs → Vanilla JS adapter + core utilities
735
+ digitojs/core → createDigito, createTimer, filterChar, filterString (no DOM)
736
+ digitojs/react → useOTP hook + HiddenOTPInput + SlotRenderProps
737
+ digitojs/vue → useOTP composable
738
+ digitojs/svelte → useOTP store + action
739
+ digitojs/alpine → DigitoAlpine plugin
740
+ digitojs/web-component → <digito-input> custom element
741
+ ```
742
+
743
+ All exports are fully typed. Core utilities are also available from the main entry:
744
+
745
+ ```ts
746
+ import { createDigito, createTimer, filterChar, filterString } from 'digitojs'
747
+ ```
748
+
749
+ ---
750
+
751
+ ## License
752
+
753
+ MIT © [Olawale Balo](https://github.com/theolawalemi)