stimeo-ui 0.14.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -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 +81 -12
  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 +85 -17
  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 +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  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 +46 -13
  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 +432 -71
  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 +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  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/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -57,16 +57,51 @@ var BlurDeferral = class {
57
57
  }
58
58
  };
59
59
 
60
+ // src/utils/frame_coalescer.ts
61
+ var FrameCoalescer = class {
62
+ #frame = null;
63
+ /**
64
+ * Runs `run` on the next frame, unless a frame is already pending — the first
65
+ * request of a burst wins and the rest are dropped. The pending frame is
66
+ * released before `run`, so `run` may request the next one.
67
+ */
68
+ schedule(run) {
69
+ if (this.#frame !== null) return;
70
+ this.#frame = requestAnimationFrame(() => {
71
+ this.#frame = null;
72
+ run();
73
+ });
74
+ }
75
+ /**
76
+ * Drops the pending frame, and reaches the platform only when there is one.
77
+ *
78
+ * There is no handle value that stands for "nothing pending":
79
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
80
+ * arrives as a large positive number that the same allocator can hand out, and
81
+ * an idle cancel would then drop a frame belonging to someone else.
82
+ */
83
+ cancel() {
84
+ if (this.#frame === null) return;
85
+ cancelAnimationFrame(this.#frame);
86
+ this.#frame = null;
87
+ }
88
+ };
89
+
60
90
  // src/utils/layout_observer.ts
61
91
  var LayoutObserver = class {
62
92
  #callback;
63
93
  #resizeObserverFactory;
64
94
  #resizeObserver = null;
65
95
  #observingViewport = false;
96
+ #loadContainer = null;
66
97
  /** Stable bound handler so add/removeEventListener target the same reference. */
67
98
  #handleViewportResize = () => {
68
99
  this.#callback();
69
100
  };
101
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
102
+ #handleDescendantLoad = () => {
103
+ this.#callback();
104
+ };
70
105
  constructor(callback, options = {}) {
71
106
  this.#callback = callback;
72
107
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -102,14 +137,59 @@ var LayoutObserver = class {
102
137
  window.removeEventListener("resize", this.#handleViewportResize);
103
138
  }
104
139
  /**
105
- * Releases every observation: disconnects the {@link ResizeObserver} and
106
- * removes the viewport listener. Safe to call multiple times. Call this from a
107
- * controller's `disconnect()`.
140
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
141
+ * frame settling changes the box it sits in, and it measures as zero high until
142
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
143
+ * on the container itself and nothing the caller spells.
144
+ *
145
+ * **One container at a time.** A further call moves the observation, so a widget
146
+ * whose content element is swapped at runtime releases the element it let go by
147
+ * naming the new one — there is no second place for the release to drift from.
148
+ */
149
+ observeDescendantLoads(container) {
150
+ this.unobserveDescendantLoads();
151
+ this.#loadContainer = container;
152
+ container.addEventListener("load", this.#handleDescendantLoad, true);
153
+ }
154
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
155
+ unobserveDescendantLoads() {
156
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
157
+ this.#loadContainer = null;
158
+ }
159
+ /**
160
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
161
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
162
+ * this from a controller's `disconnect()`.
108
163
  */
109
164
  disconnect() {
110
165
  this.#resizeObserver?.disconnect();
111
166
  this.#resizeObserver = null;
112
167
  this.unobserveViewport();
168
+ this.unobserveDescendantLoads();
169
+ }
170
+ };
171
+
172
+ // src/utils/listener_set.ts
173
+ var ListenerSet = class {
174
+ /** The generation every `add` joins until the next `dispose()`. */
175
+ #abort = new AbortController();
176
+ /**
177
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
178
+ *
179
+ * The set supplies the signal, so `options` carries everything else the DOM
180
+ * accepts — `capture` included, which has to match at release time and no
181
+ * longer has a second place to drift from.
182
+ */
183
+ add(target, type, handler, options) {
184
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
185
+ }
186
+ /**
187
+ * Releases every listener of the open generation, synchronously, and opens the
188
+ * next one. Idempotent, and safe before anything has been added.
189
+ */
190
+ dispose() {
191
+ this.#abort.abort();
192
+ this.#abort = new AbortController();
113
193
  }
114
194
  };
115
195
 
@@ -136,18 +216,31 @@ function prefersReducedMotion() {
136
216
  }
137
217
 
138
218
  // src/controllers/overflow_indicator_controller.ts
139
- var DIRECTION_BUTTON_SELECTOR = "[data-stimeo--overflow-indicator-direction-param]";
219
+ var DIRECTION_PARAM = "direction-param";
140
220
  var OverflowIndicatorController = class extends Controller {
221
+ /** The direction param above, in the namespace this controller is registered under. */
222
+ get #directionParam() {
223
+ return `data-${this.identifier}-${DIRECTION_PARAM}`;
224
+ }
225
+ /** Selects every button carrying that param. */
226
+ get #directionButtonSelector() {
227
+ return `[${this.#directionParam}]`;
228
+ }
141
229
  static targets = ["viewport"];
142
230
  static values = {
143
231
  orientation: { type: String, default: "horizontal" },
144
232
  threshold: { type: Number, default: 1 }
145
233
  };
146
- static actions = ["scrollByPage", "update"];
234
+ static actions = ["scrollByPage"];
147
235
  static events = ["change"];
148
236
  #layout = new LayoutObserver(() => {
149
- if (this.#connected) this.update();
237
+ if (this.#connected) this.#refresh();
150
238
  });
239
+ /** Folds a burst of scrolls into one measurement per frame. */
240
+ #frames = new FrameCoalescer();
241
+ /** Holds the viewport's scroll listener for exactly as long as that viewport. */
242
+ #viewportListeners = new ListenerSet();
243
+ #onScroll = () => this.#frames.schedule(() => this.#refresh());
151
244
  #connected = false;
152
245
  #observedViewport = null;
153
246
  #observedContent = /* @__PURE__ */ new Set();
@@ -183,16 +276,17 @@ var OverflowIndicatorController = class extends Controller {
183
276
  this.#syncViewport();
184
277
  }
185
278
  orientationValueChanged() {
186
- if (this.#connected) this.update();
279
+ if (this.#connected) this.#refresh();
187
280
  }
188
281
  thresholdValueChanged() {
189
- if (this.#connected) this.update();
282
+ if (this.#connected) this.#refresh();
190
283
  }
191
284
  /**
192
285
  * Re-measures remaining scroll room and reflects the state hooks.
193
- * Public so it can be wired to the viewport's `scroll`.
286
+ *
287
+ * @stimeoRenderRoot
194
288
  */
195
- update() {
289
+ #refresh() {
196
290
  if (!this.hasViewportTarget) return;
197
291
  const vp = this.viewportTarget;
198
292
  const horizontal = this.orientationValue !== "vertical";
@@ -229,16 +323,16 @@ var OverflowIndicatorController = class extends Controller {
229
323
  /** Mirrors remaining room onto any direction buttons by toggling `disabled`. */
230
324
  #syncButtons(start, end) {
231
325
  for (const button of this.#pendingButtonDisables.elements) {
232
- if (!button.isConnected || button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
326
+ if (!button.isConnected || button.closest(`[data-controller~='${this.identifier}']`) !== this.element) {
233
327
  this.#cancelPendingButtonDisable(button);
234
328
  }
235
329
  }
236
- const buttons = this.element.querySelectorAll(DIRECTION_BUTTON_SELECTOR);
330
+ const buttons = this.element.querySelectorAll(this.#directionButtonSelector);
237
331
  for (const button of buttons) {
238
- if (button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
332
+ if (button.closest(`[data-controller~='${this.identifier}']`) !== this.element) {
239
333
  continue;
240
334
  }
241
- const direction = button.getAttribute("data-stimeo--overflow-indicator-direction-param");
335
+ const direction = button.getAttribute(this.#directionParam);
242
336
  if (direction === "start") this.#toggleButton(button, start);
243
337
  else if (direction === "end") this.#toggleButton(button, end);
244
338
  }
@@ -322,15 +416,16 @@ var OverflowIndicatorController = class extends Controller {
322
416
  this.#stopObservingViewport();
323
417
  if (!next) return;
324
418
  this.#observedViewport = next;
419
+ this.#viewportListeners.add(next, "scroll", this.#onScroll, { passive: true });
325
420
  this.#layout.observe(next);
326
421
  this.#layout.observeViewport();
327
- next.addEventListener("load", this.#onContentLoad, true);
422
+ this.#layout.observeDescendantLoads(next);
328
423
  this.#syncContentObservation();
329
424
  if (typeof MutationObserver !== "undefined") {
330
425
  this.#mutationObserver = new MutationObserver(() => {
331
426
  if (!this.#connected || this.#observedViewport !== next) return;
332
427
  this.#syncContentObservation();
333
- this.update();
428
+ this.#refresh();
334
429
  });
335
430
  this.#mutationObserver.observe(this.element, {
336
431
  childList: true,
@@ -341,7 +436,7 @@ var OverflowIndicatorController = class extends Controller {
341
436
  });
342
437
  }
343
438
  this.#state = null;
344
- this.update();
439
+ this.#refresh();
345
440
  }
346
441
  /** Observes direct content boxes whose resize can change the viewport's scroll extent. */
347
442
  #syncContentObservation() {
@@ -360,18 +455,17 @@ var OverflowIndicatorController = class extends Controller {
360
455
  }
361
456
  }
362
457
  #stopObservingViewport() {
458
+ this.#frames.cancel();
459
+ this.#viewportListeners.dispose();
363
460
  this.#mutationObserver?.disconnect();
364
461
  this.#mutationObserver = null;
365
- this.#observedViewport?.removeEventListener("load", this.#onContentLoad, true);
462
+ this.#layout.unobserveDescendantLoads();
366
463
  if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
367
464
  for (const content of this.#observedContent) this.#layout.unobserve(content);
368
465
  this.#observedContent.clear();
369
466
  this.#observedViewport = null;
370
467
  this.#layout.unobserveViewport();
371
468
  }
372
- #onContentLoad = () => {
373
- if (this.#connected) this.update();
374
- };
375
469
  get #threshold() {
376
470
  const value = this.thresholdValue;
377
471
  return Number.isFinite(value) ? Math.max(0, value) : 1;
@@ -64,10 +64,15 @@ var LayoutObserver = class {
64
64
  #resizeObserverFactory;
65
65
  #resizeObserver = null;
66
66
  #observingViewport = false;
67
+ #loadContainer = null;
67
68
  /** Stable bound handler so add/removeEventListener target the same reference. */
68
69
  #handleViewportResize = () => {
69
70
  this.#callback();
70
71
  };
72
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
73
+ #handleDescendantLoad = () => {
74
+ this.#callback();
75
+ };
71
76
  constructor(callback, options = {}) {
72
77
  this.#callback = callback;
73
78
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -103,14 +108,35 @@ var LayoutObserver = class {
103
108
  window.removeEventListener("resize", this.#handleViewportResize);
104
109
  }
105
110
  /**
106
- * Releases every observation: disconnects the {@link ResizeObserver} and
107
- * removes the viewport listener. Safe to call multiple times. Call this from a
108
- * controller's `disconnect()`.
111
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
112
+ * frame settling changes the box it sits in, and it measures as zero high until
113
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
114
+ * on the container itself and nothing the caller spells.
115
+ *
116
+ * **One container at a time.** A further call moves the observation, so a widget
117
+ * whose content element is swapped at runtime releases the element it let go by
118
+ * naming the new one — there is no second place for the release to drift from.
119
+ */
120
+ observeDescendantLoads(container) {
121
+ this.unobserveDescendantLoads();
122
+ this.#loadContainer = container;
123
+ container.addEventListener("load", this.#handleDescendantLoad, true);
124
+ }
125
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
126
+ unobserveDescendantLoads() {
127
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
128
+ this.#loadContainer = null;
129
+ }
130
+ /**
131
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
132
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
133
+ * this from a controller's `disconnect()`.
109
134
  */
110
135
  disconnect() {
111
136
  this.#resizeObserver?.disconnect();
112
137
  this.#resizeObserver = null;
113
138
  this.unobserveViewport();
139
+ this.unobserveDescendantLoads();
114
140
  }
115
141
  };
116
142
 
@@ -199,15 +225,20 @@ var TabindexLoan = class {
199
225
  };
200
226
 
201
227
  // src/controllers/overflow_menu_controller.ts
202
- var BANKED = "data-stimeo--overflow-menu-banked";
203
- var INDEX = "data-stimeo--overflow-menu-index";
204
- var BOUNDARY = "data-stimeo--overflow-menu-boundary";
205
- var SAVED_ROLE = "data-stimeo--overflow-menu-role";
206
- var SAVED_TABINDEX = "data-stimeo--overflow-menu-tabindex";
207
- 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";
208
234
  var MENU_TARGET = "data-stimeo--menu-target";
209
235
  var BOOKKEEPING = [BANKED, INDEX, SAVED_ROLE, SAVED_TABINDEX, SAVED_MENU_TARGET];
236
+ var OWNED_LABEL = "owns-label";
210
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
+ }
211
242
  static targets = ["items", "more"];
212
243
  static values = {
213
244
  moreLabel: { type: String, default: "More" },
@@ -226,29 +257,42 @@ var OverflowMenuController = class extends Controller {
226
257
  #lastHidden = null;
227
258
  /** The `tabindex` this instance lends the root for the focus fallback. */
228
259
  #tabindex = new TabindexLoan();
229
- /** 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. */
230
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;
231
267
  connect() {
268
+ this.#connected = true;
232
269
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
233
- const trigger = this.#trigger();
234
- if (trigger !== null && this.#isBareTrigger(trigger)) {
235
- trigger.textContent = this.moreLabelValue;
236
- }
237
270
  this.#beforeCache.activate();
238
271
  this.#layout.observe(this.element);
239
272
  this.#layout.observeViewport();
240
273
  this.update();
241
274
  }
242
275
  disconnect() {
276
+ this.#connected = false;
243
277
  this.#layout.disconnect();
244
278
  this.#timers.clearAll();
245
279
  this.#beforeCache.deactivate();
246
280
  this.#restoreAll();
247
281
  this.#lastHidden = null;
248
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
+ }
249
292
  /** Re-measures and rebalances items between the bar and the More menu. */
250
293
  update() {
251
294
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
295
+ this.#syncLabel();
252
296
  this.#syncItems();
253
297
  this.moreTarget.hidden = false;
254
298
  if (this.#items.some((item) => item.parentElement !== this.itemsTarget && !this.#widths.has(item))) {
@@ -302,8 +346,8 @@ var OverflowMenuController = class extends Controller {
302
346
  else if (active !== null && this.#lostFocus(active)) active.focus();
303
347
  const count = hidden.size;
304
348
  for (const item of this.#items) {
305
- if (count > 0) item.setAttribute(INDEX, String(this.#indexOf(item)));
306
- else item.removeAttribute(INDEX);
349
+ if (count > 0) item.setAttribute(this.#attr(INDEX), String(this.#indexOf(item)));
350
+ else item.removeAttribute(this.#attr(INDEX));
307
351
  }
308
352
  if (count > 0 && count === this.#items.length) this.#ensureBoundary();
309
353
  else this.#removeBoundary();
@@ -318,7 +362,7 @@ var OverflowMenuController = class extends Controller {
318
362
  this.element.setAttribute("data-overflow-count", String(count));
319
363
  if (this.#lastHidden !== count) {
320
364
  this.#lastHidden = count;
321
- this.dispatch("change", { detail: { visible: this.#items.length - count, hidden: count } });
365
+ this.dispatch("change", { detail: { overflowCount: count, total: this.#items.length } });
322
366
  }
323
367
  }
324
368
  /** The flex `column-gap` on `el` in px (0 when none / unsupported). */
@@ -348,7 +392,7 @@ var OverflowMenuController = class extends Controller {
348
392
  const bar = [];
349
393
  let boundaryAt;
350
394
  for (const el of this.itemsTarget.children) {
351
- if (el instanceof HTMLTemplateElement && el.hasAttribute(BOUNDARY)) {
395
+ if (el instanceof HTMLTemplateElement && el.hasAttribute(this.#attr(BOUNDARY))) {
352
396
  boundaryAt ??= bar.length;
353
397
  continue;
354
398
  }
@@ -356,14 +400,14 @@ var OverflowMenuController = class extends Controller {
356
400
  }
357
401
  const banked = [];
358
402
  for (const el of this.#menuList().children) {
359
- if (el instanceof HTMLElement && el.hasAttribute(BANKED)) banked.push(el);
403
+ if (el instanceof HTMLElement && el.hasAttribute(this.#attr(BANKED))) banked.push(el);
360
404
  }
361
405
  banked.sort((a, b) => this.#bankedIndex(a) - this.#bankedIndex(b));
362
406
  const items = this.#merge(bar, banked, boundaryAt);
363
407
  for (const el of previous) {
364
408
  if (!items.includes(el)) {
365
409
  this.#unbank(el);
366
- el.removeAttribute(INDEX);
410
+ el.removeAttribute(this.#attr(INDEX));
367
411
  }
368
412
  }
369
413
  this.#items = items;
@@ -436,7 +480,7 @@ var OverflowMenuController = class extends Controller {
436
480
  }
437
481
  /** A finite canonical index stored on either side of the overflow split. */
438
482
  #savedIndex(item) {
439
- const raw = item.getAttribute(INDEX);
483
+ const raw = item.getAttribute(this.#attr(INDEX));
440
484
  if (raw === null) return void 0;
441
485
  const value = Number(raw);
442
486
  return Number.isFinite(value) ? value : void 0;
@@ -444,14 +488,14 @@ var OverflowMenuController = class extends Controller {
444
488
  /** The inert split point written only when every managed item lives in More. */
445
489
  #boundary() {
446
490
  for (const el of this.itemsTarget.children) {
447
- if (el instanceof HTMLTemplateElement && el.hasAttribute(BOUNDARY)) return el;
491
+ if (el instanceof HTMLTemplateElement && el.hasAttribute(this.#attr(BOUNDARY))) return el;
448
492
  }
449
493
  return null;
450
494
  }
451
495
  #ensureBoundary() {
452
496
  if (this.#boundary() !== null) return;
453
497
  const boundary = document.createElement("template");
454
- boundary.setAttribute(BOUNDARY, "");
498
+ boundary.setAttribute(this.#attr(BOUNDARY), "");
455
499
  this.itemsTarget.appendChild(boundary);
456
500
  }
457
501
  #removeBoundary() {
@@ -470,17 +514,68 @@ var OverflowMenuController = class extends Controller {
470
514
  return this.moreTarget.querySelector('[data-stimeo--menu-target="trigger"]');
471
515
  }
472
516
  /**
473
- * Whether the More trigger is bare enough for the label to be safe to write.
474
- * Assigning `textContent` replaces *every* child node, so a trigger holding an
475
- * icon would silently lose it — and the loss is permanent, since the restore
476
- * pass returns items but not authored trigger content. A trigger that already
477
- * carries an accessible name is left alone too: injecting visible text under a
478
- * different `aria-label` would make the label and the name disagree. A trigger
479
- * with neither text, nor children, nor a name has nothing to lose and gets the
480
- * 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.
481
563
  */
482
564
  #isBareTrigger(trigger) {
483
- 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");
484
579
  }
485
580
  /**
486
581
  * Whether the More menu is expanded. Menu owns that state and reflects it on the
@@ -551,9 +646,11 @@ var OverflowMenuController = class extends Controller {
551
646
  this.#reorder(this.itemsTarget, this.#items);
552
647
  for (const item of this.#items) {
553
648
  this.#unbank(item);
554
- item.removeAttribute(INDEX);
649
+ item.removeAttribute(this.#attr(INDEX));
555
650
  }
556
651
  this.#closeMenu();
652
+ const trigger = this.#trigger();
653
+ if (trigger !== null) this.#releaseLabel(trigger);
557
654
  this.moreTarget.hidden = true;
558
655
  this.element.removeAttribute("data-overflowing");
559
656
  this.element.removeAttribute("data-overflow-count");
@@ -593,24 +690,24 @@ var OverflowMenuController = class extends Controller {
593
690
  * saving every authored value it overwrites ("" means the attribute was absent).
594
691
  */
595
692
  #bank(item) {
596
- if (!item.hasAttribute(BANKED)) {
597
- item.setAttribute(BANKED, "true");
598
- item.setAttribute(SAVED_ROLE, item.getAttribute("role") ?? "");
599
- item.setAttribute(SAVED_TABINDEX, item.getAttribute("tabindex") ?? "");
600
- 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) ?? "");
601
698
  item.setAttribute("role", "menuitem");
602
699
  item.setAttribute("tabindex", "-1");
603
700
  item.setAttribute(MENU_TARGET, "item");
604
701
  }
605
- item.setAttribute(INDEX, String(this.#indexOf(item)));
702
+ item.setAttribute(this.#attr(INDEX), String(this.#indexOf(item)));
606
703
  }
607
704
  /** Undoes the banking: restores every authored value and drops the bookkeeping. */
608
705
  #unbank(item) {
609
- if (!item.hasAttribute(BANKED)) return;
610
- this.#restoreAttr(item, "role", item.getAttribute(SAVED_ROLE));
611
- this.#restoreAttr(item, "tabindex", item.getAttribute(SAVED_TABINDEX));
612
- this.#restoreAttr(item, MENU_TARGET, item.getAttribute(SAVED_MENU_TARGET));
613
- 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));
614
711
  }
615
712
  /** Re-applies a saved attribute value, or removes the attribute when it was absent. */
616
713
  #restoreAttr(item, name, original) {