stimeo-ui 0.5.0 → 0.6.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 +116 -0
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +195 -40
- data/dist/controllers/carousel_controller.js +85 -9
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/color_picker_controller.js +35 -9
- data/dist/controllers/date_range_picker_controller.js +157 -30
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/idle_controller.js +27 -5
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/number_input_controller.js +275 -51
- data/dist/controllers/overflow_menu_controller.js +4 -0
- data/dist/controllers/pagination_controller.js +33 -0
- data/dist/controllers/password_strength_controller.js +20 -2
- data/dist/controllers/persist_controller.js +24 -5
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/rating_controller.js +272 -89
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +154 -22
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/time_picker_controller.js +296 -107
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +5 -3
- data/dist/index.js +2637 -781
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -8,6 +8,90 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
8
8
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/utils/attribute_lease.ts
|
|
12
|
+
var AttributeLease = class {
|
|
13
|
+
#attribute;
|
|
14
|
+
#records = /* @__PURE__ */ new Map();
|
|
15
|
+
/** @param attribute - The attribute whose temporary values this lease owns. */
|
|
16
|
+
constructor(attribute) {
|
|
17
|
+
this.#attribute = attribute;
|
|
18
|
+
}
|
|
19
|
+
/** Writes or removes the leased attribute while preserving its authored value. */
|
|
20
|
+
write(element, value) {
|
|
21
|
+
const existing = this.#records.get(element);
|
|
22
|
+
if (existing) {
|
|
23
|
+
existing.written = value;
|
|
24
|
+
} else {
|
|
25
|
+
this.#records.set(element, {
|
|
26
|
+
original: element.getAttribute(this.#attribute),
|
|
27
|
+
written: value
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
31
|
+
else element.setAttribute(this.#attribute, value);
|
|
32
|
+
}
|
|
33
|
+
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
34
|
+
return(element) {
|
|
35
|
+
const record = this.#records.get(element);
|
|
36
|
+
if (!record) return;
|
|
37
|
+
this.#records.delete(element);
|
|
38
|
+
if (element.getAttribute(this.#attribute) !== record.written) return;
|
|
39
|
+
if (record.original === null) element.removeAttribute(this.#attribute);
|
|
40
|
+
else element.setAttribute(this.#attribute, record.original);
|
|
41
|
+
}
|
|
42
|
+
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
43
|
+
returnAll() {
|
|
44
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/utils/composition_tracker.ts
|
|
49
|
+
var CompositionTracker = class {
|
|
50
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
51
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
52
|
+
#onStart;
|
|
53
|
+
#onEnd;
|
|
54
|
+
constructor(options = {}) {
|
|
55
|
+
this.#onStart = options.onStart;
|
|
56
|
+
this.#onEnd = options.onEnd;
|
|
57
|
+
}
|
|
58
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
59
|
+
observe(target) {
|
|
60
|
+
if (this.#observedTargets.has(target)) return;
|
|
61
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
62
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
63
|
+
this.#observedTargets.add(target);
|
|
64
|
+
}
|
|
65
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
66
|
+
unobserve(target) {
|
|
67
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
68
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
69
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
70
|
+
this.#activeTargets.delete(target);
|
|
71
|
+
}
|
|
72
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
73
|
+
disconnect() {
|
|
74
|
+
for (const target of this.#observedTargets) {
|
|
75
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
76
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
77
|
+
}
|
|
78
|
+
this.#observedTargets.clear();
|
|
79
|
+
this.#activeTargets.clear();
|
|
80
|
+
}
|
|
81
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
82
|
+
isComposing(event) {
|
|
83
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
84
|
+
}
|
|
85
|
+
#handleStart = (event) => {
|
|
86
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
87
|
+
this.#onStart?.(event);
|
|
88
|
+
};
|
|
89
|
+
#handleEnd = (event) => {
|
|
90
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
91
|
+
this.#onEnd?.(event);
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
11
95
|
// src/utils/microtask_coalescer.ts
|
|
12
96
|
var MicrotaskCoalescer = class {
|
|
13
97
|
#run;
|
|
@@ -215,6 +299,7 @@ function finiteFallback(min, max) {
|
|
|
215
299
|
}
|
|
216
300
|
|
|
217
301
|
// src/controllers/number_input_controller.ts
|
|
302
|
+
var OWNED_DISABLED = "data-number-input-disabled";
|
|
218
303
|
var NumberInputController = class _NumberInputController extends Controller {
|
|
219
304
|
static targets = ["input", "increment", "decrement"];
|
|
220
305
|
static values = {
|
|
@@ -224,7 +309,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
224
309
|
pageStep: { type: Number, default: 0 }
|
|
225
310
|
};
|
|
226
311
|
static actions = ["decrement", "increment", "onInput", "onKeydown"];
|
|
227
|
-
static events = ["change"];
|
|
312
|
+
static events = ["change", "reconcile"];
|
|
228
313
|
/** Delay (ms) a button must be held before auto-repeat starts. */
|
|
229
314
|
static #HOLD_DELAY_MS = 400;
|
|
230
315
|
/** Interval (ms) between auto-repeat steps once a hold has started. */
|
|
@@ -236,8 +321,18 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
236
321
|
* of poisoning the next legitimate click.
|
|
237
322
|
*/
|
|
238
323
|
static #SUPPRESS_RESET_MS = 250;
|
|
239
|
-
/** Aborts
|
|
240
|
-
#
|
|
324
|
+
/** Aborts global pointer guards on disconnect so none outlive the element. */
|
|
325
|
+
#globalGuards = null;
|
|
326
|
+
/** Per-target guards allow an old button to be released without touching its replacement. */
|
|
327
|
+
#buttonGuards = /* @__PURE__ */ new Map();
|
|
328
|
+
/** Tracks IME lifecycle on the current input, including confirming keys without a signal. */
|
|
329
|
+
#composition = new CompositionTracker();
|
|
330
|
+
/** Restores authored custom-spinbutton ARIA when a target leaves or the controller stops. */
|
|
331
|
+
#ariaValueNow = new AttributeLease("aria-valuenow");
|
|
332
|
+
#ariaValueMin = new AttributeLease("aria-valuemin");
|
|
333
|
+
#ariaValueMax = new AttributeLease("aria-valuemax");
|
|
334
|
+
/** Last reconciled or user-committed numeric value. */
|
|
335
|
+
#lastValue = null;
|
|
241
336
|
/** Timers for the hold delay and the suppress-reset safety net. */
|
|
242
337
|
#holdTimeouts = new SafeTimeout();
|
|
243
338
|
/** The running auto-repeat interval (one at a time). */
|
|
@@ -248,63 +343,120 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
248
343
|
#repeatedDuringHold = false;
|
|
249
344
|
/** True when the next `click` is the trailing one after a hold and must be ignored. */
|
|
250
345
|
#suppressNextClick = false;
|
|
251
|
-
/**
|
|
346
|
+
/** Button and pointer that own the current hold, so other instances/pointers cannot end it. */
|
|
347
|
+
#holdButton = null;
|
|
348
|
+
#holdPointerId = null;
|
|
349
|
+
/** Collapses runtime range/step changes into one input reconciliation. */
|
|
252
350
|
#repaint = new MicrotaskCoalescer(() => this.#reconcile());
|
|
253
351
|
/** Normalizes any initial value and wires the focus/hold pointer guards. */
|
|
254
352
|
connect() {
|
|
255
353
|
this.#repaint.activate();
|
|
256
|
-
|
|
354
|
+
this.#globalGuards = new AbortController();
|
|
355
|
+
const { signal } = this.#globalGuards;
|
|
356
|
+
for (const button of this.incrementTargets) this.#wireButton(button, 1);
|
|
357
|
+
for (const button of this.decrementTargets) this.#wireButton(button, -1);
|
|
358
|
+
window.addEventListener("pointerup", this.#onPointerEnd, { signal });
|
|
359
|
+
window.addEventListener("pointercancel", this.#onPointerEnd, { signal });
|
|
360
|
+
window.addEventListener("blur", this.#onWindowBlur, { signal });
|
|
257
361
|
this.#reconcile();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, 1, signal);
|
|
261
|
-
if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -1, signal);
|
|
262
|
-
window.addEventListener("pointerup", this.#stopHold, { signal });
|
|
263
|
-
window.addEventListener("pointercancel", this.#stopHold, { signal });
|
|
264
|
-
window.addEventListener("blur", this.#stopHold, { signal });
|
|
265
|
-
}
|
|
266
|
-
/** Releases the pointer guards and tears down every pending timer and hold state. */
|
|
362
|
+
}
|
|
363
|
+
/** Releases listeners, derived attributes, timers, and transient value state. */
|
|
267
364
|
disconnect() {
|
|
268
365
|
this.#repaint.cancel();
|
|
269
|
-
this.#
|
|
270
|
-
this.#
|
|
271
|
-
this.#
|
|
366
|
+
this.#composition.disconnect();
|
|
367
|
+
this.#globalGuards?.abort();
|
|
368
|
+
this.#globalGuards = null;
|
|
369
|
+
const buttons = /* @__PURE__ */ new Set([
|
|
370
|
+
...this.#buttonGuards.keys(),
|
|
371
|
+
...this.incrementTargets,
|
|
372
|
+
...this.decrementTargets
|
|
373
|
+
]);
|
|
374
|
+
for (const guard of this.#buttonGuards.values()) guard.abort();
|
|
375
|
+
this.#buttonGuards.clear();
|
|
376
|
+
for (const button of buttons) this.#releaseButtonState(button);
|
|
377
|
+
this.#stopHold(false);
|
|
272
378
|
this.#repeatedDuringHold = false;
|
|
273
379
|
this.#suppressNextClick = false;
|
|
274
380
|
this.#holdTimeouts.clearAll();
|
|
275
381
|
this.#holdIntervals.clearAll();
|
|
382
|
+
this.#ariaValueNow.returnAll();
|
|
383
|
+
this.#ariaValueMin.returnAll();
|
|
384
|
+
this.#ariaValueMax.returnAll();
|
|
385
|
+
this.#lastValue = null;
|
|
276
386
|
}
|
|
277
|
-
/**
|
|
387
|
+
/** Reconciles the value against a minimum changed by application code or a Turbo morph. */
|
|
278
388
|
minValueChanged() {
|
|
279
389
|
this.#repaint.schedule();
|
|
280
390
|
}
|
|
281
|
-
/**
|
|
391
|
+
/** Reconciles the value against a maximum changed by application code or a Turbo morph. */
|
|
282
392
|
maxValueChanged() {
|
|
283
393
|
this.#repaint.schedule();
|
|
284
394
|
}
|
|
285
|
-
/**
|
|
395
|
+
/** Reconciles the value against a step changed by application code or a Turbo morph. */
|
|
286
396
|
stepValueChanged() {
|
|
287
397
|
this.#repaint.schedule();
|
|
288
398
|
}
|
|
399
|
+
/** Tracks and reconciles an input inserted or replaced at runtime. */
|
|
400
|
+
inputTargetConnected(input) {
|
|
401
|
+
this.#composition.observe(input);
|
|
402
|
+
this.#repaint.schedule();
|
|
403
|
+
}
|
|
404
|
+
/** Releases ARIA and composition state owned by an input that left the controller. */
|
|
405
|
+
inputTargetDisconnected(input) {
|
|
406
|
+
this.#composition.unobserve(input);
|
|
407
|
+
this.#releaseInputAria(input);
|
|
408
|
+
this.#stopHold(false);
|
|
409
|
+
this.#lastValue = null;
|
|
410
|
+
this.#repaint.schedule();
|
|
411
|
+
}
|
|
412
|
+
/** Wires focus preservation and hold behavior on a runtime increment target. */
|
|
413
|
+
incrementTargetConnected(button) {
|
|
414
|
+
this.#wireButton(button, 1);
|
|
415
|
+
this.#repaint.schedule();
|
|
416
|
+
}
|
|
417
|
+
/** Releases only the increment target that actually disconnected. */
|
|
418
|
+
incrementTargetDisconnected(button) {
|
|
419
|
+
this.#unwireButton(button);
|
|
420
|
+
this.#repaint.schedule();
|
|
421
|
+
}
|
|
422
|
+
/** Wires focus preservation and hold behavior on a runtime decrement target. */
|
|
423
|
+
decrementTargetConnected(button) {
|
|
424
|
+
this.#wireButton(button, -1);
|
|
425
|
+
this.#repaint.schedule();
|
|
426
|
+
}
|
|
427
|
+
/** Releases only the decrement target that actually disconnected. */
|
|
428
|
+
decrementTargetDisconnected(button) {
|
|
429
|
+
this.#unwireButton(button);
|
|
430
|
+
this.#repaint.schedule();
|
|
431
|
+
}
|
|
289
432
|
/** Increases by one step. Bound via `data-action` (click). */
|
|
290
433
|
increment() {
|
|
291
434
|
if (this.#consumeSuppressedClick()) return;
|
|
435
|
+
if (!this.hasInputTarget) return;
|
|
292
436
|
this.#commitStep(1);
|
|
293
437
|
this.inputTarget.focus();
|
|
294
438
|
}
|
|
295
439
|
/** Decreases by one step. Bound via `data-action` (click). */
|
|
296
440
|
decrement() {
|
|
297
441
|
if (this.#consumeSuppressedClick()) return;
|
|
442
|
+
if (!this.hasInputTarget) return;
|
|
298
443
|
this.#commitStep(-1);
|
|
299
444
|
this.inputTarget.focus();
|
|
300
445
|
}
|
|
301
446
|
/** Clamps and snaps a typed value. Bound via `data-action` (change). */
|
|
302
447
|
onInput() {
|
|
303
|
-
if (this.
|
|
448
|
+
if (!this.hasInputTarget) return;
|
|
449
|
+
if (this.inputTarget.value.trim() === "") {
|
|
450
|
+
this.#lastValue = null;
|
|
451
|
+
this.#reflectEmpty();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
304
454
|
this.#commit(this.#currentValue());
|
|
305
455
|
}
|
|
306
456
|
/** Keyboard stepping per the APG spinbutton model. */
|
|
307
457
|
onKeydown(event) {
|
|
458
|
+
if (!this.hasInputTarget) return;
|
|
459
|
+
if (this.#composition.isComposing(event)) return;
|
|
308
460
|
if (isReservedArrowChord(event)) return;
|
|
309
461
|
let next = null;
|
|
310
462
|
switch (event.key) {
|
|
@@ -339,21 +491,39 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
339
491
|
* hold; leaving the button while held stops it (the global listeners cover
|
|
340
492
|
* release/cancel/blur).
|
|
341
493
|
*/
|
|
342
|
-
#wireButton(button, direction
|
|
494
|
+
#wireButton(button, direction) {
|
|
495
|
+
if (this.#buttonGuards.has(button)) return;
|
|
496
|
+
const guard = new AbortController();
|
|
497
|
+
this.#buttonGuards.set(button, guard);
|
|
343
498
|
button.addEventListener("pointerdown", (event) => this.#armHold(event, button, direction), {
|
|
344
|
-
signal
|
|
499
|
+
signal: guard.signal
|
|
345
500
|
});
|
|
346
|
-
button.addEventListener(
|
|
501
|
+
button.addEventListener(
|
|
502
|
+
"pointerleave",
|
|
503
|
+
(event) => {
|
|
504
|
+
if (this.#holdButton === button && this.#ownsPointer(event)) this.#stopHold();
|
|
505
|
+
},
|
|
506
|
+
{ signal: guard.signal }
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
/** Aborts one target's listeners and returns any state derived onto that button. */
|
|
510
|
+
#unwireButton(button) {
|
|
511
|
+
this.#buttonGuards.get(button)?.abort();
|
|
512
|
+
this.#buttonGuards.delete(button);
|
|
513
|
+
if (this.#holdButton === button) this.#stopHold(false);
|
|
514
|
+
this.#releaseButtonState(button);
|
|
347
515
|
}
|
|
348
516
|
/** Starts a hold: focus the input, then schedule the first repeat after a delay. */
|
|
349
517
|
#armHold(event, button, direction) {
|
|
350
518
|
const pointerButton = event.button;
|
|
351
519
|
if (typeof pointerButton === "number" && pointerButton !== 0) return;
|
|
352
|
-
if (button.disabled) return;
|
|
520
|
+
if (button.disabled || !this.hasInputTarget) return;
|
|
353
521
|
event.preventDefault();
|
|
354
522
|
this.inputTarget.focus();
|
|
355
|
-
this.#stopHold();
|
|
523
|
+
this.#stopHold(false);
|
|
356
524
|
this.#holdActive = true;
|
|
525
|
+
this.#holdButton = button;
|
|
526
|
+
this.#holdPointerId = this.#pointerId(event);
|
|
357
527
|
this.#repeatedDuringHold = false;
|
|
358
528
|
this.#suppressNextClick = false;
|
|
359
529
|
this.#holdTimeouts.set(() => {
|
|
@@ -372,18 +542,39 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
372
542
|
* trailing synthetic `click` must be ignored, so it is suppressed until the
|
|
373
543
|
* click consumes it or a short safety-net timeout clears it.
|
|
374
544
|
*/
|
|
375
|
-
#stopHold =
|
|
545
|
+
#stopHold(suppressTrailingClick = true) {
|
|
376
546
|
if (!this.#holdActive) return;
|
|
377
547
|
this.#holdActive = false;
|
|
548
|
+
this.#holdButton = null;
|
|
549
|
+
this.#holdPointerId = null;
|
|
378
550
|
this.#holdTimeouts.clearAll();
|
|
379
551
|
this.#holdIntervals.clearAll();
|
|
380
|
-
if (this.#repeatedDuringHold) {
|
|
552
|
+
if (suppressTrailingClick && this.#repeatedDuringHold) {
|
|
381
553
|
this.#suppressNextClick = true;
|
|
382
554
|
this.#holdTimeouts.set(() => {
|
|
383
555
|
this.#suppressNextClick = false;
|
|
384
556
|
}, _NumberInputController.#SUPPRESS_RESET_MS);
|
|
557
|
+
} else if (!suppressTrailingClick) {
|
|
558
|
+
this.#suppressNextClick = false;
|
|
385
559
|
}
|
|
560
|
+
}
|
|
561
|
+
/** Ends a hold only for its initiating pointer (legacy/synthetic events remain accepted). */
|
|
562
|
+
#onPointerEnd = (event) => {
|
|
563
|
+
if (this.#ownsPointer(event)) this.#stopHold();
|
|
386
564
|
};
|
|
565
|
+
/** Window deactivation is global and always ends the active hold. */
|
|
566
|
+
#onWindowBlur = () => {
|
|
567
|
+
this.#stopHold();
|
|
568
|
+
};
|
|
569
|
+
/** Extracts pointer identity when the runtime event exposes it. */
|
|
570
|
+
#pointerId(event) {
|
|
571
|
+
return event.pointerId ?? null;
|
|
572
|
+
}
|
|
573
|
+
/** Whether `event` belongs to the active pointer, with a synthetic-event fallback. */
|
|
574
|
+
#ownsPointer(event) {
|
|
575
|
+
const pointerId = this.#pointerId(event);
|
|
576
|
+
return this.#holdActive && (this.#holdPointerId === null || pointerId === null || pointerId === this.#holdPointerId);
|
|
577
|
+
}
|
|
387
578
|
/** Consumes a pending trailing-click suppression; returns true if the click was swallowed. */
|
|
388
579
|
#consumeSuppressedClick() {
|
|
389
580
|
if (!this.#suppressNextClick) return false;
|
|
@@ -392,53 +583,80 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
392
583
|
return true;
|
|
393
584
|
}
|
|
394
585
|
/**
|
|
395
|
-
* Normalizes `raw`, reflects it, and dispatches `
|
|
396
|
-
*
|
|
586
|
+
* Normalizes `raw`, reflects it, and dispatches `{ value: number }` only when
|
|
587
|
+
* the committed numeric value differs from the last reconciled value.
|
|
397
588
|
*
|
|
398
589
|
* @returns Whether the value changed (drives the auto-repeat's bound stop).
|
|
399
590
|
*/
|
|
400
591
|
#commit(raw) {
|
|
401
592
|
const value = this.#normalize(raw);
|
|
402
|
-
const changed = this
|
|
593
|
+
const changed = this.#lastValue === null || value !== this.#lastValue;
|
|
403
594
|
this.#write(value);
|
|
595
|
+
this.#lastValue = value;
|
|
404
596
|
if (changed) this.dispatch("change", { detail: { value } });
|
|
405
597
|
return changed;
|
|
406
598
|
}
|
|
407
599
|
/** Commits an adjacent endpoint/grid value and reports whether it moved. */
|
|
408
600
|
#commitStep(count) {
|
|
601
|
+
if (!this.hasInputTarget) return false;
|
|
409
602
|
return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
|
|
410
603
|
}
|
|
411
604
|
/**
|
|
412
|
-
*
|
|
605
|
+
* Reflects the current value after a range or step morph, reporting a moved
|
|
606
|
+
* value as `reconcile`.
|
|
413
607
|
*
|
|
414
608
|
* @stimeoRenderRoot
|
|
415
609
|
*/
|
|
416
610
|
#reconcile() {
|
|
417
|
-
if (!this.hasInputTarget)
|
|
611
|
+
if (!this.hasInputTarget) {
|
|
612
|
+
this.#lastValue = null;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
418
615
|
if (this.inputTarget.value.trim() !== "") {
|
|
419
|
-
this.#
|
|
616
|
+
const previous = this.#lastValue;
|
|
617
|
+
const value = this.#normalize(this.#currentValue());
|
|
618
|
+
this.#write(value);
|
|
619
|
+
this.#lastValue = value;
|
|
620
|
+
if (previous !== null && value !== previous) {
|
|
621
|
+
this.dispatch("reconcile", { detail: { value } });
|
|
622
|
+
}
|
|
420
623
|
} else {
|
|
421
|
-
this.#
|
|
624
|
+
this.#lastValue = null;
|
|
625
|
+
this.#reflectEmpty();
|
|
422
626
|
}
|
|
423
627
|
}
|
|
424
628
|
/** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
|
|
425
629
|
#write(value) {
|
|
426
|
-
this.inputTarget
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (Number.isFinite(this.minValue)) {
|
|
430
|
-
this.inputTarget.setAttribute("aria-valuemin", String(this.minValue));
|
|
431
|
-
}
|
|
432
|
-
if (Number.isFinite(this.maxValue)) {
|
|
433
|
-
this.inputTarget.setAttribute("aria-valuemax", String(this.maxValue));
|
|
434
|
-
}
|
|
435
|
-
}
|
|
630
|
+
const input = this.inputTarget;
|
|
631
|
+
input.value = String(value);
|
|
632
|
+
this.#syncInputAria(input, value);
|
|
436
633
|
this.#updateButtons(value);
|
|
437
634
|
}
|
|
635
|
+
/** Reflects a blank value without inventing a numeric `aria-valuenow`. */
|
|
636
|
+
#reflectEmpty() {
|
|
637
|
+
this.#syncInputAria(this.inputTarget, null);
|
|
638
|
+
this.#updateButtons(this.#currentValue());
|
|
639
|
+
}
|
|
640
|
+
/** Synchronizes controller-derived ARIA while preserving authored teardown values. */
|
|
641
|
+
#syncInputAria(input, value) {
|
|
642
|
+
if (input.getAttribute("role") !== "spinbutton") {
|
|
643
|
+
this.#releaseInputAria(input);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this.#ariaValueNow.write(input, value === null ? null : String(value));
|
|
647
|
+
this.#ariaValueMin.write(input, Number.isFinite(this.minValue) ? String(this.minValue) : null);
|
|
648
|
+
this.#ariaValueMax.write(input, Number.isFinite(this.maxValue) ? String(this.maxValue) : null);
|
|
649
|
+
}
|
|
650
|
+
/** Returns all custom-spinbutton ARIA leased on `input`. */
|
|
651
|
+
#releaseInputAria(input) {
|
|
652
|
+
this.#ariaValueNow.return(input);
|
|
653
|
+
this.#ariaValueMin.return(input);
|
|
654
|
+
this.#ariaValueMax.return(input);
|
|
655
|
+
}
|
|
438
656
|
/** Disables a step button at its bound, returning focus to the input first. */
|
|
439
657
|
#updateButtons(value) {
|
|
440
|
-
|
|
441
|
-
|
|
658
|
+
for (const button of this.incrementTargets) this.#toggleButton(button, value < this.maxValue);
|
|
659
|
+
for (const button of this.decrementTargets) this.#toggleButton(button, value > this.minValue);
|
|
442
660
|
}
|
|
443
661
|
/**
|
|
444
662
|
* Enables or disables a step button at its bound, never disabling one while it
|
|
@@ -448,16 +666,22 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
448
666
|
*/
|
|
449
667
|
#toggleButton(button, enabled) {
|
|
450
668
|
if (enabled) {
|
|
451
|
-
if (button.hasAttribute(
|
|
669
|
+
if (button.hasAttribute(OWNED_DISABLED)) {
|
|
452
670
|
button.disabled = false;
|
|
453
|
-
button.removeAttribute(
|
|
671
|
+
button.removeAttribute(OWNED_DISABLED);
|
|
454
672
|
}
|
|
455
673
|
return;
|
|
456
674
|
}
|
|
457
675
|
if (button.disabled) return;
|
|
458
|
-
if (document.activeElement === button) this.inputTarget.focus();
|
|
676
|
+
if (document.activeElement === button && this.hasInputTarget) this.inputTarget.focus();
|
|
459
677
|
button.disabled = true;
|
|
460
|
-
button.setAttribute(
|
|
678
|
+
button.setAttribute(OWNED_DISABLED, "");
|
|
679
|
+
}
|
|
680
|
+
/** Returns `disabled` only when its explicit marker still identifies controller ownership. */
|
|
681
|
+
#releaseButtonState(button) {
|
|
682
|
+
if (!button.hasAttribute(OWNED_DISABLED)) return;
|
|
683
|
+
button.disabled = false;
|
|
684
|
+
button.removeAttribute(OWNED_DISABLED);
|
|
461
685
|
}
|
|
462
686
|
/** The current numeric value, falling back to a finite min (else 0) when blank. */
|
|
463
687
|
#currentValue() {
|
|
@@ -161,6 +161,8 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
161
161
|
var TabindexLoan = class {
|
|
162
162
|
#value;
|
|
163
163
|
#lent = /* @__PURE__ */ new Set();
|
|
164
|
+
/** Returns live loans before Turbo can copy them into its page snapshot. */
|
|
165
|
+
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
164
166
|
/**
|
|
165
167
|
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
166
168
|
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
@@ -174,6 +176,7 @@ var TabindexLoan = class {
|
|
|
174
176
|
if (element.hasAttribute("tabindex")) return;
|
|
175
177
|
element.setAttribute("tabindex", this.#value);
|
|
176
178
|
this.#lent.add(element);
|
|
179
|
+
this.#beforeCache.activate();
|
|
177
180
|
}
|
|
178
181
|
/** Takes back every loan whose value is still the one that was lent. */
|
|
179
182
|
returnAll() {
|
|
@@ -181,6 +184,7 @@ var TabindexLoan = class {
|
|
|
181
184
|
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
182
185
|
}
|
|
183
186
|
this.#lent.clear();
|
|
187
|
+
this.#beforeCache.deactivate();
|
|
184
188
|
}
|
|
185
189
|
};
|
|
186
190
|
|
|
@@ -19,10 +19,41 @@ function canTakeFocus(element) {
|
|
|
19
19
|
return !inheritsFieldsetDisabled(element);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// src/utils/before_cache_reset.ts
|
|
23
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
24
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
25
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
26
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
27
|
+
static #onBeforeCache = () => {
|
|
28
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
29
|
+
};
|
|
30
|
+
#rewind;
|
|
31
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
32
|
+
constructor(rewind) {
|
|
33
|
+
this.#rewind = rewind;
|
|
34
|
+
}
|
|
35
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
36
|
+
activate() {
|
|
37
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
38
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
39
|
+
if (first) {
|
|
40
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
44
|
+
deactivate() {
|
|
45
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
46
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
47
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
22
51
|
// src/utils/tabindex_loan.ts
|
|
23
52
|
var TabindexLoan = class {
|
|
24
53
|
#value;
|
|
25
54
|
#lent = /* @__PURE__ */ new Set();
|
|
55
|
+
/** Returns live loans before Turbo can copy them into its page snapshot. */
|
|
56
|
+
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
26
57
|
/**
|
|
27
58
|
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
28
59
|
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
@@ -36,6 +67,7 @@ var TabindexLoan = class {
|
|
|
36
67
|
if (element.hasAttribute("tabindex")) return;
|
|
37
68
|
element.setAttribute("tabindex", this.#value);
|
|
38
69
|
this.#lent.add(element);
|
|
70
|
+
this.#beforeCache.activate();
|
|
39
71
|
}
|
|
40
72
|
/** Takes back every loan whose value is still the one that was lent. */
|
|
41
73
|
returnAll() {
|
|
@@ -43,6 +75,7 @@ var TabindexLoan = class {
|
|
|
43
75
|
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
44
76
|
}
|
|
45
77
|
this.#lent.clear();
|
|
78
|
+
this.#beforeCache.deactivate();
|
|
46
79
|
}
|
|
47
80
|
};
|
|
48
81
|
|
|
@@ -55,8 +55,23 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// src/utils/string_list.ts
|
|
59
|
+
function parseStringList(raw, fallback = []) {
|
|
60
|
+
const text = raw.trim();
|
|
61
|
+
if (text.length === 0) return [...fallback];
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(text);
|
|
65
|
+
} catch {
|
|
66
|
+
return [...fallback];
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(parsed)) return [...fallback];
|
|
69
|
+
return parsed.filter((entry) => typeof entry === "string");
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
// src/controllers/password_strength_controller.ts
|
|
59
73
|
var CLASS_PATTERNS = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/];
|
|
74
|
+
var DEFAULT_LEVELS = ["weak", "fair", "good", "strong"];
|
|
60
75
|
var LENGTH_MILESTONES = [8, 12, 16];
|
|
61
76
|
var MAX_POINTS = LENGTH_MILESTONES.length + (CLASS_PATTERNS.length - 1);
|
|
62
77
|
var STRENGTH_BANDS = ["weak", "fair", "good", "strong"];
|
|
@@ -64,7 +79,10 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
64
79
|
static targets = ["input", "meter", "label"];
|
|
65
80
|
static values = {
|
|
66
81
|
minScore: { type: Number, default: 0 },
|
|
67
|
-
|
|
82
|
+
// A JSON list read through `parseStringList` rather than Stimulus's `Array`
|
|
83
|
+
// type: that reader throws out of the value observer before any callback
|
|
84
|
+
// runs, so one malformed attribute would stop the meter connecting.
|
|
85
|
+
levels: { type: String, default: "" }
|
|
68
86
|
};
|
|
69
87
|
static actions = ["evaluate"];
|
|
70
88
|
static events = ["change"];
|
|
@@ -90,7 +108,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
90
108
|
*/
|
|
91
109
|
#update(options = {}) {
|
|
92
110
|
const password = this.hasInputTarget ? this.inputTarget.value : "";
|
|
93
|
-
const labels = this.levelsValue;
|
|
111
|
+
const labels = parseStringList(this.levelsValue, DEFAULT_LEVELS);
|
|
94
112
|
const max = labels.length;
|
|
95
113
|
const score = this.#score(password, max);
|
|
96
114
|
const label = score > 0 ? labels[score - 1] ?? "" : "";
|
|
@@ -55,8 +55,23 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// src/utils/string_list.ts
|
|
59
|
+
function parseStringList(raw, fallback = []) {
|
|
60
|
+
const text = raw.trim();
|
|
61
|
+
if (text.length === 0) return [...fallback];
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(text);
|
|
65
|
+
} catch {
|
|
66
|
+
return [...fallback];
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(parsed)) return [...fallback];
|
|
69
|
+
return parsed.filter((entry) => typeof entry === "string");
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
// src/controllers/persist_controller.ts
|
|
59
73
|
var NON_VALUE_TYPES = /* @__PURE__ */ new Set(["file", "submit", "reset", "button", "image"]);
|
|
74
|
+
var DEFAULT_EXCLUDE = ["password"];
|
|
60
75
|
var STORAGE_PREFIX = "stimeo--persist:";
|
|
61
76
|
var OCCURRENCE_SEP = "\0";
|
|
62
77
|
var PersistController = class extends Controller {
|
|
@@ -64,7 +79,10 @@ var PersistController = class extends Controller {
|
|
|
64
79
|
static values = {
|
|
65
80
|
key: { type: String, default: "" },
|
|
66
81
|
debounce: { type: Number, default: 400 },
|
|
67
|
-
|
|
82
|
+
// A JSON list read through `parseStringList` rather than Stimulus's `Array`
|
|
83
|
+
// type: that reader throws out of the value observer before any callback
|
|
84
|
+
// runs, so one malformed attribute would stop the controller connecting.
|
|
85
|
+
exclude: { type: String, default: "" },
|
|
68
86
|
clearOn: { type: String, default: "" }
|
|
69
87
|
};
|
|
70
88
|
static actions = ["clear"];
|
|
@@ -208,15 +226,16 @@ var PersistController = class extends Controller {
|
|
|
208
226
|
/** The fields to persist: `field` targets, or the element's named controls. */
|
|
209
227
|
#fields() {
|
|
210
228
|
const candidates = this.hasFieldTarget ? this.fieldTargets : Array.from(this.element.querySelectorAll("input, textarea, select"));
|
|
211
|
-
|
|
229
|
+
const excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
|
|
230
|
+
return candidates.filter((field) => this.#persistable(field, excluded));
|
|
212
231
|
}
|
|
213
232
|
/** Whether a field carries a restorable, non-excluded value. */
|
|
214
|
-
#persistable(field) {
|
|
233
|
+
#persistable(field, excluded) {
|
|
215
234
|
if (this.#keyOf(field) === null) return false;
|
|
216
235
|
const type = field instanceof HTMLInputElement ? field.type : "";
|
|
217
236
|
if (NON_VALUE_TYPES.has(type)) return false;
|
|
218
|
-
if (
|
|
219
|
-
if (field.name.length > 0 &&
|
|
237
|
+
if (excluded.includes(type)) return false;
|
|
238
|
+
if (field.name.length > 0 && excluded.includes(field.name)) return false;
|
|
220
239
|
return true;
|
|
221
240
|
}
|
|
222
241
|
/** A stable storage sub-key for a field (its name, else id). */
|