stimeo-ui 0.7.0 → 0.9.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/data_grid_controller.js +150 -24
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +485 -114
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +2764 -923
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/otp_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/aria_ids.ts
|
|
6
|
+
var counter = 0;
|
|
7
|
+
function uniqueId(prefix = "stimeo") {
|
|
8
|
+
let candidate;
|
|
9
|
+
do {
|
|
10
|
+
counter += 1;
|
|
11
|
+
candidate = `${prefix}-${counter}`;
|
|
12
|
+
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
function ensureId(element, prefix = "stimeo") {
|
|
16
|
+
if (element.id) return element.id;
|
|
17
|
+
const id = uniqueId(prefix);
|
|
18
|
+
element.id = id;
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
// src/utils/logical_scroll.ts
|
|
6
23
|
function isRtl(element) {
|
|
7
24
|
return window.getComputedStyle(element).direction === "rtl";
|
|
@@ -18,6 +35,76 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
18
35
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
36
|
}
|
|
20
37
|
|
|
38
|
+
// src/utils/attribute_lease.ts
|
|
39
|
+
var AttributeLease = class {
|
|
40
|
+
#attribute;
|
|
41
|
+
#records = /* @__PURE__ */ new Map();
|
|
42
|
+
/** @param attribute - The attribute whose temporary values this lease owns. */
|
|
43
|
+
constructor(attribute) {
|
|
44
|
+
this.#attribute = attribute;
|
|
45
|
+
}
|
|
46
|
+
/** Writes or removes the leased attribute while preserving its authored value. */
|
|
47
|
+
write(element, value) {
|
|
48
|
+
const existing = this.#records.get(element);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.written = value;
|
|
51
|
+
} else {
|
|
52
|
+
this.#records.set(element, {
|
|
53
|
+
original: element.getAttribute(this.#attribute),
|
|
54
|
+
written: value
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
this.#reflect(element, value);
|
|
58
|
+
}
|
|
59
|
+
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
60
|
+
return(element) {
|
|
61
|
+
const record = this.#records.get(element);
|
|
62
|
+
if (!record) return;
|
|
63
|
+
this.#records.delete(element);
|
|
64
|
+
const stillOwned = element.getAttribute(this.#attribute) === record.written;
|
|
65
|
+
if (stillOwned) this.#reflect(element, record.original);
|
|
66
|
+
}
|
|
67
|
+
/** Reflects only a real value transition, avoiding self-triggered mutation work. */
|
|
68
|
+
#reflect(element, value) {
|
|
69
|
+
if (element.getAttribute(this.#attribute) === value) return;
|
|
70
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
71
|
+
else element.setAttribute(this.#attribute, value);
|
|
72
|
+
}
|
|
73
|
+
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
74
|
+
returnAll() {
|
|
75
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/utils/before_cache_reset.ts
|
|
80
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
81
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
82
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
83
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
84
|
+
static #onBeforeCache = () => {
|
|
85
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
86
|
+
};
|
|
87
|
+
#rewind;
|
|
88
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
89
|
+
constructor(rewind) {
|
|
90
|
+
this.#rewind = rewind;
|
|
91
|
+
}
|
|
92
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
93
|
+
activate() {
|
|
94
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
95
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
96
|
+
if (first) {
|
|
97
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
101
|
+
deactivate() {
|
|
102
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
103
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
104
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
21
108
|
// src/utils/composition_tracker.ts
|
|
22
109
|
var CompositionTracker = class {
|
|
23
110
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -65,135 +152,264 @@ var CompositionTracker = class {
|
|
|
65
152
|
};
|
|
66
153
|
};
|
|
67
154
|
|
|
155
|
+
// src/utils/half_width.ts
|
|
156
|
+
var FULL_WIDTH_SHIFT = 65248;
|
|
157
|
+
function halfWidthChar(char) {
|
|
158
|
+
if (char >= "\uFF01" && char <= "\uFF5E") {
|
|
159
|
+
return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);
|
|
160
|
+
}
|
|
161
|
+
return char === "\u3000" ? " " : char;
|
|
162
|
+
}
|
|
163
|
+
function toHalfWidth(text) {
|
|
164
|
+
let out = "";
|
|
165
|
+
for (const char of text) out += halfWidthChar(char);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/utils/microtask_coalescer.ts
|
|
170
|
+
var MicrotaskCoalescer = class {
|
|
171
|
+
#run;
|
|
172
|
+
#queued = false;
|
|
173
|
+
#active = false;
|
|
174
|
+
#generation = 0;
|
|
175
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
176
|
+
constructor(run) {
|
|
177
|
+
this.#run = run;
|
|
178
|
+
}
|
|
179
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
180
|
+
activate() {
|
|
181
|
+
this.#active = true;
|
|
182
|
+
}
|
|
183
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
184
|
+
cancel() {
|
|
185
|
+
this.#active = false;
|
|
186
|
+
this.#queued = false;
|
|
187
|
+
this.#generation += 1;
|
|
188
|
+
}
|
|
189
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
190
|
+
schedule() {
|
|
191
|
+
if (!this.#active || this.#queued) return;
|
|
192
|
+
this.#queued = true;
|
|
193
|
+
const generation = this.#generation;
|
|
194
|
+
queueMicrotask(() => {
|
|
195
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
196
|
+
this.#queued = false;
|
|
197
|
+
this.#run();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
68
202
|
// src/controllers/otp_controller.ts
|
|
203
|
+
var DEFAULT_PATTERN = "[0-9]";
|
|
204
|
+
function compilePattern(source) {
|
|
205
|
+
try {
|
|
206
|
+
return new RegExp(`^${source}$`);
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function hasModifier(event) {
|
|
212
|
+
return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
213
|
+
}
|
|
214
|
+
function statesDiffer(left, right) {
|
|
215
|
+
return left.value !== right.value || left.state !== right.state;
|
|
216
|
+
}
|
|
69
217
|
var OtpController = class extends Controller {
|
|
70
218
|
static targets = ["field", "value", "error"];
|
|
71
219
|
static values = {
|
|
72
|
-
|
|
73
|
-
pattern: { type: String, default: "[0-9]" }
|
|
220
|
+
pattern: { type: String, default: DEFAULT_PATTERN }
|
|
74
221
|
};
|
|
75
|
-
static actions = ["onInput", "onKeydown", "onPaste"];
|
|
76
|
-
static events = ["change", "complete", "invalid"];
|
|
222
|
+
static actions = ["onInput", "onKeydown", "onPaste", "onPointerDown", "clear"];
|
|
223
|
+
static events = ["change", "complete", "invalid", "reconcile"];
|
|
224
|
+
/** Validated matcher; the hot path never compiles a raw declaration. */
|
|
225
|
+
#pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
|
|
226
|
+
/** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
|
|
227
|
+
#patternSource = DEFAULT_PATTERN;
|
|
228
|
+
/** Public state carried by the last dispatch; keeps a no-op sync silent. */
|
|
229
|
+
#published = null;
|
|
230
|
+
/** Field whose confirming `input` after `compositionend` is already handled. */
|
|
231
|
+
#confirmedField = null;
|
|
232
|
+
/** True between connect and disconnect, so pre-connect Value changes stay silent. */
|
|
233
|
+
#connected = false;
|
|
234
|
+
/** Digit each field last committed, restored when rejected input replaced it. */
|
|
235
|
+
#committed = /* @__PURE__ */ new WeakMap();
|
|
236
|
+
/** Collapses one batch of field target callbacks into a single reconciliation. */
|
|
237
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileFields());
|
|
238
|
+
#ariaInvalid = new AttributeLease("aria-invalid");
|
|
239
|
+
#ariaErrorMessage = new AttributeLease("aria-errormessage");
|
|
240
|
+
#ariaDescribedBy = new AttributeLease("aria-describedby");
|
|
241
|
+
#errorHidden = new AttributeLease("hidden");
|
|
242
|
+
#state = new AttributeLease("data-state");
|
|
243
|
+
/** Rewinds the transient error surface before Turbo freezes the page. */
|
|
244
|
+
#beforeCache = new BeforeCacheReset(() => this.#clearError());
|
|
77
245
|
/** Owns IME lifecycle state across every digit field. */
|
|
78
246
|
#composition = new CompositionTracker({
|
|
247
|
+
onStart: () => {
|
|
248
|
+
this.#confirmedField = null;
|
|
249
|
+
},
|
|
79
250
|
onEnd: (event) => {
|
|
80
|
-
const input = event
|
|
81
|
-
if (input)
|
|
251
|
+
const input = this.#fieldFrom(event);
|
|
252
|
+
if (!input) return;
|
|
253
|
+
this.#confirmedField = input;
|
|
254
|
+
const committed = event.data ?? "";
|
|
255
|
+
this.#accept(input, committed.length > input.value.length ? committed : input.value);
|
|
82
256
|
}
|
|
83
257
|
});
|
|
84
258
|
connect() {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
259
|
+
this.#connected = true;
|
|
260
|
+
for (const field of this.fieldTargets) this.#bind(field);
|
|
261
|
+
document.addEventListener("reset", this.#onReset, true);
|
|
262
|
+
this.#beforeCache.activate();
|
|
263
|
+
this.#reconcile.activate();
|
|
264
|
+
this.#adopt();
|
|
265
|
+
this.#sync();
|
|
89
266
|
}
|
|
90
267
|
disconnect() {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
268
|
+
this.#connected = false;
|
|
269
|
+
for (const field of this.fieldTargets) this.#unbind(field);
|
|
94
270
|
this.#composition.disconnect();
|
|
271
|
+
document.removeEventListener("reset", this.#onReset, true);
|
|
272
|
+
this.#beforeCache.deactivate();
|
|
273
|
+
this.#reconcile.cancel();
|
|
274
|
+
this.#clearError();
|
|
275
|
+
this.#state.return(this.element);
|
|
95
276
|
}
|
|
96
277
|
/**
|
|
97
278
|
* Stimulus lifecycle callback when a new field target enters the DOM.
|
|
98
|
-
*
|
|
279
|
+
* Wires the new field and folds the wider digit count into one reconciliation,
|
|
280
|
+
* which stays inert until `connect()` opens the window.
|
|
99
281
|
*/
|
|
100
282
|
fieldTargetConnected(element) {
|
|
101
|
-
|
|
102
|
-
this.#
|
|
283
|
+
this.#bind(element);
|
|
284
|
+
this.#adoptField(element);
|
|
285
|
+
this.#reconcile.schedule();
|
|
103
286
|
}
|
|
104
|
-
/**
|
|
287
|
+
/** Releases a dropped field's listeners and leases, then reconciles the rest. */
|
|
105
288
|
fieldTargetDisconnected(element) {
|
|
106
|
-
|
|
107
|
-
this.#
|
|
289
|
+
this.#unbind(element);
|
|
290
|
+
this.#returnFieldLeases(element);
|
|
291
|
+
this.#reconcile.schedule();
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Re-validates a changed `pattern` declaration once and drops any entered digit
|
|
295
|
+
* the new pattern no longer accepts, so the combined value stays interpretable.
|
|
296
|
+
*/
|
|
297
|
+
patternValueChanged() {
|
|
298
|
+
const compiled = compilePattern(this.patternValue);
|
|
299
|
+
this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
|
|
300
|
+
this.#pattern = compiled ?? new RegExp(`^${DEFAULT_PATTERN}$`);
|
|
301
|
+
if (!this.#connected) return;
|
|
302
|
+
let dropped = false;
|
|
303
|
+
for (const field of this.fieldTargets) {
|
|
304
|
+
if (field.value === "" || !this.#isWritable(field)) continue;
|
|
305
|
+
if (this.#pattern.test(field.value)) continue;
|
|
306
|
+
this.#writeField(field, "");
|
|
307
|
+
dropped = true;
|
|
308
|
+
}
|
|
309
|
+
if (dropped) this.#syncAndDispatch();
|
|
108
310
|
}
|
|
109
|
-
/** Handles keystroke inputs and advances focus
|
|
311
|
+
/** Handles keystroke inputs, distributes autofilled text, and advances focus. */
|
|
110
312
|
onInput(event) {
|
|
111
|
-
const input = event
|
|
313
|
+
const input = this.#fieldFrom(event);
|
|
112
314
|
if (!input) return;
|
|
315
|
+
const confirmed = this.#confirmedField;
|
|
316
|
+
this.#confirmedField = null;
|
|
317
|
+
if (confirmed === input) return;
|
|
113
318
|
if (this.#composition.isComposing(event)) return;
|
|
114
|
-
this.#
|
|
319
|
+
this.#accept(input);
|
|
115
320
|
}
|
|
116
|
-
/** Handles Backspace
|
|
321
|
+
/** Handles Backspace clearing, arrows, and home/end navigation. */
|
|
117
322
|
onKeydown(event) {
|
|
118
323
|
if (isReservedArrowChord(event)) return;
|
|
119
|
-
const input = event
|
|
324
|
+
const input = this.#fieldFrom(event);
|
|
120
325
|
if (!input) return;
|
|
121
|
-
|
|
122
|
-
if (index === -1) return;
|
|
326
|
+
this.#confirmedField = null;
|
|
123
327
|
if (this.#composition.isComposing(event)) return;
|
|
328
|
+
const index = this.fieldTargets.indexOf(input);
|
|
329
|
+
const fields = this.fieldTargets;
|
|
124
330
|
switch (logicalArrowKey(event.key, this.element)) {
|
|
125
331
|
case "Backspace":
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (prevField) {
|
|
131
|
-
prevField.value = "";
|
|
132
|
-
prevField.removeAttribute("data-filled");
|
|
133
|
-
prevField.focus();
|
|
134
|
-
this.#clearError();
|
|
135
|
-
this.#syncAndDispatch();
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
} else {
|
|
139
|
-
input.value = "";
|
|
140
|
-
input.removeAttribute("data-filled");
|
|
332
|
+
if (hasModifier(event)) break;
|
|
333
|
+
if (input.value && this.#isWritable(input)) {
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
this.#writeField(input, "");
|
|
141
336
|
this.#clearError();
|
|
142
337
|
this.#syncAndDispatch();
|
|
338
|
+
} else {
|
|
339
|
+
const previous = this.#writableBefore(index);
|
|
340
|
+
if (previous) {
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
this.#writeField(previous, "");
|
|
343
|
+
previous.focus();
|
|
344
|
+
this.#clearError();
|
|
345
|
+
this.#syncAndDispatch();
|
|
346
|
+
}
|
|
143
347
|
}
|
|
144
348
|
break;
|
|
145
|
-
case "ArrowLeft":
|
|
146
|
-
|
|
349
|
+
case "ArrowLeft": {
|
|
350
|
+
const previous = this.#focusableBefore(index);
|
|
351
|
+
if (previous) {
|
|
147
352
|
event.preventDefault();
|
|
148
|
-
|
|
353
|
+
previous.focus();
|
|
149
354
|
}
|
|
150
355
|
break;
|
|
151
|
-
|
|
152
|
-
|
|
356
|
+
}
|
|
357
|
+
case "ArrowRight": {
|
|
358
|
+
const next = this.#focusableAfter(index);
|
|
359
|
+
if (next) {
|
|
153
360
|
event.preventDefault();
|
|
154
|
-
|
|
361
|
+
next.focus();
|
|
155
362
|
}
|
|
156
363
|
break;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
364
|
+
}
|
|
365
|
+
case "Home": {
|
|
366
|
+
if (hasModifier(event)) break;
|
|
367
|
+
const first = fields.find((field) => this.#isFocusable(field));
|
|
368
|
+
if (first) {
|
|
369
|
+
event.preventDefault();
|
|
370
|
+
first.focus();
|
|
371
|
+
}
|
|
160
372
|
break;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
373
|
+
}
|
|
374
|
+
case "End": {
|
|
375
|
+
if (hasModifier(event)) break;
|
|
376
|
+
const last = fields.slice().reverse().find((field) => this.#isFocusable(field));
|
|
377
|
+
if (last) {
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
last.focus();
|
|
380
|
+
}
|
|
164
381
|
break;
|
|
382
|
+
}
|
|
165
383
|
}
|
|
166
384
|
}
|
|
167
|
-
/** Divides pasted string characters across available input fields. */
|
|
385
|
+
/** Divides pasted string characters across the available input fields. */
|
|
168
386
|
onPaste(event) {
|
|
169
|
-
const input = event
|
|
387
|
+
const input = this.#fieldFrom(event);
|
|
170
388
|
if (!input) return;
|
|
171
|
-
const startIndex = this.fieldTargets.indexOf(input);
|
|
172
|
-
if (startIndex === -1) return;
|
|
173
389
|
event.preventDefault();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
390
|
+
this.#confirmedField = null;
|
|
391
|
+
this.#distribute(input, toHalfWidth(event.clipboardData?.getData("text") ?? ""));
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Redirects a pointer landing on an empty field to the earliest empty one, so
|
|
395
|
+
* a passcode is entered in order. Filled fields stay directly reachable for
|
|
396
|
+
* correction, and keyboard focus is left alone.
|
|
397
|
+
*/
|
|
398
|
+
onPointerDown(event) {
|
|
399
|
+
const input = this.#fieldFrom(event);
|
|
400
|
+
if (input?.value !== "") return;
|
|
401
|
+
const first = this.fieldTargets.find((field) => this.#isWritable(field) && field.value === "");
|
|
402
|
+
if (!first || first === input) return;
|
|
403
|
+
event.preventDefault();
|
|
404
|
+
first.focus();
|
|
405
|
+
}
|
|
406
|
+
/** Empties every writable field and restarts entry at the first of them. */
|
|
407
|
+
clear() {
|
|
408
|
+
for (const field of this.fieldTargets) {
|
|
409
|
+
if (this.#isWritable(field)) this.#writeField(field, "");
|
|
193
410
|
}
|
|
194
411
|
this.#clearError();
|
|
195
|
-
|
|
196
|
-
this.fieldTargets[focusTargetIndex]?.focus();
|
|
412
|
+
this.fieldTargets.find((field) => this.#isWritable(field))?.focus();
|
|
197
413
|
this.#syncAndDispatch();
|
|
198
414
|
}
|
|
199
415
|
#onFieldFocus = (event) => {
|
|
@@ -202,55 +418,210 @@ var OtpController = class extends Controller {
|
|
|
202
418
|
input.select();
|
|
203
419
|
}
|
|
204
420
|
};
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
421
|
+
/** Reconciles derived state after a non-cancelled reset restores the fields. */
|
|
422
|
+
#onReset = (event) => {
|
|
423
|
+
const form = event.target;
|
|
424
|
+
if (!(form instanceof HTMLFormElement) || !this.#ownedBy(form)) return;
|
|
425
|
+
queueMicrotask(() => {
|
|
426
|
+
if (event.defaultPrevented) return;
|
|
427
|
+
this.#adopt();
|
|
428
|
+
this.#syncAndDispatch();
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
/** Whether a form owns at least one field or the hidden combined value. */
|
|
432
|
+
#ownedBy(form) {
|
|
433
|
+
if (this.fieldTargets.some((field) => field.form === form)) return true;
|
|
434
|
+
return this.hasValueTarget && this.valueTarget.form === form;
|
|
435
|
+
}
|
|
436
|
+
#bind(field) {
|
|
437
|
+
field.addEventListener("focus", this.#onFieldFocus);
|
|
438
|
+
this.#composition.observe(field);
|
|
439
|
+
}
|
|
440
|
+
#unbind(field) {
|
|
441
|
+
field.removeEventListener("focus", this.#onFieldFocus);
|
|
442
|
+
this.#composition.unobserve(field);
|
|
443
|
+
if (this.#confirmedField === field) this.#confirmedField = null;
|
|
444
|
+
}
|
|
445
|
+
/** Reads every field back so a restored or reset group starts consistent. */
|
|
446
|
+
#adopt() {
|
|
447
|
+
for (const field of this.fieldTargets) this.#adoptField(field);
|
|
448
|
+
this.#clearError();
|
|
449
|
+
if (this.hasErrorTarget) this.errorTarget.setAttribute("hidden", "");
|
|
450
|
+
}
|
|
451
|
+
/** Takes one field's current value as the truth behind its derived state. */
|
|
452
|
+
#adoptField(field) {
|
|
453
|
+
this.#committed.set(field, field.value);
|
|
454
|
+
this.#markFilled(field, field.value);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Absorbs a batch of field additions or removals as one state transition.
|
|
458
|
+
*
|
|
459
|
+
* The page, not the user, moved the state here, so it is reported as
|
|
460
|
+
* `reconcile`: automation listening for `change` must not read a re-render as
|
|
461
|
+
* an edit, and a passcode that happens to end up full must not fire the
|
|
462
|
+
* `complete` that submits it.
|
|
463
|
+
*
|
|
464
|
+
* Completeness moves on its own when the field count changes: dropping a
|
|
465
|
+
* trailing empty field completes a passcode whose combined value never moved,
|
|
466
|
+
* and adding one un-completes it. Comparing the whole derived state, not the
|
|
467
|
+
* string it contains, is what makes those transitions reportable.
|
|
468
|
+
*/
|
|
469
|
+
#reconcileFields() {
|
|
470
|
+
const previous = this.#published;
|
|
471
|
+
const current = this.#sync();
|
|
472
|
+
if (previous && !statesDiffer(previous, current)) return;
|
|
473
|
+
this.dispatch("reconcile", { detail: { value: current.value } });
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Validates the text an entry point received and distributes what it accepts.
|
|
477
|
+
* `text` defaults to the field's own value; a confirmation passes the string it
|
|
478
|
+
* committed, which `maxlength` would otherwise have truncated.
|
|
479
|
+
*/
|
|
480
|
+
#accept(input, text = input.value) {
|
|
481
|
+
const raw = toHalfWidth(text);
|
|
482
|
+
if (raw === "") {
|
|
483
|
+
this.#writeField(input, "");
|
|
214
484
|
this.#clearError();
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
485
|
+
this.#syncAndDispatch();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
this.#distribute(input, raw);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Fills `text`'s accepted characters into the writable fields at and after the
|
|
492
|
+
* entry point, then leaves focus on the field after the last one filled.
|
|
493
|
+
*/
|
|
494
|
+
#distribute(from, text) {
|
|
495
|
+
const accepted = Array.from(text).filter((char) => this.#pattern.test(char));
|
|
496
|
+
let reached = false;
|
|
497
|
+
const slots = this.fieldTargets.filter((field) => {
|
|
498
|
+
reached ||= field === from;
|
|
499
|
+
return reached && this.#isWritable(field);
|
|
500
|
+
});
|
|
501
|
+
const filled = Math.min(accepted.length, slots.length);
|
|
502
|
+
if (filled === 0) {
|
|
503
|
+
this.#restore(from);
|
|
222
504
|
this.#showError();
|
|
223
|
-
|
|
224
|
-
|
|
505
|
+
this.#sync();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
for (let i = 0; i < filled; i++) {
|
|
509
|
+
const field = slots[i];
|
|
510
|
+
const char = accepted[i];
|
|
511
|
+
if (field && char) this.#writeField(field, char);
|
|
225
512
|
}
|
|
513
|
+
const last = slots[filled - 1];
|
|
514
|
+
if (last) (this.#writableAfter(last) ?? last).focus();
|
|
515
|
+
this.#clearError();
|
|
226
516
|
this.#syncAndDispatch();
|
|
227
517
|
}
|
|
228
|
-
|
|
229
|
-
|
|
518
|
+
/** Restores the digit a field committed before rejected input replaced it. */
|
|
519
|
+
#restore(field) {
|
|
520
|
+
this.#writeField(field, this.#committed.get(field) ?? "");
|
|
230
521
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this
|
|
522
|
+
/** Commits one field's value and the derived hook that reports it as entered. */
|
|
523
|
+
#writeField(field, value) {
|
|
524
|
+
field.value = value;
|
|
525
|
+
this.#committed.set(field, value);
|
|
526
|
+
this.#markFilled(field, value);
|
|
236
527
|
}
|
|
237
|
-
#
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
}
|
|
528
|
+
#markFilled(field, value) {
|
|
529
|
+
if (value) field.setAttribute("data-filled", "true");
|
|
530
|
+
else field.removeAttribute("data-filled");
|
|
241
531
|
}
|
|
242
|
-
#
|
|
532
|
+
#isWritable(field) {
|
|
533
|
+
return !field.disabled && !field.readOnly;
|
|
534
|
+
}
|
|
535
|
+
#isFocusable(field) {
|
|
536
|
+
return !field.disabled;
|
|
537
|
+
}
|
|
538
|
+
#writableAfter(field) {
|
|
243
539
|
const fields = this.fieldTargets;
|
|
244
|
-
|
|
540
|
+
return fields.slice(fields.indexOf(field) + 1).find((next) => this.#isWritable(next)) ?? null;
|
|
541
|
+
}
|
|
542
|
+
#writableBefore(index) {
|
|
543
|
+
return this.#before(index).find((field) => this.#isWritable(field)) ?? null;
|
|
544
|
+
}
|
|
545
|
+
#focusableAfter(index) {
|
|
546
|
+
return this.fieldTargets.slice(index + 1).find((field) => this.#isFocusable(field)) ?? null;
|
|
547
|
+
}
|
|
548
|
+
#focusableBefore(index) {
|
|
549
|
+
return this.#before(index).find((field) => this.#isFocusable(field)) ?? null;
|
|
550
|
+
}
|
|
551
|
+
/** Fields before `index`, nearest first; empty at the first field. */
|
|
552
|
+
#before(index) {
|
|
553
|
+
return this.fieldTargets.slice(0, Math.max(index, 0)).reverse();
|
|
554
|
+
}
|
|
555
|
+
/** The event's field target, or `null` when the wiring points somewhere else. */
|
|
556
|
+
#fieldFrom(event) {
|
|
557
|
+
const input = event.currentTarget;
|
|
558
|
+
return this.fieldTargets.find((field) => field === input) ?? null;
|
|
559
|
+
}
|
|
560
|
+
#combinedValue() {
|
|
561
|
+
return this.fieldTargets.map((field) => field.value).join("");
|
|
562
|
+
}
|
|
563
|
+
/** Every field carries a character, and there is at least one field. */
|
|
564
|
+
#isComplete() {
|
|
565
|
+
const fields = this.fieldTargets;
|
|
566
|
+
return fields.length > 0 && fields.every((field) => field.value.length > 0);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Mirrors the combined value into the form and the root's readable state, and
|
|
570
|
+
* records what was published so the next pass can compare against it.
|
|
571
|
+
*/
|
|
572
|
+
#sync() {
|
|
573
|
+
const combined = this.#combinedValue();
|
|
245
574
|
if (this.hasValueTarget) {
|
|
246
|
-
this.valueTarget.value =
|
|
575
|
+
this.valueTarget.value = combined;
|
|
247
576
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
577
|
+
const state = this.#stateName(combined);
|
|
578
|
+
this.#state.write(this.element, state);
|
|
579
|
+
const published = { value: combined, state };
|
|
580
|
+
this.#published = published;
|
|
581
|
+
return published;
|
|
582
|
+
}
|
|
583
|
+
#stateName(combined) {
|
|
584
|
+
if (combined.length === 0) return "empty";
|
|
585
|
+
return this.#isComplete() ? "complete" : "partial";
|
|
586
|
+
}
|
|
587
|
+
#syncAndDispatch() {
|
|
588
|
+
const previous = this.#published;
|
|
589
|
+
const { value: combined } = this.#sync();
|
|
590
|
+
if (previous?.value === combined) return;
|
|
591
|
+
this.dispatch("change", { detail: { value: combined } });
|
|
592
|
+
if (this.#isComplete()) {
|
|
593
|
+
this.dispatch("complete", { detail: { value: combined } });
|
|
252
594
|
}
|
|
253
595
|
}
|
|
596
|
+
/** Surfaces rejected input on every field and on the optional error target. */
|
|
597
|
+
#showError() {
|
|
598
|
+
const errorId = this.hasErrorTarget ? ensureId(this.errorTarget, "stimeo--otp-error") : null;
|
|
599
|
+
for (const field of this.fieldTargets) {
|
|
600
|
+
this.#ariaInvalid.write(field, "true");
|
|
601
|
+
if (!errorId) continue;
|
|
602
|
+
this.#ariaErrorMessage.write(field, errorId);
|
|
603
|
+
this.#ariaDescribedBy.write(field, this.#describedByWith(field, errorId));
|
|
604
|
+
}
|
|
605
|
+
if (this.hasErrorTarget) this.#errorHidden.write(this.errorTarget, null);
|
|
606
|
+
this.dispatch("invalid", { detail: { pattern: this.#patternSource } });
|
|
607
|
+
}
|
|
608
|
+
/** Returns every error lease, restoring the authored error surface. */
|
|
609
|
+
#clearError() {
|
|
610
|
+
this.#ariaInvalid.returnAll();
|
|
611
|
+
this.#ariaErrorMessage.returnAll();
|
|
612
|
+
this.#ariaDescribedBy.returnAll();
|
|
613
|
+
this.#errorHidden.returnAll();
|
|
614
|
+
}
|
|
615
|
+
#returnFieldLeases(field) {
|
|
616
|
+
this.#ariaInvalid.return(field);
|
|
617
|
+
this.#ariaErrorMessage.return(field);
|
|
618
|
+
this.#ariaDescribedBy.return(field);
|
|
619
|
+
}
|
|
620
|
+
/** The field's own description tokens with the error id appended once. */
|
|
621
|
+
#describedByWith(field, errorId) {
|
|
622
|
+
const tokens = (field.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
|
|
623
|
+
return [...tokens, errorId].join(" ");
|
|
624
|
+
}
|
|
254
625
|
};
|
|
255
626
|
|
|
256
627
|
export { OtpController };
|