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,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* digito/core/feedback
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Optional sensory feedback utilities — exported so consumers can call them
|
|
5
|
+
* in their own event handlers without reimplementing the Web Audio / vibration
|
|
6
|
+
* boilerplate ("bring your own feedback" pattern).
|
|
7
|
+
*
|
|
8
|
+
* Used internally by the core machine when `haptic` / `sound` options are set.
|
|
9
|
+
*
|
|
10
|
+
* @author Olawale Balo — Product Designer + Design Engineer
|
|
11
|
+
* @license MIT
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Trigger a short haptic pulse via `navigator.vibrate`.
|
|
16
|
+
* Silently no-ops in environments that don't support the Vibration API
|
|
17
|
+
* (e.g. desktop browsers, Safari, Node.js).
|
|
18
|
+
*/
|
|
19
|
+
export function triggerHapticFeedback(): void {
|
|
20
|
+
try { navigator?.vibrate?.(10) } catch { /* not supported — fail silently */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Play a brief 880 Hz tone via the Web Audio API.
|
|
25
|
+
* The AudioContext is closed immediately after the tone ends to prevent
|
|
26
|
+
* Chrome's ~6-concurrent-context limit from being reached across calls.
|
|
27
|
+
* Silently no-ops where Web Audio is unavailable.
|
|
28
|
+
*/
|
|
29
|
+
export function triggerSoundFeedback(): void {
|
|
30
|
+
try {
|
|
31
|
+
const audioCtx = new AudioContext()
|
|
32
|
+
const oscillator = audioCtx.createOscillator()
|
|
33
|
+
const gainNode = audioCtx.createGain()
|
|
34
|
+
oscillator.connect(gainNode)
|
|
35
|
+
gainNode.connect(audioCtx.destination)
|
|
36
|
+
oscillator.frequency.value = 880
|
|
37
|
+
gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime)
|
|
38
|
+
gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.08)
|
|
39
|
+
oscillator.start()
|
|
40
|
+
oscillator.stop(audioCtx.currentTime + 0.08)
|
|
41
|
+
// Close the context after the tone ends — prevents AudioContext instance leak
|
|
42
|
+
oscillator.onended = () => { audioCtx.close().catch(() => { /* ignore */ }) }
|
|
43
|
+
} catch { /* Web Audio not available — fail silently */ }
|
|
44
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* digito/core/filter
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Character filtering utilities — exported for use by all adapters.
|
|
5
|
+
*
|
|
6
|
+
* @author Olawale Balo — Product Designer + Design Engineer
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { InputType } from './types.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns `char` if it is valid for `type` (and optional `pattern`), otherwise `''`.
|
|
14
|
+
* Single character input only — multi-char strings always return `''`.
|
|
15
|
+
*
|
|
16
|
+
* When `pattern` is provided it takes precedence over `type` for validation.
|
|
17
|
+
*/
|
|
18
|
+
export function filterChar(char: string, type: InputType, pattern?: RegExp): string {
|
|
19
|
+
if (!char || char.length !== 1) return ''
|
|
20
|
+
if (pattern !== undefined) {
|
|
21
|
+
// A pattern with the /g flag has stateful lastIndex. Reset it before every
|
|
22
|
+
// test so the same pattern can be reused safely across multiple characters
|
|
23
|
+
// without alternating between matches.
|
|
24
|
+
if (pattern.global) pattern.lastIndex = 0
|
|
25
|
+
return pattern.test(char) ? char : ''
|
|
26
|
+
}
|
|
27
|
+
switch (type) {
|
|
28
|
+
case 'numeric': return /^[0-9]$/.test(char) ? char : ''
|
|
29
|
+
case 'alphabet': return /^[a-zA-Z]$/.test(char) ? char : ''
|
|
30
|
+
case 'alphanumeric': return /^[a-zA-Z0-9]$/.test(char) ? char : ''
|
|
31
|
+
case 'any': return char
|
|
32
|
+
default: return ''
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Filters every character in `str` using `filterChar`.
|
|
38
|
+
* Used to sanitize pasted strings and controlled-value inputs before distribution.
|
|
39
|
+
*
|
|
40
|
+
* When `pattern` is provided it takes precedence over `type` for each character.
|
|
41
|
+
*/
|
|
42
|
+
export function filterString(str: string, type: InputType, pattern?: RegExp): string {
|
|
43
|
+
// Array.from iterates over Unicode code points, not UTF-16 code units.
|
|
44
|
+
// str.split('') would split emoji and other supplementary-plane characters
|
|
45
|
+
// into surrogate pairs (two strings of length 1 each), causing filterChar
|
|
46
|
+
// to accept broken half-surrogates into slots for type:'any'.
|
|
47
|
+
return Array.from(str).filter(c => filterChar(c, type, pattern) !== '').join('')
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* digito/core
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Pure OTP state machine — zero DOM, zero framework, zero side effects.
|
|
5
|
+
* All adapters (vanilla, React, Vue, Svelte, Alpine, Web Components) import
|
|
6
|
+
* from here. Nothing else is shared between them.
|
|
7
|
+
*
|
|
8
|
+
* @author Olawale Balo — Product Designer + Design Engineer
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type { InputType, DigitoState, DigitoOptions, TimerOptions, TimerControls, StateListener } from './types.js'
|
|
13
|
+
export { filterChar, filterString } from './filter.js'
|
|
14
|
+
export { createTimer } from './timer.js'
|
|
15
|
+
export { triggerHapticFeedback, triggerSoundFeedback } from './feedback.js'
|
|
16
|
+
export { createDigito } from './machine.js'
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* digito/core/machine
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Pure OTP state machine — zero DOM, zero framework, zero side effects.
|
|
5
|
+
* All adapters import `createDigito` from here (via core/index.ts).
|
|
6
|
+
*
|
|
7
|
+
* Subscription system: pass a listener to `subscribe()` to be notified after
|
|
8
|
+
* every state mutation. Compatible with XState / Zustand-style patterns:
|
|
9
|
+
*
|
|
10
|
+
* const otp = createDigito({ length: 6 })
|
|
11
|
+
* const unsub = otp.subscribe(state => console.log(state))
|
|
12
|
+
* // ... later:
|
|
13
|
+
* unsub()
|
|
14
|
+
*
|
|
15
|
+
* @author Olawale Balo — Product Designer + Design Engineer
|
|
16
|
+
* @license MIT
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DigitoOptions, DigitoState, StateListener, InputType } from './types.js'
|
|
20
|
+
import { filterChar, filterString } from './filter.js'
|
|
21
|
+
import { triggerHapticFeedback, triggerSoundFeedback } from './feedback.js'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// INTERNAL HELPERS
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Returns `true` when every slot contains exactly one character. */
|
|
29
|
+
function allSlotsFilled(slotValues: string[]): boolean {
|
|
30
|
+
return slotValues.every(v => v.length === 1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Clamp `index` to the inclusive range `[min, max]`. */
|
|
34
|
+
function clampIndex(index: number, min: number, max: number): number {
|
|
35
|
+
return Math.max(min, Math.min(max, index))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Join all slot values into a single string (the OTP code). */
|
|
39
|
+
function joinSlots(slotValues: string[]): string {
|
|
40
|
+
return slotValues.join('')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
// FACTORY
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a pure OTP state machine.
|
|
50
|
+
* Returns action functions, a `state` getter, a `subscribe` method, and a
|
|
51
|
+
* `getState` snapshot helper — no DOM, no side effects.
|
|
52
|
+
*/
|
|
53
|
+
export function createDigito(options: DigitoOptions = {}) {
|
|
54
|
+
// Security: guard against invalid length values.
|
|
55
|
+
// Negative numbers would throw a RangeError from Array(); zero would produce
|
|
56
|
+
// a permanently-incomplete input with no slots. Clamp to a safe minimum of 1.
|
|
57
|
+
const rawLength = options.length ?? 6
|
|
58
|
+
// Guard against NaN (e.g. parseInt('', 10) from a missing data-attribute).
|
|
59
|
+
// NaN would propagate through Math.floor and crash Array() with RangeError.
|
|
60
|
+
const length = isNaN(rawLength) ? 6 : Math.max(1, Math.floor(rawLength))
|
|
61
|
+
|
|
62
|
+
const {
|
|
63
|
+
type = 'numeric' as InputType,
|
|
64
|
+
pattern,
|
|
65
|
+
onComplete,
|
|
66
|
+
onInvalidChar,
|
|
67
|
+
haptic = true,
|
|
68
|
+
sound = false,
|
|
69
|
+
pasteTransformer,
|
|
70
|
+
} = options
|
|
71
|
+
|
|
72
|
+
// `disabled` is mutable so setDisabled() can toggle it at runtime without
|
|
73
|
+
// requiring the instance to be recreated. Adapters that pass disabled at
|
|
74
|
+
// construction time still work — the initial value is honoured.
|
|
75
|
+
let disabled = options.disabled ?? false
|
|
76
|
+
|
|
77
|
+
let state: DigitoState = {
|
|
78
|
+
slotValues: Array(length).fill('') as string[],
|
|
79
|
+
activeSlot: 0,
|
|
80
|
+
hasError: false,
|
|
81
|
+
isComplete: false,
|
|
82
|
+
timerSeconds: options.timer ?? 0,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Subscription set ──────────────────────────────────────────────────────
|
|
86
|
+
const listeners = new Set<StateListener>()
|
|
87
|
+
|
|
88
|
+
function applyState(patch: Partial<DigitoState>): DigitoState {
|
|
89
|
+
state = { ...state, ...patch }
|
|
90
|
+
// Notify all subscribers with a deep copy of slotValues so they cannot
|
|
91
|
+
// mutate the live array. A simple { ...state } is a shallow copy — the
|
|
92
|
+
// slotValues array reference would be shared, letting a subscriber silently
|
|
93
|
+
// corrupt internal state.
|
|
94
|
+
if (listeners.size > 0) {
|
|
95
|
+
const snapshot = { ...state, slotValues: [...state.slotValues] }
|
|
96
|
+
listeners.forEach(fn => fn(snapshot))
|
|
97
|
+
}
|
|
98
|
+
return state
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Handle for the pending onComplete timeout.
|
|
103
|
+
* Stored so resetState() can cancel it if the user clears the input
|
|
104
|
+
* within the 10ms defer window (e.g. rapid type-then-delete).
|
|
105
|
+
*/
|
|
106
|
+
let completeTimeoutId: ReturnType<typeof setTimeout> | null = null
|
|
107
|
+
|
|
108
|
+
/** Fire onComplete after a short delay so DOM sync can finish first. */
|
|
109
|
+
function notifyCompleteIfReady(slotValues: string[]): void {
|
|
110
|
+
if (!allSlotsFilled(slotValues) || !onComplete) return
|
|
111
|
+
if (haptic) triggerHapticFeedback()
|
|
112
|
+
if (sound) triggerSoundFeedback()
|
|
113
|
+
const code = joinSlots(slotValues)
|
|
114
|
+
if (completeTimeoutId !== null) clearTimeout(completeTimeoutId)
|
|
115
|
+
completeTimeoutId = setTimeout(() => {
|
|
116
|
+
completeTimeoutId = null
|
|
117
|
+
onComplete(code)
|
|
118
|
+
}, 10)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Cancel any pending onComplete callback without clearing slot state.
|
|
123
|
+
* Use this after a programmatic fill (e.g. controlled-value sync in adapters)
|
|
124
|
+
* to prevent a parent-driven pre-fill from triggering onComplete as if the
|
|
125
|
+
* user had typed the code.
|
|
126
|
+
*/
|
|
127
|
+
function cancelPendingComplete(): void {
|
|
128
|
+
if (completeTimeoutId !== null) {
|
|
129
|
+
clearTimeout(completeTimeoutId)
|
|
130
|
+
completeTimeoutId = null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
// ── Actions ────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Process a single character typed into `slotIndex`.
|
|
139
|
+
* Invalid characters leave the slot unchanged and keep focus in place.
|
|
140
|
+
* Out-of-bounds indices are silently ignored to prevent sparse-array corruption.
|
|
141
|
+
*/
|
|
142
|
+
function inputChar(slotIndex: number, char: string): DigitoState {
|
|
143
|
+
if (disabled) return state
|
|
144
|
+
if (slotIndex < 0 || slotIndex >= length) return state
|
|
145
|
+
const validChar = filterChar(char, type, pattern)
|
|
146
|
+
if (!validChar) {
|
|
147
|
+
// Fire onInvalidChar for single rejected characters (not empty/multi-char)
|
|
148
|
+
if (char.length === 1) onInvalidChar?.(char, slotIndex)
|
|
149
|
+
// Only notify subscribers if focus actually needs to move. Firing applyState
|
|
150
|
+
// when activeSlot is already slotIndex causes a spurious state notification
|
|
151
|
+
// on every invalid keystroke, which can trigger expensive re-renders.
|
|
152
|
+
if (state.activeSlot !== slotIndex) {
|
|
153
|
+
return applyState({ activeSlot: slotIndex })
|
|
154
|
+
}
|
|
155
|
+
return state
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const slotValues = [...state.slotValues]
|
|
159
|
+
slotValues[slotIndex] = validChar
|
|
160
|
+
|
|
161
|
+
const nextSlot = slotIndex < length - 1 ? slotIndex + 1 : length - 1
|
|
162
|
+
|
|
163
|
+
const newState = applyState({
|
|
164
|
+
slotValues,
|
|
165
|
+
activeSlot: nextSlot,
|
|
166
|
+
hasError: false,
|
|
167
|
+
isComplete: allSlotsFilled(slotValues),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
notifyCompleteIfReady(slotValues)
|
|
171
|
+
return newState
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Handle backspace at `slotIndex`.
|
|
176
|
+
* Clears the current slot if filled, otherwise clears the previous slot and moves back.
|
|
177
|
+
*/
|
|
178
|
+
function deleteChar(slotIndex: number): DigitoState {
|
|
179
|
+
if (disabled) return state
|
|
180
|
+
if (slotIndex < 0 || slotIndex >= length) return state
|
|
181
|
+
const slotValues = [...state.slotValues]
|
|
182
|
+
|
|
183
|
+
if (slotValues[slotIndex]) {
|
|
184
|
+
slotValues[slotIndex] = ''
|
|
185
|
+
return applyState({ slotValues, activeSlot: slotIndex, isComplete: false })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const prevSlot = clampIndex(slotIndex - 1, 0, length - 1)
|
|
189
|
+
slotValues[prevSlot] = ''
|
|
190
|
+
return applyState({ slotValues, activeSlot: prevSlot, isComplete: false })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Move focus one slot to the left, clamped to slot 0. */
|
|
194
|
+
function moveFocusLeft(slotIndex: number): DigitoState {
|
|
195
|
+
return applyState({ activeSlot: clampIndex(slotIndex - 1, 0, length - 1) })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Move focus one slot to the right, clamped to the last slot. */
|
|
199
|
+
function moveFocusRight(slotIndex: number): DigitoState {
|
|
200
|
+
return applyState({ activeSlot: clampIndex(slotIndex + 1, 0, length - 1) })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Smart paste — distributes valid characters from `cursorSlot` forward,
|
|
205
|
+
* wrapping around to slot 0 if the string is longer than the remaining slots.
|
|
206
|
+
*
|
|
207
|
+
* Examples (length = 6, type = numeric):
|
|
208
|
+
* paste(0, '123456') → fills all slots
|
|
209
|
+
* paste(5, '847291') → slot5='8', slot0='4', slot1='7', slot2='2', slot3='9', slot4='1'
|
|
210
|
+
* paste(0, '84AB91') → filtered='8491', fills slots 0–3, slots 4–5 unchanged
|
|
211
|
+
*/
|
|
212
|
+
function pasteString(cursorSlot: number, rawText: string): DigitoState {
|
|
213
|
+
if (disabled) return state
|
|
214
|
+
|
|
215
|
+
let transformed: string
|
|
216
|
+
try {
|
|
217
|
+
transformed = pasteTransformer ? pasteTransformer(rawText) : rawText
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.warn('[digito] pasteTransformer threw — using raw paste text.', err)
|
|
220
|
+
transformed = rawText
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Report each rejected character so adapters can provide inline feedback.
|
|
224
|
+
// Tracks the effective write cursor so the reported slot index matches where
|
|
225
|
+
// the character would have landed if it had been valid.
|
|
226
|
+
if (onInvalidChar && transformed) {
|
|
227
|
+
let slotCursor = cursorSlot
|
|
228
|
+
for (const char of Array.from(transformed)) {
|
|
229
|
+
if (filterChar(char, type, pattern)) {
|
|
230
|
+
slotCursor = (slotCursor + 1) % length
|
|
231
|
+
} else {
|
|
232
|
+
onInvalidChar(char, slotCursor)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const validChars = filterString(transformed, type, pattern)
|
|
238
|
+
if (!validChars) return state
|
|
239
|
+
|
|
240
|
+
const slotValues = [...state.slotValues]
|
|
241
|
+
let writeSlot = cursorSlot
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < validChars.length && i < length; i++) {
|
|
244
|
+
slotValues[writeSlot] = validChars[i]
|
|
245
|
+
writeSlot = (writeSlot + 1) % length
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const charsWritten = Math.min(validChars.length, length)
|
|
249
|
+
const nextActiveSlot = charsWritten >= length
|
|
250
|
+
? length - 1
|
|
251
|
+
: (cursorSlot + charsWritten) % length
|
|
252
|
+
|
|
253
|
+
const newState = applyState({
|
|
254
|
+
slotValues,
|
|
255
|
+
activeSlot: nextActiveSlot,
|
|
256
|
+
hasError: false,
|
|
257
|
+
isComplete: allSlotsFilled(slotValues),
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
notifyCompleteIfReady(slotValues)
|
|
261
|
+
return newState
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Set or clear the error state. Triggers haptic feedback when setting. */
|
|
265
|
+
function setError(isError: boolean): DigitoState {
|
|
266
|
+
if (isError && haptic) triggerHapticFeedback()
|
|
267
|
+
return applyState({ hasError: isError })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Clear all slots and reset to initial state. Cancels any pending onComplete callback. */
|
|
271
|
+
function resetState(): DigitoState {
|
|
272
|
+
if (completeTimeoutId !== null) {
|
|
273
|
+
clearTimeout(completeTimeoutId)
|
|
274
|
+
completeTimeoutId = null
|
|
275
|
+
}
|
|
276
|
+
return applyState({
|
|
277
|
+
slotValues: Array(length).fill('') as string[],
|
|
278
|
+
activeSlot: 0,
|
|
279
|
+
hasError: false,
|
|
280
|
+
isComplete: false,
|
|
281
|
+
timerSeconds: options.timer ?? 0,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Move focus to a specific slot index. */
|
|
286
|
+
function moveFocusTo(slotIndex: number): DigitoState {
|
|
287
|
+
return applyState({ activeSlot: clampIndex(slotIndex, 0, length - 1) })
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Reactively enable or disable the input.
|
|
292
|
+
* When `true`, inputChar / deleteChar / pasteString are silently ignored.
|
|
293
|
+
* Navigation (moveFocusLeft/Right/To) is always allowed regardless of state.
|
|
294
|
+
*
|
|
295
|
+
* Prefer this over passing `disabled` at construction time whenever you need
|
|
296
|
+
* to toggle disabled at runtime (e.g. during async verification).
|
|
297
|
+
*/
|
|
298
|
+
function setDisabled(value: boolean): void {
|
|
299
|
+
disabled = value
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Subscribe to state changes. The listener is called after every mutation
|
|
304
|
+
* with a shallow copy of the new state.
|
|
305
|
+
*
|
|
306
|
+
* @returns An unsubscribe function — call it to stop receiving updates.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```ts
|
|
310
|
+
* const otp = createDigito({ length: 6 })
|
|
311
|
+
* const unsub = otp.subscribe(state => console.log(state.slotValues))
|
|
312
|
+
* // Later:
|
|
313
|
+
* unsub()
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
function subscribe(listener: StateListener): () => void {
|
|
317
|
+
listeners.add(listener)
|
|
318
|
+
return () => { listeners.delete(listener) }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
/** Current state snapshot. */
|
|
323
|
+
get state() { return state },
|
|
324
|
+
|
|
325
|
+
// Input actions
|
|
326
|
+
inputChar,
|
|
327
|
+
deleteChar,
|
|
328
|
+
moveFocusLeft,
|
|
329
|
+
moveFocusRight,
|
|
330
|
+
pasteString,
|
|
331
|
+
|
|
332
|
+
// State control
|
|
333
|
+
setError,
|
|
334
|
+
resetState,
|
|
335
|
+
moveFocusTo,
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Cancel any pending onComplete callback without resetting slot state.
|
|
339
|
+
* Intended for adapter-layer controlled-value syncs where a programmatic
|
|
340
|
+
* fill should not be treated as a user completing the code.
|
|
341
|
+
*/
|
|
342
|
+
cancelPendingComplete,
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Toggle the disabled state at runtime.
|
|
346
|
+
* Affects inputChar, deleteChar, and pasteString only.
|
|
347
|
+
* Navigation actions are always allowed.
|
|
348
|
+
*/
|
|
349
|
+
setDisabled,
|
|
350
|
+
|
|
351
|
+
/** Returns the current joined code string. */
|
|
352
|
+
getCode: () => joinSlots(state.slotValues),
|
|
353
|
+
/**
|
|
354
|
+
* Returns a copy of the current state with a cloned slotValues array.
|
|
355
|
+
* Mutations to the returned object (including its slotValues array) will
|
|
356
|
+
* not affect live state.
|
|
357
|
+
*/
|
|
358
|
+
getSnapshot: (): DigitoState => ({ ...state, slotValues: [...state.slotValues] }),
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Subscribe to state changes. Returns an unsubscribe function.
|
|
362
|
+
* Compatible with XState / Zustand-style patterns.
|
|
363
|
+
*/
|
|
364
|
+
subscribe,
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Returns a copy of the current state with a cloned slotValues array.
|
|
368
|
+
* Equivalent to `digito.getSnapshot()` — provided for ergonomic parity
|
|
369
|
+
* with state-management libraries that expose a `getState()` method.
|
|
370
|
+
*/
|
|
371
|
+
getState: (): DigitoState => ({ ...state, slotValues: [...state.slotValues] }),
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* digito/core/timer
|
|
3
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Standalone countdown timer — re-exported from core for use by adapters and
|
|
5
|
+
* developers who want to drive their own timer UI.
|
|
6
|
+
*
|
|
7
|
+
* @author Olawale Balo — Product Designer + Design Engineer
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TimerOptions, TimerControls } from './types.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a 1-second countdown timer.
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle notes:
|
|
17
|
+
* - `start()` is idempotent — it stops any running interval before starting a
|
|
18
|
+
* new one, so calling it twice never produces double-ticking.
|
|
19
|
+
* - If `totalSeconds <= 0`, `onExpire` fires synchronously on `start()` and no
|
|
20
|
+
* interval is created (avoids decrementing to -1 and passing invalid values).
|
|
21
|
+
* - `reset()` stops and restores remaining seconds without restarting.
|
|
22
|
+
* - `restart()` is shorthand for `reset()` followed immediately by `start()`.
|
|
23
|
+
* Used by the vanilla adapter's "Resend" button to reset the countdown.
|
|
24
|
+
*/
|
|
25
|
+
export function createTimer(options: TimerOptions): TimerControls {
|
|
26
|
+
const { totalSeconds, onTick, onExpire } = options
|
|
27
|
+
|
|
28
|
+
let remainingSeconds = totalSeconds
|
|
29
|
+
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
30
|
+
|
|
31
|
+
/** Stop the running interval. No-op if already stopped. */
|
|
32
|
+
function stop(): void {
|
|
33
|
+
if (intervalId !== null) {
|
|
34
|
+
clearInterval(intervalId)
|
|
35
|
+
intervalId = null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Stop the interval and restore `remainingSeconds` to `totalSeconds`. Does not restart. */
|
|
40
|
+
function reset(): void {
|
|
41
|
+
stop()
|
|
42
|
+
remainingSeconds = totalSeconds
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start ticking. Stops any existing interval first to prevent double-ticking.
|
|
47
|
+
* If `totalSeconds <= 0`, fires `onExpire` immediately without creating an interval.
|
|
48
|
+
*/
|
|
49
|
+
function start(): void {
|
|
50
|
+
stop()
|
|
51
|
+
// Guard: if totalSeconds is zero or negative, fire onExpire immediately
|
|
52
|
+
// without starting an interval. Without this, the first tick would decrement
|
|
53
|
+
// to -1 and pass an invalid value to onTick before calling onExpire.
|
|
54
|
+
if (totalSeconds <= 0) {
|
|
55
|
+
onExpire?.()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
intervalId = setInterval(() => {
|
|
59
|
+
remainingSeconds -= 1
|
|
60
|
+
onTick?.(remainingSeconds)
|
|
61
|
+
if (remainingSeconds <= 0) {
|
|
62
|
+
stop()
|
|
63
|
+
onExpire?.()
|
|
64
|
+
}
|
|
65
|
+
}, 1000)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reset to `totalSeconds` and immediately start ticking. */
|
|
69
|
+
function restart(): void {
|
|
70
|
+
reset()
|
|
71
|
+
start()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { start, stop, reset, restart }
|
|
75
|
+
}
|