stimeo-ui 0.15.0 → 0.16.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +54 -10
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +56 -11
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +14 -1
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +305 -46
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -225,15 +225,20 @@ var TabindexLoan = class {
225
225
  };
226
226
 
227
227
  // src/controllers/overflow_menu_controller.ts
228
- var BANKED = "data-stimeo--overflow-menu-banked";
229
- var INDEX = "data-stimeo--overflow-menu-index";
230
- var BOUNDARY = "data-stimeo--overflow-menu-boundary";
231
- var SAVED_ROLE = "data-stimeo--overflow-menu-role";
232
- var SAVED_TABINDEX = "data-stimeo--overflow-menu-tabindex";
233
- var SAVED_MENU_TARGET = "data-stimeo--overflow-menu-saved-menu";
228
+ var BANKED = "banked";
229
+ var INDEX = "index";
230
+ var BOUNDARY = "boundary";
231
+ var SAVED_ROLE = "role";
232
+ var SAVED_TABINDEX = "tabindex";
233
+ var SAVED_MENU_TARGET = "saved-menu";
234
234
  var MENU_TARGET = "data-stimeo--menu-target";
235
235
  var BOOKKEEPING = [BANKED, INDEX, SAVED_ROLE, SAVED_TABINDEX, SAVED_MENU_TARGET];
236
+ var OWNED_LABEL = "owns-label";
236
237
  var OverflowMenuController = class extends Controller {
238
+ /** One bookkeeping attribute, in the namespace this controller is registered under. */
239
+ #attr(name) {
240
+ return `data-${this.identifier}-${name}`;
241
+ }
237
242
  static targets = ["items", "more"];
