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
@@ -0,0 +1,444 @@
1
+ /**
2
+ * digito/svelte
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ * Svelte adapter — useOTP store + action (single hidden-input architecture)
5
+ *
6
+ * @author Olawale Balo — Product Designer + Design Engineer
7
+ * @license MIT
8
+ */
9
+
10
+ import { writable, derived, get } from 'svelte/store'
11
+
12
+ import {
13
+ createDigito,
14
+ createTimer,
15
+ filterString,
16
+ type DigitoOptions,
17
+ type DigitoState,
18
+ type InputType,
19
+ } from '../core/index.js'
20
+
21
+
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ // TYPES
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Extended options for the Svelte useOTP composable.
28
+ * Adds controlled-input, separator, and disabled support on top of DigitoOptions.
29
+ */
30
+ export type SvelteOTPOptions = DigitoOptions & {
31
+ /**
32
+ * Controlled value — drives the slot state from outside the composable.
33
+ * Pass a string of up to length characters to pre-fill or sync the field.
34
+ */
35
+ value?: string
36
+ /**
37
+ * Fires exactly ONCE per user interaction with the current joined code string.
38
+ * Receives partial values too — not just when the code is complete.
39
+ */
40
+ onChange?: (code: string) => void
41
+ /**
42
+ * Insert a purely visual separator after this slot index (0-based).
43
+ * Accepts a single position or an array for multiple separators.
44
+ * aria-hidden, never part of the value, no effect on the state machine.
45
+ * Default: 0 (no separator).
46
+ * @example separatorAfter: 3 -> [*][*][*] — [*][*][*]
47
+ * @example separatorAfter: [2, 4] -> [*][*] — [*][*] — [*][*]
48
+ */
49
+ separatorAfter?: number | number[]
50
+ /**
51
+ * The character or string to render as the separator.
52
+ * Default: '—'
53
+ */
54
+ separator?: string
55
+ /**
56
+ * When `true`, slot templates should display a mask glyph instead of the real
57
+ * character. The hidden input switches to `type="password"` via the action.
58
+ *
59
+ * `getCode()` and `onComplete` always return real characters.
60
+ * Use for PIN entry or any sensitive input flow.
61
+ *
62
+ * Default: `false`.
63
+ */
64
+ masked?: boolean
65
+ /**
66
+ * The glyph displayed in filled slots when `masked` is `true`.
67
+ * Returned as a `writable` store so Svelte templates can subscribe to it.
68
+ *
69
+ * Default: `'●'` (U+25CF BLACK CIRCLE).
70
+ * @example maskChar: '*'
71
+ */
72
+ maskChar?: string
73
+ }
74
+
75
+ export type UseOTPResult = {
76
+ /** Subscribe to the full state store. */
77
+ subscribe: ReturnType<typeof writable>['subscribe']
78
+ /** Derived — joined code string. */
79
+ value: ReturnType<typeof derived>
80
+ /** Derived — completion boolean. */
81
+ isComplete: ReturnType<typeof derived>
82
+ /** Derived — error boolean. */
83
+ hasError: ReturnType<typeof derived>
84
+ /** Derived — active slot index. */
85
+ activeSlot: ReturnType<typeof derived>
86
+ /** Remaining timer seconds store. */
87
+ timerSeconds: ReturnType<typeof writable>
88
+ /** Whether the field is currently disabled. */
89
+ isDisabled: ReturnType<typeof writable>
90
+ /** The separator slot index store. -1 = no separator. */
91
+ separatorAfter: ReturnType<typeof writable>
92
+ /** The separator character store. */
93
+ separator: ReturnType<typeof writable>
94
+ /** Whether masked mode is active. When true, templates should render `maskChar` instead of char. */
95
+ masked: ReturnType<typeof writable>
96
+ /**
97
+ * The configured mask glyph store. Use in templates instead of a hard-coded `●`:
98
+ * `{$otp.masked && char ? $otp.maskChar : char}`
99
+ */
100
+ maskChar: ReturnType<typeof writable>
101
+ /** The placeholder character for empty slots. Empty string when not set. */
102
+ placeholder: string
103
+ /** Svelte action to bind to the single hidden input. */
104
+ action: (node: HTMLInputElement) => { destroy: () => void }
105
+ /** Returns the current joined code string. */
106
+ getCode: () => string
107
+ /** Clear all slots, restart timer, return focus to input. */
108
+ reset: () => void
109
+ /** Apply or clear the error state. */
110
+ setError: (isError: boolean) => void
111
+ /** Enable or disable the field at runtime. */
112
+ setDisabled: (value: boolean) => void
113
+ /** Programmatically move focus to a slot index. */
114
+ focus: (slotIndex: number) => void
115
+ /**
116
+ * Programmatically set the field value without triggering `onComplete`.
117
+ * Pass `undefined` to no-op. Filters the incoming string through the current
118
+ * `type`/`pattern` before distribution, identical to controlled-value sync.
119
+ */
120
+ setValue: (v: string | undefined) => void
121
+ }
122
+
123
+
124
+ // ─────────────────────────────────────────────────────────────────────────────
125
+ // COMPOSABLE
126
+ // ─────────────────────────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Svelte composable for OTP input — single hidden-input architecture.
130
+ *
131
+ * @example
132
+ * ```svelte
133
+ * <script>
134
+ * import { useOTP } from 'digito/svelte'
135
+ * const otp = useOTP({ length: 6, onComplete: (code) => verify(code) })
136
+ * $: state = $otp
137
+ * </script>
138
+ *
139
+ * <div style="position:relative; display:inline-flex; gap:8px; align-items:center">
140
+ * <input
141
+ * use:otp.action
142
+ * style="position:absolute;inset:0;opacity:0;z-index:1;cursor:text"
143
+ * />
144
+ * {#each state.slotValues as char, i}
145
+ * {#if $otp.separatorAfter > 0 && i === $otp.separatorAfter}
146
+ * <span aria-hidden="true">{$otp.separator}</span>
147
+ * {/if}
148
+ * <div class="slot"
149
+ * class:is-active={i === state.activeSlot}
150
+ * class:is-filled={!!char}
151
+ * class:is-error={state.hasError}
152
+ * class:is-disabled={$otp.isDisabled}
153
+ * >{char}</div>
154
+ * {/each}
155
+ * </div>
156
+ * ```
157
+ */
158
+ export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
159
+ const {
160
+ length = 6,
161
+ type = 'numeric' as InputType,
162
+ timer: timerSecs = 0,
163
+ disabled: initialDisabled = false,
164
+ onComplete,
165
+ onExpire,
166
+ onResend,
167
+ haptic = true,
168
+ sound = false,
169
+ pattern,
170
+ pasteTransformer,
171
+ onInvalidChar,
172
+ value: controlledValue,
173
+ onChange: onChangeProp,
174
+ onFocus: onFocusProp,
175
+ onBlur: onBlurProp,
176
+ separatorAfter: separatorAfterOpt = 0,
177
+ separator: separatorOpt = '—',
178
+ masked: maskedOpt = false,
179
+ maskChar: maskCharOpt = '\u25CF',
180
+ autoFocus: autoFocusOpt = true,
181
+ name: nameOpt,
182
+ placeholder: placeholderOpt = '',
183
+ selectOnFocus: selectOnFocusOpt = false,
184
+ blurOnComplete: blurOnCompleteOpt = false,
185
+ } = options
186
+
187
+ // ── Core instance ──────────────────────────────────────────────────────────
188
+ const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound })
189
+
190
+ // ── Stores ─────────────────────────────────────────────────────────────────
191
+ const store = writable(digito.state)
192
+ const timerStore = writable(timerSecs)
193
+ const isDisabledStore = writable(initialDisabled)
194
+ const separatorAfterStore = writable(separatorAfterOpt)
195
+ const separatorStore = writable(separatorOpt)
196
+ const maskedStore = writable(maskedOpt)
197
+ const maskCharStore = writable(maskCharOpt)
198
+
199
+ let inputEl: HTMLInputElement | null = null
200
+
201
+ // ── sync() ─────────────────────────────────────────────────────────────────
202
+ function sync(suppressOnChange = false): void {
203
+ const s = digito.state
204
+ store.set({ ...s })
205
+ if (!suppressOnChange) {
206
+ onChangeProp?.(s.slotValues.join(''))
207
+ }
208
+ }
209
+
210
+ // ── Controlled value sync ──────────────────────────────────────────────────
211
+ function setValue(incoming: string | undefined): void {
212
+ if (incoming === undefined) return
213
+ const filtered = filterString(incoming.slice(0, length), type, pattern)
214
+ const current = digito.state.slotValues.join('')
215
+ if (filtered === current) return
216
+
217
+ digito.resetState()
218
+ for (let i = 0; i < filtered.length; i++) {
219
+ digito.inputChar(i, filtered[i])
220
+ }
221
+ // Prevent a programmatic fill from triggering onComplete as if the user
222
+ // had typed the code. cancelPendingComplete cancels the 10ms deferred
223
+ // callback scheduled by the final inputChar above.
224
+ digito.cancelPendingComplete()
225
+ sync(true)
226
+ if (inputEl) {
227
+ inputEl.value = filtered
228
+ inputEl.setSelectionRange(filtered.length, filtered.length)
229
+ }
230
+ onChangeProp?.(filtered)
231
+ }
232
+
233
+ if (controlledValue !== undefined) {
234
+ setValue(controlledValue)
235
+ }
236
+
237
+ // ── Timer ──────────────────────────────────────────────────────────────────
238
+ let timerControls: ReturnType<typeof createTimer> | null = null
239
+
240
+ if (timerSecs > 0) {
241
+ timerControls = createTimer({
242
+ totalSeconds: timerSecs,
243
+ onTick: (r) => timerStore.set(r),
244
+ onExpire: () => { timerStore.set(0); onExpire?.() },
245
+ })
246
+ }
247
+
248
+ // ── Svelte Action ──────────────────────────────────────────────────────────
249
+ function action(node: HTMLInputElement): { destroy: () => void } {
250
+ inputEl = node
251
+
252
+ node.type = maskedOpt ? 'password' : 'text'
253
+ node.inputMode = type === 'numeric' ? 'numeric' : 'text'
254
+ node.autocomplete = 'one-time-code'
255
+ node.maxLength = length
256
+ node.disabled = get(isDisabledStore)
257
+ node.spellcheck = false
258
+ if (nameOpt) node.name = nameOpt
259
+ node.setAttribute('aria-label', `Enter your ${length}-${type === 'numeric' ? 'digit' : 'character'} code`)
260
+ node.setAttribute('autocorrect', 'off')
261
+ node.setAttribute('autocapitalize', 'off')
262
+
263
+ const unsubDisabled = isDisabledStore.subscribe((v: boolean) => { node.disabled = v })
264
+
265
+ function onKeydown(e: KeyboardEvent): void {
266
+ if (get(isDisabledStore)) return
267
+ const pos = node.selectionStart ?? 0
268
+ if (e.key === 'Backspace') {
269
+ e.preventDefault()
270
+ digito.deleteChar(pos)
271
+ sync()
272
+ const next = digito.state.activeSlot
273
+ requestAnimationFrame(() => node.setSelectionRange(next, next))
274
+ } else if (e.key === 'ArrowLeft') {
275
+ e.preventDefault()
276
+ digito.moveFocusLeft(pos)
277
+ sync()
278
+ const next = digito.state.activeSlot
279
+ requestAnimationFrame(() => node.setSelectionRange(next, next))
280
+ } else if (e.key === 'ArrowRight') {
281
+ e.preventDefault()
282
+ digito.moveFocusRight(pos)
283
+ sync()
284
+ const next = digito.state.activeSlot
285
+ requestAnimationFrame(() => node.setSelectionRange(next, next))
286
+ } else if (e.key === 'Tab') {
287
+ if (e.shiftKey) {
288
+ if (pos === 0) return
289
+ e.preventDefault()
290
+ digito.moveFocusLeft(pos)
291
+ } else {
292
+ if (!digito.state.slotValues[pos]) return
293
+ if (pos >= length - 1) return
294
+ e.preventDefault()
295
+ digito.moveFocusRight(pos)
296
+ }
297
+ sync()
298
+ const next = digito.state.activeSlot
299
+ requestAnimationFrame(() => node.setSelectionRange(next, next))
300
+ }
301
+ }
302
+
303
+ function onChange(e: Event): void {
304
+ if (get(isDisabledStore)) return
305
+ const raw = (e.target as HTMLInputElement).value
306
+ if (!raw) {
307
+ digito.resetState()
308
+ node.value = ''
309
+ node.setSelectionRange(0, 0)
310
+ sync()
311
+ return
312
+ }
313
+ const valid = filterString(raw, type, pattern).slice(0, length)
314
+ digito.resetState()
315
+ for (let i = 0; i < valid.length; i++) digito.inputChar(i, valid[i])
316
+ const next = Math.min(valid.length, length - 1)
317
+ node.value = valid
318
+ node.setSelectionRange(next, next)
319
+ digito.moveFocusTo(next)
320
+ sync()
321
+ if (blurOnCompleteOpt && digito.state.isComplete) {
322
+ requestAnimationFrame(() => node.blur())
323
+ }
324
+ }
325
+
326
+ function onPaste(e: ClipboardEvent): void {
327
+ if (get(isDisabledStore)) return
328
+ e.preventDefault()
329
+ const text = e.clipboardData?.getData('text') ?? ''
330
+ const pos = node.selectionStart ?? 0
331
+ digito.pasteString(pos, text)
332
+ const { slotValues, activeSlot } = digito.state
333
+ node.value = slotValues.join('')
334
+ node.setSelectionRange(activeSlot, activeSlot)
335
+ sync()
336
+ if (blurOnCompleteOpt && digito.state.isComplete) {
337
+ requestAnimationFrame(() => node.blur())
338
+ }
339
+ }
340
+
341
+ function onFocus(): void {
342
+ onFocusProp?.()
343
+ const pos = digito.state.activeSlot
344
+ requestAnimationFrame(() => {
345
+ const char = digito.state.slotValues[pos]
346
+ if (selectOnFocusOpt && char) {
347
+ node.setSelectionRange(pos, pos + 1)
348
+ } else {
349
+ node.setSelectionRange(pos, pos)
350
+ }
351
+ })
352
+ }
353
+
354
+ function onBlur(): void {
355
+ onBlurProp?.()
356
+ }
357
+
358
+ node.addEventListener('keydown', onKeydown)
359
+ node.addEventListener('input', onChange)
360
+ node.addEventListener('paste', onPaste)
361
+ node.addEventListener('focus', onFocus)
362
+ node.addEventListener('blur', onBlur)
363
+
364
+ if (autoFocusOpt && !get(isDisabledStore)) {
365
+ requestAnimationFrame(() => node.focus())
366
+ }
367
+
368
+ // Start timer now that the component is mounted and the input element is
369
+ // available — matching Vue's onMounted pattern.
370
+ timerControls?.start()
371
+
372
+ return {
373
+ destroy() {
374
+ node.removeEventListener('keydown', onKeydown)
375
+ node.removeEventListener('input', onChange)
376
+ node.removeEventListener('paste', onPaste)
377
+ node.removeEventListener('focus', onFocus)
378
+ node.removeEventListener('blur', onBlur)
379
+ unsubDisabled()
380
+ timerControls?.stop()
381
+ inputEl = null
382
+ },
383
+ }
384
+ }
385
+
386
+ // ── Public API ─────────────────────────────────────────────────────────────
387
+
388
+ function reset(): void {
389
+ digito.resetState()
390
+ if (inputEl) { inputEl.value = ''; inputEl.focus(); inputEl.setSelectionRange(0, 0) }
391
+ timerStore.set(timerSecs)
392
+ timerControls?.restart()
393
+ sync()
394
+ }
395
+
396
+ function setError(isError: boolean): void {
397
+ digito.setError(isError)
398
+ sync(true)
399
+ }
400
+
401
+ function setDisabled(value: boolean): void {
402
+ isDisabledStore.set(value)
403
+ digito.setDisabled(value)
404
+ }
405
+
406
+ function focus(slotIndex: number): void {
407
+ digito.moveFocusTo(slotIndex)
408
+ inputEl?.focus()
409
+ requestAnimationFrame(() => inputEl?.setSelectionRange(slotIndex, slotIndex))
410
+ sync(true)
411
+ }
412
+
413
+ function getCode(): string {
414
+ return digito.getCode()
415
+ }
416
+
417
+ // Derived stores
418
+ const value = derived(store, ($s: DigitoState) => $s.slotValues.join(''))
419
+ const isComplete = derived(store, ($s: DigitoState) => $s.isComplete)
420
+ const hasError = derived(store, ($s: DigitoState) => $s.hasError)
421
+ const activeSlot = derived(store, ($s: DigitoState) => $s.activeSlot)
422
+
423
+ return {
424
+ subscribe: store.subscribe,
425
+ value,
426
+ isComplete,
427
+ hasError,
428
+ activeSlot,
429
+ timerSeconds: timerStore,
430
+ isDisabled: isDisabledStore,
431
+ separatorAfter: separatorAfterStore,
432
+ separator: separatorStore,
433
+ masked: maskedStore,
434
+ maskChar: maskCharStore,
435
+ placeholder: placeholderOpt,
436
+ action,
437
+ getCode,
438
+ reset,
439
+ setError,
440
+ setDisabled,
441
+ setValue,
442
+ focus,
443
+ }
444
+ }