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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/carousel_controller.js +85 -9
  6. data/dist/controllers/checkbox_controller.js +136 -25
  7. data/dist/controllers/color_picker_controller.js +35 -9
  8. data/dist/controllers/date_range_picker_controller.js +157 -30
  9. data/dist/controllers/file_dropzone_controller.js +26 -3
  10. data/dist/controllers/idle_controller.js +27 -5
  11. data/dist/controllers/menubar_controller.js +5 -3
  12. data/dist/controllers/multi_select_controller.js +460 -151
  13. data/dist/controllers/number_input_controller.js +275 -51
  14. data/dist/controllers/overflow_menu_controller.js +4 -0
  15. data/dist/controllers/pagination_controller.js +33 -0
  16. data/dist/controllers/password_strength_controller.js +20 -2
  17. data/dist/controllers/persist_controller.js +24 -5
  18. data/dist/controllers/radio_group_controller.js +540 -56
  19. data/dist/controllers/rating_controller.js +272 -89
  20. data/dist/controllers/resizable_controller.js +33 -0
  21. data/dist/controllers/roving_controller.js +60 -5
  22. data/dist/controllers/scroll_area_controller.js +154 -22
  23. data/dist/controllers/scroll_visibility_controller.js +33 -0
  24. data/dist/controllers/tags_input_controller.js +356 -120
  25. data/dist/controllers/time_picker_controller.js +296 -107
  26. data/dist/controllers/toggle_group_controller.js +378 -55
  27. data/dist/controllers/toolbar_controller.js +5 -3
  28. data/dist/controllers/tree_view_controller.js +5 -3
  29. data/dist/index.js +2637 -781
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. 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 the pointer listeners on disconnect so none outlive the element. */
240
- #guards = null;
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
- /** Collapses runtime range/step changes into one silent input reconciliation. */
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
- if (!this.hasInputTarget) return;
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
- this.#guards = new AbortController();
259
- const { signal } = this.#guards;
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.#guards?.abort();
270
- this.#guards = null;
271
- this.#holdActive = false;
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
- /** Silently reconciles a minimum changed by application code or a Turbo morph. */
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
- /** Silently reconciles a maximum changed by application code or a Turbo morph. */
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
- /** Silently reconciles a step changed by application code or a Turbo morph. */
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.inputTarget.value.trim() === "") return;
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, signal) {
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("pointerleave", this.#stopHold, { signal });
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 `change` only when the
396
- * displayed value actually changes.
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.inputTarget.value !== String(value);
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
- * Silently reflects the current value after a range or step morph.
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) return;
611
+ if (!this.hasInputTarget) {
612
+ this.#lastValue = null;
613
+ return;
614
+ }
418
615
  if (this.inputTarget.value.trim() !== "") {
419
- this.#write(this.#normalize(this.#currentValue()));
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.#updateButtons(this.#currentValue());
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.value = String(value);
427
- if (this.inputTarget.getAttribute("role") === "spinbutton") {
428
- this.inputTarget.setAttribute("aria-valuenow", String(value));
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
- if (this.hasIncrementTarget) this.#toggleButton(this.incrementTarget, value < this.maxValue);
441
- if (this.hasDecrementTarget) this.#toggleButton(this.decrementTarget, value > this.minValue);
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("data-number-input-disabled")) {
669
+ if (button.hasAttribute(OWNED_DISABLED)) {
452
670
  button.disabled = false;
453
- button.removeAttribute("data-number-input-disabled");
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("data-number-input-disabled", "");
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
- levels: { type: Array, default: ["weak", "fair", "good", "strong"] }
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
- exclude: { type: Array, default: ["password"] },
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
- return candidates.filter((field) => this.#persistable(field));
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 (this.excludeValue.includes(type)) return false;
219
- if (field.name.length > 0 && this.excludeValue.includes(field.name)) return false;
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). */