digitojs 1.2.2 → 1.2.4

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.
@@ -1,497 +0,0 @@
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, type Readable, type Writable } 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: Writable<DigitoState>['subscribe']
78
- /** Derived — joined code string. */
79
- value: Readable<string>
80
- /** Derived — completion boolean. */
81
- isComplete: Readable<boolean>
82
- /** Derived — error boolean. */
83
- hasError: Readable<boolean>
84
- /** Derived — active slot index. */
85
- activeSlot: Readable<number>
86
- /** Remaining timer seconds store. */
87
- timerSeconds: Writable<number>
88
- /** Whether the field is currently disabled. */
89
- isDisabled: Writable<boolean>
90
- /** The separator slot index store. */
91
- separatorAfter: Writable<number | number[]>
92
- /** The separator character store. */
93
- separator: Writable<string>
94
- /** Whether masked mode is active. When true, templates should render `maskChar` instead of char. */
95
- masked: Writable<boolean>
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: Writable<string>
101
- /** The placeholder character for empty slots. Empty string when not set. */
102
- placeholder: string
103
- /** Derived — spread onto the wrapper element as data attributes for CSS/Tailwind targeting. */
104
- wrapperAttrs: Readable<Record<string, string | undefined>>
105
- /** Svelte action to bind to the single hidden input. */
106
- action: (node: HTMLInputElement) => { destroy: () => void }
107
- /** Returns the current joined code string. */
108
- getCode: () => string
109
- /** Clear all slots, restart timer, return focus to input. */
110
- reset: () => void
111
- /** Apply or clear the error state. */
112
- setError: (isError: boolean) => void
113
- /** Enable or disable the field at runtime. */
114
- setDisabled: (value: boolean) => void
115
- /**
116
- * Toggle readOnly at runtime. When `true`, all slot mutations are blocked
117
- * but focus, navigation, and copy remain fully functional.
118
- */
119
- setReadOnly: (value: boolean) => void
120
- /** Programmatically move focus to a slot index. */
121
- focus: (slotIndex: number) => void
122
- /**
123
- * Programmatically set the field value without triggering `onComplete`.
124
- * Pass `undefined` to no-op. Filters the incoming string through the current
125
- * `type`/`pattern` before distribution, identical to controlled-value sync.
126
- */
127
- setValue: (v: string | undefined) => void
128
- }
129
-
130
-
131
- // ─────────────────────────────────────────────────────────────────────────────
132
- // COMPOSABLE
133
- // ─────────────────────────────────────────────────────────────────────────────
134
-
135
- /**
136
- * Svelte composable for OTP input — single hidden-input architecture.
137
- *
138
- * @example
139
- * ```svelte
140
- * <script>
141
- * import { useOTP } from 'digito/svelte'
142
- * const otp = useOTP({ length: 6, onComplete: (code) => verify(code) })
143
- * $: state = $otp
144
- * </script>
145
- *
146
- * <div style="position:relative; display:inline-flex; gap:8px; align-items:center">
147
- * <input
148
- * use:otp.action
149
- * style="position:absolute;inset:0;opacity:0;z-index:1;cursor:text"
150
- * />
151
- * {#each state.slotValues as char, i}
152
- * {#if $otp.separatorAfter > 0 && i === $otp.separatorAfter}
153
- * <span aria-hidden="true">{$otp.separator}</span>
154
- * {/if}
155
- * <div class="slot"
156
- * class:is-active={i === state.activeSlot}
157
- * class:is-filled={!!char}
158
- * class:is-error={state.hasError}
159
- * class:is-disabled={$otp.isDisabled}
160
- * >{char}</div>
161
- * {/each}
162
- * </div>
163
- * ```
164
- */
165
- export function useOTP(options: SvelteOTPOptions = {}): UseOTPResult {
166
- const {
167
- length = 6,
168
- type = 'numeric' as InputType,
169
- timer: timerSecs = 0,
170
- disabled: initialDisabled = false,
171
- onComplete,
172
- onExpire,
173
- onResend,
174
- haptic = true,
175
- sound = false,
176
- pattern,
177
- pasteTransformer,
178
- onInvalidChar,
179
- value: controlledValue,
180
- defaultValue,
181
- readOnly: readOnlyOpt = false,
182
- onChange: onChangeProp,
183
- onFocus: onFocusProp,
184
- onBlur: onBlurProp,
185
- separatorAfter: separatorAfterOpt = 0,
186
- separator: separatorOpt = '—',
187
- masked: maskedOpt = false,
188
- maskChar: maskCharOpt = '\u25CF',
189
- autoFocus: autoFocusOpt = true,
190
- name: nameOpt,
191
- placeholder: placeholderOpt = '',
192
- selectOnFocus: selectOnFocusOpt = false,
193
- blurOnComplete: blurOnCompleteOpt = false,
194
- } = options
195
-
196
- // ── Core instance ──────────────────────────────────────────────────────────
197
- const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound, readOnly: readOnlyOpt })
198
-
199
- // ── Stores ─────────────────────────────────────────────────────────────────
200
- const store = writable(digito.state)
201
- const timerStore = writable(timerSecs)
202
- const isDisabledStore = writable(initialDisabled)
203
- const isReadOnlyStore = writable(readOnlyOpt)
204
- const separatorAfterStore = writable(separatorAfterOpt)
205
- const separatorStore = writable(separatorOpt)
206
- const maskedStore = writable(maskedOpt)
207
- const maskCharStore = writable(maskCharOpt)
208
-
209
- let inputEl: HTMLInputElement | null = null
210
- let isReadOnly: boolean = readOnlyOpt
211
-
212
- // ── sync() ─────────────────────────────────────────────────────────────────
213
- function sync(suppressOnChange = false): void {
214
- const s = digito.state
215
- store.set({ ...s })
216
- if (!suppressOnChange) {
217
- onChangeProp?.(s.slotValues.join(''))
218
- }
219
- }
220
-
221
- // ── Controlled value sync ──────────────────────────────────────────────────
222
- function setValue(incoming: string | undefined): void {
223
- if (incoming === undefined) return
224
- const filtered = filterString(incoming.slice(0, length), type, pattern)
225
- const current = digito.state.slotValues.join('')
226
- if (filtered === current) return
227
-
228
- digito.resetState()
229
- for (let i = 0; i < filtered.length; i++) {
230
- digito.inputChar(i, filtered[i])
231
- }
232
- // Prevent a programmatic fill from triggering onComplete as if the user
233
- // had typed the code. cancelPendingComplete cancels the 10ms deferred
234
- // callback scheduled by the final inputChar above.
235
- digito.cancelPendingComplete()
236
- sync(true)
237
- if (inputEl) {
238
- inputEl.value = filtered
239
- inputEl.setSelectionRange(filtered.length, filtered.length)
240
- }
241
- onChangeProp?.(filtered)
242
- }
243
-
244
- if (controlledValue !== undefined) {
245
- setValue(controlledValue)
246
- } else if (defaultValue) {
247
- // Apply defaultValue once — no onComplete, no onChange
248
- const filtered = filterString(defaultValue.slice(0, length), type, pattern)
249
- if (filtered) {
250
- for (let i = 0; i < filtered.length; i++) digito.inputChar(i, filtered[i])
251
- digito.cancelPendingComplete()
252
- sync(true)
253
- }
254
- }
255
-
256
- // ── Timer ──────────────────────────────────────────────────────────────────
257
- let timerControls: ReturnType<typeof createTimer> | null = null
258
-
259
- if (timerSecs > 0) {
260
- timerControls = createTimer({
261
- totalSeconds: timerSecs,
262
- onTick: (r) => timerStore.set(r),
263
- onExpire: () => { timerStore.set(0); onExpire?.() },
264
- })
265
- }
266
-
267
- // ── Svelte Action ──────────────────────────────────────────────────────────
268
- function action(node: HTMLInputElement): { destroy: () => void } {
269
- inputEl = node
270
-
271
- node.type = maskedOpt ? 'password' : 'text'
272
- node.inputMode = type === 'numeric' ? 'numeric' : 'text'
273
- node.autocomplete = 'one-time-code'
274
- node.maxLength = length
275
- node.disabled = get(isDisabledStore)
276
- node.spellcheck = false
277
- if (nameOpt) node.name = nameOpt
278
- node.setAttribute('aria-label', `Enter your ${length}-${type === 'numeric' ? 'digit' : 'character'} code`)
279
- node.setAttribute('autocorrect', 'off')
280
- node.setAttribute('autocapitalize', 'off')
281
- if (readOnlyOpt) node.setAttribute('aria-readonly', 'true')
282
-
283
- const unsubDisabled = isDisabledStore.subscribe((v: boolean) => { node.disabled = v })
284
-
285
- function onKeydown(e: KeyboardEvent): void {
286
- if (get(isDisabledStore)) return
287
- const pos = node.selectionStart ?? 0
288
- if (e.key === 'Backspace') {
289
- e.preventDefault()
290
- if (isReadOnly) return
291
- digito.deleteChar(pos)
292
- sync()
293
- const next = digito.state.activeSlot
294
- requestAnimationFrame(() => node.setSelectionRange(next, next))
295
- } else if (e.key === 'Delete') {
296
- e.preventDefault()
297
- if (isReadOnly) return
298
- digito.clearSlot(pos)
299
- sync()
300
- requestAnimationFrame(() => node.setSelectionRange(pos, pos))
301
- } else if (e.key === 'ArrowLeft') {
302
- e.preventDefault()
303
- digito.moveFocusLeft(pos)
304
- sync()
305
- const next = digito.state.activeSlot
306
- requestAnimationFrame(() => node.setSelectionRange(next, next))
307
- } else if (e.key === 'ArrowRight') {
308
- e.preventDefault()
309
- digito.moveFocusRight(pos)
310
- sync()
311
- const next = digito.state.activeSlot
312
- requestAnimationFrame(() => node.setSelectionRange(next, next))
313
- } else if (e.key === 'Tab') {
314
- if (e.shiftKey) {
315
- if (pos === 0) return
316
- e.preventDefault()
317
- digito.moveFocusLeft(pos)
318
- } else {
319
- if (!digito.state.slotValues[pos]) return
320
- if (pos >= length - 1) return
321
- e.preventDefault()
322
- digito.moveFocusRight(pos)
323
- }
324
- sync()
325
- const next = digito.state.activeSlot
326
- requestAnimationFrame(() => node.setSelectionRange(next, next))
327
- }
328
- }
329
-
330
- function onChange(e: Event): void {
331
- if (get(isDisabledStore) || isReadOnly) return
332
- const raw = (e.target as HTMLInputElement).value
333
- if (!raw) {
334
- digito.resetState()
335
- node.value = ''
336
- node.setSelectionRange(0, 0)
337
- sync()
338
- return
339
- }
340
- const valid = filterString(raw, type, pattern).slice(0, length)
341
- digito.resetState()
342
- for (let i = 0; i < valid.length; i++) digito.inputChar(i, valid[i])
343
- const next = Math.min(valid.length, length - 1)
344
- node.value = valid
345
- node.setSelectionRange(next, next)
346
- digito.moveFocusTo(next)
347
- sync()
348
- if (blurOnCompleteOpt && digito.state.isComplete) {
349
- requestAnimationFrame(() => node.blur())
350
- }
351
- }
352
-
353
- function onPaste(e: ClipboardEvent): void {
354
- if (get(isDisabledStore) || isReadOnly) return
355
- e.preventDefault()
356
- const text = e.clipboardData?.getData('text') ?? ''
357
- const pos = node.selectionStart ?? 0
358
- digito.pasteString(pos, text)
359
- const { slotValues, activeSlot } = digito.state
360
- node.value = slotValues.join('')
361
- node.setSelectionRange(activeSlot, activeSlot)
362
- sync()
363
- if (blurOnCompleteOpt && digito.state.isComplete) {
364
- requestAnimationFrame(() => node.blur())
365
- }
366
- }
367
-
368
- function onFocus(): void {
369
- onFocusProp?.()
370
- const pos = digito.state.activeSlot
371
- requestAnimationFrame(() => {
372
- const char = digito.state.slotValues[pos]
373
- if (selectOnFocusOpt && char) {
374
- node.setSelectionRange(pos, pos + 1)
375
- } else {
376
- node.setSelectionRange(pos, pos)
377
- }
378
- })
379
- }
380
-
381
- function onBlur(): void {
382
- onBlurProp?.()
383
- }
384
-
385
- node.addEventListener('keydown', onKeydown)
386
- node.addEventListener('input', onChange)
387
- node.addEventListener('paste', onPaste)
388
- node.addEventListener('focus', onFocus)
389
- node.addEventListener('blur', onBlur)
390
-
391
- if (autoFocusOpt && !get(isDisabledStore)) {
392
- requestAnimationFrame(() => node.focus())
393
- }
394
-
395
- // Start timer now that the component is mounted and the input element is
396
- // available — matching Vue's onMounted pattern.
397
- timerControls?.start()
398
-
399
- return {
400
- destroy() {
401
- node.removeEventListener('keydown', onKeydown)
402
- node.removeEventListener('input', onChange)
403
- node.removeEventListener('paste', onPaste)
404
- node.removeEventListener('focus', onFocus)
405
- node.removeEventListener('blur', onBlur)
406
- unsubDisabled()
407
- timerControls?.stop()
408
- inputEl = null
409
- },
410
- }
411
- }
412
-
413
- // ── Public API ─────────────────────────────────────────────────────────────
414
-
415
- function reset(): void {
416
- digito.resetState()
417
- if (inputEl) { inputEl.value = ''; inputEl.focus(); inputEl.setSelectionRange(0, 0) }
418
- timerStore.set(timerSecs)
419
- timerControls?.restart()
420
- sync()
421
- }
422
-
423
- function setError(isError: boolean): void {
424
- digito.setError(isError)
425
- sync(true)
426
- }
427
-
428
- function setDisabled(value: boolean): void {
429
- isDisabledStore.set(value)
430
- digito.setDisabled(value)
431
- }
432
-
433
- function setReadOnly(value: boolean): void {
434
- isReadOnly = value
435
- isReadOnlyStore.set(value)
436
- digito.setReadOnly(value)
437
- if (inputEl) {
438
- if (value) {
439
- inputEl.setAttribute('aria-readonly', 'true')
440
- } else {
441
- inputEl.removeAttribute('aria-readonly')
442
- }
443
- }
444
- }
445
-
446
- function focus(slotIndex: number): void {
447
- digito.moveFocusTo(slotIndex)
448
- inputEl?.focus()
449
- requestAnimationFrame(() => inputEl?.setSelectionRange(slotIndex, slotIndex))
450
- sync(true)
451
- }
452
-
453
- function getCode(): string {
454
- return digito.getCode()
455
- }
456
-
457
- // Derived stores
458
- const value = derived(store, ($s: DigitoState) => $s.slotValues.join(''))
459
- const isComplete = derived(store, ($s: DigitoState) => $s.isComplete)
460
- const hasError = derived(store, ($s: DigitoState) => $s.hasError)
461
- const activeSlot = derived(store, ($s: DigitoState) => $s.activeSlot)
462
-
463
- // Derived wrapper data attributes for CSS/Tailwind targeting
464
- const wrapperAttrs = derived(
465
- [store, isDisabledStore, isReadOnlyStore],
466
- ([$s, $dis, $ro]: [DigitoState, boolean, boolean]) => ({
467
- ...($s.isComplete ? { 'data-complete': '' } : {}),
468
- ...($s.hasError ? { 'data-invalid': '' } : {}),
469
- ...($dis ? { 'data-disabled': '' } : {}),
470
- ...($ro ? { 'data-readonly': '' } : {}),
471
- })
472
- )
473
-
474
- return {
475
- subscribe: store.subscribe,
476
- value,
477
- isComplete,
478
- hasError,
479
- activeSlot,
480
- timerSeconds: timerStore,
481
- isDisabled: isDisabledStore,
482
- separatorAfter: separatorAfterStore,
483
- separator: separatorStore,
484
- masked: maskedStore,
485
- maskChar: maskCharStore,
486
- placeholder: placeholderOpt,
487
- wrapperAttrs,
488
- action,
489
- getCode,
490
- reset,
491
- setError,
492
- setDisabled,
493
- setReadOnly,
494
- setValue,
495
- focus,
496
- }
497
- }