stimeo-ui 0.2.1 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/breadcrumb_controller.js +225 -13
  5. data/dist/controllers/calendar_controller.js +89 -22
  6. data/dist/controllers/carousel_controller.js +47 -6
  7. data/dist/controllers/collapsible_controller.js +2 -2
  8. data/dist/controllers/color_picker_controller.js +46 -7
  9. data/dist/controllers/combobox_controller.js +162 -23
  10. data/dist/controllers/command_palette_controller.js +194 -17
  11. data/dist/controllers/context_menu_controller.js +32 -10
  12. data/dist/controllers/data_grid_controller.js +82 -4
  13. data/dist/controllers/date_range_picker_controller.js +27 -3
  14. data/dist/controllers/editable_controller.js +1 -0
  15. data/dist/controllers/form_validation_controller.js +1 -1
  16. data/dist/controllers/intersection_controller.js +36 -11
  17. data/dist/controllers/lazy_frame_controller.js +31 -10
  18. data/dist/controllers/listbox_controller.js +257 -53
  19. data/dist/controllers/local_time_controller.js +2 -2
  20. data/dist/controllers/menu_controller.js +104 -17
  21. data/dist/controllers/menubar_controller.js +415 -63
  22. data/dist/controllers/multi_select_controller.js +312 -29
  23. data/dist/controllers/navigation_menu_controller.js +154 -27
  24. data/dist/controllers/number_input_controller.js +7 -0
  25. data/dist/controllers/otp_controller.js +18 -1
  26. data/dist/controllers/overflow_indicator_controller.js +81 -13
  27. data/dist/controllers/overflow_menu_controller.js +381 -57
  28. data/dist/controllers/pagination_controller.js +163 -32
  29. data/dist/controllers/persist_controller.js +6 -6
  30. data/dist/controllers/pointer_drag_controller.js +9 -1
  31. data/dist/controllers/popover_controller.js +2 -2
  32. data/dist/controllers/radio_group_controller.js +22 -3
  33. data/dist/controllers/range_slider_controller.js +32 -6
  34. data/dist/controllers/rating_controller.js +16 -2
  35. data/dist/controllers/read_more_controller.js +63 -19
  36. data/dist/controllers/resizable_controller.js +65 -1
  37. data/dist/controllers/roving_controller.js +17 -2
  38. data/dist/controllers/scroll_area_controller.js +86 -12
  39. data/dist/controllers/scroll_restore_controller.js +1 -1
  40. data/dist/controllers/scroll_visibility_controller.js +33 -3
  41. data/dist/controllers/scrollspy_controller.js +346 -73
  42. data/dist/controllers/separator_controller.js +9 -0
  43. data/dist/controllers/skeleton_controller.js +1 -1
  44. data/dist/controllers/slider_controller.js +32 -6
  45. data/dist/controllers/sortable_controller.js +34 -3
  46. data/dist/controllers/spinner_controller.js +1 -1
  47. data/dist/controllers/stick_to_bottom_controller.js +2 -1
  48. data/dist/controllers/sticky_observer_controller.js +32 -11
  49. data/dist/controllers/switch_controller.js +1 -0
  50. data/dist/controllers/tabs_controller.js +26 -3
  51. data/dist/controllers/tags_input_controller.js +22 -2
  52. data/dist/controllers/theme_controller.js +22 -3
  53. data/dist/controllers/time_picker_controller.js +20 -1
  54. data/dist/controllers/toast_controller.js +4 -5
  55. data/dist/controllers/toggle_group_controller.js +23 -2
  56. data/dist/controllers/toolbar_controller.js +230 -31
  57. data/dist/controllers/tree_view_controller.js +467 -51
  58. data/dist/index.js +3514 -689
  59. data/lib/stimeo/ui/version.rb +2 -3
  60. metadata +2 -2
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/menubar_controller.ts
4
4
 
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function isReservedArrowChord(event, allow = []) {
12
+ if (!event.key.startsWith("Arrow")) return false;
13
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
+ }
15
+
5
16
  // src/utils/escape_layer.ts
