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,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* digito/alpine
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Alpine.js adapter — x-digito directive (single hidden-input architecture)
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import Alpine from 'alpinejs'
|
|
8
|
+
* import { DigitoAlpine } from 'digito/alpine'
|
|
9
|
+
* Alpine.plugin(DigitoAlpine)
|
|
10
|
+
* Alpine.start()
|
|
11
|
+
*
|
|
12
|
+
* <div x-digito="{ length: 6, type: 'numeric', timer: 60 }"></div>
|
|
13
|
+
*
|
|
14
|
+
* // With separator:
|
|
15
|
+
* <div x-digito="{ length: 6, separatorAfter: 2 }"></div>
|
|
16
|
+
*
|
|
17
|
+
* // Access public API:
|
|
18
|
+
* el._digito.setDisabled(true)
|
|
19
|
+
* el._digito.setError(true)
|
|
20
|
+
* el._digito.reset()
|
|
21
|
+
* el._digito.getCode()
|
|
22
|
+
*
|
|
23
|
+
* @author Olawale Balo — Product Designer + Design Engineer
|
|
24
|
+
* @license MIT
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createDigito,
|
|
29
|
+
createTimer,
|
|
30
|
+
filterString,
|
|
31
|
+
type DigitoOptions,
|
|
32
|
+
type InputType,
|
|
33
|
+
} from '../core/index.js'
|
|
34
|
+
|
|
35
|
+
/** Shape of the data object Alpine passes to every directive handler. */
|
|
36
|
+
type AlpineDirectiveData = {
|
|
37
|
+
/** The raw expression string from `x-digito="{ ... }"`. */
|
|
38
|
+
expression: string
|
|
39
|
+
/** Directive value segment, e.g. `x-digito:value`. Unused by Digito. */
|
|
40
|
+
value: string
|
|
41
|
+
/** Modifier segments, e.g. `x-digito.modifier`. Unused by Digito. */
|
|
42
|
+
modifiers: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Utility functions Alpine passes to every directive handler. */
|
|
46
|
+
type AlpineDirectiveUtilities = {
|
|
47
|
+
/** Evaluate an Alpine expression in the component scope and return its value. */
|
|
48
|
+
evaluate: (expr: string) => unknown
|
|
49
|
+
/** Return a function that evaluates `expr` reactively via Alpine's effect system. */
|
|
50
|
+
evaluateLater: (expr: string) => (callback: (value: unknown) => void) => void
|
|
51
|
+
/** Register a teardown function called when the component or directive is destroyed. */
|
|
52
|
+
cleanup: (fn: () => void) => void
|
|
53
|
+
/** Run `fn` reactively; re-runs whenever its Alpine reactive dependencies change. */
|
|
54
|
+
effect: (fn: () => void) => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Minimal interface for the Alpine.js instance — only the parts Digito needs. */
|
|
58
|
+
type AlpinePlugin = {
|
|
59
|
+
directive: (
|
|
60
|
+
name: string,
|
|
61
|
+
handler: (el: HTMLElement, data: AlpineDirectiveData, utilities: AlpineDirectiveUtilities) => { cleanup(): void } | void
|
|
62
|
+
) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extended options for the Alpine x-digito directive.
|
|
67
|
+
* Adds separator and disabled support on top of DigitoOptions.
|
|
68
|
+
*/
|
|
69
|
+
type AlpineOTPOptions = DigitoOptions & {
|
|
70
|
+
/**
|
|
71
|
+
* Insert a purely visual separator after this slot index (0-based).
|
|
72
|
+
* Accepts a single position or an array for multiple separators.
|
|
73
|
+
* Default: 0 (no separator).
|
|
74
|
+
*/
|
|
75
|
+
separatorAfter?: number | number[]
|
|
76
|
+
separator?: string
|
|
77
|
+
onChange?: (code: string) => void
|
|
78
|
+
onTick?: (remaining: number) => void
|
|
79
|
+
/**
|
|
80
|
+
* Cooldown seconds before the built-in Resend button re-enables after being clicked.
|
|
81
|
+
* Default: 30.
|
|
82
|
+
*/
|
|
83
|
+
resendAfter?: number
|
|
84
|
+
/**
|
|
85
|
+
* When `true`, each filled slot displays a mask glyph instead of the real
|
|
86
|
+
* character. The hidden input switches to `type="password"`. `getCode()`
|
|
87
|
+
* still returns real characters. Use for PIN entry or any sensitive input flow.
|
|
88
|
+
* Default: `false`.
|
|
89
|
+
*/
|
|
90
|
+
masked?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* The glyph displayed in filled slots when `masked` is `true`.
|
|
93
|
+
* Default: `'●'` (U+25CF BLACK CIRCLE).
|
|
94
|
+
* @example maskChar: '*'
|
|
95
|
+
*/
|
|
96
|
+
maskChar?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
// HELPERS
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function formatCountdown(totalSeconds: number): string {
|
|
104
|
+
const minutes = Math.floor(totalSeconds / 60)
|
|
105
|
+
const seconds = totalSeconds % 60
|
|
106
|
+
return minutes > 0
|
|
107
|
+
? `${minutes}:${String(seconds).padStart(2, '0')}`
|
|
108
|
+
: `0:${String(seconds).padStart(2, '0')}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
// PLUGIN
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Alpine.js plugin that registers the `x-digito` directive.
|
|
117
|
+
*
|
|
118
|
+
* Install once before `Alpine.start()`:
|
|
119
|
+
* ```js
|
|
120
|
+
* import Alpine from 'alpinejs'
|
|
121
|
+
* import { DigitoAlpine } from 'digito/alpine'
|
|
122
|
+
* Alpine.plugin(DigitoAlpine)
|
|
123
|
+
* Alpine.start()
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* The directive accepts the same options as `DigitoOptions` plus `separatorAfter`,
|
|
127
|
+
* `separator`, `masked`, and `maskChar`. Options are evaluated via Alpine's
|
|
128
|
+
* `evaluate()` so reactive expressions and Alpine `$data` references work.
|
|
129
|
+
*
|
|
130
|
+
* @param Alpine - The Alpine.js global passed automatically by `Alpine.plugin()`.
|
|
131
|
+
*/
|
|
132
|
+
export const DigitoAlpine = (Alpine: AlpinePlugin): void => {
|
|
133
|
+
Alpine.directive('digito', (wrapperEl, { expression }, { evaluate }): { cleanup(): void } => {
|
|
134
|
+
let options: AlpineOTPOptions
|
|
135
|
+
try {
|
|
136
|
+
options = (expression ? evaluate(expression) : {}) as AlpineOTPOptions
|
|
137
|
+
if (!options || typeof options !== 'object') {
|
|
138
|
+
console.error('[x-digito] expression did not return a plain object. Got:', options)
|
|
139
|
+
options = {} as AlpineOTPOptions
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error('[x-digito] failed to evaluate expression:', err)
|
|
143
|
+
options = {} as AlpineOTPOptions
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const {
|
|
147
|
+
length = 6,
|
|
148
|
+
type = 'numeric' as InputType,
|
|
149
|
+
timer: timerSecs = 0,
|
|
150
|
+
disabled: initialDisabled = false,
|
|
151
|
+
onComplete,
|
|
152
|
+
onExpire,
|
|
153
|
+
onResend,
|
|
154
|
+
onTick: onTickProp,
|
|
155
|
+
haptic = true,
|
|
156
|
+
sound = false,
|
|
157
|
+
pattern,
|
|
158
|
+
pasteTransformer,
|
|
159
|
+
onInvalidChar,
|
|
160
|
+
onChange: onChangeProp,
|
|
161
|
+
onFocus: onFocusProp,
|
|
162
|
+
onBlur: onBlurProp,
|
|
163
|
+
separatorAfter: rawSepAfter = 0,
|
|
164
|
+
separator = '—',
|
|
165
|
+
resendAfter: resendCooldown = 30,
|
|
166
|
+
masked = false,
|
|
167
|
+
maskChar = '\u25CF',
|
|
168
|
+
autoFocus = true,
|
|
169
|
+
name: inputName,
|
|
170
|
+
placeholder = '',
|
|
171
|
+
selectOnFocus = false,
|
|
172
|
+
blurOnComplete = false,
|
|
173
|
+
} = options
|
|
174
|
+
|
|
175
|
+
// Normalise separatorAfter to an array for consistent rendering
|
|
176
|
+
const separatorAfterPositions: number[] = Array.isArray(rawSepAfter) ? rawSepAfter : [rawSepAfter]
|
|
177
|
+
|
|
178
|
+
const digito = createDigito({ length, type, pattern, pasteTransformer, onInvalidChar, onComplete, onExpire, onResend, haptic, sound })
|
|
179
|
+
|
|
180
|
+
let isDisabled = initialDisabled
|
|
181
|
+
let successState = false
|
|
182
|
+
|
|
183
|
+
// ── Build DOM ─────────────────────────────────────────────────────────────
|
|
184
|
+
while (wrapperEl.firstChild) wrapperEl.removeChild(wrapperEl.firstChild)
|
|
185
|
+
wrapperEl.style.cssText = 'position:relative;display:inline-flex;gap:var(--digito-gap,12px);align-items:center;flex-wrap:wrap'
|
|
186
|
+
|
|
187
|
+
const slotEls: HTMLDivElement[] = []
|
|
188
|
+
const caretEls: HTMLDivElement[] = []
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < length; i++) {
|
|
191
|
+
const slotEl = document.createElement('div')
|
|
192
|
+
slotEl.style.cssText = [
|
|
193
|
+
`width:var(--digito-size,56px)`,
|
|
194
|
+
`height:var(--digito-size,56px)`,
|
|
195
|
+
`border:1px solid var(--digito-border-color,#E5E5E5)`,
|
|
196
|
+
`border-radius:var(--digito-radius,10px)`,
|
|
197
|
+
`font-size:var(--digito-font-size,24px)`,
|
|
198
|
+
`font-weight:600`,
|
|
199
|
+
`display:flex`,
|
|
200
|
+
`align-items:center`,
|
|
201
|
+
`justify-content:center`,
|
|
202
|
+
`background:var(--digito-bg,#FAFAFA)`,
|
|
203
|
+
`color:var(--digito-color,#0A0A0A)`,
|
|
204
|
+
`position:relative`,
|
|
205
|
+
`cursor:text`,
|
|
206
|
+
`transition:border-color 150ms ease,box-shadow 150ms ease`,
|
|
207
|
+
`font-family:inherit`,
|
|
208
|
+
`user-select:none`,
|
|
209
|
+
].join(';')
|
|
210
|
+
slotEl.setAttribute('aria-hidden', 'true')
|
|
211
|
+
|
|
212
|
+
const caretEl = document.createElement('div')
|
|
213
|
+
caretEl.style.cssText = 'position:absolute;width:2px;height:52%;background:var(--digito-caret-color,#3D3D3D);border-radius:1px;animation:digito-alpine-blink 1s step-start infinite;pointer-events:none;display:none'
|
|
214
|
+
slotEl.appendChild(caretEl)
|
|
215
|
+
caretEls.push(caretEl)
|
|
216
|
+
|
|
217
|
+
slotEls.push(slotEl)
|
|
218
|
+
wrapperEl.appendChild(slotEl)
|
|
219
|
+
|
|
220
|
+
// Separator — purely decorative, aria-hidden, no effect on value
|
|
221
|
+
if (separatorAfterPositions.some(pos => pos > 0 && i === pos - 1)) {
|
|
222
|
+
const sepEl = document.createElement('div')
|
|
223
|
+
sepEl.setAttribute('aria-hidden', 'true')
|
|
224
|
+
sepEl.style.cssText = [
|
|
225
|
+
`display:flex`,
|
|
226
|
+
`align-items:center`,
|
|
227
|
+
`justify-content:center`,
|
|
228
|
+
`color:var(--digito-separator-color,#A1A1A1)`,
|
|
229
|
+
`font-size:var(--digito-separator-size,18px)`,
|
|
230
|
+
`font-weight:400`,
|
|
231
|
+
`user-select:none`,
|
|
232
|
+
`flex-shrink:0`,
|
|
233
|
+
].join(';')
|
|
234
|
+
sepEl.textContent = separator
|
|
235
|
+
wrapperEl.appendChild(sepEl)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Inject styles once — caret keyframes + timer/resend classes matching vanilla
|
|
240
|
+
if (!document.getElementById('digito-alpine-styles')) {
|
|
241
|
+
const s = document.createElement('style')
|
|
242
|
+
s.id = 'digito-alpine-styles'
|
|
243
|
+
s.textContent = [
|
|
244
|
+
'@keyframes digito-alpine-blink{0%,100%{opacity:1}50%{opacity:0}}',
|
|
245
|
+
'.digito-timer{display:flex;align-items:center;gap:8px;font-size:14px;padding:20px 0 0}',
|
|
246
|
+
'.digito-timer-label{color:var(--digito-timer-color,#5C5C5C);font-size:14px}',
|
|
247
|
+
'.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}',
|
|
248
|
+
'.digito-resend{display:none;align-items:center;gap:8px;font-size:14px;color:var(--digito-timer-color,#5C5C5C);padding:20px 0 0}',
|
|
249
|
+
'.digito-resend.is-visible{display:flex}',
|
|
250
|
+
'.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}',
|
|
251
|
+
'.digito-resend-btn:hover{background:#E5E5E5}',
|
|
252
|
+
'.digito-resend-btn:disabled{color:#A1A1A1;cursor:not-allowed;background:#F5F5F5}',
|
|
253
|
+
].join('')
|
|
254
|
+
document.head.appendChild(s)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Hidden real input
|
|
258
|
+
const hiddenInputEl = document.createElement('input')
|
|
259
|
+
hiddenInputEl.type = masked ? 'password' : 'text'
|
|
260
|
+
hiddenInputEl.inputMode = type === 'numeric' ? 'numeric' : 'text'
|
|
261
|
+
hiddenInputEl.autocomplete = 'one-time-code'
|
|
262
|
+
hiddenInputEl.maxLength = length
|
|
263
|
+
hiddenInputEl.disabled = isDisabled
|
|
264
|
+
hiddenInputEl.setAttribute('aria-label', `Enter your ${length}-${type === 'numeric' ? 'digit' : 'character'} code`)
|
|
265
|
+
hiddenInputEl.setAttribute('spellcheck', 'false')
|
|
266
|
+
hiddenInputEl.setAttribute('autocorrect', 'off')
|
|
267
|
+
hiddenInputEl.setAttribute('autocapitalize', 'off')
|
|
268
|
+
hiddenInputEl.style.cssText = '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'
|
|
269
|
+
if (inputName) hiddenInputEl.name = inputName
|
|
270
|
+
wrapperEl.appendChild(hiddenInputEl)
|
|
271
|
+
|
|
272
|
+
// ── Built-in timer + resend (mirrors vanilla adapter) ──────────────────────
|
|
273
|
+
let timerBadgeEl: HTMLSpanElement | null = null
|
|
274
|
+
let resendActionBtn: HTMLButtonElement | null = null
|
|
275
|
+
let mainCountdown: ReturnType<typeof createTimer> | null = null
|
|
276
|
+
let resendCountdown: ReturnType<typeof createTimer> | null = null
|
|
277
|
+
let builtInFooterEl: HTMLDivElement | null = null
|
|
278
|
+
let builtInResendRowEl: HTMLDivElement | null = null
|
|
279
|
+
|
|
280
|
+
if (timerSecs > 0) {
|
|
281
|
+
const shouldUseBuiltInFooter = !onTickProp
|
|
282
|
+
|
|
283
|
+
if (shouldUseBuiltInFooter) {
|
|
284
|
+
builtInFooterEl = document.createElement('div')
|
|
285
|
+
builtInFooterEl.className = 'digito-timer'
|
|
286
|
+
|
|
287
|
+
const expiresLabel = document.createElement('span')
|
|
288
|
+
expiresLabel.className = 'digito-timer-label'
|
|
289
|
+
expiresLabel.textContent = 'Code expires in'
|
|
290
|
+
|
|
291
|
+
timerBadgeEl = document.createElement('span')
|
|
292
|
+
timerBadgeEl.className = 'digito-timer-badge'
|
|
293
|
+
timerBadgeEl.textContent = formatCountdown(timerSecs)
|
|
294
|
+
|
|
295
|
+
builtInFooterEl.appendChild(expiresLabel)
|
|
296
|
+
builtInFooterEl.appendChild(timerBadgeEl)
|
|
297
|
+
wrapperEl.insertAdjacentElement('afterend', builtInFooterEl)
|
|
298
|
+
|
|
299
|
+
builtInResendRowEl = document.createElement('div')
|
|
300
|
+
builtInResendRowEl.className = 'digito-resend'
|
|
301
|
+
|
|
302
|
+
const didntReceiveLabel = document.createElement('span')
|
|
303
|
+
didntReceiveLabel.textContent = 'Didn\u2019t receive the code?'
|
|
304
|
+
|
|
305
|
+
resendActionBtn = document.createElement('button')
|
|
306
|
+
resendActionBtn.className = 'digito-resend-btn'
|
|
307
|
+
resendActionBtn.textContent = 'Resend'
|
|
308
|
+
resendActionBtn.type = 'button'
|
|
309
|
+
|
|
310
|
+
builtInResendRowEl.appendChild(didntReceiveLabel)
|
|
311
|
+
builtInResendRowEl.appendChild(resendActionBtn)
|
|
312
|
+
builtInFooterEl.insertAdjacentElement('afterend', builtInResendRowEl)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
mainCountdown = createTimer({
|
|
316
|
+
totalSeconds: timerSecs,
|
|
317
|
+
onTick: (remaining) => {
|
|
318
|
+
if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(remaining)
|
|
319
|
+
onTickProp?.(remaining)
|
|
320
|
+
},
|
|
321
|
+
onExpire: () => {
|
|
322
|
+
if (builtInFooterEl) builtInFooterEl.style.display = 'none'
|
|
323
|
+
if (builtInResendRowEl) builtInResendRowEl.classList.add('is-visible')
|
|
324
|
+
onExpire?.()
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
mainCountdown.start()
|
|
328
|
+
|
|
329
|
+
if (shouldUseBuiltInFooter && resendActionBtn) {
|
|
330
|
+
resendActionBtn.addEventListener('click', () => {
|
|
331
|
+
if (!resendActionBtn || !timerBadgeEl || !builtInFooterEl || !builtInResendRowEl) return
|
|
332
|
+
builtInResendRowEl.classList.remove('is-visible')
|
|
333
|
+
builtInFooterEl.style.display = 'flex'
|
|
334
|
+
timerBadgeEl.textContent = formatCountdown(resendCooldown)
|
|
335
|
+
resendCountdown?.stop()
|
|
336
|
+
resendCountdown = createTimer({
|
|
337
|
+
totalSeconds: resendCooldown,
|
|
338
|
+
onTick: (r) => { if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(r) },
|
|
339
|
+
onExpire: () => {
|
|
340
|
+
if (builtInFooterEl) builtInFooterEl.style.display = 'none'
|
|
341
|
+
if (builtInResendRowEl) builtInResendRowEl.classList.add('is-visible')
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
resendCountdown.start()
|
|
345
|
+
onResend?.()
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── DOM sync ───────────────────────────────────────────────────────────────
|
|
351
|
+
/**
|
|
352
|
+
* Reconcile every visual slot div with the current core state.
|
|
353
|
+
* Called after every user action (input, keydown, paste, focus, click).
|
|
354
|
+
*
|
|
355
|
+
* The Alpine adapter uses inline styles exclusively — no shared stylesheet
|
|
356
|
+
* is injected, so each mounted element is fully self-contained. State
|
|
357
|
+
* priority order for slot appearance: disabled > error > complete > active > default.
|
|
358
|
+
*
|
|
359
|
+
* Font size and color are also set inline here to support placeholder
|
|
360
|
+
* character styling (`--digito-placeholder-size` / `--digito-placeholder-color`),
|
|
361
|
+
* since CSS `:not(.is-filled)` selectors are unavailable without a stylesheet.
|
|
362
|
+
*/
|
|
363
|
+
function syncSlotsToDOM(): void {
|
|
364
|
+
const { slotValues, activeSlot, hasError } = digito.state
|
|
365
|
+
const focused = document.activeElement === hiddenInputEl
|
|
366
|
+
|
|
367
|
+
slotEls.forEach((slotEl, i) => {
|
|
368
|
+
const char = slotValues[i] ?? ''
|
|
369
|
+
const isActive = i === activeSlot && focused
|
|
370
|
+
const isFilled = char.length === 1
|
|
371
|
+
|
|
372
|
+
let textNode = slotEl.childNodes[1] as Text | undefined
|
|
373
|
+
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
|
374
|
+
textNode = document.createTextNode('')
|
|
375
|
+
slotEl.appendChild(textNode)
|
|
376
|
+
}
|
|
377
|
+
textNode.nodeValue = masked && char ? maskChar : char || placeholder
|
|
378
|
+
slotEl.style.fontSize = isFilled
|
|
379
|
+
? (masked ? 'var(--digito-masked-size,16px)' : 'var(--digito-font-size,24px)')
|
|
380
|
+
: 'var(--digito-placeholder-size,16px)'
|
|
381
|
+
slotEl.style.color = isFilled
|
|
382
|
+
? 'var(--digito-color,#0A0A0A)'
|
|
383
|
+
: 'var(--digito-placeholder-color,#D3D3D3)'
|
|
384
|
+
|
|
385
|
+
const activeColor = 'var(--digito-active-color,#3D3D3D)'
|
|
386
|
+
const errorColor = 'var(--digito-error-color,#FB2C36)'
|
|
387
|
+
const successColor = 'var(--digito-success-color,#00C950)'
|
|
388
|
+
|
|
389
|
+
if (isDisabled) {
|
|
390
|
+
slotEl.style.opacity = '0.45'
|
|
391
|
+
slotEl.style.cursor = 'not-allowed'
|
|
392
|
+
slotEl.style.pointerEvents = 'none'
|
|
393
|
+
slotEl.style.borderColor = 'var(--digito-border-color,#E5E5E5)'
|
|
394
|
+
slotEl.style.boxShadow = 'none'
|
|
395
|
+
} else if (hasError) {
|
|
396
|
+
slotEl.style.opacity = ''
|
|
397
|
+
slotEl.style.cursor = 'text'
|
|
398
|
+
slotEl.style.pointerEvents = ''
|
|
399
|
+
slotEl.style.borderColor = errorColor
|
|
400
|
+
slotEl.style.boxShadow = `0 0 0 3px color-mix(in srgb,${errorColor} 12%,transparent)`
|
|
401
|
+
} else if (successState) {
|
|
402
|
+
slotEl.style.opacity = ''
|
|
403
|
+
slotEl.style.cursor = 'text'
|
|
404
|
+
slotEl.style.pointerEvents = ''
|
|
405
|
+
slotEl.style.borderColor = successColor
|
|
406
|
+
slotEl.style.boxShadow = `0 0 0 3px color-mix(in srgb,${successColor} 12%,transparent)`
|
|
407
|
+
} else if (isActive) {
|
|
408
|
+
slotEl.style.opacity = ''
|
|
409
|
+
slotEl.style.cursor = 'text'
|
|
410
|
+
slotEl.style.pointerEvents = ''
|
|
411
|
+
slotEl.style.borderColor = activeColor
|
|
412
|
+
slotEl.style.boxShadow = `0 0 0 3px color-mix(in srgb,${activeColor} 10%,transparent)`
|
|
413
|
+
slotEl.style.background = 'var(--digito-bg-filled,#FFFFFF)'
|
|
414
|
+
} else {
|
|
415
|
+
slotEl.style.opacity = ''
|
|
416
|
+
slotEl.style.cursor = 'text'
|
|
417
|
+
slotEl.style.pointerEvents = ''
|
|
418
|
+
slotEl.style.borderColor = 'var(--digito-border-color,#E5E5E5)'
|
|
419
|
+
slotEl.style.boxShadow = 'none'
|
|
420
|
+
slotEl.style.background = isFilled ? 'var(--digito-bg-filled,#FFFFFF)' : 'var(--digito-bg,#FAFAFA)'
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
caretEls[i].style.display = isActive && !isFilled && !isDisabled ? 'block' : 'none'
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
// Only update value when it actually differs — assigning the same string
|
|
427
|
+
// resets selectionStart/End in some browsers, clobbering the cursor.
|
|
428
|
+
const newValue = slotValues.join('')
|
|
429
|
+
if (hiddenInputEl.value !== newValue) hiddenInputEl.value = newValue
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Event handlers ─────────────────────────────────────────────────────────
|
|
433
|
+
hiddenInputEl.addEventListener('keydown', (e) => {
|
|
434
|
+
if (isDisabled) return
|
|
435
|
+
const pos = hiddenInputEl.selectionStart ?? 0
|
|
436
|
+
if (e.key === 'Backspace') {
|
|
437
|
+
e.preventDefault()
|
|
438
|
+
digito.deleteChar(pos)
|
|
439
|
+
syncSlotsToDOM()
|
|
440
|
+
onChangeProp?.(digito.getCode())
|
|
441
|
+
const next = digito.state.activeSlot
|
|
442
|
+
requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
|
|
443
|
+
} else if (e.key === 'ArrowLeft') {
|
|
444
|
+
e.preventDefault()
|
|
445
|
+
digito.moveFocusLeft(pos)
|
|
446
|
+
syncSlotsToDOM()
|
|
447
|
+
const next = digito.state.activeSlot
|
|
448
|
+
requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
|
|
449
|
+
} else if (e.key === 'ArrowRight') {
|
|
450
|
+
e.preventDefault()
|
|
451
|
+
digito.moveFocusRight(pos)
|
|
452
|
+
syncSlotsToDOM()
|
|
453
|
+
const next = digito.state.activeSlot
|
|
454
|
+
requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
|
|
455
|
+
} else if (e.key === 'Tab') {
|
|
456
|
+
if (e.shiftKey) {
|
|
457
|
+
if (pos === 0) return
|
|
458
|
+
e.preventDefault()
|
|
459
|
+
digito.moveFocusLeft(pos)
|
|
460
|
+
} else {
|
|
461
|
+
if (!digito.state.slotValues[pos]) return
|
|
462
|
+
if (pos >= length - 1) return
|
|
463
|
+
e.preventDefault()
|
|
464
|
+
digito.moveFocusRight(pos)
|
|
465
|
+
}
|
|
466
|
+
syncSlotsToDOM()
|
|
467
|
+
const next = digito.state.activeSlot
|
|
468
|
+
requestAnimationFrame(() => hiddenInputEl.setSelectionRange(next, next))
|
|
469
|
+
}
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
hiddenInputEl.addEventListener('input', () => {
|
|
473
|
+
if (isDisabled) return
|
|
474
|
+
const raw = hiddenInputEl.value
|
|
475
|
+
if (!raw) {
|
|
476
|
+
digito.resetState()
|
|
477
|
+
hiddenInputEl.value = ''
|
|
478
|
+
hiddenInputEl.setSelectionRange(0, 0)
|
|
479
|
+
syncSlotsToDOM()
|
|
480
|
+
onChangeProp?.('')
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
const valid = filterString(raw, type, pattern).slice(0, length)
|
|
484
|
+
digito.resetState()
|
|
485
|
+
for (let i = 0; i < valid.length; i++) digito.inputChar(i, valid[i])
|
|
486
|
+
const next = Math.min(valid.length, length - 1)
|
|
487
|
+
hiddenInputEl.value = valid
|
|
488
|
+
hiddenInputEl.setSelectionRange(next, next)
|
|
489
|
+
digito.moveFocusTo(next)
|
|
490
|
+
syncSlotsToDOM()
|
|
491
|
+
onChangeProp?.(digito.getCode())
|
|
492
|
+
if (blurOnComplete && digito.state.isComplete) {
|
|
493
|
+
requestAnimationFrame(() => hiddenInputEl.blur())
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
hiddenInputEl.addEventListener('paste', (e) => {
|
|
498
|
+
if (isDisabled) return
|
|
499
|
+
e.preventDefault()
|
|
500
|
+
const text = e.clipboardData?.getData('text') ?? ''
|
|
501
|
+
const pos = hiddenInputEl.selectionStart ?? 0
|
|
502
|
+
digito.pasteString(pos, text)
|
|
503
|
+
const { slotValues, activeSlot } = digito.state
|
|
504
|
+
hiddenInputEl.value = slotValues.join('')
|
|
505
|
+
hiddenInputEl.setSelectionRange(activeSlot, activeSlot)
|
|
506
|
+
syncSlotsToDOM()
|
|
507
|
+
onChangeProp?.(digito.getCode())
|
|
508
|
+
if (blurOnComplete && digito.state.isComplete) {
|
|
509
|
+
requestAnimationFrame(() => hiddenInputEl.blur())
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
hiddenInputEl.addEventListener('focus', () => {
|
|
514
|
+
onFocusProp?.()
|
|
515
|
+
requestAnimationFrame(() => {
|
|
516
|
+
const pos = digito.state.activeSlot
|
|
517
|
+
const char = digito.state.slotValues[pos]
|
|
518
|
+
if (selectOnFocus && char) {
|
|
519
|
+
hiddenInputEl.setSelectionRange(pos, pos + 1)
|
|
520
|
+
} else {
|
|
521
|
+
hiddenInputEl.setSelectionRange(pos, pos)
|
|
522
|
+
}
|
|
523
|
+
syncSlotsToDOM()
|
|
524
|
+
})
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
hiddenInputEl.addEventListener('blur', () => {
|
|
528
|
+
onBlurProp?.()
|
|
529
|
+
slotEls.forEach(s => { s.style.borderColor = 'var(--digito-border-color,#E5E5E5)'; s.style.boxShadow = 'none' })
|
|
530
|
+
caretEls.forEach(c => { c.style.display = 'none' })
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
hiddenInputEl.addEventListener('click', (e: MouseEvent) => {
|
|
534
|
+
if (isDisabled) return
|
|
535
|
+
// click fires after the browser places cursor (always 0 due to font-size:1px).
|
|
536
|
+
// Coordinate hit-test determines which slot was visually clicked, then
|
|
537
|
+
// setSelectionRange overrides the browser's placement.
|
|
538
|
+
let rawSlot = slotEls.length - 1
|
|
539
|
+
for (let i = 0; i < slotEls.length; i++) {
|
|
540
|
+
if (e.clientX <= slotEls[i].getBoundingClientRect().right) { rawSlot = i; break }
|
|
541
|
+
}
|
|
542
|
+
// Clamp to filled count so the visual active slot matches the actual cursor position.
|
|
543
|
+
const clickedSlot = Math.min(rawSlot, hiddenInputEl.value.length)
|
|
544
|
+
digito.moveFocusTo(clickedSlot)
|
|
545
|
+
const char = digito.state.slotValues[clickedSlot]
|
|
546
|
+
if (selectOnFocus && char) {
|
|
547
|
+
hiddenInputEl.setSelectionRange(clickedSlot, clickedSlot + 1)
|
|
548
|
+
} else {
|
|
549
|
+
hiddenInputEl.setSelectionRange(clickedSlot, clickedSlot)
|
|
550
|
+
}
|
|
551
|
+
syncSlotsToDOM()
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
requestAnimationFrame(() => {
|
|
555
|
+
if (!isDisabled && autoFocus) hiddenInputEl.focus()
|
|
556
|
+
hiddenInputEl.setSelectionRange(0, 0)
|
|
557
|
+
syncSlotsToDOM()
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// ── Public API on element ──────────────────────────────────────────────────
|
|
561
|
+
// Exposed on `el._digito` for programmatic control from Alpine components or
|
|
562
|
+
// external JavaScript. Mirrors the DigitoInstance interface from the vanilla adapter.
|
|
563
|
+
;(wrapperEl as HTMLElement & { _digito: unknown })._digito = {
|
|
564
|
+
/** Returns the current joined code string (e.g. `"123456"`). */
|
|
565
|
+
getCode: () => digito.getCode(),
|
|
566
|
+
|
|
567
|
+
/** Stop timers and remove built-in footer elements. Call before removing the element. */
|
|
568
|
+
destroy: () => {
|
|
569
|
+
mainCountdown?.stop()
|
|
570
|
+
resendCountdown?.stop()
|
|
571
|
+
builtInFooterEl?.remove()
|
|
572
|
+
builtInResendRowEl?.remove()
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
/** Clear all slots, re-focus, reset to idle state, and restart the built-in timer. */
|
|
576
|
+
reset: () => {
|
|
577
|
+
digito.resetState()
|
|
578
|
+
hiddenInputEl.value = ''
|
|
579
|
+
if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSecs)
|
|
580
|
+
if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
|
|
581
|
+
if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
|
|
582
|
+
resendCountdown?.stop()
|
|
583
|
+
mainCountdown?.restart()
|
|
584
|
+
if (!isDisabled) hiddenInputEl.focus()
|
|
585
|
+
hiddenInputEl.setSelectionRange(0, 0)
|
|
586
|
+
syncSlotsToDOM()
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
/** Reset and fire the `onResend` callback. */
|
|
590
|
+
resend: () => {
|
|
591
|
+
digito.resetState()
|
|
592
|
+
hiddenInputEl.value = ''
|
|
593
|
+
if (timerBadgeEl) timerBadgeEl.textContent = formatCountdown(timerSecs)
|
|
594
|
+
if (builtInFooterEl) builtInFooterEl.style.display = 'flex'
|
|
595
|
+
if (builtInResendRowEl) builtInResendRowEl.classList.remove('is-visible')
|
|
596
|
+
resendCountdown?.stop()
|
|
597
|
+
mainCountdown?.restart()
|
|
598
|
+
if (!isDisabled) hiddenInputEl.focus()
|
|
599
|
+
hiddenInputEl.setSelectionRange(0, 0)
|
|
600
|
+
syncSlotsToDOM()
|
|
601
|
+
onResend?.()
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
/** Apply or clear the error state on all visual slots. */
|
|
605
|
+
setError: (isError: boolean) => {
|
|
606
|
+
if (isError) successState = false
|
|
607
|
+
digito.setError(isError)
|
|
608
|
+
syncSlotsToDOM()
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
/** Apply or clear the success state. On success, stops the timer and hides the footer. */
|
|
612
|
+
setSuccess: (isSuccess: boolean) => {
|
|
613
|
+
successState = isSuccess
|
|
614
|
+
if (isSuccess) {
|
|
615
|
+
digito.setError(false)
|
|
616
|
+
mainCountdown?.stop()
|
|
617
|
+
resendCountdown?.stop()
|
|
618
|
+
if (builtInFooterEl) builtInFooterEl.style.display = 'none'
|
|
619
|
+
if (builtInResendRowEl) builtInResendRowEl.style.display = 'none'
|
|
620
|
+
}
|
|
621
|
+
syncSlotsToDOM()
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Enable or disable the input at runtime.
|
|
626
|
+
* When disabled, all keyboard input, paste events, and click-to-focus
|
|
627
|
+
* are silently ignored. Re-enabling automatically restores focus.
|
|
628
|
+
*/
|
|
629
|
+
setDisabled: (value: boolean) => {
|
|
630
|
+
isDisabled = value
|
|
631
|
+
digito.setDisabled(value)
|
|
632
|
+
hiddenInputEl.disabled = value
|
|
633
|
+
syncSlotsToDOM()
|
|
634
|
+
if (!value) {
|
|
635
|
+
requestAnimationFrame(() => {
|
|
636
|
+
hiddenInputEl.focus()
|
|
637
|
+
hiddenInputEl.setSelectionRange(digito.state.activeSlot, digito.state.activeSlot)
|
|
638
|
+
})
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Programmatically move focus to a specific slot index.
|
|
644
|
+
* Focuses the hidden input and positions the cursor at `slotIndex`.
|
|
645
|
+
*/
|
|
646
|
+
focus: (slotIndex: number) => {
|
|
647
|
+
if (isDisabled) return
|
|
648
|
+
digito.moveFocusTo(slotIndex)
|
|
649
|
+
hiddenInputEl.focus()
|
|
650
|
+
hiddenInputEl.setSelectionRange(slotIndex, slotIndex)
|
|
651
|
+
syncSlotsToDOM()
|
|
652
|
+
},
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
/** Alpine calls this when the component is destroyed. Stops timers and removes footer elements. */
|
|
657
|
+
cleanup() {
|
|
658
|
+
mainCountdown?.stop()
|
|
659
|
+
resendCountdown?.stop()
|
|
660
|
+
builtInFooterEl?.remove()
|
|
661
|
+
builtInResendRowEl?.remove()
|
|
662
|
+
digito.resetState()
|
|
663
|
+
},
|
|
664
|
+
}
|
|
665
|
+
})
|
|
666
|
+
}
|