stimeo-ui 0.2.1 → 0.4.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +32 -5
  5. data/dist/controllers/announcer_controller.js +255 -20
  6. data/dist/controllers/breadcrumb_controller.js +225 -13
  7. data/dist/controllers/calendar_controller.js +89 -22
  8. data/dist/controllers/carousel_controller.js +47 -6
  9. data/dist/controllers/collapsible_controller.js +2 -2
  10. data/dist/controllers/color_picker_controller.js +92 -7
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +226 -22
  13. data/dist/controllers/confirm_controller.js +32 -5
  14. data/dist/controllers/context_menu_controller.js +32 -10
  15. data/dist/controllers/countdown_controller.js +112 -12
  16. data/dist/controllers/data_grid_controller.js +82 -4
  17. data/dist/controllers/date_range_picker_controller.js +77 -3
  18. data/dist/controllers/dialog_controller.js +32 -5
  19. data/dist/controllers/drawer_controller.js +32 -5
  20. data/dist/controllers/editable_controller.js +1 -0
  21. data/dist/controllers/empty_state_controller.js +24 -10
  22. data/dist/controllers/focus_controller.js +32 -5
  23. data/dist/controllers/form_validation_controller.js +1 -1
  24. data/dist/controllers/frame_loading_controller.js +177 -15
  25. data/dist/controllers/intersection_controller.js +36 -11
  26. data/dist/controllers/lazy_frame_controller.js +31 -10
  27. data/dist/controllers/listbox_controller.js +257 -53
  28. data/dist/controllers/local_time_controller.js +102 -8
  29. data/dist/controllers/menu_controller.js +104 -17
  30. data/dist/controllers/menubar_controller.js +415 -63
  31. data/dist/controllers/meter_controller.js +145 -26
  32. data/dist/controllers/multi_select_controller.js +312 -29
  33. data/dist/controllers/navigation_menu_controller.js +154 -27
  34. data/dist/controllers/network_status_controller.js +28 -8
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +81 -13
  38. data/dist/controllers/overflow_menu_controller.js +408 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/persist_controller.js +6 -6
  41. data/dist/controllers/pointer_drag_controller.js +9 -1
  42. data/dist/controllers/popover_controller.js +2 -2
  43. data/dist/controllers/progress_controller.js +116 -9
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +100 -9
  46. data/dist/controllers/rating_controller.js +69 -2
  47. data/dist/controllers/read_more_controller.js +63 -19
  48. data/dist/controllers/relative_time_controller.js +133 -12
  49. data/dist/controllers/resizable_controller.js +65 -1
  50. data/dist/controllers/roving_controller.js +17 -2
  51. data/dist/controllers/scroll_area_controller.js +86 -12
  52. data/dist/controllers/scroll_restore_controller.js +1 -1
  53. data/dist/controllers/scroll_visibility_controller.js +33 -3
  54. data/dist/controllers/scrollspy_controller.js +346 -73
  55. data/dist/controllers/separator_controller.js +9 -0
  56. data/dist/controllers/sidebar_controller.js +37 -8
  57. data/dist/controllers/skeleton_controller.js +73 -20
  58. data/dist/controllers/slider_controller.js +49 -8
  59. data/dist/controllers/sortable_controller.js +34 -3
  60. data/dist/controllers/spinner_controller.js +228 -27
  61. data/dist/controllers/step_indicator_controller.js +82 -5
  62. data/dist/controllers/stick_to_bottom_controller.js +61 -10
  63. data/dist/controllers/sticky_observer_controller.js +32 -11
  64. data/dist/controllers/switch_controller.js +1 -0
  65. data/dist/controllers/tabs_controller.js +26 -3
  66. data/dist/controllers/tags_input_controller.js +22 -2
  67. data/dist/controllers/theme_controller.js +22 -3
  68. data/dist/controllers/time_picker_controller.js +20 -1
  69. data/dist/controllers/toast_controller.js +4 -5
  70. data/dist/controllers/toggle_group_controller.js +23 -2
  71. data/dist/controllers/toolbar_controller.js +230 -31
  72. data/dist/controllers/tree_view_controller.js +467 -51
  73. data/dist/index.js +4507 -907
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +2 -2
@@ -2,6 +2,52 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/overflow_menu_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
34
+ // src/utils/focus_candidate.ts
35
+ function inheritsFieldsetDisabled(control) {
36
+ let fieldset = control.closest("fieldset[disabled]");
37
+ while (fieldset) {
38
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
39
+ if (!legend?.contains(control)) return true;
40
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
41
+ }
42
+ return false;
43
+ }
44
+ function canTakeFocus(element) {
45
+ if (element.closest("[hidden]")) return false;
46
+ if (!("disabled" in element)) return true;
47
+ if (element.disabled) return false;
48
+ return !inheritsFieldsetDisabled(element);
49
+ }
50
+
5
51
  // src/utils/layout_observer.ts
