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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +753 -0
- package/dist/adapters/alpine.d.ts +71 -0
- package/dist/adapters/alpine.d.ts.map +1 -0
- package/dist/adapters/alpine.js +560 -0
- package/dist/adapters/alpine.js.map +1 -0
- package/dist/adapters/react.d.ts +223 -0
- package/dist/adapters/react.d.ts.map +1 -0
- package/dist/adapters/react.js +337 -0
- package/dist/adapters/react.js.map +1 -0
- package/dist/adapters/svelte.d.ts +139 -0
- package/dist/adapters/svelte.d.ts.map +1 -0
- package/dist/adapters/svelte.js +295 -0
- package/dist/adapters/svelte.js.map +1 -0
- package/dist/adapters/vanilla.d.ts +110 -0
- package/dist/adapters/vanilla.d.ts.map +1 -0
- package/dist/adapters/vanilla.js +650 -0
- package/dist/adapters/vanilla.js.map +1 -0
- package/dist/adapters/vue.d.ts +163 -0
- package/dist/adapters/vue.d.ts.map +1 -0
- package/dist/adapters/vue.js +298 -0
- package/dist/adapters/vue.js.map +1 -0
- package/dist/adapters/web-component.d.ts +192 -0
- package/dist/adapters/web-component.d.ts.map +1 -0
- package/dist/adapters/web-component.js +832 -0
- package/dist/adapters/web-component.js.map +1 -0
- package/dist/core/feedback.d.ts +26 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +47 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/filter.d.ts +24 -0
- package/dist/core/filter.d.ts.map +1 -0
- package/dist/core/filter.js +47 -0
- package/dist/core/filter.js.map +1 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +15 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/machine.d.ts +67 -0
- package/dist/core/machine.d.ts.map +1 -0
- package/dist/core/machine.js +328 -0
- package/dist/core/machine.js.map +1 -0
- package/dist/core/timer.d.ts +24 -0
- package/dist/core/timer.d.ts.map +1 -0
- package/dist/core/timer.js +67 -0
- package/dist/core/timer.js.map +1 -0
- package/dist/core/types.d.ts +162 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +10 -0
- package/dist/core/types.js.map +1 -0
- package/dist/digito-wc.min.js +254 -0
- package/dist/digito-wc.min.js.map +7 -0
- package/dist/digito.min.js +91 -0
- package/dist/digito.min.js.map +7 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +109 -0
- package/src/adapters/alpine.ts +666 -0
- package/src/adapters/react.tsx +603 -0
- package/src/adapters/svelte.ts +444 -0
- package/src/adapters/vanilla.ts +810 -0
- package/src/adapters/vue.ts +462 -0
- package/src/adapters/web-component.ts +858 -0
- package/src/core/feedback.ts +44 -0
- package/src/core/filter.ts +48 -0
- package/src/core/index.ts +16 -0
- package/src/core/machine.ts +373 -0
- package/src/core/timer.ts +75 -0
- package/src/core/types.ts +167 -0
- 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
|