6
17
  function claimsWhileFocusWithin(element) {
7
18
  return () => {
@@ -178,32 +189,198 @@ var SafeTimeout = class extends TimerRegistry {
178
189
  }
179
190
  };
180
191
 
181
- // src/controllers/menubar_controller.ts
192
+ // src/utils/typeahead.ts
182
193
  var TYPEAHEAD_RESET_MS = 500;
194
+ var Typeahead = class {
195
+ /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
196
+ #timers = new SafeTimeout();
197
+ /** Idle window before the query resets, in milliseconds. */
198
+ #resetMs;
199
+ /** The accumulated lowercase query, empty when idle. */
200
+ #query = "";
201
+ /** Id of the pending reset timer, `0` when none is scheduled. */
202
+ #timerId = 0;
203
+ /** @param options - Overrides for the idle window. */
204
+ constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
205
+ this.#resetMs = resetMs;
206
+ }
207
+ /** The query a search would currently run with; empty while idle. */
208
+ get query() {
209
+ return this.#query;
210
+ }
211
+ /**
212
+ * Folds `key` into the query, restarts the idle window, and returns the query to
213
+ * search with. A repeated character collapses the query to that one character.
214
+ */
215
+ push(key) {
216
+ const char = key.toLowerCase();
217
+ const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
218
+ this.#query = repeated ? char : this.#query + char;
219
+ this.#timers.clear(this.#timerId);
220
+ this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
221
+ return this.#query;
222
+ }
223
+ /** Clears the query and cancels the pending idle reset. */
224
+ reset() {
225
+ this.#query = "";
226
+ this.#timers.clear(this.#timerId);
227
+ this.#timerId = 0;
228
+ }
229
+ };
230
+ function isTypeaheadKey(event) {
231
+ return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing;
232
+ }
233
+ function typeaheadLabel(element, fallbackText) {
234
+ const label = element.getAttribute("aria-label")?.trim();
235
+ if (label) return label.toLowerCase();
236
+ const text = element.textContent ?? "";
237
+ return text.trim().toLowerCase();
238
+ }
239
+ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel(item)) {
240
+ if (query === "") return -1;
241
+ const count = items.length;
242
+ for (let step = 1; step <= count; step += 1) {
243
+ const index = ((from + step) % count + count) % count;
244
+ const candidate = items[index];
245
+ if (candidate && label(candidate).startsWith(query)) return index;
246
+ }
247
+ return -1;
248
+ }
249
+
250
+ // src/controllers/menubar_controller.ts
251
+ var STATE_ATTRIBUTES = ["disabled", "hidden"];
183
252
  var MenubarController = class extends Controller {
184
253
  static targets = ["top", "menu", "item"];
185
254
  static actions = ["activate", "onItemKeydown", "onTopKeydown", "toggle"];
186
255
  /** Roving tabindex across the top-level menuitems (one Tab stop). */
187
256
  #roving = new RovingTabindex(() => this.topTargets);
188
- /** Typeahead buffer and its idle-reset timer (scoped to the open menu). */
189
- #typeahead = "";
190
- #typeaheadId = 0;
257
+ /** Typeahead query and its idle-reset timer (scoped to the open menu). */
258
+ #typeahead = new Typeahead();
191
259
  #timers = new SafeTimeout();
192
260
  /** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */
193
261
  #escapeLayer = new EscapeLayer();
194
- /** Establishes the single tab stop and the closed baseline. */
262
+ /** Whether {@link #escapeLayer} is currently on the stack (see {@link #syncEscapeLayer}). */
263
+ #layerActive = false;
264
+ /** Watches `disabled`/`hidden` under the menubar; `null` while disconnected. */
265
+ #observer = null;
266
+ /**
267
+ * Live between `connect()` and `disconnect()`. Stimulus reports the *initial*
268
+ * targets before `connect()` and re-reports them as disconnected afterwards;
269
+ * gating on this keeps the target callbacks from clobbering the authored Tab
270
+ * stop on mount and from resurrecting one after teardown.
271
+ */
272
+ #connected = false;
273
+ /** The element inside the menubar that last took DOM focus, if any. */
274
+ #focused = null;
275
+ /**
276
+ * Records where DOM focus sits inside the menubar. `focusin` bubbles, so one
277
+ * listener covers the top items and everything nested in their menus. The
278
+ * record is needed because a top or menu becomes unreachable *before* this
279
+ * controller hears about it, by which point the browser has already reset
280
+ * `document.activeElement`.
281
+ */
282
+ #onFocusIn = (event) => {
283
+ this.#focused = event.target;
284
+ };
285
+ /**
286
+ * Forgets the record once focus genuinely lands on something else, so a later
287
+ * mutation cannot pull focus back in. A `relatedTarget` of `null` is the case
288
+ * that must be kept: it is what "focus went nowhere" looks like, which is
289
+ * exactly the state the rescue exists for.
290
+ */
291
+ #onFocusOut = (event) => {
292
+ const next = event.relatedTarget;
293
+ if (next && !this.element.contains(next)) this.#focused = null;
294
+ };
295
+ /**
296
+ * Establishes the closed baseline and the single tab stop: keep an existing tab
297
+ * stop when it is still navigable (so a Turbo cache restore preserves the user's
298
+ * position), else fall back to the first navigable top item.
299
+ *
300
+ * The outside-click listener is registered in the **capture** phase: in the
301
+ * bubble phase an inside handler that removes its own click target detaches the
302
+ * node before `contains()` runs, so an *inside* click would read as outside and
303
+ * close the menu.
304
+ */
195
305
  connect() {
196
- const active = this.#roving.activeIndex;
197
- this.#roving.setActive(active === -1 ? 0 : active);
198
306
  this.#closeAllMenus();
199
- document.addEventListener("click", this.#onOutsideClick);
307
+ this.#reconcile();
308
+ this.element.addEventListener("click", this.#onDisabledClickCapture, true);
309
+ this.element.addEventListener("focusin", this.#onFocusIn);
310
+ this.element.addEventListener("focusout", this.#onFocusOut);
311
+ document.addEventListener("click", this.#onOutsideClick, true);
312
+ if (typeof MutationObserver !== "undefined") {
313
+ this.#observer = new MutationObserver(() => this.#reconcile());
314
+ this.#observer.observe(this.element, {
315
+ subtree: true,
316
+ childList: true,
317
+ attributes: true,
318
+ attributeFilter: STATE_ATTRIBUTES
319
+ });
320
+ }
321
+ this.#connected = true;
200
322
  }
201
- /** Removes the document listener, stack membership, and any pending typeahead timer. */
323
+ /** Removes the listeners, stack membership, and any pending timer (typeahead / Tab close). */
202
324
  disconnect() {
325
+ this.#connected = false;
203
326
  this.#escapeLayer.deactivate();
204
- document.removeEventListener("click", this.#onOutsideClick);
327
+ this.#layerActive = false;
328
+ this.element.removeEventListener("click", this.#onDisabledClickCapture, true);
329
+ this.element.removeEventListener("focusin", this.#onFocusIn);
330
+ this.element.removeEventListener("focusout", this.#onFocusOut);
331
+ document.removeEventListener("click", this.#onOutsideClick, true);
332
+ this.#focused = null;
333
+ this.#observer?.disconnect();
334
+ this.#observer = null;
335
+ this.#typeahead.reset();
205
336
  this.#timers.clearAll();
206
337
  }
338
+ /**
339
+ * A top item added at runtime is dropped out of the Tab sequence first — a fresh
340
+ * `<button>` is tabbable by default, which would leave the menubar with two Tab
341
+ * stops — before the lone stop is re-established.
342
+ */
343
+ topTargetConnected(top) {
344
+ if (!this.#connected) return;
345
+ top.tabIndex = -1;
346
+ this.#reconcile();
347
+ }
348
+ /**
349
+ * Removing a top item can strand two things: the Tab stop it held, and the menu
350
+ * it owned (which nothing could close afterwards).
351
+ *
352
+ * The departing element is handed back in a neutral state first. A target can
353
+ * leave without leaving the document — a morph that only drops the
354
+ * `data-*-target` token keeps the node — and it would then sit in the page
355
+ * carrying this controller's `tabindex="0"` (a second Tab stop next to the one
356
+ * re-established below) and an `aria-expanded="true"` nothing can collapse.
357
+ * When the node really is gone these writes are harmless no-ops.
358
+ */
359
+ topTargetDisconnected(top) {
360
+ if (!this.#connected) return;
361
+ top.tabIndex = -1;
362
+ if (this.#isExpanded(top)) top.setAttribute("aria-expanded", "false");
363
+ this.#reconcile();
364
+ }
365
+ /** See {@link MenubarController.menuTargetDisconnected}. */
366
+ menuTargetConnected() {
367
+ if (!this.#connected) return;
368
+ this.#reconcile();
369
+ }
370
+ /**
371
+ * A menu removed while open leaves its owner claiming `aria-expanded="true"` for
372
+ * a popup that no longer exists, and the menubar holding an Escape layer that
373
+ * would swallow presses meant for something else.
374
+ *
375
+ * As with {@link MenubarController.topTargetDisconnected}, the departing menu is
376
+ * closed first: a token-only removal leaves a visible popup in the page that no
377
+ * key and no click can dismiss any more.
378
+ */
379
+ menuTargetDisconnected(menu) {
380
+ if (!this.#connected) return;
381
+ if (!menu.hidden) menu.hidden = true;
382
+ this.#reconcile();
383
+ }
207
384
  /** Toggles a top item's menu. Bound via `data-action` (click on the top item). */
208
385
  toggle(event) {
209
386
  const top = event.currentTarget;
@@ -215,19 +392,22 @@ var MenubarController = class extends Controller {
215
392
  }
216
393
  /** Keyboard handling while focus is on a top item. */
217
394
  onTopKeydown(event) {
218
- const tops = this.topTargets;
395
+ if (event.defaultPrevented) return;
396
+ if (isReservedArrowChord(event)) return;
397
+ const tops = this.#navigableTops;
219
398
  const index = tops.indexOf(event.currentTarget);
220
399
  if (index === -1) return;
221
400
  const length = tops.length;
222
401
  const anyOpen = this.#isAnyOpen;
402
+ const step = isRtl(this.element) ? -1 : 1;
223
403
  switch (event.key) {
224
404
  case "ArrowRight":
225
405
  event.preventDefault();
226
- this.#gotoTop((index + 1) % length, anyOpen);
406
+ this.#gotoTop(tops[(index + step + length) % length], anyOpen);
227
407
  break;
228
408
  case "ArrowLeft":
229
409
  event.preventDefault();
230
- this.#gotoTop((index - 1 + length) % length, anyOpen);
410
+ this.#gotoTop(tops[(index - step + length) % length], anyOpen);
231
411
  break;
232
412
  case "ArrowDown":
233
413
  event.preventDefault();
@@ -239,16 +419,21 @@ var MenubarController = class extends Controller {
239
419
  break;
240
420
  case "Home":
241
421
  event.preventDefault();
242
- this.#gotoTop(0, anyOpen);
422
+ this.#gotoTop(tops[0], anyOpen);
243
423
  break;
244
424
  case "End":
245
425
  event.preventDefault();
246
- this.#gotoTop(length - 1, anyOpen);
426
+ this.#gotoTop(tops[length - 1], anyOpen);
427
+ break;
428
+ case "Tab":
429
+ this.#closeMenusSoon();
247
430
  break;
248
431
  }
249
432
  }
250
433
  /** Keyboard handling while focus is on a menu item. */
251
434
  onItemKeydown(event) {
435
+ if (event.defaultPrevented) return;
436
+ if (isReservedArrowChord(event)) return;
252
437
  const item = event.currentTarget;
253
438
  const menu = item.closest("[role='menu']");
254
439
  if (!menu) return;
@@ -274,17 +459,17 @@ var MenubarController = class extends Controller {
274
459
  break;
275
460
  case "ArrowRight":
276
461
  event.preventDefault();
277
- this.#moveToAdjacentMenu(menu, 1);
462
+ this.#moveToAdjacentMenu(menu, isRtl(this.element) ? -1 : 1);
278
463
  break;
279
464
  case "ArrowLeft":
280
465
  event.preventDefault();
281
- this.#moveToAdjacentMenu(menu, -1);
466
+ this.#moveToAdjacentMenu(menu, isRtl(this.element) ? 1 : -1);
282
467
  break;
283
468
  case "Tab":
284
- this.#closeAllMenus();
469
+ this.#closeMenusSoon();
285
470
  break;
286
471
  default:
287
- if (this.#isTypeaheadKey(event)) {
472
+ if (isTypeaheadKey(event)) {
288
473
  event.preventDefault();
289
474
  this.#typeaheadTo(items, index, event.key);
290
475
  }
@@ -299,44 +484,89 @@ var MenubarController = class extends Controller {
299
484
  this.#closeAllMenus();
300
485
  this.#focusTop(top);
301
486
  }
302
- /** Moves the roving focus to top `index`, opening its menu when one was open. */
303
- #gotoTop(index, reopen) {
304
- const top = this.topTargets[index];
487
+ /** Moves the roving focus to `top`, opening its menu when one was open. */
488
+ #gotoTop(top, reopen) {
305
489
  if (!top) return;
306
490
  if (reopen) {
307
491
  this.#openMenu(top, "first");
308
492
  } else {
309
- this.#roving.setActive(index, { focus: true });
493
+ this.#roving.setActive(this.topTargets.indexOf(top), { focus: true });
310
494
  }
311
495
  }
312
- /** Opens `top`'s menu (closing others) and focuses its first/last item. */
496
+ /**
497
+ * Opens `top`'s menu (closing others) and focuses its first/last item.
498
+ *
499
+ * Three kinds of top item never open a menu:
500
+ * - **`aria-disabled`** — focusable but never activated, and opening a popup is
501
+ * activation.
502
+ * - **plain command** (no `aria-controls` at all) — a legitimate top item that
503
+ * simply has no popup.
504
+ * - **dangling `aria-controls`** (names a menu that is not a target) — broken
505
+ * markup.
506
+ *
507
+ * The first two still take the roving focus, and any open menu closes: the APG
508
+ * makes closing unconditional for the horizontal move ("closes the submenu,
509
+ * moves focus to the next menubar item, and *optionally* opens that item's
510
+ * submenu"), so only the opening half is skipped. Leaving the old menu open
511
+ * would strand a popup that no longer contains focus.
512
+ *
513
+ * The dangling case instead leaves the open/closed state and focus untouched
514
+ * rather than closing everything and dropping focus to the body — but only on
515
+ * this "open a menu" path. A dangling top is still an ordinary roving
516
+ * destination while nothing is open.
517
+ */
313
518
  #openMenu(top, focus) {
314
519
  if (!top) return;
315
- this.#closeAllMenus();
316
520
  const menu = this.#menuFor(top);
317
- if (!menu) return;
521
+ if (!menu || this.#isActivationBlocked(top)) {
522
+ if (menu || !top.hasAttribute("aria-controls")) {
523
+ this.#closeAllMenus();
524
+ this.#focusTop(top);
525
+ }
526
+ return;
527
+ }
528
+ this.#timers.clearAll();
529
+ this.#closeAllMenus();
318
530
  menu.hidden = false;
319
531
  top.setAttribute("aria-expanded", "true");
320
532
  this.#escapeLayer.activate(document, {
321
533
  onDismiss: () => this.#dismissOpenMenu(),
322
534
  claims: claimsWhileFocusWithin(this.element)
323
535
  });
536
+ this.#layerActive = true;
324
537
  this.#roving.setActive(this.topTargets.indexOf(top));
325
538
  const items = this.#itemsIn(menu);
326
539
  this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
327
540
  }
328
- /** Hides `top`'s menu and reflects the collapsed state. */
541
+ /**
542
+ * Hides `top`'s menu and reflects the collapsed state. A top item that controls
543
+ * no menu (a plain command in an otherwise popup-bearing menubar) is left alone
544
+ * — stamping `aria-expanded="false"` on it would announce a popup it lacks.
545
+ */
329
546
  #closeMenu(top) {
330
547
  if (!top) return;
331
548
  const menu = this.#menuFor(top);
332
- if (menu) menu.hidden = true;
549
+ if (!menu) return;
550
+ menu.hidden = true;
333
551
  top.setAttribute("aria-expanded", "false");
334
- if (!this.#isAnyOpen) this.#escapeLayer.deactivate();
552
+ this.#syncEscapeLayer();
335
553
  }
336
- /** Closes every menu and resets the typeahead buffer. */
554
+ /** Closes every menu and resets the typeahead query. */
337
555
  #closeAllMenus() {
338
556
  for (const top of this.topTargets) this.#closeMenu(top);
339
- this.#resetTypeahead();
557
+ this.#typeahead.reset();
558
+ }
559
+ /**
560
+ * Closes every menu on the next task instead of synchronously.
561
+ *
562
+ * Used by `Tab`: closing right away removes the focused element before the
563
+ * browser performs its own Tab move, which can restart traversal at the
564
+ * document head and lose the user's place in the Tab order.
565
+ */
566
+ #closeMenusSoon() {
567
+ if (!this.#isAnyOpen) return;
568
+ this.#timers.clearAll();
569
+ this.#timers.set(() => this.#closeAllMenus(), 0);
340
570
  }
341
571
  /**
342
572
  * Escape path, invoked by the shared resolver: pressed inside an open menu it
@@ -355,18 +585,143 @@ var MenubarController = class extends Controller {
355
585
  }
356
586
  this.#closeAllMenus();
357
587
  }
358
- /** Opens the menu of the top item `delta` steps from the one owning `menu`. */
588
+ /** Opens the menu of the navigable top item `delta` steps from `menu`'s owner. */
359
589
  #moveToAdjacentMenu(menu, delta) {
360
590
  const top = this.#topFor(menu);
361
591
  if (!top) return;
362
- const tops = this.topTargets;
363
- const next = (tops.indexOf(top) + delta + tops.length) % tops.length;
592
+ const tops = this.#navigableTops;
593
+ const current = tops.indexOf(top);
594
+ if (current === -1) return;
595
+ const next = (current + delta + tops.length) % tops.length;
364
596
  this.#openMenu(tops[next], "first");
365
597
  }
366
- /** Closes when a click lands outside the controller's element. */
598
+ /**
599
+ * Closes when a click lands outside the controller's element. Focus is left on
600
+ * whatever the user clicked — see the focus-restoration contract in the class
601
+ * docs.
602
+ */
367
603
  #onOutsideClick = (event) => {
368
604
  if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAllMenus();
369
605
  };
606
+ /**
607
+ * Captures clicks so `aria-disabled` top items and commands cannot reach
608
+ * consumer handlers (nor `toggle`/`activate`). Native Enter/Space activation
609
+ * also synthesizes a click and is blocked here; natively `disabled` buttons
610
+ * dispatch no click at all.
611
+ */
612
+ #onDisabledClickCapture = (event) => {
613
+ const target = event.target;
614
+ if (!(target instanceof Node)) return;
615
+ const blocked = [...this.topTargets, ...this.itemTargets].some(
616
+ (element) => this.#isActivationBlocked(element) && element.contains(target)
617
+ );
618
+ if (!blocked) return;
619
+ event.preventDefault();
620
+ event.stopImmediatePropagation();
621
+ };
622
+ /**
623
+ * Re-derives every piece of state this controller owns from the live targets:
624
+ * expanded flags, menu visibility, the single Tab stop, and Escape-stack
625
+ * membership. Runtime DOM edits can leave those out of step with each other — a
626
+ * top can claim `aria-expanded="true"` after its menu target was removed or after
627
+ * being hidden itself, a menu can stay visible after its owning top was removed,
628
+ * an open pair can appear from a morph with no layer registered for it, and the
629
+ * Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
630
+ * all. Nothing is remembered between calls (except which side of the Escape stack
631
+ * this layer is on, which the stack itself does not expose), so the outcome is the
632
+ * same whichever mutation arrived and calling it more often than needed is free.
633
+ */
634
+ #reconcile() {
635
+ for (const top of this.topTargets) {
636
+ const own = this.#menuFor(top);
637
+ if (this.#isExpanded(top) && (!own || own.hidden || !this.#isNavigable(top))) {
638
+ top.setAttribute("aria-expanded", "false");
639
+ }
640
+ }
641
+ for (const menu of this.menuTargets) {
642
+ if (menu.hidden) continue;
643
+ const owner = this.#topFor(menu);
644
+ if (!owner || !this.#isExpanded(owner)) menu.hidden = true;
645
+ }
646
+ this.#ensureTabStop();
647
+ this.#rescueFocus();
648
+ this.#syncEscapeLayer();
649
+ }
650
+ /**
651
+ * Returns DOM focus to the menubar when the element holding it became
652
+ * unreachable — hidden, natively disabled, or removed — and the document had
653
+ * nowhere to put it. The destination is the top item that now owns the Tab
654
+ * stop, which is where `Escape` from a closed menu would have left the user.
655
+ *
656
+ * Focus is only *restored*, never *stolen*: it moves solely when the tracked
657
+ * element can no longer take it **and** focus is either already gone or still
658
+ * sitting on that element. The second half is what makes a `hidden` ancestor
659
+ * work — a browser blurs the element it hides on its next style pass, not when
660
+ * the attribute is written, so waiting to observe `<body>` here would always
661
+ * come too early. Removal is the other side of the same condition: the browser
662
+ * has already fallen back to the body, or left a detached `activeElement`.
663
+ */
664
+ #rescueFocus() {
665
+ const focused = this.#focused;
666
+ if (!focused) return;
667
+ const owner = this.topTargets.find(
668
+ (top2) => top2 === focused || this.#menuFor(top2)?.contains(focused)
669
+ );
670
+ if (focused.isConnected && owner && this.#isNavigable(owner)) return;
671
+ const doc = this.element.ownerDocument;
672
+ const active = doc.activeElement;
673
+ const lost = active === null || active === doc.body || active === doc.documentElement || !active.isConnected || active === focused || focused.contains(active);
674
+ if (!lost) return;
675
+ const index = this.#roving.activeIndex;
676
+ const top = index === -1 ? void 0 : this.topTargets[index];
677
+ if (top && top !== focused && !focused.contains(top)) top.focus();
678
+ }
679
+ /**
680
+ * Brings Escape-stack membership back in line with what the DOM now says is
681
+ * open, in both directions.
682
+ *
683
+ * The deactivate half is the common one (something closed). The activate half
684
+ * covers a menu that became open *without* going through {@link #openMenu} —
685
+ * a Turbo morph that patches `aria-expanded` and the menu's `hidden` in place,
686
+ * which is legitimate here because the DOM is this controller's only source of
687
+ * truth. Without it the popup is visible but `Escape` does nothing, since no
688
+ * layer is registered to claim the press.
689
+ *
690
+ * `#layerActive` exists because re-activating an already-active layer moves it
691
+ * to the top of the stack, which would reshuffle nested layers on every
692
+ * unrelated mutation. Registering only on the false→true edge keeps activation
693
+ * ordered by when each layer actually opened.
694
+ */
695
+ #syncEscapeLayer() {
696
+ const open = this.#isAnyOpen;
697
+ if (open === this.#layerActive) return;
698
+ if (open) {
699
+ this.#escapeLayer.activate(document, {
700
+ onDismiss: () => this.#dismissOpenMenu(),
701
+ claims: claimsWhileFocusWithin(this.element)
702
+ });
703
+ } else {
704
+ this.#escapeLayer.deactivate();
705
+ }
706
+ this.#layerActive = open;
707
+ }
708
+ /**
709
+ * Re-establishes the single Tab stop: keep the current one while it is still
710
+ * navigable, else hand it to the first navigable top item. Every other top is
711
+ * explicitly removed from the Tab sequence, so a top that arrived tabbable
712
+ * cannot leave two stops behind. No Tab stop at all (`-1`) happens only when
713
+ * every top is inert, and is recovered from as soon as one becomes navigable.
714
+ */
715
+ #ensureTabStop() {
716
+ const active = this.#roving.activeIndex;
717
+ const activeTop = active === -1 ? void 0 : this.topTargets[active];
718
+ if (activeTop && this.#isNavigable(activeTop)) {
719
+ this.#roving.setActive(active);
720
+ return;
721
+ }
722
+ const first = this.#navigableTops[0];
723
+ this.#roving.setActive(first ? this.topTargets.indexOf(first) : -1);
724
+ }
370
725
  /** The menu element controlled by `top` (matched by `aria-controls`/`id`). */
371
726
  #menuFor(top) {
372
727
  const id = top.getAttribute("aria-controls");
@@ -377,9 +732,29 @@ var MenubarController = class extends Controller {
377
732
  if (!menu) return null;
378
733
  return this.topTargets.find((top) => top.getAttribute("aria-controls") === menu.id) ?? null;
379
734
  }
380
- /** The item targets that live inside `menu`, in DOM order. */
735
+ /** The navigable item targets that live inside `menu`, in DOM order. */
381
736
  #itemsIn(menu) {
382
- return this.itemTargets.filter((item) => menu.contains(item));
737
+ return this.itemTargets.filter((item) => menu.contains(item) && this.#isNavigable(item));
738
+ }
739
+ /** Top items eligible for the roving tab stop and the arrow keys. */
740
+ get #navigableTops() {
741
+ return this.topTargets.filter((top) => this.#isNavigable(top));
742
+ }
743
+ /**
744
+ * Whether `element` can take roving focus: `hidden` and natively `disabled`
745
+ * controls are out of reach and are skipped. `aria-disabled` is deliberately
746
+ * **not** checked — the APG keeps such items focusable so they stay
747
+ * discoverable, and {@link MenubarController.#isActivationBlocked} is what
748
+ * suppresses acting on them. CSS-only visibility is not detectable headlessly
749
+ * and stays the consumer's responsibility.
750
+ */
751
+ #isNavigable(element) {
752
+ if (element.hasAttribute("hidden")) return false;
753
+ return !element.disabled;
754
+ }
755
+ /** Whether `element` is `aria-disabled`: reachable, but never activated. */
756
+ #isActivationBlocked(element) {
757
+ return element.getAttribute("aria-disabled") === "true";
383
758
  }
384
759
  /** Moves DOM focus to the item at `index` (no-op if out of range). */
385
760
  #focusAt(items, index) {
@@ -398,33 +773,10 @@ var MenubarController = class extends Controller {
398
773
  get #isAnyOpen() {
399
774
  return this.topTargets.some((top) => this.#isExpanded(top));
400
775
  }
401
- /**
402
- * Whether the event is a single printable character usable for typeahead.
403
- * `Space` is excluded: on a `<button>` menuitem it natively activates the item
404
- * (Enter/Space → click), so swallowing it for typeahead would break activation.
405
- */
406
- #isTypeaheadKey(event) {
407
- return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey;
408
- }
409
- /** Advances focus to the next item in `items` matching the accumulated buffer. */
776
+ /** Advances focus to the next item in `items` matching the accumulated query. */
410
777
  #typeaheadTo(items, current, key) {
411
- this.#typeahead += key.toLowerCase();
412
- this.#timers.clear(this.#typeaheadId);
413
- this.#typeaheadId = this.#timers.set(() => this.#resetTypeahead(), TYPEAHEAD_RESET_MS);
414
- const count = items.length;
415
- for (let step = 1; step <= count; step++) {
416
- const candidate = items[(current + step) % count];
417
- const label = (candidate?.textContent ?? "").trim().toLowerCase();
418
- if (label.startsWith(this.#typeahead)) {
419
- candidate?.focus();
420
- return;
421
- }
422
- }
423
- }
424
- /** Clears the typeahead buffer and its timer. */
425
- #resetTypeahead() {
426
- this.#typeahead = "";
427
- this.#timers.clear(this.#typeaheadId);
778
+ const index = findTypeaheadMatch(items, current, this.#typeahead.push(key));
779
+ if (index !== -1) items[index]?.focus();
428
780
  }
429
781
  };
430
782