digitojs 1.0.1 → 1.2.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 (58) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +112 -39
  3. package/dist/adapters/alpine.d.ts.map +1 -1
  4. package/dist/adapters/alpine.js +65 -58
  5. package/dist/adapters/alpine.js.map +1 -1
  6. package/dist/adapters/react.d.ts +9 -0
  7. package/dist/adapters/react.d.ts.map +1 -1
  8. package/dist/adapters/react.js +45 -4
  9. package/dist/adapters/react.js.map +1 -1
  10. package/dist/adapters/svelte.d.ts +16 -14
  11. package/dist/adapters/svelte.d.ts.map +1 -1
  12. package/dist/adapters/svelte.js +34 -4
  13. package/dist/adapters/svelte.js.map +1 -1
  14. package/dist/adapters/vanilla.d.ts.map +1 -1
  15. package/dist/adapters/vanilla.js +36 -12
  16. package/dist/adapters/vanilla.js.map +1 -1
  17. package/dist/adapters/vue.d.ts +2 -0
  18. package/dist/adapters/vue.d.ts.map +1 -1
  19. package/dist/adapters/vue.js +35 -4
  20. package/dist/adapters/vue.js.map +1 -1
  21. package/dist/adapters/web-component.d.ts +3 -0
  22. package/dist/adapters/web-component.d.ts.map +1 -1
  23. package/dist/adapters/web-component.js +37 -12
  24. package/dist/adapters/web-component.js.map +1 -1
  25. package/dist/core/index.d.ts +1 -1
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +1 -1
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/machine.d.ts +3 -0
  30. package/dist/core/machine.d.ts.map +1 -1
  31. package/dist/core/machine.js +31 -3
  32. package/dist/core/machine.js.map +1 -1
  33. package/dist/core/timer.d.ts +8 -0
  34. package/dist/core/timer.d.ts.map +1 -1
  35. package/dist/core/timer.js +14 -0
  36. package/dist/core/timer.js.map +1 -1
  37. package/dist/core/types.d.ts +30 -2
  38. package/dist/core/types.d.ts.map +1 -1
  39. package/dist/digito-wc.min.js +2 -2
  40. package/dist/digito-wc.min.js.map +3 -3
  41. package/dist/digito.min.js +1 -1
  42. package/dist/digito.min.js.map +3 -3
  43. package/dist/index.d.ts +0 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +0 -1
  46. package/dist/index.js.map +1 -1
  47. package/package.json +8 -2
  48. package/src/adapters/alpine.ts +58 -50
  49. package/src/adapters/react.tsx +50 -5
  50. package/src/adapters/svelte.ts +48 -16
  51. package/src/adapters/vanilla.ts +32 -14
  52. package/src/adapters/vue.ts +32 -3
  53. package/src/adapters/web-component.ts +35 -13
  54. package/src/core/index.ts +1 -1
  55. package/src/core/machine.ts +31 -3
  56. package/src/core/timer.ts +15 -0
  57. package/src/core/types.ts +30 -2
  58. package/src/index.ts +0 -1
@@ -129,6 +129,8 @@ export type UseOTPResult = {
129
129
  inputRef: Ref<HTMLInputElement | null>
130
130
  /** Attribute object to spread onto the hidden input via v-bind. */
131
131
  hiddenInputAttrs: Ref<Record<string, unknown>>
132
+ /** Spread onto the wrapper element to expose state as data attributes for CSS/Tailwind targeting. */
133
+ wrapperAttrs: Ref<Record<string, string | undefined>>
132
134
  /** Returns the current joined code string. */
133
135
  getCode: () => string
134
136
  /** Clear all slots, restart timer, return focus to input. */
@@ -203,6 +205,8 @@ export function useOTP(options: VueOTPOptions = {}): UseOTPResult {
203
205
  pasteTransformer,
204
206
  onInvalidChar,
205
207
  value: controlledValue,
208
+ defaultValue,
209
+ readOnly: readOnlyOpt = false,
206
210
  onChange: onChangeProp,
207
211
  onFocus: onFocusProp,
208
212
  onBlur: onBlurProp,
@@ -218,7 +222,7 @@ export function useOTP(options: VueOTPOptions = {}): UseOTPResult {
218
222
  } = options
219
223
 
220
224
  // ── Core instance ──────────────────────────────────────────────────────────
221
- const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound })
225
+ const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound, readOnly: readOnlyOpt })
222
226
 
