digitojs 1.0.1 → 1.2.1
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 +15 -0
- package/README.md +129 -56
- package/dist/adapters/alpine.d.ts.map +1 -1
- package/dist/adapters/alpine.js +82 -58
- package/dist/adapters/alpine.js.map +1 -1
- package/dist/adapters/react.d.ts +9 -0
- package/dist/adapters/react.d.ts.map +1 -1
- package/dist/adapters/react.js +45 -4
- package/dist/adapters/react.js.map +1 -1
- package/dist/adapters/svelte.d.ts +21 -14
- package/dist/adapters/svelte.d.ts.map +1 -1
- package/dist/adapters/svelte.js +50 -4
- package/dist/adapters/svelte.js.map +1 -1
- package/dist/adapters/vanilla.d.ts +6 -0
- package/dist/adapters/vanilla.d.ts.map +1 -1
- package/dist/adapters/vanilla.js +48 -13
- package/dist/adapters/vanilla.js.map +1 -1
- package/dist/adapters/vue.d.ts +2 -0
- package/dist/adapters/vue.d.ts.map +1 -1
- package/dist/adapters/vue.js +35 -4
- package/dist/adapters/vue.js.map +1 -1
- package/dist/adapters/web-component.d.ts +9 -0
- package/dist/adapters/web-component.d.ts.map +1 -1
- package/dist/adapters/web-component.js +55 -12
- package/dist/adapters/web-component.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/machine.d.ts +3 -0
- package/dist/core/machine.d.ts.map +1 -1
- package/dist/core/machine.js +31 -3
- package/dist/core/machine.js.map +1 -1
- package/dist/core/timer.d.ts +8 -0
- package/dist/core/timer.d.ts.map +1 -1
- package/dist/core/timer.js +14 -0
- package/dist/core/timer.js.map +1 -1
- package/dist/core/types.d.ts +30 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/digito-wc.min.js +3 -3
- package/dist/digito-wc.min.js.map +3 -3
- package/dist/digito.min.js +2 -2
- package/dist/digito.min.js.map +3 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
- package/src/adapters/alpine.ts +75 -50
- package/src/adapters/react.tsx +50 -5
- package/src/adapters/svelte.ts +70 -17
- package/src/adapters/vanilla.ts +50 -15
- package/src/adapters/vue.ts +32 -3
- package/src/adapters/web-component.ts +53 -13
- package/src/core/index.ts +1 -1
- package/src/core/machine.ts +31 -3
- package/src/core/timer.ts +15 -0
- package/src/core/types.ts +30 -2
- package/src/index.ts +0 -1
package/src/adapters/vanilla.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
createDigito,
|
|
37
37
|
createTimer,
|
|
38
38
|
filterString,
|
|
39
|
+
formatCountdown,
|
|
39
40
|
type DigitoOptions,
|
|
40
41
|
type InputType,
|
|
41
42
|
} from '../core/index.js'
|
|
@@ -62,6 +63,12 @@ export type DigitoInstance = {
|
|
|
62
63
|
* verification to prevent the user from modifying the code mid-request.
|
|
63
64
|
*/
|
|
64
65
|
setDisabled: (isDisabled: boolean) => void
|
|
66
|
+
/**
|
|
67
|
+
* Toggle readOnly at runtime. When `true`, all slot mutations are blocked
|
|
68
|
+
* but focus, navigation, and copy remain fully functional.
|
|
69
|
+
* Distinct from `disabled` — no opacity/cursor change, `aria-readonly` is set.
|
|
70
|
+
*/
|
|
71
|
+
setReadOnly: (isReadOnly: boolean) => void
|
|
65
72
|
/** Returns the current joined code string. */
|
|
66
73
|
getCode: () => string
|
|
67
74
|
/** Programmatically move focus to a slot index (focuses the hidden input). */
|
|
@@ -89,7 +96,7 @@ const INJECTED_STYLE_ID = 'digito-styles'
|
|
|
89
96
|
* --digito-bg-filled Slot background when filled (default: #FFFFFF)
|
|
90
97
|
* --digito-color Digit text colour (default: #0A0A0A)
|
|
91
98
|
* --digito-border-color Default slot border (default: #E5E5E5)
|
|
92
|
-
* --digito-active-color Active slot border + ring (default: #
|
|
99
|
+
* --digito-active-color Active slot border + ring (default: #3D3D3D)
|
|
93
100
|
* --digito-error-color Error border, ring + badge (default: #FB2C36)
|
|
94
101
|
* --digito-success-color Success border + ring (default: #00C950)
|
|
95
102
|
* --digito-timer-color Timer label text colour (default: #5C5C5C)
|
|
@@ -237,6 +244,8 @@ function mountOnWrapper(
|
|
|
237
244
|
const onExpire = options.onExpire
|
|
238
245
|
const pattern = options.pattern
|
|
239
246
|
let isDisabled = options.disabled ?? false
|
|
247
|
+
let isReadOnly = options.readOnly ?? false
|
|
248
|
+
const defaultValue = options.defaultValue ?? ''
|
|
240
249
|
|
|
241
250
|
// New options
|
|
242
251
|
const autoFocus = options.autoFocus !== false // default true
|
|
@@ -275,6 +284,7 @@ function mountOnWrapper(
|
|
|
275
284
|
sound: options.sound ?? false,
|
|
276
285
|
haptic: options.haptic ?? true,
|
|
277
286
|
disabled: isDisabled,
|
|
287
|
+
readOnly: isReadOnly,
|
|
278
288
|
})
|
|
279
289
|
|
|
280
290
|
// ── Build DOM ────────────────────────────────────────────────────────────
|
|
@@ -330,6 +340,18 @@ function mountOnWrapper(
|
|
|
330
340
|
rootEl.appendChild(hiddenInputEl)
|
|
331
341
|
wrapperEl.appendChild(rootEl)
|
|
332
342
|
|
|
343
|
+
if (isReadOnly) hiddenInputEl.setAttribute('aria-readonly', 'true')
|
|
344
|
+
|
|
345
|
+
// Apply defaultValue once on mount — no onComplete, no onChange
|
|
346
|
+
if (defaultValue) {
|
|
347
|
+
const filtered = filterString(defaultValue.slice(0, slotCount), inputType, pattern)
|
|
348
|
+
if (filtered) {
|
|
349
|
+
for (let i = 0; i < filtered.length; i++) otpCore.inputChar(i, filtered[i])
|
|
350
|
+
otpCore.cancelPendingComplete()
|
|
351
|
+
hiddenInputEl.value = filtered
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
333
355
|
// ── Password manager badge guard ─────────────────────────────────────────
|
|
334
356
|
let disconnectPasswordManagerWatch: () => void = () => {}
|
|
335
357
|
requestAnimationFrame(() => {
|
|
@@ -487,6 +509,12 @@ function mountOnWrapper(
|
|
|
487
509
|
// resets selectionStart/End in some browsers, clobbering the cursor.
|
|
488
510
|
const newValue = slotValues.join('')
|
|
489
511
|
if (hiddenInputEl.value !== newValue) hiddenInputEl.value = newValue
|
|
512
|
+
|
|
513
|
+
// Expose component state as data attributes for CSS/Tailwind targeting
|
|
514
|
+
wrapperEl.toggleAttribute('data-complete', otpCore.state.isComplete)
|
|
515
|
+
wrapperEl.toggleAttribute('data-invalid', otpCore.state.hasError)
|
|
516
|
+
wrapperEl.toggleAttribute('data-disabled', isDisabled)
|
|
517
|
+
wrapperEl.toggleAttribute('data-readonly', isReadOnly)
|
|
490
518
|
}
|
|
491
519
|
|
|
492
520
|
// ── Event handlers ────────────────────────────────────────────────────────
|
|
@@ -496,9 +524,16 @@ function mountOnWrapper(
|
|
|
496
524
|
|
|
497
525
|
if (event.key === 'Backspace') {
|
|
498
526
|
event.preventDefault()
|
|
527
|
+
if (isReadOnly) return
|
|
499
528
|
otpCore.deleteChar(cursorPos)
|
|
500
529
|
syncSlotsToDOM()
|
|
501
530
|
hiddenInputEl.setSelectionRange(otpCore.state.activeSlot, otpCore.state.activeSlot)
|
|
531
|
+
} else if (event.key === 'Delete') {
|
|
532
|
+
event.preventDefault()
|
|
533
|
+
if (isReadOnly) return
|
|
534
|
+
otpCore.clearSlot(cursorPos)
|
|
535
|
+
syncSlotsToDOM()
|
|
536
|
+
hiddenInputEl.setSelectionRange(cursorPos, cursorPos)
|
|
502
537
|
} else if (event.key === 'Tab') {
|
|
503
538
|
if (event.shiftKey) {
|
|
504
539
|
// Shift+Tab: move to previous slot, or exit to previous DOM element from first slot
|
|
@@ -532,6 +567,7 @@ function mountOnWrapper(
|
|
|
532
567
|
}
|
|
533
568
|
|
|
534
569
|
function onHiddenInputChange(_event: Event): void {
|
|
570
|
+
if (isReadOnly) return
|
|
535
571
|
const rawValue = hiddenInputEl.value
|
|
536
572
|
|
|
537
573
|
if (!rawValue) {
|
|
@@ -567,6 +603,7 @@ function mountOnWrapper(
|
|
|
567
603
|
|
|
568
604
|
function onHiddenInputPaste(event: ClipboardEvent): void {
|
|
569
605
|
event.preventDefault()
|
|
606
|
+
if (isReadOnly) return
|
|
570
607
|
const pastedText = event.clipboardData?.getData('text') ?? ''
|
|
571
608
|
const cursorPos = hiddenInputEl.selectionStart ?? 0
|
|
572
609
|
otpCore.pasteString(cursorPos, pastedText)
|
|
@@ -686,6 +723,17 @@ function mountOnWrapper(
|
|
|
686
723
|
})
|
|
687
724
|
}
|
|
688
725
|
|
|
726
|
+
function setReadOnly(value: boolean): void {
|
|
727
|
+
isReadOnly = value
|
|
728
|
+
otpCore.setReadOnly(value)
|
|
729
|
+
if (value) {
|
|
730
|
+
hiddenInputEl.setAttribute('aria-readonly', 'true')
|
|
731
|
+
} else {
|
|
732
|
+
hiddenInputEl.removeAttribute('aria-readonly')
|
|
733
|
+
}
|
|
734
|
+
syncSlotsToDOM()
|
|
735
|
+
}
|
|
736
|
+
|
|
689
737
|
function getCode(): string {
|
|
690
738
|
return otpCore.getCode()
|
|
691
739
|
}
|
|
@@ -712,20 +760,7 @@ function mountOnWrapper(
|
|
|
712
760
|
wrapperEl.__digitoResendRowEl = null
|
|
713
761
|
}
|
|
714
762
|
|
|
715
|
-
return { reset, resend, setError, setSuccess, setDisabled, getCode, focus, destroy }
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
720
|
-
// HELPERS
|
|
721
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
722
|
-
|
|
723
|
-
function formatCountdown(totalSeconds: number): string {
|
|
724
|
-
const minutes = Math.floor(totalSeconds / 60)
|
|
725
|
-
const seconds = totalSeconds % 60
|
|
726
|
-
return minutes > 0
|
|
727
|
-
? `${minutes}:${String(seconds).padStart(2, '0')}`
|
|
728
|
-
: `0:${String(seconds).padStart(2, '0')}`
|
|
763
|
+
return { reset, resend, setError, setSuccess, setDisabled, setReadOnly, getCode, focus, destroy }
|
|
729
764
|
}
|
|
730
765
|
|
|
731
766
|
|
package/src/adapters/vue.ts
CHANGED
|
@@ -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
|
|
@@ -845,6 +867,24 @@ class DigitoInput extends HTMLElement {
|
|
|
845
867
|
}
|
|
846
868
|
}
|
|
847
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Toggle readOnly at runtime. When `true`, all slot mutations are blocked
|
|
872
|
+
* but focus, navigation, and copy remain fully functional.
|
|
873
|
+
* Distinct from `disabled` — no opacity/cursor change, `aria-readonly` is set.
|
|
874
|
+
*/
|
|
875
|
+
setReadOnly(value: boolean): void {
|
|
876
|
+
this._isReadOnly = value
|
|
877
|
+
this.digito?.setReadOnly(value)
|
|
878
|
+
if (this.hiddenInput) {
|
|
879
|
+
if (value) {
|
|
880
|
+
this.hiddenInput.setAttribute('aria-readonly', 'true')
|
|
881
|
+
} else {
|
|
882
|
+
this.hiddenInput.removeAttribute('aria-readonly')
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
this.syncSlotsToDOM()
|
|
886
|
+
}
|
|
887
|
+
|
|
848
888
|
/** Returns the current code as a joined string (e.g. `"123456"`). */
|
|
849
889
|
getCode(): string {
|
|
850
890
|
return this.digito?.getCode() ?? ''
|
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 }
|
|
14
|
+
export { createTimer, formatCountdown } from './timer.js'
|
|
15
15
|
export { triggerHapticFeedback, triggerSoundFeedback } from './feedback.js'
|
|
16
16
|
export { createDigito } from './machine.js'
|
package/src/core/machine.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|