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,650 @@
1
+ /**
2
+ * digito/vanilla
3
+ * ─────────────────────────────────────────────────────────────────────────────
4
+ * DOM adapter using the single-hidden-input architecture.
5
+ *
6
+ * Architecture:
7
+ * One real <input> sits invisibly behind the visual slot divs.
8
+ * It captures ALL keyboard input, paste, and native SMS autofill.
9
+ * The visual slot <div>s are pure mirrors — they display characters
10
+ * from the hidden input's value, show a fake caret on the active slot,
11
+ * and forward click events to focus the real input.
12
+ *
13
+ * Why this is better than multiple inputs:
14
+ * - autocomplete="one-time-code" works as native single-input autofill
15
+ * - iOS SMS autofill works without any hacks
16
+ * - Screen readers see one real input — perfect a11y
17
+ * - No focus-juggling between inputs on every keystroke
18
+ * - Password managers can't confuse the slots for separate fields
19
+ *
20
+ * Web OTP API:
21
+ * When supported (Android Chrome), navigator.credentials.get is called
22
+ * automatically to intercept the SMS OTP code without any user interaction.
23
+ * The AbortController is wired to destroy() so the request is cancelled
24
+ * on cleanup. Falls back gracefully in all other environments.
25
+ *
26
+ * Two timer modes:
27
+ * Built-in UI — omit onTick. Digito renders "Code expires in [0:60]"
28
+ * and "Didn't receive the code? Resend" automatically.
29
+ * Custom UI — pass onTick. Digito fires the callback and skips its timer.
30
+ *
31
+ * @author Olawale Balo — Product Designer + Design Engineer
32
+ * @license MIT
33
+ */
34
+ import { createDigito, createTimer, filterString, } from '../core/index.js';
35
+ export { createTimer } from '../core/index.js';
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // STYLES
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ const INJECTED_STYLE_ID = 'digito-styles';
40
+ /**
41
+ * Injects the digito stylesheet into head exactly once per page.
42
+ *
43
+ * CSS custom properties (set on .digito-wrapper to override):
44
+ * --digito-size Slot width + height (default: 56px)
45
+ * --digito-gap Gap between slots (default: 12px)
46
+ * --digito-radius Slot border radius (default: 10px)
47
+ * --digito-font-size Digit font size (default: 24px)
48
+ * --digito-bg Slot background (default: #FAFAFA)
49
+ * --digito-bg-filled Slot background when filled (default: #FFFFFF)
50
+ * --digito-color Digit text colour (default: #0A0A0A)
51
+ * --digito-border-color Default slot border (default: #E5E5E5)
52
+ * --digito-active-color Active slot border + ring (default: #757575)
53
+ * --digito-error-color Error border, ring + badge (default: #FB2C36)
54
+ * --digito-success-color Success border + ring (default: #00C950)
55
+ * --digito-timer-color Timer label text colour (default: #5C5C5C)
56
+ * --digito-caret-color Fake caret colour (default: #3D3D3D)
57
+ * --digito-separator-color Separator text colour (default: #A1A1A1)
58
+ * --digito-separator-size Separator font size (default: 18px)
59
+ * --digito-masked-size Mask character font size (default: 16px)
60
+ */
61
+ function injectStylesOnce() {
62
+ if (typeof document === 'undefined')
63
+ return;
64
+ if (document.getElementById(INJECTED_STYLE_ID))
65
+ return;
66
+ const styleEl = document.createElement('style');
67
+ styleEl.id = INJECTED_STYLE_ID;
68
+ styleEl.textContent = [
69
+ '.digito-element{position:relative;display:inline-block;line-height:1}',
70
+ '.digito-hidden-input{position:absolute;inset:0;width:100%;height:100%;opacity:0;border:none;outline:none;background:transparent;color:transparent;caret-color:transparent;z-index:1;cursor:text;font-size:1px}',
71
+ '.digito-content{display:inline-flex;gap:var(--digito-gap,12px);align-items:center;padding:24px 0 0px;position:relative}',
72
+ '.digito-slot{position:relative;width:var(--digito-size,56px);height:var(--digito-size,56px);border:1px solid var(--digito-border-color,#E5E5E5);border-radius:var(--digito-radius,10px);font-size:var(--digito-font-size,24px);font-weight:600;display:flex;align-items:center;justify-content:center;background:var(--digito-bg,#FAFAFA);color:var(--digito-color,#0A0A0A);transition:border-color 150ms ease,box-shadow 150ms ease,background 150ms ease;user-select:none;-webkit-user-select:none;cursor:text;font-family:inherit}',
73
+ '.digito-slot.is-active{border-color:var(--digito-active-color,#3D3D3D);box-shadow:0 0 0 3px color-mix(in srgb,var(--digito-active-color,#3D3D3D) 10%,transparent);background:var(--digito-bg-filled,#FFFFFF)}',
74
+ '.digito-slot.is-filled{background:var(--digito-bg-filled,#FFFFFF)}',
75
+ '.digito-slot.is-error{border-color:var(--digito-error-color,#FB2C36);box-shadow:0 0 0 3px color-mix(in srgb,var(--digito-error-color,#FB2C36) 12%,transparent)}',
76
+ '.digito-slot.is-success{border-color:var(--digito-success-color,#00C950);box-shadow:0 0 0 3px color-mix(in srgb,var(--digito-success-color,#00C950) 12%,transparent)}',
77
+ '.digito-slot.is-disabled{opacity:0.45;cursor:not-allowed;pointer-events:none}',
78
+ '.digito-caret{position:absolute;width:2px;height:52%;background:var(--digito-caret-color,#3D3D3D);border-radius:1px;animation:digito-blink 1s step-start infinite;pointer-events:none}',
79
+ '@keyframes digito-blink{0%,100%{opacity:1}50%{opacity:0}}',
80
+ '.digito-separator{display:flex;align-items:center;justify-content:center;color:var(--digito-separator-color,#A1A1A1);font-size:var(--digito-separator-size,18px);font-weight:400;user-select:none;flex-shrink:0;}',
81
+ '.digito-slot:not(.is-filled){font-size:var(--digito-placeholder-size,16px);color:var(--digito-placeholder-color,#D3D3D3)}',
82
+ '.digito-slot.is-masked.is-filled{font-size:var(--digito-masked-size,16px)}',
83
+ '.digito-timer{display:flex;align-items:center;gap:8px;font-size:14px;padding:20px 0 0}',
84
+ '.digito-timer-label{color:var(--digito-timer-color,#5C5C5C);font-size:14px}',
85
+ '.digito-timer-badge{display:inline-flex;align-items:center;background:color-mix(in srgb,var(--digito-error-color,#FB2C36) 10%,transparent);color:var(--digito-error-color,#FB2C36);font-weight:500;font-size:14px;padding:2px 10px;border-radius:99px;height: 24px}',
86
+ '.digito-resend{display:none;align-items:center;gap:8px;font-size:14px;color:var(--digito-timer-color,#5C5C5C);padding:20px 0 0}',
87
+ '.digito-resend.is-visible{display:flex}',
88
+ '.digito-resend-btn{display:inline-flex;align-items:center;background:#E8E8E8;border:none;padding:2px 10px;border-radius:99px;color:#0A0A0A;font-weight:500;font-size:14px;transition:background 150ms ease;cursor:pointer;height: 24px}',
89
+ '.digito-resend-btn:hover{background:#E5E5E5}',
90
+ '.digito-resend-btn:disabled{color:#A1A1A1;cursor:not-allowed;background:#F5F5F5}',
91
+ ].join('');
92
+ document.head.appendChild(styleEl);
93
+ }
94
+ /**
95
+ * Mount digito on one or more wrapper elements.
96
+ *
97
+ * @param target CSS selector or HTMLElement. Default: '.digito-wrapper'
98
+ * @param options Runtime options — supplement or override data attributes.
99
+ * @returns Array of DigitoInstance objects, one per wrapper found.
100
+ */
101
+ export function initDigito(target = '.digito-wrapper', options = {}) {
102
+ injectStylesOnce();
103
+ const wrapperElements = typeof target === 'string'
104
+ ? Array.from(document.querySelectorAll(target))
105
+ : [target];
106
+ return wrapperElements.map(wrapperEl => mountOnWrapper(wrapperEl, options));
107
+ }
108
+ function mountOnWrapper(wrapperEl, options) {
109
+ // Guard against double-initialisation on the same element. Calling initDigito
110
+ // twice without destroy() would orphan the first instance's event listeners
111
+ // and timers. Warn and clean up the stale DOM before proceeding.
112
+ if (wrapperEl.querySelector('.digito-element')) {
113
+ console.warn('[digito] initDigito() called on an already-initialised wrapper — call instance.destroy() first to avoid leaks.');
114
+ }
115
+ // ── Config ───────────────────────────────────────────────────────────────
116
+ const slotCount = options.length ?? parseInt(wrapperEl.dataset.length ?? '6', 10);
117
+ const inputType = (options.type ?? wrapperEl.dataset.type ?? 'numeric');
118
+ const timerSeconds = options.timer ?? parseInt(wrapperEl.dataset.timer ?? '0', 10);
119
+ const resendCooldown = options.resendAfter ?? parseInt(wrapperEl.dataset.resend ?? '30', 10);
120
+ const onResend = options.onResend;
121
+ const onComplete = options.onComplete;
122
+ const onTickCallback = options.onTick;
123
+ const onExpire = options.onExpire;
124
+ const pattern = options.pattern;
125
+ let isDisabled = options.disabled ?? false;
126
+ // New options
127
+ const autoFocus = options.autoFocus !== false; // default true
128
+ const inputName = options.name;
129
+ const onFocusProp = options.onFocus;
130
+ const onBlurProp = options.onBlur;
131
+ const placeholder = options.placeholder ?? '';
132
+ const selectOnFocus = options.selectOnFocus ?? false;
133
+ const blurOnComplete = options.blurOnComplete ?? false;
134
+ const rawSepAfter = options.separatorAfter
135
+ ?? (wrapperEl.dataset.separatorAfter !== undefined
136
+ ? wrapperEl.dataset.separatorAfter.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
137
+ : []);
138
+ // Normalise to array so all rendering logic is consistent
139
+ const separatorAfterPositions = Array.isArray(rawSepAfter) ? rawSepAfter : [rawSepAfter];
140
+ const separatorChar = options.separator
141
+ ?? wrapperEl.dataset.separator
142
+ ?? '—';
143
+ const masked = options.masked ?? wrapperEl.dataset.masked === 'true';
144
+ const maskChar = options.maskChar ?? wrapperEl.dataset.maskChar ?? '\u25CF';
145
+ // ── Core state machine ───────────────────────────────────────────────────
146
+ const otpCore = createDigito({
147
+ length: slotCount,
148
+ type: inputType,
149
+ timer: timerSeconds,
150
+ resendAfter: resendCooldown,
151
+ onComplete,
152
+ onTick: onTickCallback,
153
+ onExpire,
154
+ pattern,
155
+ pasteTransformer: options.pasteTransformer,
156
+ onInvalidChar: options.onInvalidChar,
157
+ sound: options.sound ?? false,
158
+ haptic: options.haptic ?? true,
159
+ disabled: isDisabled,
160
+ });
161
+ // ── Build DOM ────────────────────────────────────────────────────────────
162
+ // Clear wrapper using safe DOM removal (avoids innerHTML)
163
+ while (wrapperEl.firstChild)
164
+ wrapperEl.removeChild(wrapperEl.firstChild);
165
+ const rootEl = document.createElement('div');
166
+ rootEl.className = 'digito-element';
167
+ const slotRowEl = document.createElement('div');
168
+ slotRowEl.className = 'digito-content';
169
+ const slotEls = [];
170
+ const caretEls = [];
171
+ for (let i = 0; i < slotCount; i++) {
172
+ const slotEl = document.createElement('div');
173
+ slotEl.className = 'digito-slot';
174
+ slotEl.setAttribute('aria-hidden', 'true');
175
+ slotEl.setAttribute('data-slot', String(i));
176
+ const caretEl = document.createElement('div');
177
+ caretEl.className = 'digito-caret';
178
+ caretEl.style.display = 'none';
179
+ slotEl.appendChild(caretEl);
180
+ caretEls.push(caretEl);
181
+ slotEls.push(slotEl);
182
+ slotRowEl.appendChild(slotEl);
183
+ if (separatorAfterPositions.some(pos => pos > 0 && i === pos - 1)) {
184
+ const sepEl = document.createElement('div');
185
+ sepEl.className = 'digito-separator';
186
+ sepEl.textContent = separatorChar;
187
+ sepEl.setAttribute('aria-hidden', 'true');
188
+ slotRowEl.appendChild(sepEl);
189
+ }
190
+ }
191
+ const hiddenInputEl = document.createElement('input');
192
+ hiddenInputEl.type = masked ? 'password' : 'text';
193
+ hiddenInputEl.inputMode = inputType === 'numeric' ? 'numeric' : 'text';
194
+ hiddenInputEl.autocomplete = 'one-time-code';
195
+ hiddenInputEl.maxLength = slotCount;
196
+ hiddenInputEl.className = 'digito-hidden-input';
197
+ if (inputName)
198
+ hiddenInputEl.name = inputName;
199
+ hiddenInputEl.setAttribute('aria-label', `Enter your ${slotCount}-${inputType === 'numeric' ? 'digit' : 'character'} code`);
200
+ hiddenInputEl.setAttribute('spellcheck', 'false');
201
+ hiddenInputEl.setAttribute('autocorrect', 'off');
202
+ hiddenInputEl.setAttribute('autocapitalize', 'off');
203
+ rootEl.appendChild(slotRowEl);
204
+ rootEl.appendChild(hiddenInputEl);
205
+ wrapperEl.appendChild(rootEl);
206
+ // ── Password manager badge guard ─────────────────────────────────────────
207
+ let disconnectPasswordManagerWatch = () => { };
208
+ requestAnimationFrame(() => {
209
+ const slotRowWidth = slotRowEl.getBoundingClientRect().width || 0;
210
+ disconnectPasswordManagerWatch = watchForPasswordManagerBadge(hiddenInputEl, slotRowWidth);
211
+ });
212
+ // ── Timer + resend ────────────────────────────────────────────────────────
213
+ let timerBadgeEl = null;
214
+ let resendActionBtn = null;
215
+ let mainCountdown = null;
216
+ let resendCountdown = null;
217
+ let builtInFooterEl = null;
218
+ let builtInResendRowEl = null;
219
+ // Remove any footer elements left by a previous mount on this same wrapper.
220
+ // Using stored references is reliable regardless of DOM siblings inserted between them.
221
+ wrapperEl.__digitoFooterEl?.remove();
222
+ wrapperEl.__digitoResendRowEl?.remove();
223
+ wrapperEl.__digitoFooterEl = null;
224
+ wrapperEl.__digitoResendRowEl = null;
225
+ if (timerSeconds > 0) {
226
+ const shouldUseBuiltInFooter = !onTickCallback;
227
+ if (shouldUseBuiltInFooter) {
228
+ builtInFooterEl = document.createElement('div');
229
+ builtInFooterEl.className = 'digito-timer';
230
+ const expiresLabel = document.createElement('span');
231
+ expiresLabel.className = 'digito-timer-label';
232
+ expiresLabel.textContent = 'Code expires in';
233
+ timerBadgeEl = document.createElement('span');
234
+ timerBadgeEl.className = 'digito-timer-badge';
235
+ timerBadgeEl.textContent = formatCountdown(timerSeconds);
236
+ builtInFooterEl.appendChild(expiresLabel);
237
+ builtInFooterEl.appendChild(timerBadgeEl);
238
+ wrapperEl.insertAdjacentElement('afterend', builtInFooterEl);
239
+ builtInResendRowEl = document.createElement('div');
240
+ builtInResendRowEl.className = 'digito-resend';
241
+ const didntReceiveLabel = document.createElement('span');
242
+ didntReceiveLabel.textContent = 'Didn\u2019t receive the code?';
243
+ resendActionBtn = document.createElement('button');
244
+ resendActionBtn.className = 'digito-resend-btn';
245
+ resendActionBtn.textContent = 'Resend';
246
+ resendActionBtn.type = 'button';
247
+ builtInResendRowEl.appendChild(didntReceiveLabel);
248
+ builtInResendRowEl.appendChild(resendActionBtn);
249
+ builtInFooterEl.insertAdjacentElement('afterend', builtInResendRowEl);
250
+ // Store on the wrapper element so subsequent mounts on the same element
251
+ // can clean these up reliably without fragile sibling DOM walks.
252
+ wrapperEl.__digitoFooterEl = builtInFooterEl;
253
+ wrapperEl.__digitoResendRowEl = builtInResendRowEl;
254
+ }
255
+ mainCountdown = createTimer({
256
+ totalSeconds: timerSeconds,
257
+ onTick: (remaining) => {
258
+ if (timerBadgeEl)
259
+ timerBadgeEl.textContent = formatCountdown(remaining);
260
+ onTickCallback?.(remaining);
261
+ },
262
+ onExpire: () => {
263
+ if (builtInFooterEl)
264
+ builtInFooterEl.style.display = 'none';
265
+ if (builtInResendRowEl)
266
+ builtInResendRowEl.classList.add('is-visible');
267
+ onExpire?.();
268
+ },
269
+ });
270
+ mainCountdown.start();
271
+ if (shouldUseBuiltInFooter && resendActionBtn) {
272
+ resendActionBtn.addEventListener('click', () => {
273
+ if (!resendActionBtn || !timerBadgeEl || !builtInFooterEl || !builtInResendRowEl)
274
+ return;
275
+ builtInResendRowEl.classList.remove('is-visible');
276
+ builtInFooterEl.style.display = 'flex';
277
+ timerBadgeEl.textContent = formatCountdown(resendCooldown);
278
+ resendCountdown?.stop();
279
+ resendCountdown = createTimer({
280
+ totalSeconds: resendCooldown,
281
+ onTick: (r) => { if (timerBadgeEl)
282
+ timerBadgeEl.textContent = formatCountdown(r); },
283
+ onExpire: () => {
284
+ if (builtInFooterEl)
285
+ builtInFooterEl.style.display = 'none';
286
+ if (builtInResendRowEl)
287
+ builtInResendRowEl.classList.add('is-visible');
288
+ },
289
+ });
290
+ resendCountdown.start();
291
+ onResend?.();
292
+ });
293
+ }
294
+ }
295
+ // ── Web OTP API (SMS autofill) ────────────────────────────────────────────
296
+ // navigator.credentials.get({ otp: { transport: ['sms'] } }) intercepts
297
+ // incoming OTP SMSes on Android Chrome without any user gesture.
298
+ // The AbortController is stored so destroy() can cancel the pending request.
299
+ let webOTPController = null;
300
+ if (typeof navigator !== 'undefined' && 'credentials' in navigator) {
301
+ webOTPController = new AbortController();
302
+ navigator.credentials.get({
303
+ otp: { transport: ['sms'] },
304
+ signal: webOTPController.signal,
305
+ }).then((credential) => {
306
+ if (!credential?.code)
307
+ return;
308
+ const valid = filterString(credential.code, inputType, pattern).slice(0, slotCount);
309
+ if (!valid)
310
+ return;
311
+ otpCore.resetState();
312
+ for (let i = 0; i < valid.length; i++) {
313
+ if (valid[i])
314
+ otpCore.inputChar(i, valid[i]);
315
+ }
316
+ const filledCount = valid.length;
317
+ const nextCursor = Math.min(filledCount, slotCount - 1);
318
+ hiddenInputEl.value = valid;
319
+ hiddenInputEl.setSelectionRange(nextCursor, nextCursor);
320
+ otpCore.moveFocusTo(nextCursor);
321
+ syncSlotsToDOM();
322
+ }).catch(() => { });
323
+ }
324
+ // ── DOM sync ─────────────────────────────────────────────────────────────
325
+ function syncSlotsToDOM() {
326
+ const { slotValues, activeSlot, hasError } = otpCore.state;
327
+ const inputIsFocused = document.activeElement === hiddenInputEl;
328
+ slotEls.forEach((slotEl, i) => {
329
+ const char = slotValues[i] ?? '';
330
+ const isActive = i === activeSlot && inputIsFocused;
331
+ const isFilled = char.length === 1;
332
+ let textNode = slotEl.childNodes[1];
333
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
334
+ textNode = document.createTextNode('');
335
+ slotEl.appendChild(textNode);
336
+ }
337
+ textNode.nodeValue = masked && char ? maskChar : char || placeholder;
338
+ slotEl.classList.toggle('is-active', isActive);
339
+ slotEl.classList.toggle('is-filled', isFilled);
340
+ slotEl.classList.toggle('is-masked', masked);
341
+ slotEl.classList.toggle('is-error', hasError);
342
+ if (hasError)
343
+ slotEl.classList.remove('is-success');
344
+ caretEls[i].style.display = isActive && !isFilled ? 'block' : 'none';
345
+ });
346
+ // Only update value when it actually differs — assigning the same string
347
+ // resets selectionStart/End in some browsers, clobbering the cursor.
348
+ const newValue = slotValues.join('');
349
+ if (hiddenInputEl.value !== newValue)
350
+ hiddenInputEl.value = newValue;
351
+ }
352
+ // ── Event handlers ────────────────────────────────────────────────────────
353
+ function onHiddenInputKeydown(event) {
354
+ const cursorPos = hiddenInputEl.selectionStart ?? 0;
355
+ if (event.key === 'Backspace') {
356
+ event.preventDefault();
357
+ otpCore.deleteChar(cursorPos);
358
+ syncSlotsToDOM();
359
+ hiddenInputEl.setSelectionRange(otpCore.state.activeSlot, otpCore.state.activeSlot);
360
+ }
361
+ else if (event.key === 'Tab') {
362
+ if (event.shiftKey) {
363
+ // Shift+Tab: move to previous slot, or exit to previous DOM element from first slot
364
+ if (cursorPos === 0)
365
+ return;
366
+ event.preventDefault();
367
+ otpCore.moveFocusLeft(cursorPos);
368
+ }
369
+ else {
370
+ // Tab: advance to next slot only if current slot is filled
371
+ // On last slot (filled), fall through to let browser move to next DOM element
372
+ if (!otpCore.state.slotValues[cursorPos])
373
+ return;
374
+ if (cursorPos >= slotCount - 1)
375
+ return;
376
+ event.preventDefault();
377
+ otpCore.moveFocusRight(cursorPos);
378
+ }
379
+ const nextSlot = otpCore.state.activeSlot;
380
+ hiddenInputEl.setSelectionRange(nextSlot, nextSlot);
381
+ syncSlotsToDOM();
382
+ }
383
+ else if (event.key === 'ArrowLeft') {
384
+ event.preventDefault();
385
+ otpCore.moveFocusLeft(cursorPos);
386
+ const nextSlot = otpCore.state.activeSlot;
387
+ hiddenInputEl.setSelectionRange(nextSlot, nextSlot);
388
+ syncSlotsToDOM();
389
+ }
390
+ else if (event.key === 'ArrowRight') {
391
+ event.preventDefault();
392
+ otpCore.moveFocusRight(cursorPos);
393
+ const nextSlot = otpCore.state.activeSlot;
394
+ hiddenInputEl.setSelectionRange(nextSlot, nextSlot);
395
+ syncSlotsToDOM();
396
+ }
397
+ }
398
+ function onHiddenInputChange(_event) {
399
+ const rawValue = hiddenInputEl.value;
400
+ if (!rawValue) {
401
+ otpCore.resetState();
402
+ hiddenInputEl.value = '';
403
+ hiddenInputEl.setSelectionRange(0, 0);
404
+ syncSlotsToDOM();
405
+ return;
406
+ }
407
+ const validValue = filterString(rawValue, inputType, pattern);
408
+ const newSlotValues = Array(slotCount).fill('');
409
+ for (let i = 0; i < Math.min(validValue.length, slotCount); i++) {
410
+ newSlotValues[i] = validValue[i];
411
+ }
412
+ otpCore.resetState();
413
+ for (let i = 0; i < newSlotValues.length; i++) {
414
+ if (newSlotValues[i])
415
+ otpCore.inputChar(i, newSlotValues[i]);
416
+ }
417
+ const filledCount = newSlotValues.filter(v => v !== '').length;
418
+ const nextCursor = Math.min(filledCount, slotCount - 1);
419
+ hiddenInputEl.value = validValue.slice(0, slotCount);
420
+ hiddenInputEl.setSelectionRange(nextCursor, nextCursor);
421
+ otpCore.moveFocusTo(nextCursor);
422
+ syncSlotsToDOM();
423
+ if (blurOnComplete && otpCore.state.isComplete) {
424
+ requestAnimationFrame(() => hiddenInputEl.blur());
425
+ }
426
+ }
427
+ function onHiddenInputPaste(event) {
428
+ event.preventDefault();
429
+ const pastedText = event.clipboardData?.getData('text') ?? '';
430
+ const cursorPos = hiddenInputEl.selectionStart ?? 0;
431
+ otpCore.pasteString(cursorPos, pastedText);
432
+ const { slotValues, activeSlot } = otpCore.state;
433
+ hiddenInputEl.value = slotValues.join('');
434
+ hiddenInputEl.setSelectionRange(activeSlot, activeSlot);
435
+ syncSlotsToDOM();
436
+ if (blurOnComplete && otpCore.state.isComplete) {
437
+ requestAnimationFrame(() => hiddenInputEl.blur());
438
+ }
439
+ }
440
+ function onHiddenInputFocus() {
441
+ onFocusProp?.();
442
+ // Read activeSlot inside the RAF — not before it.
443
+ // Browser event order on a fresh click: focus fires → click fires → RAF fires.
444
+ // Capturing pos outside the RAF would snapshot activeSlot=0 before the click
445
+ // handler has a chance to call moveFocusTo(clickedSlot), causing the RAF to
446
+ // overwrite the correct slot back to 0.
447
+ requestAnimationFrame(() => {
448
+ const pos = otpCore.state.activeSlot;
449
+ const char = otpCore.state.slotValues[pos];
450
+ if (selectOnFocus && char) {
451
+ hiddenInputEl.setSelectionRange(pos, pos + 1);
452
+ }
453
+ else {
454
+ hiddenInputEl.setSelectionRange(pos, pos);
455
+ }
456
+ syncSlotsToDOM();
457
+ });
458
+ }
459
+ function onHiddenInputBlur() {
460
+ onBlurProp?.();
461
+ slotEls.forEach(slotEl => slotEl.classList.remove('is-active'));
462
+ caretEls.forEach(caretEl => { caretEl.style.display = 'none'; });
463
+ }
464
+ hiddenInputEl.addEventListener('keydown', onHiddenInputKeydown);
465
+ hiddenInputEl.addEventListener('input', onHiddenInputChange);
466
+ hiddenInputEl.addEventListener('paste', onHiddenInputPaste);
467
+ hiddenInputEl.addEventListener('focus', onHiddenInputFocus);
468
+ hiddenInputEl.addEventListener('blur', onHiddenInputBlur);
469
+ hiddenInputEl.addEventListener('click', onHiddenInputClick);
470
+ function onHiddenInputClick(e) {
471
+ if (isDisabled)
472
+ return;
473
+ // Click fires after the browser has already placed the cursor (at 0 due to
474
+ // font-size:1px). Coordinate hit-test to find the intended slot, then
475
+ // override the browser's placement with an explicit setSelectionRange.
476
+ let rawSlot = slotEls.length - 1;
477
+ for (let i = 0; i < slotEls.length; i++) {
478
+ if (e.clientX <= slotEls[i].getBoundingClientRect().right) {
479
+ rawSlot = i;
480
+ break;
481
+ }
482
+ }
483
+ // Clamp to filled count: setSelectionRange(N, N) on a string of length L
484
+ // silently clamps to L, so cursor ends up at 0 on an empty field. Clamping
485
+ // keeps the visual active slot and the actual cursor position in sync.
486
+ const clickedSlot = Math.min(rawSlot, hiddenInputEl.value.length);
487
+ otpCore.moveFocusTo(clickedSlot);
488
+ const char = otpCore.state.slotValues[clickedSlot];
489
+ if (selectOnFocus && char) {
490
+ hiddenInputEl.setSelectionRange(clickedSlot, clickedSlot + 1);
491
+ }
492
+ else {
493
+ hiddenInputEl.setSelectionRange(clickedSlot, clickedSlot);
494
+ }
495
+ syncSlotsToDOM();
496
+ }
497
+ requestAnimationFrame(() => {
498
+ if (!isDisabled && autoFocus)
499
+ hiddenInputEl.focus();
500
+ hiddenInputEl.setSelectionRange(0, 0);
501
+ syncSlotsToDOM();
502
+ });
503
+ // ── Public API ────────────────────────────────────────────────────────────
504
+ function reset() {
505
+ otpCore.resetState();
506
+ hiddenInputEl.value = '';
507
+ if (timerBadgeEl)
508
+ timerBadgeEl.textContent = formatCountdown(timerSeconds);
509
+ if (builtInFooterEl)
510
+ builtInFooterEl.style.display = 'flex';
511
+ if (builtInResendRowEl)
512
+ builtInResendRowEl.classList.remove('is-visible');
513
+ resendCountdown?.stop();
514
+ mainCountdown?.restart();
515
+ requestAnimationFrame(() => {
516
+ hiddenInputEl.focus();
517
+ hiddenInputEl.setSelectionRange(0, 0);
518
+ syncSlotsToDOM();
519
+ });
520
+ }
521
+ function resend() {
522
+ reset();
523
+ onResend?.();
524
+ }
525
+ function setError(isError) {
526
+ otpCore.setError(isError);
527
+ syncSlotsToDOM();
528
+ }
529
+ function setSuccess(isSuccess) {
530
+ slotEls.forEach(slotEl => {
531
+ slotEl.classList.toggle('is-success', isSuccess);
532
+ if (isSuccess)
533
+ slotEl.classList.remove('is-error');
534
+ });
535
+ }
536
+ function setDisabled(value) {
537
+ isDisabled = value;
538
+ otpCore.setDisabled(value);
539
+ hiddenInputEl.disabled = value;
540
+ slotEls.forEach(slotEl => {
541
+ slotEl.classList.toggle('is-disabled', value);
542
+ slotEl.style.pointerEvents = value ? 'none' : '';
543
+ });
544
+ }
545
+ function getCode() {
546
+ return otpCore.getCode();
547
+ }
548
+ function focus(slotIndex) {
549
+ otpCore.moveFocusTo(slotIndex);
550
+ hiddenInputEl.focus();
551
+ hiddenInputEl.setSelectionRange(slotIndex, slotIndex);
552
+ syncSlotsToDOM();
553
+ }
554
+ function destroy() {
555
+ hiddenInputEl.removeEventListener('keydown', onHiddenInputKeydown);
556
+ hiddenInputEl.removeEventListener('input', onHiddenInputChange);
557
+ hiddenInputEl.removeEventListener('paste', onHiddenInputPaste);
558
+ hiddenInputEl.removeEventListener('focus', onHiddenInputFocus);
559
+ hiddenInputEl.removeEventListener('blur', onHiddenInputBlur);
560
+ hiddenInputEl.removeEventListener('click', onHiddenInputClick);
561
+ mainCountdown?.stop();
562
+ resendCountdown?.stop();
563
+ disconnectPasswordManagerWatch();
564
+ webOTPController?.abort();
565
+ wrapperEl.__digitoFooterEl = null;
566
+ wrapperEl.__digitoResendRowEl = null;
567
+ }
568
+ return { reset, resend, setError, setSuccess, setDisabled, getCode, focus, destroy };
569
+ }
570
+ // ─────────────────────────────────────────────────────────────────────────────
571
+ // HELPERS
572
+ // ─────────────────────────────────────────────────────────────────────────────
573
+ function formatCountdown(totalSeconds) {
574
+ const minutes = Math.floor(totalSeconds / 60);
575
+ const seconds = totalSeconds % 60;
576
+ return minutes > 0
577
+ ? `${minutes}:${String(seconds).padStart(2, '0')}`
578
+ : `0:${String(seconds).padStart(2, '0')}`;
579
+ }
580
+ // ─────────────────────────────────────────────────────────────────────────────
581
+ // PASSWORD MANAGER BADGE GUARD
582
+ // ─────────────────────────────────────────────────────────────────────────────
583
+ //
584
+ // Password managers (LastPass, 1Password, Dashlane, Bitwarden) inject a small
585
+ // icon badge into or beside <input> elements they detect as credential fields.
586
+ // On OTP inputs this badge physically overlaps the last visual slot.
587
+ //
588
+ // Fix: detect when any of these extensions are active, then push the hidden
589
+ // input's width ~40px wider so the badge renders outside the slot boundary.
590
+ const PASSWORD_MANAGER_SELECTORS = [
591
+ '[data-lastpass-icon-root]',
592
+ '[data-lastpass-root]',
593
+ '[data-op-autofill]',
594
+ '[data-1p-ignore]',
595
+ '[data-dashlane-rid]',
596
+ '[data-dashlane-label]',
597
+ '[data-kwimpalastatus]',
598
+ '[data-bwautofill]',
599
+ 'com-bitwarden-browser-arctic-modal',
600
+ ];
601
+ const PASSWORD_MANAGER_BADGE_OFFSET_PX = 40;
602
+ function isPasswordManagerActive() {
603
+ if (typeof document === 'undefined')
604
+ return false;
605
+ return PASSWORD_MANAGER_SELECTORS.some(sel => {
606
+ try {
607
+ return document.querySelector(sel) !== null;
608
+ }
609
+ catch {
610
+ return false;
611
+ }
612
+ });
613
+ }
614
+ function watchForPasswordManagerBadge(hiddenInputEl, baseWidthPx) {
615
+ if (typeof MutationObserver === 'undefined')
616
+ return () => { };
617
+ function applyOffset() {
618
+ hiddenInputEl.style.width = `${baseWidthPx + PASSWORD_MANAGER_BADGE_OFFSET_PX}px`;
619
+ }
620
+ if (isPasswordManagerActive()) {
621
+ applyOffset();
622
+ return () => { };
623
+ }
624
+ const observer = new MutationObserver(() => {
625
+ if (isPasswordManagerActive()) {
626
+ applyOffset();
627
+ observer.disconnect();
628
+ }
629
+ });
630
+ observer.observe(document.documentElement, {
631
+ childList: true,
632
+ subtree: true,
633
+ attributes: true,
634
+ attributeFilter: [
635
+ 'data-lastpass-icon-root',
636
+ 'data-lastpass-root',
637
+ 'data-1p-ignore',
638
+ 'data-dashlane-rid',
639
+ 'data-kwimpalastatus',
640
+ ],
641
+ });
642
+ return () => observer.disconnect();
643
+ }
644
+ // ─────────────────────────────────────────────────────────────────────────────
645
+ // CDN GLOBAL
646
+ // ─────────────────────────────────────────────────────────────────────────────
647
+ if (typeof window !== 'undefined') {
648
+ window['Digito'] = { init: initDigito };
649
+ }
650
+ //# sourceMappingURL=vanilla.js.map