stimeo-ui 0.16.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,37 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/overflow_menu_controller.ts
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/event_owner.ts
35
3
  function ownerIndex(candidates, node) {
36
4
  if (!(node instanceof Node)) return -1;
37
5
  return candidates.findIndex((candidate) => candidate.contains(node));
@@ -40,7 +8,6 @@ function ownerOf(candidates, node) {
40
8
  return candidates[ownerIndex(candidates, node)] ?? null;
41
9
  }
42
10
 
43
- // src/utils/focus_candidate.ts
44
11
  function inheritsFieldsetDisabled(control) {
45
12
  let fieldset = control.closest("fieldset[disabled]");
46
13
  while (fieldset) {
@@ -50,26 +17,34 @@ function inheritsFieldsetDisabled(control) {
50
17
  }
51
18
  return false;
52
19
  }
20
+ function unslotted(element) {
21
+ return element.assignedSlot === null && element.parentElement?.shadowRoot != null;
22
+ }
23
+ function flatTreeParent(element) {
24
+ if (unslotted(element)) return null;
25
+ const parent = element.assignedSlot ?? element.parentNode;
26
+ if (parent instanceof ShadowRoot) return parent.host;
27
+ return parent instanceof Element ? parent : null;
28
+ }
53
29
  function canTakeFocus(element) {
54
- if (element.closest("[hidden], [inert]")) return false;
30
+ for (let node = element; node; node = flatTreeParent(node)) {
31
+ if (unslotted(node) || node.matches("[hidden], [inert]")) return false;
32
+ }
55
33
  if (element instanceof HTMLInputElement && element.type === "hidden") return false;
56
34
  if (!("disabled" in element)) return true;
57
35
  if (element.disabled) return false;
58
36
  return !inheritsFieldsetDisabled(element);
59
37
  }
60
38
 
61
- // src/utils/layout_observer.ts
62
39
  var LayoutObserver = class {
63
40
  #callback;
64
41
  #resizeObserverFactory;
65
42
  #resizeObserver = null;
66
43
  #observingViewport = false;
67
44
  #loadContainer = null;
68
- /** Stable bound handler so add/removeEventListener target the same reference. */
69
45
  #handleViewportResize = () => {
70
46
  this.#callback();
71
47
  };
72
- /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
73
48
  #handleDescendantLoad = () => {
74
49
  this.#callback();
75
50
  };
@@ -77,11 +52,6 @@ var LayoutObserver = class {
77
52
  this.#callback = callback;
78
53
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
79
54
  }
80
- /**
81
- * Starts observing an element's size. Repeated calls observe additional
82
- * elements through the same shared observer. No-ops when no
83
- * `ResizeObserver` implementation is available.
84
- */
85
55
  observe(element) {
86
56
  if (!this.#resizeObserverFactory) return;
87
57
  if (!this.#resizeObserver) {
@@ -91,47 +61,28 @@ var LayoutObserver = class {
91
61
  }
92
62
  this.#resizeObserver.observe(element);
93
63
  }
94
- /** Stops observing a single element while leaving any others in place. */
95
64
  unobserve(element) {
96
65
  this.#resizeObserver?.unobserve(element);
97
66
  }
98
- /** Starts observing viewport resizes. Idempotent: the listener is added once. */
99
67
  observeViewport() {
100
68
  if (this.#observingViewport) return;
101
69
  this.#observingViewport = true;
102
70
  window.addEventListener("resize", this.#handleViewportResize);
103
71
  }
104
- /** Stops observing viewport resizes without affecting element observation. */
105
72
  unobserveViewport() {
106
73
  if (!this.#observingViewport) return;
107
74
  this.#observingViewport = false;
108
75
  window.removeEventListener("resize", this.#handleViewportResize);
109
76
  }
110
- /**
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
77
  observeDescendantLoads(container) {
121
78
  this.unobserveDescendantLoads();
122
79
  this.#loadContainer = container;
123
80
  container.addEventListener("load", this.#handleDescendantLoad, true);
124
81
  }
125
- /** Stops reporting descendant loads without affecting element or viewport observation. */
126
82
  unobserveDescendantLoads() {
127
83
  this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
128
84
  this.#loadContainer = null;
129
85
  }
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()`.
134
- */
135
86
  disconnect() {
136
87
  this.#resizeObserver?.disconnect();
137
88
  this.#resizeObserver = null;
@@ -140,43 +91,134 @@ var LayoutObserver = class {
140
91
  }
141
92
  };
142
93
 
143
- // src/utils/safe_timeout.ts
94
+ var ListenerSet = class {
95
+ #abort = new AbortController();
96
+ add(target, type, handler, options) {
97
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
98
+ }
99
+ dispose() {
100
+ this.#abort.abort();
101
+ this.#abort = new AbortController();
102
+ }
103
+ };
104
+
105
+ var MicrotaskCoalescer = class {
106
+ #run;
107
+ #queued = false;
108
+ #active = false;
109
+ #generation = 0;
110
+ constructor(run) {
111
+ this.#run = run;
112
+ }
113
+ activate() {
114
+ this.#active = true;
115
+ }
116
+ cancel() {
117
+ this.#active = false;
118
+ this.#queued = false;
119
+ this.#generation += 1;
120
+ }
121
+ schedule() {
122
+ if (!this.#active || this.#queued) return;
123
+ this.#queued = true;
124
+ const generation = this.#generation;
125
+ queueMicrotask(() => {
126
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
127
+ this.#queued = false;
128
+ this.#run();
129
+ });
130
+ }
131
+ };
132
+
133
+ var MorphRenderWatcher = class {
134
+ #listeners = new ListenerSet();
135
+ #pass;
136
+ #element = null;
137
+ #onMorph = () => this.#pass.schedule();
138
+ constructor(run) {
139
+ this.#pass = new MicrotaskCoalescer(run);
140
+ }
141
+ observe(element) {
142
+ if (this.#element === element) return;
143
+ this.disconnect();
144
+ this.#element = element;
145
+ this.#pass.activate();
146
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
147
+ }
148
+ schedule() {
149
+ this.#pass.schedule();
150
+ }
151
+ disconnect() {
152
+ this.#listeners.dispose();
153
+ this.#pass.cancel();
154
+ this.#element = null;
155
+ }
156
+ };
157
+
158
+ var MAX_TIMER_DELAY_MS = 2147483647;
159
+
160
+ var NUMBER_BOUNDS = {
161
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
162
+ function matchesNumberBounds(value, bounds) {
163
+ if (!Number.isFinite(value)) {
164
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
165
+ if (direction === null) return false;
166
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
167
+ }
168
+ if (bounds.min !== void 0 && value < bounds.min) return false;
169
+ if (bounds.max !== void 0 && value > bounds.max) return false;
170
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
171
+ if (bounds.integer && !Number.isInteger(value)) return false;
172
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
173
+ return true;
174
+ }
175
+
176
+ function readNumber(raw, fallback, bounds) {
177
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
178
+ }
179
+
180
+ var NumberValueReader = class {
181
+ #lastRejected = /* @__PURE__ */ new Map();
182
+ read(owner, name, raw, fallback, bounds) {
183
+ const resolved = readNumber(raw, fallback, bounds);
184
+ if (matchesNumberBounds(raw, bounds)) {
185
+ this.#lastRejected.delete(name);
186
+ return resolved;
187
+ }
188
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
189
+ const literal = owner.element.getAttribute(attribute);
190
+ if (literal === null) {
191
+ this.#lastRejected.delete(name);
192
+ return resolved;
193
+ }
194
+ if (this.#lastRejected.get(name) !== literal) {
195
+ this.#lastRejected.set(name, literal);
196
+ console.warn(
197
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
198
+ );
199
+ }
200
+ return resolved;
201
+ }
202
+ };
203
+
144
204
  var TimerRegistry = class {
145
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
146
205
  ids = /* @__PURE__ */ new Set();
147
- /**
148
- * Cancels a single tracked timer.
149
- *
150
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
151
- * registry), so callers can clear defensively without guarding.
152
- */
153
206
  clear(id) {
154
207
  if (this.ids.delete(id)) {
155
208
  this.cancel(id);
156
209
  }
157
210
  }
158
- /**
159
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
160
- * to guarantee no timer outlives the element.
161
- */
162
211
  clearAll() {
163
212
  for (const id of this.ids) {
164
213
  this.cancel(id);
165
214
  }
166
215
  this.ids.clear();
167
216
  }
168
- /** Number of timers currently tracked (pending). */
169
217
  get size() {
170
218
  return this.ids.size;
171
219
  }
172
220
  };
173
221
  var SafeTimeout = class extends TimerRegistry {
174
- /**
175
- * Schedules `callback` after `delay` ms and returns the timer id.
176
- *
177
- * The id is removed from the registry automatically when the timeout fires,
178
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
179
- */
180
222
  set(callback, delay) {
181
223
  const id = this.schedule(() => {
182
224
  this.ids.delete(id);
@@ -193,38 +235,121 @@ var SafeTimeout = class extends TimerRegistry {
193
235
  }
194
236
  };
195
237
 
196
- // src/utils/tabindex_loan.ts
197
- var TabindexLoan = class {
238
+ function queryOpenDescendants(root, selector) {
239
+ const matches = [];
240
+ const roots = [root];
241
+ for (const scope of roots) {
242
+ matches.push(...scope.querySelectorAll(selector));
243
+ if (scope.nodeType === Node.ELEMENT_NODE) {
244
+ const shadow = scope.shadowRoot;
245
+ if (shadow) roots.push(shadow);
246
+ }
247
+ for (const element of scope.querySelectorAll("*")) {
248
+ if (element.shadowRoot) roots.push(element.shadowRoot);
249
+ }
250
+ }
251
+ return matches;
252
+ }
253
+
254
+ function sharedRegistry(key, create) {
255
+ const symbol = Symbol.for(key);
256
+ const scope = globalThis;
257
+ const existing = scope[symbol];
258
+ if (existing !== void 0) return existing;
259
+ const registry = create();
260
+ Object.defineProperty(globalThis, symbol, { value: registry });
261
+ return registry;
262
+ }
263
+
264
+ var TabindexLoan = class _TabindexLoan {
265
+ static #registry = sharedRegistry(
266
+ "stimeo-ui.tabindex-loan.registry.v1",
267
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
268
+ );
198
269
  #value;
270
+ #record;
199
271
  #lent = /* @__PURE__ */ new Set();
200
- /** Returns live loans before Turbo can copy them into its page snapshot. */
201
- #beforeCache = new BeforeCacheReset(() => this.returnAll());
202
- /**
203
- * @param value - the `tabindex` to lend. `"-1"` (the default) is
204
- * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
205
- * which a scroll region with no focusable content of its own needs.
206
- */
207
- constructor(value = "-1") {
272
+ constructor(value, owner) {
208
273
  this.#value = value;
274
+ this.#record = `data-${owner}-tabindex-loan`;
275
+ _TabindexLoan.#registry.names.add(this.#record);
209
276
  }
210
- /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
211
277
  lend(element) {
212
- if (element.hasAttribute("tabindex")) return;
213
- element.setAttribute("tabindex", this.#value);
278
+ const stack = this.#stack(element);
279
+ const current = element.getAttribute("tabindex");
280
+ const held = stack.find((hold) => hold.loan === this) ?? stack.find(
281
+ (hold) => hold.loan === null && hold.record === this.#record && hold.value === this.#value
282
+ );
283
+ const linked = stack.at(-1)?.value === current;
284
+ if (!held && current !== null && !linked) return;
214
285
  this.#lent.add(element);
215
- this.#beforeCache.activate();
286
+ if (held) held.loan = this;
287
+ else stack.push({ loan: this, record: this.#record, value: this.#value, linked });
288
+ this.#mark(element, stack);
289
+ if (current === null || !held && current !== this.#value) {
290
+ element.setAttribute("tabindex", this.#value);
291
+ }
216
292
  }
217
- /** Takes back every loan whose value is still the one that was lent. */
218
293
  returnAll() {
219
294
  for (const element of this.#lent) {
220
- if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
295
+ const stack = this.#stack(element);
296
+ const hold = stack.find((held) => held.loan === this);
297
+ if (hold) this.#release(element, stack, hold);
221
298
  }
222
299
  this.#lent.clear();
223
- this.#beforeCache.deactivate();
300
+ }
301
+ reclaim(element) {
302
+ if (element.getAttribute(this.#record) !== this.#value) return;
303
+ const stack = this.#stack(element);
304
+ const hold = stack.find((held) => held.loan === null && held.record === this.#record);
305
+ if (hold && !element.matches(":focus")) this.#release(element, stack, hold);
306
+ }
307
+ reclaimWithin(root) {
308
+ for (const element of queryOpenDescendants(root, `[${this.#record}]`)) {
309
+ this.reclaim(element);
310
+ }
311
+ }
312
+ #stack(element) {
313
+ const stacks = _TabindexLoan.#registry.stacks;
314
+ const stack = stacks.get(element) ?? this.#seed(element);
315
+ stacks.set(element, stack);
316
+ return stack;
317
+ }
318
+ #seed(element) {
319
+ const current = element.getAttribute("tabindex");
320
+ const holds = [];
321
+ for (const record of _TabindexLoan.#registry.names) {
322
+ const value = element.getAttribute(record);
323
+ if (value !== null) holds.push({ loan: null, record, value, linked: true });
324
+ }
325
+ holds.sort((one, other) => Number(one.value === current) - Number(other.value === current));
326
+ for (const [index, hold] of holds.entries()) hold.linked = index > 0;
327
+ return holds;
328
+ }
329
+ #release(element, stack, hold) {
330
+ const current = element.getAttribute("tabindex");
331
+ const index = stack.indexOf(hold);
332
+ const owns = current === hold.value && !stack.slice(index + 1).some((above2) => above2.value === current);
333
+ stack.splice(index, 1);
334
+ const above = stack[index];
335
+ if (above) above.linked &&= hold.linked;
336
+ this.#mark(element, stack);
337
+ if (!owns) return;
338
+ const below = hold.linked ? stack[index - 1] : void 0;
339
+ if (!below) element.removeAttribute("tabindex");
340
+ else if (below.value !== current) element.setAttribute("tabindex", below.value);
341
+ }
342
+ #mark(element, stack) {
343
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
344
+ for (const hold of stack) records.set(hold.record, hold.value);
345
+ for (const [name, recorded] of records) {
346
+ if (element.getAttribute(name) === recorded) continue;
347
+ if (recorded === null) element.removeAttribute(name);
348
+ else element.setAttribute(name, recorded);
349
+ }
224
350
  }
225
351
  };
226
352
 
227
- // src/controllers/overflow_menu_controller.ts
228
353
  var BANKED = "banked";
229
354
  var INDEX = "index";
230
355
  var BOUNDARY = "boundary";
@@ -234,8 +359,8 @@ var SAVED_MENU_TARGET = "saved-menu";
234
359
  var MENU_TARGET = "data-stimeo--menu-target";
235
360
  var BOOKKEEPING = [BANKED, INDEX, SAVED_ROLE, SAVED_TABINDEX, SAVED_MENU_TARGET];
236
361
  var OWNED_LABEL = "owns-label";
237
- var OverflowMenuController = class extends Controller {
238
- /** One bookkeeping attribute, in the namespace this controller is registered under. */
362
+ var OverflowMenuController = class _OverflowMenuController extends Controller {
363
+ #numbers = new NumberValueReader();
239
364
  #attr(name) {
240
365
  return `data-${this.identifier}-${name}`;
241
366
  }
@@ -244,66 +369,84 @@ var OverflowMenuController = class extends Controller {
244
369
  moreLabel: { type: String, default: "More" },
245
370
  debounce: { type: Number, default: 100 }
246
371
  };
372
+ static valueConstraints = {
373
+ debounce: NUMBER_BOUNDS.timer
374
+ };
247
375
  static actions = ["update"];
248
- static events = ["change"];
376
+ static events = ["change", "reconcile"];
249
377
  #layout = new LayoutObserver(() => this.#scheduleUpdate());
250
378
  #timers = new SafeTimeout();
251
- /** The managed items in canonical (authored) order, rebuilt from the DOM each pass. */
252
379
  #items = [];
253
380
  #index = /* @__PURE__ */ new WeakMap();
254
- /** Cached bar-context width per item; see the class remarks for the staleness caveat. */
255
381
  #widths = /* @__PURE__ */ new WeakMap();
256
- /** Last reported overflow count, so `change` fires only on transitions. */
257
382
  #lastHidden = null;
258
- /** The `tabindex` this instance lends the root for the focus fallback. */
259
- #tabindex = new TabindexLoan();
260
- /** Hands Turbo a snapshot of the bar with every item back in place. */
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
- */
383
+ #pass = 0;
384
+ #restoring = false;
385
+ #tabindex = new TabindexLoan("-1", this.identifier);
386
+ #bar = null;
387
+ #wrapper = null;
266
388
  #connected = false;
389
+ #morphRender = new MorphRenderWatcher(() => this.#reconcile());
267
390
  connect() {
391
+ this.#tabindex.reclaim(this.element);
392
+ this.#morphRender.observe(this.element);
268
393
  this.#connected = true;
269
394
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
270
- this.#beforeCache.activate();
271
395
  this.#layout.observe(this.element);
272
396
  this.#layout.observeViewport();
273
397
  this.update();
274
398
  }
275
399
  disconnect() {
400
+ this.#morphRender.disconnect();
276
401
  this.#connected = false;
277
402
  this.#layout.disconnect();
278
403
  this.#timers.clearAll();
279
- this.#beforeCache.deactivate();
280
404
  this.#restoreAll();
281
405
  this.#lastHidden = null;
282
406
  }
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
- */
407
+ itemsTargetConnected() {
408
+ this.#followTargets();
409
+ }
410
+ itemsTargetDisconnected(bar) {
411
+ if (bar === this.#bar && !this.itemsTargets.includes(bar)) this.#release();
412
+ this.#followTargets();
413
+ }
414
+ moreTargetConnected() {
415
+ this.#followTargets();
416
+ }
417
+ moreTargetDisconnected(more) {
418
+ if (more === this.#wrapper && !this.moreTargets.includes(more)) this.#release();
419
+ this.#followTargets();
420
+ }
288
421
  moreLabelValueChanged() {
289
422
  if (!this.#connected || !this.hasItemsTarget || !this.hasMoreTarget) return;
290
423
  this.#syncLabel();
291
424
  }
292
- /** Re-measures and rebalances items between the bar and the More menu. */
293
425
  update() {
294
- if (!this.hasItemsTarget || !this.hasMoreTarget) return;
426
+ const detail = this.#render();
427
+ if (detail) this.dispatch("change", { detail });
428
+ }
429
+ #reconcile() {
430
+ const detail = this.#render();
431
+ if (detail) this.dispatch("reconcile", { detail });
432
+ }
433
+ #render() {
434
+ if (this.#restoring || !this.hasItemsTarget || !this.hasMoreTarget) return null;
435
+ const pass = ++this.#pass;
436
+ this.#bar = this.itemsTarget;
437
+ this.#wrapper = this.moreTarget;
295
438
  this.#syncLabel();
296
- this.#syncItems();
439
+ this.#syncItems(this.itemsTarget, this.moreTarget);
297
440
  this.moreTarget.hidden = false;
298
441
  if (this.#items.some((item) => item.parentElement !== this.itemsTarget && !this.#widths.has(item))) {
299
- this.#removeBoundary();
442
+ this.#removeBoundary(this.itemsTarget);
300
443
  for (const item of this.#items) this.#unbank(item);
301
444
  this.#reorder(this.itemsTarget, this.#items);
302
445
  }
303
446
  for (const item of this.#items) {
304
447
  if (item.parentElement === this.itemsTarget) this.#widths.set(item, item.offsetWidth);
305
448
  }
306
- const moreWidth = (this.#trigger() ?? this.moreTarget).offsetWidth;
449
+ const moreWidth = (this.#trigger(this.moreTarget) ?? this.moreTarget).offsetWidth;
307
450
  const itemGap = this.#gap(this.itemsTarget);
308
451
  const barGap = this.#gap(this.element);
309
452
  const widthOf = (el) => this.#widths.get(el) ?? el.offsetWidth;
@@ -325,36 +468,39 @@ var OverflowMenuController = class extends Controller {
325
468
  }
326
469
  const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
327
470
  const focused = active === null ? null : this.#ownerOf(active);
328
- const retreating = focused !== null && hidden.has(focused) && focused.parentElement === this.itemsTarget && // i.e. *starting* to retreat
329
- !this.#menuExpanded();
330
- const returning = focused !== null && !hidden.has(focused) && focused.parentElement !== this.itemsTarget && // i.e. *leaving* the menu
331
- this.#menuExpanded();
332
- if (hidden.size === 0 || hidden.size !== this.#items.length) this.#removeBoundary();
471
+ const retreating = focused !== null && hidden.has(focused) && focused.parentElement === this.itemsTarget &&
472
+ !this.#menuExpanded(this.moreTarget);
473
+ const returning = focused !== null && !hidden.has(focused) && focused.parentElement !== this.itemsTarget &&
474
+ this.#menuExpanded(this.moreTarget);
475
+ if (hidden.size === 0 || hidden.size !== this.#items.length) {
476
+ this.#removeBoundary(this.itemsTarget);
477
+ }
333
478
  for (const item of this.#items) {
334
479
  if (hidden.has(item)) this.#bank(item);
335
480
  else this.#unbank(item);
336
481
  }
337
482
  this.#reorder(
338
- this.#menuList(),
483
+ this.#menuList(this.moreTarget),
339
484
  this.#items.filter((item) => hidden.has(item))
340
485
  );
341
486
  this.#reorder(
342
487
  this.itemsTarget,
343
488
  this.#items.filter((item) => !hidden.has(item))
344
489
  );
345
- if (retreating) (this.#trigger() ?? this.moreTarget).focus();
490
+ if (retreating) (this.#trigger(this.moreTarget) ?? this.moreTarget).focus();
346
491
  else if (active !== null && this.#lostFocus(active)) active.focus();
492
+ if (pass !== this.#pass) return null;
347
493
  const count = hidden.size;
348
494
  for (const item of this.#items) {
349
495
  if (count > 0) item.setAttribute(this.#attr(INDEX), String(this.#indexOf(item)));
350
496
  else item.removeAttribute(this.#attr(INDEX));
351
497
  }
352
498
  if (count > 0 && count === this.#items.length) this.#ensureBoundary();
353
- else this.#removeBoundary();
354
499
  const inMore = document.activeElement instanceof HTMLElement && this.moreTarget.contains(document.activeElement);
355
500
  if (count === 0 || returning) {
356
- this.#closeMenu();
357
- if (inMore) this.#rescueFocus();
501
+ this.#closeMenu(this.moreTarget);
502
+ if (inMore && pass === this.#pass) this.#rescueFocus();
503
+ if (pass !== this.#pass) return null;
358
504
  }
359
505
  this.moreTarget.hidden = count === 0;
360
506
  if (count > 0) this.element.setAttribute("data-overflowing", "true");
@@ -362,15 +508,14 @@ var OverflowMenuController = class extends Controller {
362
508
  this.element.setAttribute("data-overflow-count", String(count));
363
509
  if (this.#lastHidden !== count) {
364
510
  this.#lastHidden = count;
365
- this.dispatch("change", { detail: { overflowCount: count, total: this.#items.length } });
511
+ return { overflowCount: count, total: this.#items.length };
366
512
  }
513
+ return null;
367
514
  }
368
- /** The flex `column-gap` on `el` in px (0 when none / unsupported). */
369
515
  #gap(el) {
370
516
  const style = this.#styleOf(el);
371
517
  return style === null ? 0 : this.#px(style.columnGap || style.gap);
372
518
  }
373
- /** Horizontal padding on `el` in px — `clientWidth` counts it, the content box cannot. */
374
519
  #paddingX(el) {
375
520
  const style = this.#styleOf(el);
376
521
  return style === null ? 0 : this.#px(style.paddingLeft) + this.#px(style.paddingRight);
@@ -382,16 +527,11 @@ var OverflowMenuController = class extends Controller {
382
527
  const parsed = Number.parseFloat(value ?? "");
383
528
  return Number.isNaN(parsed) ? 0 : parsed;
384
529
  }
385
- /**
386
- * Rebuilds the managed set and the canonical order from the DOM. The bar's children
387
- * give the order of everything inline (so a leading insert is not silently appended)
388
- * and {@link #merge} weaves the banked items back among them.
389
- */
390
- #syncItems() {
530
+ #syncItems(row, more) {
391
531
  const previous = this.#items;
392
532
  const bar = [];
393
533
  let boundaryAt;
394
- for (const el of this.itemsTarget.children) {
534
+ for (const el of row.children) {
395
535
  if (el instanceof HTMLTemplateElement && el.hasAttribute(this.#attr(BOUNDARY))) {
396
536
  boundaryAt ??= bar.length;
397
537
  continue;
@@ -399,7 +539,7 @@ var OverflowMenuController = class extends Controller {
399
539
  if (el instanceof HTMLElement) bar.push(el);
400
540
  }
401
541
  const banked = [];
402
- for (const el of this.#menuList().children) {
542
+ for (const el of this.#menuList(more).children) {
403
543
  if (el instanceof HTMLElement && el.hasAttribute(this.#attr(BANKED))) banked.push(el);
404
544
  }
405
545
  banked.sort((a, b) => this.#bankedIndex(a) - this.#bankedIndex(b));
@@ -416,29 +556,6 @@ var OverflowMenuController = class extends Controller {
416
556
  this.#index.set(el, i);
417
557
  });
418
558
  }
419
- /**
420
- * Interleaves the banked items back among the bar's children.
421
- *
422
- * A saved index is a position in the *canonical* order, so it can only be compared
423
- * against another canonical position — never used as an offset into the current bar,
424
- * which is a shorter and differently-indexed list. (Doing so survives an untouched
425
- * round trip and silently scrambles the order as soon as the consumer inserts into a
426
- * bar that is already short, and `#bank()` then burns the scrambled index back into
427
- * the attribute, so widening never heals it.) Each bar child the controller already
428
- * knows carries its canonical index from the previous pass; a banked item goes in
429
- * front of the first known child whose index is higher.
430
- *
431
- * Children never seen before — inserted by the consumer since the last pass — have no
432
- * canonical position yet, so they keep their DOM neighbours. The exception is a
433
- * *trailing* run of them: appending to the bar means appending to the toolbar, so
434
- * everything banked is emitted before it.
435
- *
436
- * With no known child at all (a fresh instance connecting to markup that already
437
- * holds banked items), a fully-banked snapshot's inert boundary preserves whether an
438
- * unindexed run was prepended or appended before connect. Markup that holds banked items but no boundary —
439
- * server-rendered or hand-authored — uses the saved index as the
440
- * offset, the inverse of the move that banked it.
441
- */
442
559
  #merge(bar, banked, boundaryAt) {
443
560
  const canonical = bar.map((el) => this.#index.get(el) ?? this.#savedIndex(el));
444
561
  let lastKnown = -1;
@@ -474,56 +591,50 @@ var OverflowMenuController = class extends Controller {
474
591
  drainWhile(() => true);
475
592
  return items;
476
593
  }
477
- /** The canonical index a banked item saved, or ∞ (append) when it carries none. */
478
594
  #bankedIndex(item) {
479
595
  return this.#savedIndex(item) ?? Number.POSITIVE_INFINITY;
480
596
  }
481
- /** A finite canonical index stored on either side of the overflow split. */
482
597
  #savedIndex(item) {
483
598
  const raw = item.getAttribute(this.#attr(INDEX));
484
599
  if (raw === null) return void 0;
485
600
  const value = Number(raw);
486
601
  return Number.isFinite(value) ? value : void 0;
487
602
  }
488
- /** The inert split point written only when every managed item lives in More. */
489
- #boundary() {
490
- for (const el of this.itemsTarget.children) {
603
+ #boundary(bar) {
604
+ for (const el of bar.children) {
491
605
  if (el instanceof HTMLTemplateElement && el.hasAttribute(this.#attr(BOUNDARY))) return el;
492
606
  }
493
607
  return null;
494
608
  }
495
609
  #ensureBoundary() {
496
- if (this.#boundary() !== null) return;
610
+ if (this.#boundary(this.itemsTarget) !== null) return;
497
611
  const boundary = document.createElement("template");
498
612
  boundary.setAttribute(this.#attr(BOUNDARY), "");
499
613
  this.itemsTarget.appendChild(boundary);
500
614
  }
501
- #removeBoundary() {
502
- this.#boundary()?.remove();
615
+ #removeBoundary(bar) {
616
+ this.#boundary(bar)?.remove();
503
617
  }
504
- /** Debounced re-measure for resize-driven churn. */
505
618
  #scheduleUpdate() {
506
619
  this.#timers.clearAll();
507
- this.#timers.set(() => this.update(), this.debounceValue);
508
- }
509
- /** Returns the menu list the items are banked into (falls back to the More wrapper). */
510
- #menuList() {
511
- return this.moreTarget.querySelector('[data-stimeo--menu-target="menu"]') ?? this.moreTarget;
512
- }
513
- #trigger() {
514
- return this.moreTarget.querySelector('[data-stimeo--menu-target="trigger"]');
515
- }
516
- /**
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
- */
620
+ this.#timers.set(() => this.update(), this.#safeDebounce);
621
+ }
622
+ #followTargets() {
623
+ if (this.#restoring || this.#lastHidden === null) return;
624
+ const bar = this.hasItemsTarget ? this.itemsTarget : null;
625
+ const more = this.hasMoreTarget ? this.moreTarget : null;
626
+ if (bar === this.#bar && more === this.#wrapper) return;
627
+ this.#release();
628
+ this.#morphRender.schedule();
629
+ }
630
+ #menuList(more) {
631
+ return more.querySelector('[data-stimeo--menu-target="menu"]') ?? more;
632
+ }
633
+ #trigger(more) {
634
+ return more.querySelector('[data-stimeo--menu-target="trigger"]');
635
+ }
525
636
  #syncLabel() {
526
- const trigger = this.#trigger();
637
+ const trigger = this.#trigger(this.moreTarget);
527
638
  if (trigger === null) return;
528
639
  const owned = this.#ownsLabel(trigger);
529
640
  if (!owned && !this.#isBareTrigger(trigger)) return;
@@ -536,89 +647,43 @@ var OverflowMenuController = class extends Controller {
536
647
  trigger.textContent = label;
537
648
  trigger.setAttribute(this.#attr(OWNED_LABEL), label);
538
649
  }
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
650
  #ownsLabel(trigger) {
546
651
  return trigger.getAttribute(this.#attr(OWNED_LABEL)) === trigger.textContent && this.#carriesOnlyText(trigger);
547
652
  }
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
653
  #releaseLabel(trigger) {
554
654
  if (!this.#ownsLabel(trigger)) return;
555
655
  trigger.textContent = "";
556
656
  trigger.removeAttribute(this.#attr(OWNED_LABEL));
557
657
  }
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.
563
- */
564
658
  #isBareTrigger(trigger) {
565
659
  return (trigger.textContent ?? "").trim() === "" && this.#carriesOnlyText(trigger);
566
660
  }
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
661
  #carriesOnlyText(trigger) {
578
662
  return trigger.firstElementChild === null && !trigger.hasAttribute("aria-label") && !trigger.hasAttribute("aria-labelledby");
579
663
  }
580
- /**
581
- * Whether the More menu is expanded. Menu owns that state and reflects it on the
582
- * trigger's `aria-expanded`, so read the contract rather than guess from `hidden`
583
- * (which is absent until Menu connects).
584
- */
585
- #menuExpanded() {
586
- return this.#trigger()?.getAttribute("aria-expanded") === "true";
587
- }
588
- /**
589
- * Collapses the More menu before its wrapper is hidden. Menu owns the state (and the
590
- * Escape-stack membership that goes with it), so ask it; the DOM fallback covers markup
591
- * that reached an expanded state with no Menu mounted.
592
- */
593
- #closeMenu() {
594
- if (!this.#menuExpanded()) return;
664
+ #menuExpanded(more) {
665
+ return this.#trigger(more)?.getAttribute("aria-expanded") === "true";
666
+ }
667
+ #closeMenu(more) {
668
+ if (!this.#menuExpanded(more)) return;
595
669
  const menu = this.application.getControllerForElementAndIdentifier(
596
- this.moreTarget,
670
+ more,
597
671
  "stimeo--menu"
598
672
  );
599
673
  if (menu) {
600
674
  menu.close();
601
675
  return;
602
676
  }
603
- this.#trigger()?.setAttribute("aria-expanded", "false");
604
- const list = this.#menuList();
605
- if (list !== this.moreTarget) list.hidden = true;
677
+ this.#trigger(more)?.setAttribute("aria-expanded", "false");
678
+ const list = this.#menuList(more);
679
+ if (list !== more) list.hidden = true;
606
680
  }
607
- /** The managed item that is, or contains, `el` — focus rescue works on either. */
608
681
  #ownerOf(el) {
609
682
  return ownerOf(this.#items, el);
610
683
  }
611
- /** Whether the item takes up room in the bar; an authored `hidden` one does not. */
612
684
  #rendered(item) {
613
685
  return !item.hasAttribute("hidden");
614
686
  }
615
- /**
616
- * Moves focus out of the More wrapper just before it is hidden, to the last
617
- * item in canonical order that can hold it. When every item refuses, the root
618
- * takes focus instead of letting the browser drop it to `<body>`, borrowing a
619
- * `tabindex="-1"` just-in-time — not a Tab stop, and handed back on restore
620
- * when this controller was the one that added it.
621
- */
622
687
  #rescueFocus() {
623
688
  for (let i = this.#items.length - 1; i >= 0; i--) {
624
689
  const item = this.#items[i];
@@ -630,38 +695,38 @@ var OverflowMenuController = class extends Controller {
630
695
  this.#tabindex.lend(this.element);
631
696
  this.element.focus();
632
697
  }
633
- /**
634
- * Whether `el` held focus, is still in a visible part of this controller, and lost it —
635
- * what a re-insert does in a real browser. An item that retreated into a collapsed menu
636
- * is excluded: it sits in a `hidden` subtree and must not be focused back.
637
- */
638
698
  #lostFocus(el) {
639
699
  return el.isConnected && document.activeElement !== el && this.element.contains(el) && el.closest("[hidden]") === null;
640
700
  }
641
- /** Returns every item to the bar and removes all traces of this controller. */
642
701
  #restoreAll() {
643
702
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
644
- this.#syncItems();
645
- this.#removeBoundary();
646
- this.#reorder(this.itemsTarget, this.#items);
703
+ this.#giveBack(this.itemsTarget, this.moreTarget);
704
+ }
705
+ #release() {
706
+ if (this.#bar && this.#wrapper) this.#giveBack(this.#bar, this.#wrapper);
707
+ }
708
+ #giveBack(bar, more) {
709
+ this.#bar = null;
710
+ this.#wrapper = null;
711
+ const restoring = this.#restoring;
712
+ this.#restoring = true;
713
+ this.#pass++;
714
+ this.#syncItems(bar, more);
715
+ this.#removeBoundary(bar);
716
+ this.#reorder(bar, this.#items);
647
717
  for (const item of this.#items) {
648
718
  this.#unbank(item);
649
719
  item.removeAttribute(this.#attr(INDEX));
650
720
  }
651
- this.#closeMenu();
652
- const trigger = this.#trigger();
721
+ this.#closeMenu(more);
722
+ const trigger = this.#trigger(more);
653
723
  if (trigger !== null) this.#releaseLabel(trigger);
654
- this.moreTarget.hidden = true;
724
+ more.hidden = true;
655
725
  this.element.removeAttribute("data-overflowing");
656
726
  this.element.removeAttribute("data-overflow-count");
657
727
  this.#tabindex.returnAll();
728
+ this.#restoring = restoring;
658
729
  }
659
- /**
660
- * Makes `parent`'s managed children match `sequence`, moving only what is out of place.
661
- * Walking backwards lets each item be compared against the element that should follow
662
- * it, so an unchanged run costs zero DOM mutations — the point of the exercise, since
663
- * every re-insert blurs the focused node.
664
- */
665
730
  #reorder(parent, sequence) {
666
731
  let next = null;
667
732
  for (let i = sequence.length - 1; i >= 0; i--) {
@@ -673,7 +738,6 @@ var OverflowMenuController = class extends Controller {
673
738
  next = item;
674
739
  }
675
740
  }
676
- /** Relocates `item`, preferring `moveBefore` (keeps focus and element state). */
677
741
  #insert(parent, item, before) {
678
742
  const move = parent.moveBefore;
679
743
  if (typeof move === "function") {
@@ -685,10 +749,6 @@ var OverflowMenuController = class extends Controller {
685
749
  }
686
750
  parent.insertBefore(item, before);
687
751
  }
688
- /**
689
- * Gives an item menuitem semantics for Menu to drive and records its canonical index,
690
- * saving every authored value it overwrites ("" means the attribute was absent).
691
- */
692
752
  #bank(item) {
693
753
  if (!item.hasAttribute(this.#attr(BANKED))) {
694
754
  item.setAttribute(this.#attr(BANKED), "true");
@@ -701,7 +761,6 @@ var OverflowMenuController = class extends Controller {
701
761
  }
702
762
  item.setAttribute(this.#attr(INDEX), String(this.#indexOf(item)));
703
763
  }
704
- /** Undoes the banking: restores every authored value and drops the bookkeeping. */
705
764
  #unbank(item) {
706
765
  if (!item.hasAttribute(this.#attr(BANKED))) return;
707
766
  this.#restoreAttr(item, "role", item.getAttribute(this.#attr(SAVED_ROLE)));
@@ -709,7 +768,6 @@ var OverflowMenuController = class extends Controller {
709
768
  this.#restoreAttr(item, MENU_TARGET, item.getAttribute(this.#attr(SAVED_MENU_TARGET)));
710
769
  for (const name of BOOKKEEPING) item.removeAttribute(this.#attr(name));
711
770
  }
712
- /** Re-applies a saved attribute value, or removes the attribute when it was absent. */
713
771
  #restoreAttr(item, name, original) {
714
772
  if (original === null || original === "") item.removeAttribute(name);
715
773
  else item.setAttribute(name, original);
@@ -717,20 +775,22 @@ var OverflowMenuController = class extends Controller {
717
775
  #indexOf(item) {
718
776
  return this.#index.get(item) ?? 0;
719
777
  }
720
- /**
721
- * Retention rank: lower keeps longer. An absent, empty, or non-numeric `data-priority`
722
- * ranks lowest (drops first) — empty is the common ERB accident
723
- * (`data-priority="<%= maybe_nil %>"`), and `Number("")` is 0, which would otherwise
724
- * read as the *highest* retention.
725
- */
726
778
  #rank(item) {
727
779
  const raw = item.getAttribute("data-priority");
728
780
  if (raw === null || raw.trim() === "") return Number.POSITIVE_INFINITY;
729
781
  const value = Number(raw);
730
782
  return Number.isNaN(value) ? Number.POSITIVE_INFINITY : value;
731
783
  }
784
+ get #safeDebounce() {
785
+ return this.#numbers.read(
786
+ this,
787
+ "debounce",
788
+ this.debounceValue,
789
+ _OverflowMenuController.values.debounce.default,
790
+ _OverflowMenuController.valueConstraints.debounce
791
+ );
792
+ }
732
793
  };
733
794
 
734
795
  export { OverflowMenuController };
735
- //# sourceMappingURL=overflow_menu_controller.js.map
736
796
  //# sourceMappingURL=overflow_menu_controller.js.map