6
52
  var LayoutObserver = class {
7
53
  #callback;
@@ -111,7 +157,42 @@ var SafeTimeout = class extends TimerRegistry {
111
157
  }
112
158
  };
113
159
 
160
+ // src/utils/tabindex_loan.ts
161
+ var TabindexLoan = class {
162
+ #value;
163
+ #lent = /* @__PURE__ */ new Set();
164
+ /**
165
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
166
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
167
+ * which a scroll region with no focusable content of its own needs.
168
+ */
169
+ constructor(value = "-1") {
170
+ this.#value = value;
171
+ }
172
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
173
+ lend(element) {
174
+ if (element.hasAttribute("tabindex")) return;
175
+ element.setAttribute("tabindex", this.#value);
176
+ this.#lent.add(element);
177
+ }
178
+ /** Takes back every loan whose value is still the one that was lent. */
179
+ returnAll() {
180
+ for (const element of this.#lent) {
181
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
182
+ }
183
+ this.#lent.clear();
184
+ }
185
+ };
186
+
114
187
  // src/controllers/overflow_menu_controller.ts
188
+ var BANKED = "data-stimeo--overflow-menu-banked";
189
+ var INDEX = "data-stimeo--overflow-menu-index";
190
+ var BOUNDARY = "data-stimeo--overflow-menu-boundary";
191
+ var SAVED_ROLE = "data-stimeo--overflow-menu-role";
192
+ var SAVED_TABINDEX = "data-stimeo--overflow-menu-tabindex";
193
+ var SAVED_MENU_TARGET = "data-stimeo--overflow-menu-menu-target";
194
+ var MENU_TARGET = "data-stimeo--menu-target";
195
+ var BOOKKEEPING = [BANKED, INDEX, SAVED_ROLE, SAVED_TABINDEX, SAVED_MENU_TARGET];
115
196
  var OverflowMenuController = class extends Controller {
116
197
  static targets = ["items", "more"];
117
198
  static values = {
@@ -122,24 +203,24 @@ var OverflowMenuController = class extends Controller {
122
203
  static events = ["change"];
123
204
  #layout = new LayoutObserver(() => this.#scheduleUpdate());
124
205
  #timers = new SafeTimeout();
125
- /** The managed items in canonical (authored) order, captured at connect. */
206
+ /** The managed items in canonical (authored) order, rebuilt from the DOM each pass. */
126
207
  #items = [];
127
208
  #index = /* @__PURE__ */ new WeakMap();
128
- /**
129
- * Cached bar-context width per item. An item's natural width is location-independent,
130
- * so this lets a measure pass read only the items currently in the bar (menu items
131
- * reuse their last value) instead of pulling everything back to measure — one reflow,
132
- * not two.
133
- */
209
+ /** Cached bar-context width per item; see the class remarks for the staleness caveat. */
134
210
  #widths = /* @__PURE__ */ new WeakMap();
135
211
  /** Last reported overflow count, so `change` fires only on transitions. */
136
212
  #lastHidden = null;
213
+ /** The `tabindex` this instance lends the root for the focus fallback. */
214
+ #tabindex = new TabindexLoan();
215
+ /** Hands Turbo a pristine snapshot of the bar, with every item back in place. */
216
+ #beforeCache = new BeforeCacheReset(() => this.#restoreAll());
137
217
  connect() {
138
218
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
139
219
  const trigger = this.#trigger();
140
- if (trigger && (trigger.textContent ?? "").trim() === "") {
220
+ if (trigger !== null && this.#isBareTrigger(trigger)) {
141
221
  trigger.textContent = this.moreLabelValue;
142
222
  }
223
+ this.#beforeCache.activate();
143
224
  this.#layout.observe(this.element);
144
225
  this.#layout.observeViewport();
145
226
  this.update();
@@ -147,6 +228,8 @@ var OverflowMenuController = class extends Controller {
147
228
  disconnect() {
148
229
  this.#layout.disconnect();
149
230
  this.#timers.clearAll();
231
+ this.#beforeCache.deactivate();
232
+ this.#restoreAll();
150
233
  this.#lastHidden = null;
151
234
  }
152
235
  /** Re-measures and rebalances items between the bar and the More menu. */
@@ -154,36 +237,67 @@ var OverflowMenuController = class extends Controller {
154
237
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
155
238
  this.#syncItems();
156
239
  this.moreTarget.hidden = false;
240
+ if (this.#items.some((item) => item.parentElement !== this.itemsTarget && !this.#widths.has(item))) {
241
+ this.#removeBoundary();
242
+ for (const item of this.#items) this.#unbank(item);
243
+ this.#reorder(this.itemsTarget, this.#items);
244
+ }
157
245
  for (const item of this.#items) {
158
246
  if (item.parentElement === this.itemsTarget) this.#widths.set(item, item.offsetWidth);
159
247
  }
160
248
  const moreWidth = (this.#trigger() ?? this.moreTarget).offsetWidth;
161
- const gap = this.#gap();
249
+ const itemGap = this.#gap(this.itemsTarget);
250
+ const barGap = this.#gap(this.element);
162
251
  const widthOf = (el) => this.#widths.get(el) ?? el.offsetWidth;
163
- const containerWidth = this.element.clientWidth;
164
- const itemsWidth = this.#items.reduce((sum, el) => sum + widthOf(el), 0);
165
- let visibleWidth = itemsWidth + Math.max(0, this.#items.length - 1) * gap;
252
+ const containerWidth = this.element.clientWidth - this.#paddingX(this.element);
253
+ const rendered = this.#items.filter((item) => this.#rendered(item));
254
+ const itemsWidth = rendered.reduce((sum, el) => sum + widthOf(el), 0);
255
+ let visibleWidth = itemsWidth + Math.max(0, rendered.length - 1) * itemGap;
166
256
  const hidden = /* @__PURE__ */ new Set();
167
257
  if (visibleWidth > containerWidth) {
168
- const budget = containerWidth - moreWidth - gap;
169
- const dropOrder = [...this.#items].sort(
258
+ const budget = containerWidth - moreWidth - barGap;
259
+ const dropOrder = [...rendered].sort(
170
260
  (a, b) => this.#rank(b) - this.#rank(a) || this.#indexOf(b) - this.#indexOf(a)
171
261
  );
172
262
  for (const item of dropOrder) {
173
263
  if (visibleWidth <= budget) break;
174
264
  hidden.add(item);
175
- visibleWidth -= widthOf(item) + gap;
265
+ visibleWidth -= widthOf(item) + itemGap;
176
266
  }
177
267
  }
178
- const menu = this.#menuList();
179
- const active = document.activeElement;
180
- const refocusTrigger = active instanceof HTMLElement && hidden.has(active);
268
+ const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
269
+ const focused = active === null ? null : this.#ownerOf(active);
270
+ const retreating = focused !== null && hidden.has(focused) && focused.parentElement === this.itemsTarget && // i.e. *starting* to retreat
271
+ !this.#menuExpanded();
272
+ const returning = focused !== null && !hidden.has(focused) && focused.parentElement !== this.itemsTarget && // i.e. *leaving* the menu
273
+ this.#menuExpanded();
274
+ if (hidden.size === 0 || hidden.size !== this.#items.length) this.#removeBoundary();
181
275
  for (const item of this.#items) {
182
- if (hidden.has(item)) this.#toMenu(item, menu);
183
- else this.#toBar(item);
276
+ if (hidden.has(item)) this.#bank(item);
277
+ else this.#unbank(item);
184
278
  }
185
- if (refocusTrigger) this.#trigger()?.focus();
279
+ this.#reorder(
280
+ this.#menuList(),
281
+ this.#items.filter((item) => hidden.has(item))
282
+ );
283
+ this.#reorder(
284
+ this.itemsTarget,
285
+ this.#items.filter((item) => !hidden.has(item))
286
+ );
287
+ if (retreating) (this.#trigger() ?? this.moreTarget).focus();
288
+ else if (active !== null && this.#lostFocus(active)) active.focus();
186
289
  const count = hidden.size;
290
+ for (const item of this.#items) {
291
+ if (count > 0) item.setAttribute(INDEX, String(this.#indexOf(item)));
292
+ else item.removeAttribute(INDEX);
293
+ }
294
+ if (count > 0 && count === this.#items.length) this.#ensureBoundary();
295
+ else this.#removeBoundary();
296
+ const inMore = document.activeElement instanceof HTMLElement && this.moreTarget.contains(document.activeElement);
297
+ if (count === 0 || returning) {
298
+ this.#closeMenu();
299
+ if (inMore) this.#rescueFocus();
300
+ }
187
301
  this.moreTarget.hidden = count === 0;
188
302
  if (count > 0) this.element.setAttribute("data-overflowing", "true");
189
303
  else this.element.removeAttribute("data-overflowing");
@@ -193,30 +307,142 @@ var OverflowMenuController = class extends Controller {
193
307
  this.dispatch("change", { detail: { visible: this.#items.length - count, hidden: count } });
194
308
  }
195
309
  }
196
- /** The flex `column-gap` between items in px (0 when none / unsupported). */
197
- #gap() {
198
- if (typeof window.getComputedStyle !== "function") return 0;
199
- const style = window.getComputedStyle(this.itemsTarget);
200
- const value = Number.parseFloat(style.columnGap || style.gap || "");
201
- return Number.isNaN(value) ? 0 : value;
310
+ /** The flex `column-gap` on `el` in px (0 when none / unsupported). */
311
+ #gap(el) {
312
+ const style = this.#styleOf(el);
313
+ return style === null ? 0 : this.#px(style.columnGap || style.gap);
314
+ }
315
+ /** Horizontal padding on `el` in px — `clientWidth` counts it, the content box cannot. */
316
+ #paddingX(el) {
317
+ const style = this.#styleOf(el);
318
+ return style === null ? 0 : this.#px(style.paddingLeft) + this.#px(style.paddingRight);
319
+ }
320
+ #styleOf(el) {
321
+ return typeof window.getComputedStyle === "function" ? window.getComputedStyle(el) : null;
322
+ }
323
+ #px(value) {
324
+ const parsed = Number.parseFloat(value ?? "");
325
+ return Number.isNaN(parsed) ? 0 : parsed;
202
326
  }
203
327
  /**
204
- * Reconciles the managed item set with the DOM each pass: adopts items appended to the
205
- * bar since last time (so the documented "add items, then call `update`" flow works)
206
- * and drops any the consumer removed, then renumbers canonical indices. Banked items
207
- * live in the menu (still within the controller element) and are retained.
328
+ * Rebuilds the managed set and the canonical order from the DOM. The bar's children
329
+ * give the order of everything inline (so a leading insert is not silently appended)
330
+ * and {@link #merge} weaves the banked items back among them.
208
331
  */
209
332
  #syncItems() {
210
- const known = new Set(this.#items);
333
+ const previous = this.#items;
334
+ const bar = [];
335
+ let boundaryAt;
211
336
  for (const el of this.itemsTarget.children) {
212
- if (el instanceof HTMLElement && !known.has(el)) this.#items.push(el);
337
+ if (el instanceof HTMLTemplateElement && el.hasAttribute(BOUNDARY)) {
338
+ boundaryAt ??= bar.length;
339
+ continue;
340
+ }
341
+ if (el instanceof HTMLElement) bar.push(el);
342
+ }
343
+ const banked = [];
344
+ for (const el of this.#menuList().children) {
345
+ if (el instanceof HTMLElement && el.hasAttribute(BANKED)) banked.push(el);
213
346
  }
214
- this.#items = this.#items.filter((el) => this.element.contains(el));
347
+ banked.sort((a, b) => this.#bankedIndex(a) - this.#bankedIndex(b));
348
+ const items = this.#merge(bar, banked, boundaryAt);
349
+ for (const el of previous) {
350
+ if (!items.includes(el)) {
351
+ this.#unbank(el);
352
+ el.removeAttribute(INDEX);
353
+ }
354
+ }
355
+ this.#items = items;
215
356
  this.#index = /* @__PURE__ */ new WeakMap();
216
- this.#items.forEach((el, i) => {
357
+ items.forEach((el, i) => {
217
358
  this.#index.set(el, i);
218
359
  });
219
360
  }
361
+ /**
362
+ * Interleaves the banked items back among the bar's children.
363
+ *
364
+ * A saved index is a position in the *canonical* order, so it can only be compared
365
+ * against another canonical position — never used as an offset into the current bar,
366
+ * which is a shorter and differently-indexed list. (Doing so survives an untouched
367
+ * round trip and silently scrambles the order as soon as the consumer inserts into a
368
+ * bar that is already short, and `#bank()` then burns the scrambled index back into
369
+ * the attribute, so widening never heals it.) Each bar child the controller already
370
+ * knows carries its canonical index from the previous pass; a banked item goes in
371
+ * front of the first known child whose index is higher.
372
+ *
373
+ * Children never seen before — inserted by the consumer since the last pass — have no
374
+ * canonical position yet, so they keep their DOM neighbours. The exception is a
375
+ * *trailing* run of them: appending to the bar means appending to the toolbar, so
376
+ * everything banked is emitted before it.
377
+ *
378
+ * With no known child at all (a fresh instance connecting to markup that already
379
+ * holds banked items), a fully-banked snapshot's inert boundary preserves whether an
380
+ * unindexed run was prepended or appended before connect. Older or server-rendered
381
+ * markup has no boundary; for that compatibility path the saved index is used as the
382
+ * offset, the inverse of the move that banked it.
383
+ */
384
+ #merge(bar, banked, boundaryAt) {
385
+ const canonical = bar.map((el) => this.#index.get(el) ?? this.#savedIndex(el));
386
+ let lastKnown = -1;
387
+ canonical.forEach((index, i) => {
388
+ if (index !== void 0) lastKnown = i;
389
+ });
390
+ if (lastKnown === -1) {
391
+ if (boundaryAt !== void 0) {
392
+ return [...bar.slice(0, boundaryAt), ...banked, ...bar.slice(boundaryAt)];
393
+ }
394
+ const restored = [...bar];
395
+ for (const item of banked) {
396
+ restored.splice(Math.min(this.#bankedIndex(item), restored.length), 0, item);
397
+ }
398
+ return restored;
399
+ }
400
+ const items = [];
401
+ let next = 0;
402
+ const drainWhile = (accept) => {
403
+ while (next < banked.length) {
404
+ const item = banked[next];
405
+ if (item === void 0 || !accept(this.#bankedIndex(item))) break;
406
+ items.push(item);
407
+ next++;
408
+ }
409
+ };
410
+ bar.forEach((el, i) => {
411
+ const index = canonical[i];
412
+ if (index !== void 0) drainWhile((saved) => saved < index);
413
+ else if (i > lastKnown) drainWhile(() => true);
414
+ items.push(el);
415
+ });
416
+ drainWhile(() => true);
417
+ return items;
418
+ }
419
+ /** The canonical index a banked item saved, or ∞ (append) when it carries none. */
420
+ #bankedIndex(item) {
421
+ return this.#savedIndex(item) ?? Number.POSITIVE_INFINITY;
422
+ }
423
+ /** A finite canonical index stored on either side of the overflow split. */
424
+ #savedIndex(item) {
425
+ const raw = item.getAttribute(INDEX);
426
+ if (raw === null) return void 0;
427
+ const value = Number(raw);
428
+ return Number.isFinite(value) ? value : void 0;
429
+ }
430
+ /** The inert split point written only when every managed item lives in More. */
431
+ #boundary() {
432
+ for (const el of this.itemsTarget.children) {
433
+ if (el instanceof HTMLTemplateElement && el.hasAttribute(BOUNDARY)) return el;
434
+ }
435
+ return null;
436
+ }
437
+ #ensureBoundary() {
438
+ if (this.#boundary() !== null) return;
439
+ const boundary = document.createElement("template");
440
+ boundary.setAttribute(BOUNDARY, "");
441
+ this.itemsTarget.appendChild(boundary);
442
+ }
443
+ #removeBoundary() {
444
+ this.#boundary()?.remove();
445
+ }
220
446
  /** Debounced re-measure for resize-driven churn. */
221
447
  #scheduleUpdate() {
222
448
  this.#timers.clearAll();
@@ -229,41 +455,166 @@ var OverflowMenuController = class extends Controller {
229
455
  #trigger() {
230
456
  return this.moreTarget.querySelector('[data-stimeo--menu-target="trigger"]');
231
457
  }
232
- /** Restores an item to the bar, stripping the menu semantics we may have added. */
233
- #toBar(item) {
234
- this.itemsTarget.appendChild(item);
235
- if (item.dataset.overflowMenuized === void 0) return;
236
- item.removeAttribute("data-stimeo--menu-target");
237
- this.#restoreAttr(item, "role", item.dataset.overflowOrigRole);
238
- this.#restoreAttr(item, "tabindex", item.dataset.overflowOrigTabindex);
239
- delete item.dataset.overflowMenuized;
240
- delete item.dataset.overflowOrigRole;
241
- delete item.dataset.overflowOrigTabindex;
242
- }
243
- /** Banks an item into the menu with menuitem semantics for Menu to manage. */
244
- #toMenu(item, menu) {
245
- if (item.dataset.overflowMenuized === void 0) {
246
- item.dataset.overflowMenuized = "true";
247
- item.dataset.overflowOrigRole = item.getAttribute("role") ?? "";
248
- item.dataset.overflowOrigTabindex = item.getAttribute("tabindex") ?? "";
458
+ /**
459
+ * Whether the More trigger is bare enough for the label to be safe to write.
460
+ * Assigning `textContent` replaces *every* child node, so a trigger holding an
461
+ * icon would silently lose it — and the loss is permanent, since the restore
462
+ * pass returns items but not authored trigger content. A trigger that already
463
+ * carries an accessible name is left alone too: injecting visible text under a
464
+ * different `aria-label` would make the label and the name disagree. A trigger
465
+ * with neither text, nor children, nor a name has nothing to lose and gets the
466
+ * fallback; one that is empty *and* unnamed is the only case the label rescues.
467
+ */
468
+ #isBareTrigger(trigger) {
469
+ return (trigger.textContent ?? "").trim() === "" && trigger.firstElementChild === null && !trigger.hasAttribute("aria-label") && !trigger.hasAttribute("aria-labelledby");
470
+ }
471
+ /**
472
+ * Whether the More menu is expanded. Menu owns that state and reflects it on the
473
+ * trigger's `aria-expanded`, so read the contract rather than guess from `hidden`
474
+ * (which is absent until Menu connects).
475
+ */
476
+ #menuExpanded() {
477
+ return this.#trigger()?.getAttribute("aria-expanded") === "true";
478
+ }
479
+ /**
480
+ * Collapses the More menu before its wrapper is hidden. Menu owns the state (and the
481
+ * Escape-stack membership that goes with it), so ask it; the DOM fallback covers markup
482
+ * that reached an expanded state with no Menu mounted.
483
+ */
484
+ #closeMenu() {
485
+ if (!this.#menuExpanded()) return;
486
+ const menu = this.application.getControllerForElementAndIdentifier(
487
+ this.moreTarget,
488
+ "stimeo--menu"
489
+ );
490
+ if (menu) {
491
+ menu.close();
492
+ return;
493
+ }
494
+ this.#trigger()?.setAttribute("aria-expanded", "false");
495
+ const list = this.#menuList();
496
+ if (list !== this.moreTarget) list.hidden = true;
497
+ }
498
+ /** The managed item that is, or contains, `el` — focus rescue works on either. */
499
+ #ownerOf(el) {
500
+ return this.#items.find((item) => item === el || item.contains(el)) ?? null;
501
+ }
502
+ /** Whether the item takes up room in the bar; an authored `hidden` one does not. */
503
+ #rendered(item) {
504
+ return !item.hasAttribute("hidden");
505
+ }
506
+ /**
507
+ * Moves focus out of the More wrapper just before it is hidden, to the last
508
+ * item in canonical order that can hold it. When every item refuses, the root
509
+ * takes focus instead of letting the browser drop it to `<body>`, borrowing a
510
+ * `tabindex="-1"` just-in-time — not a Tab stop, and handed back on restore
511
+ * when this controller was the one that added it.
512
+ */
513
+ #rescueFocus() {
514
+ for (let i = this.#items.length - 1; i >= 0; i--) {
515
+ const item = this.#items[i];
516
+ if (item !== void 0 && canTakeFocus(item)) {
517
+ item.focus();
518
+ return;
519
+ }
520
+ }
521
+ this.#tabindex.lend(this.element);
522
+ this.element.focus();
523
+ }
524
+ /**
525
+ * Whether `el` held focus, is still in a visible part of this controller, and lost it —
526
+ * what a re-insert does in a real browser. An item that retreated into a collapsed menu
527
+ * is excluded: it sits in a `hidden` subtree and must not be focused back.
528
+ */
529
+ #lostFocus(el) {
530
+ return el.isConnected && document.activeElement !== el && this.element.contains(el) && el.closest("[hidden]") === null;
531
+ }
532
+ /** Returns every item to the bar and removes all traces of this controller. */
533
+ #restoreAll() {
534
+ if (!this.hasItemsTarget || !this.hasMoreTarget) return;
535
+ this.#syncItems();
536
+ this.#removeBoundary();
537
+ this.#reorder(this.itemsTarget, this.#items);
538
+ for (const item of this.#items) {
539
+ this.#unbank(item);
540
+ item.removeAttribute(INDEX);
541
+ }
542
+ this.#closeMenu();
543
+ this.moreTarget.hidden = true;
544
+ this.element.removeAttribute("data-overflowing");
545
+ this.element.removeAttribute("data-overflow-count");
546
+ this.#tabindex.returnAll();
547
+ }
548
+ /**
549
+ * Makes `parent`'s managed children match `sequence`, moving only what is out of place.
550
+ * Walking backwards lets each item be compared against the element that should follow
551
+ * it, so an unchanged run costs zero DOM mutations — the point of the exercise, since
552
+ * every re-insert blurs the focused node.
553
+ */
554
+ #reorder(parent, sequence) {
555
+ let next = null;
556
+ for (let i = sequence.length - 1; i >= 0; i--) {
557
+ const item = sequence[i];
558
+ if (item === void 0) continue;
559
+ if (item.parentElement !== parent || item.nextElementSibling !== next) {
560
+ this.#insert(parent, item, next);
561
+ }
562
+ next = item;
563
+ }
564
+ }
565
+ /** Relocates `item`, preferring `moveBefore` (keeps focus and element state). */
566
+ #insert(parent, item, before) {
567
+ const move = parent.moveBefore;
568
+ if (typeof move === "function") {
569
+ try {
570
+ move.call(parent, item, before);
571
+ return;
572
+ } catch {
573
+ }
574
+ }
575
+ parent.insertBefore(item, before);
576
+ }
577
+ /**
578
+ * Gives an item menuitem semantics for Menu to drive and records its canonical index,
579
+ * saving every authored value it overwrites ("" means the attribute was absent).
580
+ */
581
+ #bank(item) {
582
+ if (!item.hasAttribute(BANKED)) {
583
+ item.setAttribute(BANKED, "true");
584
+ item.setAttribute(SAVED_ROLE, item.getAttribute("role") ?? "");
585
+ item.setAttribute(SAVED_TABINDEX, item.getAttribute("tabindex") ?? "");
586
+ item.setAttribute(SAVED_MENU_TARGET, item.getAttribute(MENU_TARGET) ?? "");
249
587
  item.setAttribute("role", "menuitem");
250
588
  item.setAttribute("tabindex", "-1");
251
- item.setAttribute("data-stimeo--menu-target", "item");
589
+ item.setAttribute(MENU_TARGET, "item");
252
590
  }
253
- menu.appendChild(item);
591
+ item.setAttribute(INDEX, String(this.#indexOf(item)));
592
+ }
593
+ /** Undoes the banking: restores every authored value and drops the bookkeeping. */
594
+ #unbank(item) {
595
+ if (!item.hasAttribute(BANKED)) return;
596
+ this.#restoreAttr(item, "role", item.getAttribute(SAVED_ROLE));
597
+ this.#restoreAttr(item, "tabindex", item.getAttribute(SAVED_TABINDEX));
598
+ this.#restoreAttr(item, MENU_TARGET, item.getAttribute(SAVED_MENU_TARGET));
599
+ for (const name of BOOKKEEPING) item.removeAttribute(name);
254
600
  }
255
601
  /** Re-applies a saved attribute value, or removes the attribute when it was absent. */
256
602
  #restoreAttr(item, name, original) {
257
- if (original === void 0 || original === "") item.removeAttribute(name);
603
+ if (original === null || original === "") item.removeAttribute(name);
258
604
  else item.setAttribute(name, original);
259
605
  }
260
606
  #indexOf(item) {
261
607
  return this.#index.get(item) ?? 0;
262
608
  }
263
- /** Retention rank: lower keeps longer; no `data-priority` ranks lowest (drops first). */
609
+ /**
610
+ * Retention rank: lower keeps longer. An absent, empty, or non-numeric `data-priority`
611
+ * ranks lowest (drops first) — empty is the common ERB accident
612
+ * (`data-priority="<%= maybe_nil %>"`), and `Number("")` is 0, which would otherwise
613
+ * read as the *highest* retention.
614
+ */
264
615
  #rank(item) {
265
616
  const raw = item.getAttribute("data-priority");
266
- if (raw === null) return Number.POSITIVE_INFINITY;
617
+ if (raw === null || raw.trim() === "") return Number.POSITIVE_INFINITY;
267
618
  const value = Number(raw);
268
619
  return Number.isNaN(value) ? Number.POSITIVE_INFINITY : value;
269
620
  }