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