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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +753 -0
- package/dist/adapters/alpine.d.ts +71 -0
- package/dist/adapters/alpine.d.ts.map +1 -0
- package/dist/adapters/alpine.js +560 -0
- package/dist/adapters/alpine.js.map +1 -0
- package/dist/adapters/react.d.ts +223 -0
- package/dist/adapters/react.d.ts.map +1 -0
- package/dist/adapters/react.js +337 -0
- package/dist/adapters/react.js.map +1 -0
- package/dist/adapters/svelte.d.ts +139 -0
- package/dist/adapters/svelte.d.ts.map +1 -0
- package/dist/adapters/svelte.js +295 -0
- package/dist/adapters/svelte.js.map +1 -0
- package/dist/adapters/vanilla.d.ts +110 -0
- package/dist/adapters/vanilla.d.ts.map +1 -0
- package/dist/adapters/vanilla.js +650 -0
- package/dist/adapters/vanilla.js.map +1 -0
- package/dist/adapters/vue.d.ts +163 -0
- package/dist/adapters/vue.d.ts.map +1 -0
- package/dist/adapters/vue.js +298 -0
- package/dist/adapters/vue.js.map +1 -0
- package/dist/adapters/web-component.d.ts +192 -0
- package/dist/adapters/web-component.d.ts.map +1 -0
- package/dist/adapters/web-component.js +832 -0
- package/dist/adapters/web-component.js.map +1 -0
- package/dist/core/feedback.d.ts +26 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +47 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/filter.d.ts +24 -0
- package/dist/core/filter.d.ts.map +1 -0
- package/dist/core/filter.js +47 -0
- package/dist/core/filter.js.map +1 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +15 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/machine.d.ts +67 -0
- package/dist/core/machine.d.ts.map +1 -0
- package/dist/core/machine.js +328 -0
- package/dist/core/machine.js.map +1 -0
- package/dist/core/timer.d.ts +24 -0
- package/dist/core/timer.d.ts.map +1 -0
- package/dist/core/timer.js +67 -0
- package/dist/core/timer.js.map +1 -0
- package/dist/core/types.d.ts +162 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +10 -0
- package/dist/core/types.js.map +1 -0
- package/dist/digito-wc.min.js +254 -0
- package/dist/digito-wc.min.js.map +7 -0
- package/dist/digito.min.js +91 -0
- package/dist/digito.min.js.map +7 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +109 -0
- package/src/adapters/alpine.ts +666 -0
- package/src/adapters/react.tsx +603 -0
- package/src/adapters/svelte.ts +444 -0
- package/src/adapters/vanilla.ts +810 -0
- package/src/adapters/vue.ts +462 -0
- package/src/adapters/web-component.ts +858 -0
- package/src/core/feedback.ts +44 -0
- package/src/core/filter.ts +48 -0
- package/src/core/index.ts +16 -0
- package/src/core/machine.ts +373 -0
- package/src/core/timer.ts +75 -0
- package/src/core/types.ts +167 -0
- 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)
|