223
227
  // ── Reactive state ─────────────────────────────────────────────────────────
224
228
  const slotValues = ref<string[]>(Array(length).fill(''))
@@ -248,6 +252,14 @@ export function useOTP(options: VueOTPOptions = {}): UseOTPResult {
248
252
  spellcheck: 'false',
249
253
  autocorrect: 'off',
250
254
  autocapitalize: 'off',
255
+ ...(readOnlyOpt ? { 'aria-readonly': 'true' } : {}),
256
+ }))
257
+
258
+ const wrapperAttrs = computed<Record<string, string | undefined>>(() => ({
259
+ ...(isComplete.value ? { 'data-complete': '' } : {}),
260
+ ...(hasError.value ? { 'data-invalid': '' } : {}),
261
+ ...(isDisabled.value ? { 'data-disabled': '' } : {}),
262
+ ...(readOnlyOpt ? { 'data-readonly': '' } : {}),
251
263
  }))
252
264
 
253
265
  // ── sync() ─────────────────────────────────────────────────────────────────
@@ -298,6 +310,15 @@ export function useOTP(options: VueOTPOptions = {}): UseOTPResult {
298
310
  let timerControls: ReturnType<typeof createTimer> | null = null
299
311
 
300
312
  onMounted(() => {
313
+ if (controlledValue === undefined && defaultValue) {
314
+ const filtered = filterString(defaultValue.slice(0, length), type, pattern)
315
+ if (filtered) {
316
+ for (let i = 0; i < filtered.length; i++) digito.inputChar(i, filtered[i])
317
+ digito.cancelPendingComplete()
318
+ sync(true)
319
+ if (inputRef.value) { inputRef.value.value = filtered; inputRef.value.setSelectionRange(filtered.length, filtered.length) }
320
+ }
321
+ }
301
322
  if (autoFocusOpt && !initialDisabled && inputRef.value) {
302
323
  inputRef.value.focus()
303
324
  inputRef.value.setSelectionRange(0, 0)
@@ -320,10 +341,17 @@ export function useOTP(options: VueOTPOptions = {}): UseOTPResult {
320
341
  const pos = inputRef.value?.selectionStart ?? 0
321
342
  if (e.key === 'Backspace') {
322
343
  e.preventDefault()
344
+ if (readOnlyOpt) return
323
345
  digito.deleteChar(pos)
324
346
  sync()
325
347
  const next = digito.state.activeSlot
326
348
  requestAnimationFrame(() => inputRef.value?.setSelectionRange(next, next))
349
+ } else if (e.key === 'Delete') {
350
+ e.preventDefault()
351
+ if (readOnlyOpt) return
352
+ digito.clearSlot(pos)
353
+ sync()
354
+ requestAnimationFrame(() => inputRef.value?.setSelectionRange(pos, pos))
327
355
  } else if (e.key === 'ArrowLeft') {
328
356
  e.preventDefault()
329
357
  digito.moveFocusLeft(pos)
@@ -354,7 +382,7 @@ export function useOTP(options: VueOTPOptions = {}): UseOTPResult {
354
382
  }
355
383
 
356
384
  function onChange(e: Event): void {
357
- if (isDisabled.value) return
385
+ if (isDisabled.value || readOnlyOpt) return
358
386
  const raw = (e.target as HTMLInputElement).value
359
387
  if (!raw) {
360
388
  digito.resetState()
@@ -375,7 +403,7 @@ export function useOTP(options: VueOTPOptions = {}): UseOTPResult {
375
403
  }
376
404
 
377
405
  function onPaste(e: ClipboardEvent): void {
378
- if (isDisabled.value) return
406
+ if (isDisabled.value || readOnlyOpt) return
379
407
  e.preventDefault()
380
408
  const text = e.clipboardData?.getData('text') ?? ''
381
409
  const pos = inputRef.value?.selectionStart ?? 0
@@ -449,6 +477,7 @@ export function useOTP(options: VueOTPOptions = {}): UseOTPResult {
449
477
  placeholder: placeholderOpt,
450
478
  inputRef,
451
479
  hiddenInputAttrs,
480
+ wrapperAttrs,
452
481
  getCode,
453
482
  reset,
454
483
  setError,
@@ -45,6 +45,7 @@ import {
45
45
  createDigito,
46
46
  createTimer,
47
47
  filterString,
48
+ formatCountdown,
48
49
  type InputType,
49
50
  } from '../core/index.js'
50
51
 
@@ -208,16 +209,6 @@ const STYLES = `
208
209
  .digito-wc-resend-btn:disabled { color: #A1A1A1; cursor: not-allowed; background: #F5F5F5; }
209
210
  `
210
211
 
211
- // ─────────────────────────────────────────────────────────────────────────────
212
- // HELPERS
213
- // ─────────────────────────────────────────────────────────────────────────────
214
-
215
- function formatCountdown(totalSeconds: number): string {
216
- const m = Math.floor(totalSeconds / 60)
217
- const s = totalSeconds % 60
218
- return m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`
219
- }
220
-
221
212
  // ─────────────────────────────────────────────────────────────────────────────
222
213
  // WEB COMPONENT
223
214
  // ─────────────────────────────────────────────────────────────────────────────
@@ -228,7 +219,7 @@ class DigitoInput extends HTMLElement {
228
219
  * Any change to these attributes causes a full shadow DOM rebuild so the
229
220
  * component always reflects its attribute state without manual reconciliation.
230
221
  */
231
- static observedAttributes = ['length', 'type', 'timer', 'resend-after', 'disabled', 'separator-after', 'separator', 'masked', 'mask-char', 'name', 'placeholder', 'auto-focus', 'select-on-focus', 'blur-on-complete']
222
+ static observedAttributes = ['length', 'type', 'timer', 'resend-after', 'disabled', 'readonly', 'separator-after', 'separator', 'masked', 'mask-char', 'name', 'placeholder', 'auto-focus', 'select-on-focus', 'blur-on-complete', 'default-value']
232
223
 
233
224
  // Shadow DOM references — rebuilt in full on every attributeChangedCallback.
234
225
  private slotEls: HTMLDivElement[] = []
@@ -245,6 +236,7 @@ class DigitoInput extends HTMLElement {
245
236
  // Runtime mutable state — toggled by setDisabled() without a full rebuild.
246
237
  private _isDisabled = false
247
238
  private _isSuccess = false
239
+ private _isReadOnly = false
248
240
 
249
241
  // JS-property-only options. These cannot be expressed as HTML attributes
250
242
  // (RegExp and functions are not serialisable to strings), so they are stored
@@ -375,6 +367,8 @@ class DigitoInput extends HTMLElement {
375
367
  return isNaN(v) || v < 1 ? 30 : Math.floor(v)
376
368
  }
377
369
  private get _disabledAttr(): boolean { return this.hasAttribute('disabled') }
370
+ private get _readOnlyAttr(): boolean { return this.hasAttribute('readonly') }
371
+ private get _defaultValue(): string { return this.getAttribute('default-value') ?? '' }
378
372
  /** Parses `separator-after="2,4"` into `[2, 4]`. Filters NaN and zero values. */
379
373
  private get _separatorAfter(): number[] {
380
374
  const v = this.getAttribute('separator-after')
@@ -418,6 +412,7 @@ class DigitoInput extends HTMLElement {
418
412
  const selectOnFocus = this._selectOnFocus
419
413
  const blurOnComplete = this._blurOnComplete
420
414
  this._isDisabled = this._disabledAttr
415
+ this._isReadOnly = this._readOnlyAttr
421
416
 
422
417
  this.timerCtrl?.stop()
423
418
  this.resendCountdown?.stop()
@@ -495,6 +490,7 @@ class DigitoInput extends HTMLElement {
495
490
  pattern: this._pattern,
496
491
  pasteTransformer: this._pasteTransformer,
497
492
  onInvalidChar: this._onInvalidChar,
493
+ readOnly: this._isReadOnly,
498
494
  onComplete: (code) => {
499
495
  // Call JS property setter AND dispatch CustomEvent
500
496
  this._onComplete?.(code)
@@ -573,6 +569,19 @@ class DigitoInput extends HTMLElement {
573
569
  })
574
570
  }
575
571
 
572
+ if (this._isReadOnly) hiddenInput.setAttribute('aria-readonly', 'true')
573
+
574
+ // Apply defaultValue once on build — no onComplete, no change event
575
+ const dv = this._defaultValue
576
+ if (dv) {
577
+ const filtered = filterString(dv.slice(0, length), type, this._pattern)
578
+ if (filtered) {
579
+ for (let i = 0; i < filtered.length; i++) this.digito!.inputChar(i, filtered[i])
580
+ this.digito!.cancelPendingComplete()
581
+ hiddenInput.value = filtered
582
+ }
583
+ }
584
+
576
585
  this.attachEvents(selectOnFocus, blurOnComplete)
577
586
 
578
587
  if (this._isDisabled) this.applyDisabledDOM(true)
@@ -645,6 +654,11 @@ class DigitoInput extends HTMLElement {
645
654
  // resets selectionStart/End in some browsers, clobbering the cursor.
646
655
  const newValue = slotValues.join('')
647
656
  if (this.hiddenInput.value !== newValue) this.hiddenInput.value = newValue
657
+
658
+ this.toggleAttribute('data-complete', this.digito.state.isComplete)
659
+ this.toggleAttribute('data-invalid', this.digito.state.hasError)
660
+ this.toggleAttribute('data-disabled', this._isDisabled)
661
+ this.toggleAttribute('data-readonly', this._isReadOnly)
648
662
  }
649
663
 
650
664
  /**
@@ -679,11 +693,19 @@ class DigitoInput extends HTMLElement {
679
693
  const pos = input.selectionStart ?? 0
680
694
  if (e.key === 'Backspace') {
681
695
  e.preventDefault()
696
+ if (this._isReadOnly) return
682
697
  digito.deleteChar(pos)
683
698
  this.syncSlotsToDOM()
684
699
  this.dispatchChange()
685
700
  const next = digito.state.activeSlot
686
701
  requestAnimationFrame(() => input.setSelectionRange(next, next))
702
+ } else if (e.key === 'Delete') {
703
+ e.preventDefault()
704
+ if (this._isReadOnly) return
705
+ digito.clearSlot(pos)
706
+ this.syncSlotsToDOM()
707
+ this.dispatchChange()
708
+ requestAnimationFrame(() => input.setSelectionRange(pos, pos))
687
709
  } else if (e.key === 'ArrowLeft') {
688
710
  e.preventDefault()
689
711
  digito.moveFocusLeft(pos)
@@ -712,7 +734,7 @@ class DigitoInput extends HTMLElement {
712
734
  })
713
735
 
714
736
  input.addEventListener('input', () => {
715
- if (this._isDisabled) return
737
+ if (this._isDisabled || this._isReadOnly) return
716
738
  const raw = input.value
717
739
  if (!raw) {
718
740
  digito.resetState()
@@ -737,7 +759,7 @@ class DigitoInput extends HTMLElement {
737
759
  })
738
760
 
739
761
  input.addEventListener('paste', (e) => {
740
- if (this._isDisabled) return
762
+ if (this._isDisabled || this._isReadOnly) return
741
763
  e.preventDefault()
742
764
  const text = e.clipboardData?.getData('text') ?? ''
743
765
  const pos = input.selectionStart ?? 0
package/src/core/index.ts CHANGED
@@ -11,6 +11,6 @@
11
11
 
12
12
  export type { InputType, DigitoState, DigitoOptions, TimerOptions, TimerControls, StateListener } from './types.js'
13
13
  export { filterChar, filterString } from './filter.js'
14
- export { createTimer } from './timer.js'
14
+ export { createTimer, formatCountdown } from './timer.js'
15
15
  export { triggerHapticFeedback, triggerSoundFeedback } from './feedback.js'
16
16
  export { createDigito } from './machine.js'
@@ -73,6 +73,8 @@ export function createDigito(options: DigitoOptions = {}) {
73
73
  // requiring the instance to be recreated. Adapters that pass disabled at
74
74
  // construction time still work — the initial value is honoured.
75
75
  let disabled = options.disabled ?? false
76
+ // `readOnly` allows focus/navigation while blocking all slot mutations.
77
+ let readOnly = options.readOnly ?? false
76
78
 
77
79
  let state: DigitoState = {
78
80
  slotValues: Array(length).fill('') as string[],
@@ -140,7 +142,7 @@ export function createDigito(options: DigitoOptions = {}) {
140
142
  * Out-of-bounds indices are silently ignored to prevent sparse-array corruption.
141
143
  */
142
144
  function inputChar(slotIndex: number, char: string): DigitoState {
143
- if (disabled) return state
145
+ if (disabled || readOnly) return state
144
146
  if (slotIndex < 0 || slotIndex >= length) return state
145
147
  const validChar = filterChar(char, type, pattern)
146
148
  if (!validChar) {
@@ -176,7 +178,7 @@ export function createDigito(options: DigitoOptions = {}) {
176
178
  * Clears the current slot if filled, otherwise clears the previous slot and moves back.
177
179
  */
178
180
  function deleteChar(slotIndex: number): DigitoState {
179
- if (disabled) return state
181
+ if (disabled || readOnly) return state
180
182
  if (slotIndex < 0 || slotIndex >= length) return state
181
183
  const slotValues = [...state.slotValues]
182
184
 
@@ -210,7 +212,7 @@ export function createDigito(options: DigitoOptions = {}) {
210
212
  * paste(0, '84AB91') → filtered='8491', fills slots 0–3, slots 4–5 unchanged
211
213
  */
212
214
  function pasteString(cursorSlot: number, rawText: string): DigitoState {
213
- if (disabled) return state
215
+ if (disabled || readOnly) return state
214
216
 
215
217
  let transformed: string
216
218
  try {
@@ -261,6 +263,20 @@ export function createDigito(options: DigitoOptions = {}) {
261
263
  return newState
262
264
  }
263
265
 
266
+ /**
267
+ * Clear the slot at slotIndex without moving focus.
268
+ * Used by the Delete key — differs from deleteChar (backspace) which steps the cursor back.
269
+ * Returns early when the slot is already empty, the field is disabled, or readOnly.
270
+ */
271
+ function clearSlot(slotIndex: number): DigitoState {
272
+ if (disabled || readOnly) return state
273
+ if (slotIndex < 0 || slotIndex >= length) return state
274
+ if (!state.slotValues[slotIndex]) return state
275
+ const slotValues = [...state.slotValues]
276
+ slotValues[slotIndex] = ''
277
+ return applyState({ slotValues, activeSlot: slotIndex, isComplete: false })
278
+ }
279
+
264
280
  /** Set or clear the error state. Triggers haptic feedback when setting. */
265
281
  function setError(isError: boolean): DigitoState {
266
282
  if (isError && haptic) triggerHapticFeedback()
@@ -299,6 +315,14 @@ export function createDigito(options: DigitoOptions = {}) {
299
315
  disabled = value
300
316
  }
301
317
 
318
+ /**
319
+ * Toggle readOnly at runtime. When `true`, all slot mutations are blocked
320
+ * but navigation and focus remain fully functional.
321
+ */
322
+ function setReadOnly(value: boolean): void {
323
+ readOnly = value
324
+ }
325
+
302
326
  /**
303
327
  * Subscribe to state changes. The listener is called after every mutation
304
328
  * with a shallow copy of the new state.
@@ -325,6 +349,7 @@ export function createDigito(options: DigitoOptions = {}) {
325
349
  // Input actions
326
350
  inputChar,
327
351
  deleteChar,
352
+ clearSlot,
328
353
  moveFocusLeft,
329
354
  moveFocusRight,
330
355
  pasteString,
@@ -348,6 +373,9 @@ export function createDigito(options: DigitoOptions = {}) {
348
373
  */
349
374
  setDisabled,
350
375
 
376
+ /** Toggle readOnly at runtime. Blocks mutations, preserves navigation. */
377
+ setReadOnly,
378
+
351
379
  /** Returns the current joined code string. */
352
380
  getCode: () => joinSlots(state.slotValues),
353
381
  /**
package/src/core/timer.ts CHANGED
@@ -73,3 +73,18 @@ export function createTimer(options: TimerOptions): TimerControls {
73
73
 
74
74
  return { start, stop, reset, restart }
75
75
  }
76
+
77
+ /**
78
+ * Format a second count as a `m:ss` countdown string (e.g. `"1:05"`, `"0:30"`).
79
+ * Used by the vanilla, alpine, and web-component adapters for their built-in timer UI.
80
+ *
81
+ * @example formatCountdown(65) → "1:05"
82
+ * @example formatCountdown(9) → "0:09"
83
+ */
84
+ export function formatCountdown(totalSeconds: number): string {
85
+ const minutes = Math.floor(totalSeconds / 60)
86
+ const seconds = totalSeconds % 60
87
+ return minutes > 0
88
+ ? `${minutes}:${String(seconds).padStart(2, '0')}`
89
+ : `0:${String(seconds).padStart(2, '0')}`
90
+ }
package/src/core/types.ts CHANGED
@@ -40,11 +40,25 @@ export type DigitoOptions = {
40
40
  resendAfter?: number
41
41
  /** Called with the joined code string when all slots are filled. */
42
42
  onComplete?: (code: string) => void
43
- /** Called every second with the remaining seconds. Use to drive a custom timer UI. */
43
+ /**
44
+ * Called every second with the remaining seconds. Use to drive a custom timer UI.
45
+ *
46
+ * **Adapter note:** Only fires in adapters that include a built-in countdown timer
47
+ * (vanilla, alpine, web component). In React, Vue, and Svelte the timer is managed
48
+ * separately inside each adapter — pass `onTick` as part of those adapters' options.
49
+ * Has no effect when passed directly to `createDigito`.
50
+ */
44
51
  onTick?: (remainingSeconds: number) => void
45
52
  /** Called when the countdown reaches zero. */
46
53
  onExpire?: () => void
47
- /** Called when the resend action is triggered. */
54
+ /**
55
+ * Called when the resend action is triggered.
56
+ *
57
+ * **Adapter note:** Only fires automatically in adapters with a built-in Resend button
58
+ * (vanilla, alpine, web component). In React, Vue, and Svelte there is no built-in
59
+ * Resend button — call `onResend` manually in your own UI handler.
60
+ * Has no effect when passed directly to `createDigito`.
61
+ */
48
62
  onResend?: () => void
49
63
  /** Vibrate on completion and error via `navigator.vibrate`. Default: `true`. */
50
64
  haptic?: boolean
@@ -127,6 +141,20 @@ export type DigitoOptions = {
127
141
  * Default: `false`.
128
142
  */
129
143
  blurOnComplete?: boolean
144
+ /**
145
+ * Uncontrolled initial value applied once on mount.
146
+ * Distributed across slots exactly like user input but does NOT trigger
147
+ * `onComplete` or fire change events. Ignored when a `value` prop is present.
148
+ * Default: `undefined` (no pre-fill).
149
+ */
150
+ defaultValue?: string
151
+ /**
152
+ * When `true`, all slot mutations (typing, backspace, delete, paste) are
153
+ * blocked while focus, selection, arrow navigation, and copy remain allowed.
154
+ * Semantically distinct from `disabled` — the field is readable and focusable.
155
+ * Default: `false`.
156
+ */
157
+ readOnly?: boolean
130
158
  /**
131
159
  * Called when the user types or pastes a character that is rejected by the
132
160
  * current `type` or `pattern` filter.
package/src/index.ts CHANGED
@@ -6,7 +6,6 @@
6
6
  *
7
7
  * @author Olawale Balo — Product Designer + Design Engineer
8
8
  * @license MIT
9
- * @version 1.0.0
10
9
  */
11
10
 
12
11
  // Core — pure logic, zero DOM