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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +160 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +92 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +226 -22
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +77 -3
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +102 -8
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +408 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/progress_controller.js +116 -9
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +100 -9
- data/dist/controllers/rating_controller.js +69 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +49 -8
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +61 -10
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +4507 -907
- data/lib/stimeo/ui/version.rb +2 -3
- 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,
|
|
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
|
|
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
|
|
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
|
|
165
|
-
|
|
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 -
|
|
169
|
-
const dropOrder = [...
|
|
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) +
|
|
265
|
+
visibleWidth -= widthOf(item) + itemGap;
|
|
176
266
|
}
|
|
177
267
|
}
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
const
|
|
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.#
|
|
183
|
-
else this.#
|
|
276
|
+
if (hidden.has(item)) this.#bank(item);
|
|
277
|
+
else this.#unbank(item);
|
|
184
278
|
}
|
|
185
|
-
|
|
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`
|
|
197
|
-
#gap() {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* and
|
|
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
|
|
333
|
+
const previous = this.#items;
|
|
334
|
+
const bar = [];
|
|
335
|
+
let boundaryAt;
|
|
211
336
|
for (const el of this.itemsTarget.children) {
|
|
212
|
-
if (el instanceof
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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(
|
|
589
|
+
item.setAttribute(MENU_TARGET, "item");
|
|
252
590
|
}
|
|
253
|
-
|
|
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 ===
|
|
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
|
-
/**
|
|
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
|
}
|