238
243
  static values = {
239
244
  moreLabel: { type: String, default: "More" },
@@ -252,29 +257,42 @@ var OverflowMenuController = class extends Controller {
252
257
  #lastHidden = null;
253
258
  /** The `tabindex` this instance lends the root for the focus fallback. */
254
259
  #tabindex = new TabindexLoan();
255
- /** Hands Turbo a pristine snapshot of the bar, with every item back in place. */
260
+ /** Hands Turbo a snapshot of the bar with every item back in place. */
256
261
  #beforeCache = new BeforeCacheReset(() => this.#restoreAll());
262
+ /**
263
+ * Whether `connect()` has run for this connection. Stimulus delivers Value
264
+ * callbacks ahead of it, and the first pass writes the label anyway.
265
+ */
266
+ #connected = false;
257
267
  connect() {
268
+ this.#connected = true;
258
269
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
259
- const trigger = this.#trigger();
260
- if (trigger !== null && this.#isBareTrigger(trigger)) {
261
- trigger.textContent = this.moreLabelValue;
262
- }
263
270
  this.#beforeCache.activate();
264
271
  this.#layout.observe(this.element);
265
272
  this.#layout.observeViewport();
266
273
  this.update();
267
274
  }
268
275
  disconnect() {
276
+ this.#connected = false;
269
277
  this.#layout.disconnect();
270
278
  this.#timers.clearAll();
271
279
  this.#beforeCache.deactivate();
272
280
  this.#restoreAll();
273
281
  this.#lastHidden = null;
274
282
  }
283
+ /**
284
+ * Follows a More label swapped in place by a morph, on a trigger that still holds
285
+ * the label this controller wrote and nothing else. Render only: nothing is
286
+ * measured and `change` is not dispatched here; the next pass measures the bar.
287
+ */
288
+ moreLabelValueChanged() {
289
+ if (!this.#connected || !this.hasItemsTarget || !this.hasMoreTarget) return;
290
+ this.#syncLabel();
291
+ }
275
292
  /** Re-measures and rebalances items between the bar and the More menu. */
276
293
  update() {
277
294
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
295
+ this.#syncLabel();
278
296
  this.#syncItems();
279
297
  this.moreTarget.hidden = false;
280
298
  if (this.#items.some((item) => item.parentElement !== this.itemsTarget && !this.#widths.has(item))) {
@@ -328,8 +346,8 @@ var OverflowMenuController = class extends Controller {
328
346
  else if (active !== null && this.#lostFocus(active)) active.focus();
329
347
  const count = hidden.size;
330
348
  for (const item of this.#items) {
331
- if (count > 0) item.setAttribute(INDEX, String(this.#indexOf(item)));
332
- else item.removeAttribute(INDEX);
349
+ if (count > 0) item.setAttribute(this.#attr(INDEX), String(this.#indexOf(item)));
350
+ else item.removeAttribute(this.#attr(INDEX));
333
351
  }
334
352
  if (count > 0 && count === this.#items.length) this.#ensureBoundary();
335
353
  else this.#removeBoundary();
@@ -344,7 +362,7 @@ var OverflowMenuController = class extends Controller {
344
362
  this.element.setAttribute("data-overflow-count", String(count));
345
363
  if (this.#lastHidden !== count) {
346
364
  this.#lastHidden = count;
347
- this.dispatch("change", { detail: { visible: this.#items.length - count, hidden: count } });
365
+ this.dispatch("change", { detail: { overflowCount: count, total: this.#items.length } });
348
366
  }
349
367
  }
350
368
  /** The flex `column-gap` on `el` in px (0 when none / unsupported). */
@@ -374,7 +392,7 @@ var OverflowMenuController = class extends Controller {
374
392
  const bar = [];
375
393
  let boundaryAt;
376
394
  for (const el of this.itemsTarget.children) {
377
- if (el instanceof HTMLTemplateElement && el.hasAttribute(BOUNDARY)) {
395
+ if (el instanceof HTMLTemplateElement && el.hasAttribute(this.#attr(BOUNDARY))) {
378
396
  boundaryAt ??= bar.length;
379
397
  continue;
380
398
  }
@@ -382,14 +400,14 @@ var OverflowMenuController = class extends Controller {
382
400
  }
383
401
  const banked = [];
384
402
  for (const el of this.#menuList().children) {
385
- if (el instanceof HTMLElement && el.hasAttribute(BANKED)) banked.push(el);
403
+ if (el instanceof HTMLElement && el.hasAttribute(this.#attr(BANKED))) banked.push(el);
386
404
  }
387
405
  banked.sort((a, b) => this.#bankedIndex(a) - this.#bankedIndex(b));
388
406
  const items = this.#merge(bar, banked, boundaryAt);
389
407
  for (const el of previous) {
390
408
  if (!items.includes(el)) {
391
409
  this.#unbank(el);
392
- el.removeAttribute(INDEX);
410
+ el.removeAttribute(this.#attr(INDEX));
393
411
  }
394
412
  }
395
413
  this.#items = items;
@@ -462,7 +480,7 @@ var OverflowMenuController = class extends Controller {
462
480
  }
463
481
  /** A finite canonical index stored on either side of the overflow split. */
464
482
  #savedIndex(item) {
465
- const raw = item.getAttribute(INDEX);
483
+ const raw = item.getAttribute(this.#attr(INDEX));
466
484
  if (raw === null) return void 0;
467
485
  const value = Number(raw);
468
486
  return Number.isFinite(value) ? value : void 0;
@@ -470,14 +488,14 @@ var OverflowMenuController = class extends Controller {
470
488
  /** The inert split point written only when every managed item lives in More. */
471
489
  #boundary() {
472
490
  for (const el of this.itemsTarget.children) {
473
- if (el instanceof HTMLTemplateElement && el.hasAttribute(BOUNDARY)) return el;
491
+ if (el instanceof HTMLTemplateElement && el.hasAttribute(this.#attr(BOUNDARY))) return el;
474
492
  }
475
493
  return null;
476
494
  }
477
495
  #ensureBoundary() {
478
496
  if (this.#boundary() !== null) return;
479
497
  const boundary = document.createElement("template");
480
- boundary.setAttribute(BOUNDARY, "");
498
+ boundary.setAttribute(this.#attr(BOUNDARY), "");
481
499
  this.itemsTarget.appendChild(boundary);
482
500
  }
483
501
  #removeBoundary() {
@@ -496,17 +514,68 @@ var OverflowMenuController = class extends Controller {
496
514
  return this.moreTarget.querySelector('[data-stimeo--menu-target="trigger"]');
497
515
  }
498
516
  /**
499
- * Whether the More trigger is bare enough for the label to be safe to write.
500
- * Assigning `textContent` replaces *every* child node, so a trigger holding an
501
- * icon would silently lose it — and the loss is permanent, since the restore
502
- * pass returns items but not authored trigger content. A trigger that already
503
- * carries an accessible name is left alone too: injecting visible text under a
504
- * different `aria-label` would make the label and the name disagree. A trigger
505
- * with neither text, nor children, nor a name has nothing to lose and gets the
506
- * fallback; one that is empty *and* unnamed is the only case the label rescues.
517
+ * Keeps the More trigger's text on `moreLabel` wherever that text is this
518
+ * controller's to write: a bare trigger, or one still holding the label this
519
+ * controller wrote and nothing else. An empty `moreLabel` hands such a trigger
520
+ * back bare. A pass that finds the label already in place writes nothing, and a
521
+ * trigger that is neither is not written at all.
522
+ *
523
+ * @stimeoRenderRoot
524
+ */
525
+ #syncLabel() {
526
+ const trigger = this.#trigger();
527
+ if (trigger === null) return;
528
+ const owned = this.#ownsLabel(trigger);
529
+ if (!owned && !this.#isBareTrigger(trigger)) return;
530
+ const label = this.moreLabelValue;
531
+ if (label === "") {
532
+ this.#releaseLabel(trigger);
533
+ return;
534
+ }
535
+ if (owned && trigger.textContent === label) return;
536
+ trigger.textContent = label;
537
+ trigger.setAttribute(this.#attr(OWNED_LABEL), label);
538
+ }
539
+ /**
540
+ * Whether the trigger holds the label this controller wrote there and nothing
541
+ * else: the text still matches the marker, and the trigger carries nothing but
542
+ * text. Ownership is read afresh on every call, so a trigger the consumer adds to
543
+ * is theirs for as long as the addition is there.
544
+ */
545
+ #ownsLabel(trigger) {
546
+ return trigger.getAttribute(this.#attr(OWNED_LABEL)) === trigger.textContent && this.#carriesOnlyText(trigger);
547
+ }
548
+ /**
549
+ * Takes the label this controller wrote back out of the trigger, leaving it bare.
550
+ * A trigger that is not this controller's is left exactly as it is, marker
551
+ * included: taking the marker off would itself write to it.
552
+ */
553
+ #releaseLabel(trigger) {
554
+ if (!this.#ownsLabel(trigger)) return;
555
+ trigger.textContent = "";
556
+ trigger.removeAttribute(this.#attr(OWNED_LABEL));
557
+ }
558
+ /**
559
+ * Whether the More trigger is bare, so the label is safe to write: no text, no
560
+ * element child, no `aria-label` and no `aria-labelledby`. A trigger with neither
561
+ * text, nor children, nor a name has nothing to lose and gets the fallback; one
562
+ * that is empty *and* unnamed is the only case the label rescues.
507
563
  */
508
564
  #isBareTrigger(trigger) {
509
- return (trigger.textContent ?? "").trim() === "" && trigger.firstElementChild === null && !trigger.hasAttribute("aria-label") && !trigger.hasAttribute("aria-labelledby");
565
+ return (trigger.textContent ?? "").trim() === "" && this.#carriesOnlyText(trigger);
566
+ }
567
+ /**
568
+ * Whether the trigger carries nothing but text: no element child, no
569
+ * `aria-label` and no `aria-labelledby`. Assigning `textContent` replaces
570
+ * *every* child node, so a trigger holding an icon would silently lose it — and
571
+ * the loss is permanent, since only the label this controller wrote is ever
572
+ * taken back. A trigger that carries an accessible name is left alone too:
573
+ * visible text written under a different `aria-label` would make the label and
574
+ * the name disagree. Only a trigger that carries nothing but text is ever
575
+ * written or emptied.
576
+ */
577
+ #carriesOnlyText(trigger) {
578
+ return trigger.firstElementChild === null && !trigger.hasAttribute("aria-label") && !trigger.hasAttribute("aria-labelledby");
510
579
  }
511
580
  /**
512
581
  * Whether the More menu is expanded. Menu owns that state and reflects it on the
@@ -577,9 +646,11 @@ var OverflowMenuController = class extends Controller {
577
646
  this.#reorder(this.itemsTarget, this.#items);
578
647
  for (const item of this.#items) {
579
648
  this.#unbank(item);
580
- item.removeAttribute(INDEX);
649
+ item.removeAttribute(this.#attr(INDEX));
581
650
  }
582
651
  this.#closeMenu();
652
+ const trigger = this.#trigger();
653
+ if (trigger !== null) this.#releaseLabel(trigger);
583
654
  this.moreTarget.hidden = true;
584
655
  this.element.removeAttribute("data-overflowing");
585
656
  this.element.removeAttribute("data-overflow-count");
@@ -619,24 +690,24 @@ var OverflowMenuController = class extends Controller {
619
690
  * saving every authored value it overwrites ("" means the attribute was absent).
620
691
  */
621
692
  #bank(item) {
622
- if (!item.hasAttribute(BANKED)) {
623
- item.setAttribute(BANKED, "true");
624
- item.setAttribute(SAVED_ROLE, item.getAttribute("role") ?? "");
625
- item.setAttribute(SAVED_TABINDEX, item.getAttribute("tabindex") ?? "");
626
- item.setAttribute(SAVED_MENU_TARGET, item.getAttribute(MENU_TARGET) ?? "");
693
+ if (!item.hasAttribute(this.#attr(BANKED))) {
694
+ item.setAttribute(this.#attr(BANKED), "true");
695
+ item.setAttribute(this.#attr(SAVED_ROLE), item.getAttribute("role") ?? "");
696
+ item.setAttribute(this.#attr(SAVED_TABINDEX), item.getAttribute("tabindex") ?? "");
697
+ item.setAttribute(this.#attr(SAVED_MENU_TARGET), item.getAttribute(MENU_TARGET) ?? "");
627
698
  item.setAttribute("role", "menuitem");
628
699
  item.setAttribute("tabindex", "-1");
629
700
  item.setAttribute(MENU_TARGET, "item");
630
701
  }
631
- item.setAttribute(INDEX, String(this.#indexOf(item)));
702
+ item.setAttribute(this.#attr(INDEX), String(this.#indexOf(item)));
632
703
  }
633
704
  /** Undoes the banking: restores every authored value and drops the bookkeeping. */
634
705
  #unbank(item) {
635
- if (!item.hasAttribute(BANKED)) return;
636
- this.#restoreAttr(item, "role", item.getAttribute(SAVED_ROLE));
637
- this.#restoreAttr(item, "tabindex", item.getAttribute(SAVED_TABINDEX));
638
- this.#restoreAttr(item, MENU_TARGET, item.getAttribute(SAVED_MENU_TARGET));
639
- for (const name of BOOKKEEPING) item.removeAttribute(name);
706
+ if (!item.hasAttribute(this.#attr(BANKED))) return;
707
+ this.#restoreAttr(item, "role", item.getAttribute(this.#attr(SAVED_ROLE)));
708
+ this.#restoreAttr(item, "tabindex", item.getAttribute(this.#attr(SAVED_TABINDEX)));
709
+ this.#restoreAttr(item, MENU_TARGET, item.getAttribute(this.#attr(SAVED_MENU_TARGET)));
710
+ for (const name of BOOKKEEPING) item.removeAttribute(this.#attr(name));
640
711
  }
641
712
  /** Re-applies a saved attribute value, or removes the attribute when it was absent. */
642
713
  #restoreAttr(item, name, original) {
@@ -20,6 +20,39 @@ function canTakeFocus(element) {
20
20
  return !inheritsFieldsetDisabled(element);
21
21
  }
22
22
 
23
+ // src/utils/microtask_coalescer.ts
24
+ var MicrotaskCoalescer = class {
25
+ #run;
26
+ #queued = false;
27
+ #active = false;
28
+ #generation = 0;
29
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
30
+ constructor(run) {
31
+ this.#run = run;
32
+ }
33
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
34
+ activate() {
35
+ this.#active = true;
36
+ }
37
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
38
+ cancel() {
39
+ this.#active = false;
40
+ this.#queued = false;
41
+ this.#generation += 1;
42
+ }
43
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
44
+ schedule() {
45
+ if (!this.#active || this.#queued) return;
46
+ this.#queued = true;
47
+ const generation = this.#generation;
48
+ queueMicrotask(() => {
49
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
50
+ this.#queued = false;
51
+ this.#run();
52
+ });
53
+ }
54
+ };
55
+
23
56
  // src/utils/before_cache_reset.ts
24
57
  var BeforeCacheReset = class _BeforeCacheReset {
25
58
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -81,53 +114,57 @@ var TabindexLoan = class {
81
114
  };
82
115
 
83
116
  // src/controllers/pagination_controller.ts
84
- var PaginationController = class _PaginationController extends Controller {
117
+ var PaginationController = class extends Controller {
85
118
  static targets = ["page", "prev", "next"];
86
119
  static values = {
87
120
  page: { type: Number, default: 1 },
88
121
  total: { type: Number, default: 1 }
89
122
  };
90
123
  static actions = ["next", "prev", "select"];
91
- static events = ["change"];
92
- /** Marks a `disabled` this controller applied at a boundary (ownership flag). */
93
- static #BOUNDARY_ATTR = "data-stimeo--pagination-boundary-disabled";
94
- #isConnected = false;
124
+ static events = ["change", "reconcile"];
125
+ /** Marks a `disabled` this controller applied at a boundary, in its own namespace. */
126
+ get #boundaryAttribute() {
127
+ return `data-${this.identifier}-boundary-disabled`;
128
+ }
95
129
  /** The `tabindex` this instance lends the root for the focus fallback. */
96
130
  #tabindex = new TabindexLoan();
97
- /** Normalizes out-of-range initial values and renders the initial state. */
131
+ /**
132
+ * Collapses the Value and target callbacks of one mutation into one pass, and
133
+ * refuses the ones Stimulus delivers before `connect()`, which renders itself.
134
+ */
135
+ #repaint = new MicrotaskCoalescer(() => this.#reconcilePage());
136
+ /** The page shown last, which the next move of the current page is measured from. */
137
+ #shown = 1;
138
+ /** Renders the initial state from the clamped `page` and `total`. */
98
139
  connect() {
99
- this.#isConnected = true;
100
- this.#normalizeAndRender();
140
+ this.#repaint.activate();
141
+ this.#shown = this.#page;
142
+ this.#render();
101
143
  }
102
- /** Reverts the one attribute the controller adds outside its state hooks. */
144
+ /** Drops a pending pass and reverts the one attribute added outside the state hooks. */
103
145
  disconnect() {
104
- this.#isConnected = false;
146
+ this.#repaint.cancel();
105
147
  this.#tabindex.returnAll();
106
148
  }
107
149
  /** Re-renders when application code (or a Turbo morph) changes `page` at runtime. */
108
150
  pageValueChanged() {
109
- if (!this.#isConnected) return;
110
- this.#normalizeAndRender();
151
+ this.#repaint.schedule();
111
152
  }
112
153
  /** Re-renders when application code (or a Turbo morph) changes `total` at runtime. */
113
154
  totalValueChanged() {
114
- if (!this.#isConnected) return;
115
- this.#normalizeAndRender();
155
+ this.#repaint.schedule();
116
156
  }
117
157
  /** Syncs a page button appended/replaced at runtime (the consumer owns the list). */
118
158
  pageTargetConnected() {
119
- if (!this.#isConnected) return;
120
- this.#render();
159
+ this.#repaint.schedule();
121
160
  }
122
161
  /** Syncs a `prev` button appended/replaced at runtime. */
123
162
  prevTargetConnected() {
124
- if (!this.#isConnected) return;
125
- this.#render();
163
+ this.#repaint.schedule();
126
164
  }
127
165
  /** Syncs a `next` button appended/replaced at runtime. */
128
166
  nextTargetConnected() {
129
- if (!this.#isConnected) return;
130
- this.#render();
167
+ this.#repaint.schedule();
131
168
  }
132
169
  /** Makes the clicked page button (its `data-page`) current. */
133
170
  select(event) {
@@ -153,22 +190,31 @@ var PaginationController = class _PaginationController extends Controller {
153
190
  const target = this.#clamp(page);
154
191
  if (target === previous) return;
155
192
  this.pageValue = target;
193
+ this.#shown = target;
156
194
  this.#render();
157
195
  this.dispatch("change", {
158
196
  detail: { page: target, total: this.#total, previous }
159
197
  });
160
198
  }
161
- /** Writes the normalized Values back (only when they differ) and renders. */
162
- #normalizeAndRender() {
163
- const total = this.#total;
164
- if (!Object.is(total, this.totalValue)) this.totalValue = total;
199
+ /**
200
+ * Renders one settled batch of Value and target changes, and reports a current
201
+ * page that moved from the one shown before as `reconcile`.
202
+ */
203
+ #reconcilePage() {
204
+ const previous = this.#shown;
165
205
  const page = this.#page;
166
- if (!Object.is(page, this.pageValue)) this.pageValue = page;
206
+ this.#shown = page;
167
207
  this.#render();
208
+ if (page !== previous) {
209
+ this.dispatch("reconcile", { detail: { page, total: this.#total, previous } });
210
+ }
168
211
  }
169
212
  /**
170
213
  * Syncs `aria-current` on the page buttons and the prev/next `disabled` state.
171
214
  *
215
+ * It reads `page` and `total` through their clamps and never writes either Value,
216
+ * so a declaration outside the range stays in the attributes as the page wrote it.
217
+ *
172
218
  * @stimeoRenderRoot
173
219
  */
174
220
  #render() {
@@ -201,7 +247,7 @@ var PaginationController = class _PaginationController extends Controller {
201
247
  #release(button, atBoundary) {
202
248
  if (!button || atBoundary || !this.#owns(button)) return;
203
249
  button.disabled = false;
204
- button.removeAttribute(_PaginationController.#BOUNDARY_ATTR);
250
+ button.removeAttribute(this.#boundaryAttribute);
205
251
  }
206
252
  /**
207
253
  * Disables a boundary button, first moving focus off it when it is the active
@@ -212,7 +258,7 @@ var PaginationController = class _PaginationController extends Controller {
212
258
  if (button.disabled && !this.#owns(button)) return;
213
259
  if (button === document.activeElement) this.#moveFocusAwayFrom(opposite);
214
260
  button.disabled = true;
215
- button.setAttribute(_PaginationController.#BOUNDARY_ATTR, "");
261
+ button.setAttribute(this.#boundaryAttribute, "");
216
262
  }
217
263
  /**
218
264
  * Moves focus to `opposite` (already resolved to `null` when it will stay
@@ -249,7 +295,7 @@ var PaginationController = class _PaginationController extends Controller {
249
295
  }
250
296
  /** Whether the button's current `disabled` was applied by boundary control. */
251
297
  #owns(button) {
252
- return button.hasAttribute(_PaginationController.#BOUNDARY_ATTR);
298
+ return button.hasAttribute(this.#boundaryAttribute);
253
299
  }
254
300
  };
255
301
 
@@ -84,10 +84,51 @@ var SafeTimeout = class extends TimerRegistry {
84
84
  }
85
85
  };
86
86
 
87
+ // src/utils/state_regions.ts
88
+ var StateRegions = class {
89
+ #whenTrue;
90
+ #whenFalse;
91
+ #taken = /* @__PURE__ */ new WeakSet();
92
+ constructor(options) {
93
+ this.#whenTrue = options.whenTrue;
94
+ this.#whenFalse = options.whenFalse ?? null;
95
+ }
96
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
97
+ reflect(host, isTrue) {
98
+ const shown = this.#inside(host, this.#whenTrue());
99
+ if (!this.#whenFalse) {
100
+ for (const region of shown) this.#write(region, !isTrue);
101
+ return;
102
+ }
103
+ const hiddenSide = this.#inside(host, this.#whenFalse());
104
+ if (shown.length === 0 || hiddenSide.length === 0) {
105
+ for (const region of shown) this.#give(region);
106
+ for (const region of hiddenSide) this.#give(region);
107
+ return;
108
+ }
109
+ for (const region of shown) this.#write(region, !isTrue);
110
+ for (const region of hiddenSide) this.#write(region, isTrue);
111
+ }
112
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
113
+ #write(region, hidden) {
114
+ if (hidden) this.#taken.add(region);
115
+ else this.#taken.delete(region);
116
+ if (region.hidden !== hidden) region.hidden = hidden;
117
+ }
118
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
119
+ #give(region) {
120
+ if (this.#taken.delete(region)) region.hidden = false;
121
+ }
122
+ /** The declared regions that sit within `host`, which may be the host itself. */
123
+ #inside(host, regions) {
124
+ return regions.filter((region) => host.contains(region));
125
+ }
126
+ };
127
+
87
128
  // src/controllers/password_reveal_controller.ts
88
129
  var MAX_DELAY = 2 ** 31 - 1;
89
130
  var PasswordRevealController = class extends Controller {
90
- static targets = ["input", "toggle"];
131
+ static targets = ["input", "toggle", "onLabel", "offLabel"];
91
132
  static values = {
92
133
  autoHide: { type: Number, default: 0 }
93
134
  };
@@ -105,6 +146,11 @@ var PasswordRevealController = class extends Controller {
105
146
  * document does not answer this — unloading the controller leaves it there.
106
147
  */
107
148
  #connected = false;
149
+ /** Owns `hidden` on the label pair the toggle button carries. */
150
+ #labels = new StateRegions({
151
+ whenTrue: () => this.onLabelTargets,
152
+ whenFalse: () => this.offLabelTargets
153
+ });
108
154
  /** Masks the field before Turbo copies the page into its snapshot. */
109
155
  #beforeCache = new BeforeCacheReset(() => this.#rewindToMasked());
110
156
  connect() {
@@ -144,6 +190,14 @@ var PasswordRevealController = class extends Controller {
144
190
  toggleTargetConnected() {
145
191
  this.#reflect(this.#isVisible);
146
192
  }
193
+ /** Describes the state to a revealed-side label that arrives after connect. */
194
+ onLabelTargetConnected() {
195
+ this.#reflect(this.#isVisible);
196
+ }
197
+ /** Describes the state to a masked-side label that arrives after connect. */
198
+ offLabelTargetConnected() {
199
+ this.#reflect(this.#isVisible);
200
+ }
147
201
  /** Toggles the input between masked and revealed. Bound via `data-action`. */
148
202
  toggle() {
149
203
  this.#setVisible(!this.#isVisible);
@@ -187,6 +241,8 @@ var PasswordRevealController = class extends Controller {
187
241
  * the opposite of what the declaration asked for. A value that is no delay at
188
242
  * all stays out of the positive range {@link PasswordRevealController.#arm}
189
243
  * requires, so it schedules nothing.
244
+ *
245
+ * @stimeoRuntimeOnly `autoHide` is the delay of the one re-masking timer it arms.
190
246
  */
191
247
  get #autoHideDelay() {
192
248
  return Math.min(this.autoHideValue, MAX_DELAY);
@@ -202,10 +258,11 @@ var PasswordRevealController = class extends Controller {
202
258
  this.inputTarget.type = "password";
203
259
  this.#reflect(false);
204
260
  }
205
- /** Reflects the visible state onto `aria-pressed` and `data-state`. */
261
+ /** Reflects the visible state onto `aria-pressed`, `data-state` and the labels. */
206
262
  #reflect(visible) {
207
263
  if (this.hasToggleTarget) {
208
264
  this.toggleTarget.setAttribute("aria-pressed", visible ? "true" : "false");
265
+ this.#labels.reflect(this.toggleTarget, visible);
209
266
  }
210
267
  this.element.setAttribute("data-state", visible ? "visible" : "hidden");
211
268
  }
@@ -127,7 +127,20 @@ function parseStringList(raw, fallback = []) {
127
127
  return parsed.filter((entry) => typeof entry === "string");
128
128
  }
129
129
 
130
+ // src/utils/transient_hooks.ts
131
+ var TransientHooks = class {
132
+ #attributes;
133
+ constructor(options) {
134
+ this.#attributes = options.attributes;
135
+ }
136
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
137
+ reset(element) {
138
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
139
+ }
140
+ };
141
+
130
142
  // src/controllers/persist_controller.ts
143
+ var TRANSIENT = new TransientHooks({ attributes: ["data-persist-restored"] });
131
144
  var NON_VALUE_TYPES = /* @__PURE__ */ new Set(["file", "submit", "reset", "button", "image"]);
132
145
  var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
133
146
  var DEFAULT_EXCLUDE = ["authenticity_token", "_method", "utf8"];
@@ -197,7 +210,6 @@ var PersistController = class extends Controller {
197
210
  this.#excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
198
211
  this.#knownFields = /* @__PURE__ */ new WeakSet();
199
212
  this.#forcedRestore.clear();
200
- this.element.removeAttribute("data-persist-restored");
201
213
  this.element.addEventListener("input", this.#onInput);
202
214
  this.element.addEventListener("change", this.#onInput);
203
215
  this.#syncClearOn();
@@ -207,7 +219,7 @@ var PersistController = class extends Controller {
207
219
  attributeFilter: [
208
220
  "checked",
209
221
  "data-controller",
210
- "data-stimeo--persist-target",
222
+ `data-${this.identifier}-target`,
211
223
  "id",
212
224
  "multiple",
213
225
  "name",
@@ -230,7 +242,7 @@ var PersistController = class extends Controller {
230
242
  this.#restoreDynamic.cancel();
231
243
  this.#forcedRestore.clear();
232
244
  this.#timeouts.clearAll();
233
- this.element.removeAttribute("data-persist-restored");
245
+ TRANSIENT.reset(this.element);
234
246
  this.#payload = null;
235
247
  }
236
248
  /** Drops the active draft after storage confirms the removal. */
@@ -329,7 +341,7 @@ var PersistController = class extends Controller {
329
341
  }
330
342
  /** Reads, validates, and restores the active namespace. */
331
343
  #loadActiveDraft() {
332
- this.element.removeAttribute("data-persist-restored");
344
+ TRANSIENT.reset(this.element);
333
345
  this.#payload = null;
334
346
  this.#knownFields = /* @__PURE__ */ new WeakSet();
335
347
  this.#forcedRestore.clear();
@@ -481,7 +493,7 @@ var PersistController = class extends Controller {
481
493
  }
482
494
  /** Whether this instance, rather than a nested Persist host, owns a field. */
483
495
  #ownsField(field) {
484
- return field.closest('[data-controller~="stimeo--persist"]') === this.element;
496
+ return field.closest(`[data-controller~="${this.identifier}"]`) === this.element;
485
497
  }
486
498
  /** Marks all currently eligible fields without applying a payload. */
487
499
  #markCurrentFieldsKnown() {
@@ -515,7 +527,12 @@ var PersistController = class extends Controller {
515
527
  const name = value.trim();
516
528
  return name.length > 0 && !/\s/.test(name) ? name : null;
517
529
  }
518
- /** Collects dynamic controls and select-option changes into one restore pass. */
530
+ /**
531
+ * Collects dynamic controls and select-option changes into one restore pass.
532
+ *
533
+ * @stimeoRuntimeOnly `key` decides whether an id change moves the storage namespace; it decides
534
+ * nothing shown.
535
+ */
519
536
  #onMutations(records) {
520
537
  let rootIdChanged = false;
521
538
  for (const record of records) {
@@ -563,7 +580,12 @@ var PersistController = class extends Controller {
563
580
  this.#forcedRestore.add(field);
564
581
  }
565
582
  }
566
- /** Resolves the logical key from the Value, falling back to the host id. */
583
+ /**
584
+ * Resolves the logical key from the Value, falling back to the host id.
585
+ *
586
+ * @stimeoRuntimeOnly `key` names the storage namespace; the restored fields come from what is
587
+ * stored there.
588
+ */
567
589
  #resolveLogicalKey() {
568
590
  const key = this.keyValue || this.element.id;
569
591
  return key.length > 0 ? key : null;
@@ -572,7 +594,7 @@ var PersistController = class extends Controller {
572
594
  #storageKey(logicalKey) {
573
595
  return `${STORAGE_PREFIX}${logicalKey}`;
574
596
  }
575
- /** Normalizes invalid debounce Values to the documented default. */
597
+ /** Normalizes an invalid `debounce` Value to the `debounce` default. */
576
598
  get #debounceDelay() {
577
599
  const value = this.debounceValue;
578
600
  return Number.isFinite(value) && value >= 0 ? value : DEFAULT_DEBOUNCE;