stimeo-ui 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +209 -0
  3. data/README.md +128 -1
  4. data/dist/cable/index.js +392 -417
  5. data/dist/controllers/accordion_controller.js +228 -28
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -191
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +295 -110
  10. data/dist/controllers/avatar_controller.js +172 -106
  11. data/dist/controllers/breadcrumb_controller.js +0 -129
  12. data/dist/controllers/bulk_select_controller.js +264 -53
  13. data/dist/controllers/calendar_controller.js +346 -180
  14. data/dist/controllers/carousel_controller.js +587 -300
  15. data/dist/controllers/character_counter_controller.js +271 -143
  16. data/dist/controllers/checkbox_controller.js +77 -75
  17. data/dist/controllers/clipboard_controller.js +187 -108
  18. data/dist/controllers/collapsible_controller.js +374 -108
  19. data/dist/controllers/color_picker_controller.js +183 -127
  20. data/dist/controllers/combobox_controller.js +426 -109
  21. data/dist/controllers/command_palette_controller.js +994 -350
  22. data/dist/controllers/conditional_fields_controller.js +212 -125
  23. data/dist/controllers/confirm_controller.js +939 -245
  24. data/dist/controllers/context_menu_controller.js +275 -127
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +235 -97
  27. data/dist/controllers/currency_input_controller.js +241 -118
  28. data/dist/controllers/data_grid_controller.js +229 -135
  29. data/dist/controllers/date_range_picker_controller.js +359 -165
  30. data/dist/controllers/dialog_controller.js +887 -225
  31. data/dist/controllers/direct_upload_controller.js +80 -142
  32. data/dist/controllers/dirty_form_controller.js +26 -47
  33. data/dist/controllers/dismissible_controller.js +175 -20
  34. data/dist/controllers/drawer_controller.js +963 -336
  35. data/dist/controllers/dropdown_controller.js +342 -85
  36. data/dist/controllers/editable_controller.js +182 -58
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +426 -229
  39. data/dist/controllers/filter_controller.js +78 -36
  40. data/dist/controllers/flash_controller.js +328 -211
  41. data/dist/controllers/focus_controller.js +654 -239
  42. data/dist/controllers/form_field_controller.js +153 -136
  43. data/dist/controllers/form_validation_controller.js +29 -96
  44. data/dist/controllers/frame_loading_controller.js +263 -198
  45. data/dist/controllers/highlight_controller.js +119 -70
  46. data/dist/controllers/hover_card_controller.js +335 -118
  47. data/dist/controllers/idle_controller.js +316 -65
  48. data/dist/controllers/input_mask_controller.js +116 -61
  49. data/dist/controllers/intersection_controller.js +147 -103
  50. data/dist/controllers/lazy_frame_controller.js +9 -56
  51. data/dist/controllers/listbox_controller.js +413 -153
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -95
  54. data/dist/controllers/menu_controller.js +254 -167
  55. data/dist/controllers/menubar_controller.js +206 -331
  56. data/dist/controllers/meter_controller.js +159 -71
  57. data/dist/controllers/multi_select_controller.js +797 -388
  58. data/dist/controllers/navigation_menu_controller.js +156 -231
  59. data/dist/controllers/nested_form_controller.js +373 -174
  60. data/dist/controllers/network_status_controller.js +241 -45
  61. data/dist/controllers/number_input_controller.js +387 -227
  62. data/dist/controllers/optimistic_controller.js +28 -75
  63. data/dist/controllers/otp_controller.js +344 -196
  64. data/dist/controllers/overflow_indicator_controller.js +167 -93
  65. data/dist/controllers/overflow_menu_controller.js +408 -277
  66. data/dist/controllers/pagination_controller.js +342 -120
  67. data/dist/controllers/password_reveal_controller.js +424 -99
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +252 -96
  70. data/dist/controllers/pointer_drag_controller.js +263 -164
  71. data/dist/controllers/popover_controller.js +267 -94
  72. data/dist/controllers/portal_controller.js +209 -72
  73. data/dist/controllers/preview_guard_controller.js +243 -112
  74. data/dist/controllers/progress_controller.js +132 -59
  75. data/dist/controllers/radio_group_controller.js +138 -133
  76. data/dist/controllers/range_slider_controller.js +245 -112
  77. data/dist/controllers/rating_controller.js +429 -194
  78. data/dist/controllers/read_more_controller.js +185 -47
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +294 -118
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -221
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -146
  87. data/dist/controllers/scrollspy_controller.js +181 -236
  88. data/dist/controllers/separator_controller.js +288 -132
  89. data/dist/controllers/sidebar_controller.js +1071 -375
  90. data/dist/controllers/skeleton_controller.js +248 -92
  91. data/dist/controllers/slider_controller.js +211 -96
  92. data/dist/controllers/smart_sticky_header_controller.js +163 -54
  93. data/dist/controllers/sortable_controller.js +13 -110
  94. data/dist/controllers/spinner_controller.js +277 -161
  95. data/dist/controllers/step_indicator_controller.js +107 -53
  96. data/dist/controllers/stepper_controller.js +171 -48
  97. data/dist/controllers/stick_to_bottom_controller.js +310 -116
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -192
  100. data/dist/controllers/switch_controller.js +107 -43
  101. data/dist/controllers/tabs_controller.js +50 -25
  102. data/dist/controllers/tags_input_controller.js +290 -149
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +148 -80
  105. data/dist/controllers/time_picker_controller.js +162 -81
  106. data/dist/controllers/toast_controller.js +365 -193
  107. data/dist/controllers/toggle_group_controller.js +194 -99
  108. data/dist/controllers/toolbar_controller.js +92 -90
  109. data/dist/controllers/tooltip_controller.js +185 -125
  110. data/dist/controllers/transition_controller.js +123 -115
  111. data/dist/controllers/tree_view_controller.js +248 -290
  112. data/dist/index.js +8649 -8572
  113. data/dist/positioning/index.js +148 -45
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,19 +1,46 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/menubar_controller.ts
3
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
4
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
5
+ function stateReasonFor(event) {
6
+ if (!event) return "api";
7
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
8
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
9
+ return "user";
10
+ }
11
+
12
+ function actionSource(source) {
13
+ if (source instanceof HTMLElement) {
14
+ return { event: null, host: source, origin: source, reason: "api" };
15
+ }
16
+ const event = source ?? null;
17
+ return {
18
+ event,
19
+ host: event?.currentTarget instanceof Element ? event.currentTarget : null,
20
+ origin: event?.target instanceof Element ? event.target : null,
21
+ reason: stateReasonFor(event)
22
+ };
23
+ }
4
24
 
5
- // src/utils/logical_scroll.ts
6
25
  function isRtl(element) {
7
26
  return window.getComputedStyle(element).direction === "rtl";
8
27
  }
9
28
 
10
- // src/utils/arrow_step.ts
11
29
  function isReservedArrowChord(event, allow = []) {
12
30
  if (!event.key.startsWith("Arrow")) return false;
13
31
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
32
  }
15
33
 
16
- // src/utils/escape_layer.ts
34
+ function sharedRegistry(key, create) {
35
+ const symbol = Symbol.for(key);
36
+ const scope = globalThis;
37
+ const existing = scope[symbol];
38
+ if (existing !== void 0) return existing;
39
+ const registry = create();
40
+ Object.defineProperty(globalThis, symbol, { value: registry });
41
+ return registry;
42
+ }
43
+
17
44
  function claimsWhileFocusWithin(element) {
18
45
  return () => {
19
46
  const active = element.ownerDocument.activeElement;
@@ -21,17 +48,11 @@ function claimsWhileFocusWithin(element) {
21
48
  };
22
49
  }
23
50
  var EscapeLayer = class _EscapeLayer {
24
- static #registries = /* @__PURE__ */ new WeakMap();
25
- #ownerDocument = null;
26
- /** Dismissal callback while active; `null` when inactive. */
27
- #onDismiss = null;
28
- /** Live predicate deciding whether the layer claims a press; `null` = always. */
29
- #claims = null;
30
- /**
31
- * Activates this layer at the top of its document's Escape stack, installing
32
- * the document's shared resolver listener if this is its first layer.
33
- * Re-activating an already-active layer moves it to the top.
34
- */
51
+ static #registries = sharedRegistry(
52
+ "stimeo-ui.escape-layer.registry.v1",
53
+ () => /* @__PURE__ */ new WeakMap()
54
+ );
55
+ #active = null;
35
56
  activate(ownerDocument = document, options) {
36
57
  this.deactivate();
37
58
  let registry = _EscapeLayer.#registries.get(ownerDocument);
@@ -40,44 +61,31 @@ var EscapeLayer = class _EscapeLayer {
40
61
  _EscapeLayer.#registries.set(ownerDocument, registry);
41
62
  ownerDocument.addEventListener("keydown", registry.onKeydown);
42
63
  }
43
- registry.stack.push(this);
44
- this.#ownerDocument = ownerDocument;
45
- this.#onDismiss = options.onDismiss;
46
- this.#claims = options.claims ?? null;
47
- }
48
- /**
49
- * Removes this layer from its document's Escape stack, uninstalling the
50
- * shared listener when the stack empties. Safe to call when inactive.
51
- */
64
+ const entry = { onDismiss: options.onDismiss, claims: options.claims ?? null };
65
+ registry.stack.push(entry);
66
+ this.#active = { document: ownerDocument, entry };
67
+ }
52
68
  deactivate() {
53
- const ownerDocument = this.#ownerDocument;
54
- if (!ownerDocument) return;
55
- const registry = _EscapeLayer.#registries.get(ownerDocument);
69
+ const active = this.#active;
70
+ if (!active) return;
71
+ const registry = _EscapeLayer.#registries.get(active.document);
56
72
  if (registry) {
57
- const index = registry.stack.lastIndexOf(this);
73
+ const index = registry.stack.lastIndexOf(active.entry);
58
74
  if (index >= 0) registry.stack.splice(index, 1);
59
75
  if (registry.stack.length === 0) {
60
- ownerDocument.removeEventListener("keydown", registry.onKeydown);
61
- _EscapeLayer.#registries.delete(ownerDocument);
76
+ active.document.removeEventListener("keydown", registry.onKeydown);
77
+ _EscapeLayer.#registries.delete(active.document);
62
78
  }
63
79
  }
64
- this.#ownerDocument = null;
65
- this.#onDismiss = null;
66
- this.#claims = null;
67
- }
68
- /**
69
- * Whether this active layer would own a press right now: it is the topmost
70
- * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
71
- * and diagnostics — production dismissal goes through the shared listener.
72
- */
80
+ this.#active = null;
81
+ }
73
82
  get ownsEscape() {
74
- const ownerDocument = this.#ownerDocument;
75
- if (!ownerDocument) return false;
76
- const registry = _EscapeLayer.#registries.get(ownerDocument);
83
+ const active = this.#active;
84
+ if (!active) return false;
85
+ const registry = _EscapeLayer.#registries.get(active.document);
77
86
  if (!registry) return false;
78
- return _EscapeLayer.#resolveOwner(registry.stack) === this;
87
+ return _EscapeLayer.#resolveOwner(registry.stack) === active.entry;
79
88
  }
80
- /** Builds a document's registry with its shared resolver listener. */
81
89
  static #createRegistry() {
82
90
  const registry = {
83
91
  stack: [],
@@ -86,24 +94,22 @@ var EscapeLayer = class _EscapeLayer {
86
94
  const owner = _EscapeLayer.#resolveOwner(registry.stack);
87
95
  if (!owner) return;
88
96
  event.preventDefault();
89
- owner.#onDismiss?.();
97
+ owner.onDismiss();
90
98
  }
91
99
  };
92
100
  return registry;
93
101
  }
94
- /** The topmost stack layer whose claims predicate passes, or `null`. */
95
102
  static #resolveOwner(stack) {
96
103
  for (let index = stack.length - 1; index >= 0; index--) {
97
104
  const layer = stack[index];
98
105
  if (!layer) continue;
99
- if (layer.#claims && !layer.#claims()) continue;
106
+ if (layer.claims && !layer.claims()) continue;
100
107
  return layer;
101
108
  }
102
109
  return null;
103
110
  }
104
111
  };
105
112
 
106
- // src/utils/event_owner.ts
107
113
  function ownerIndex(candidates, node) {
108
114
  if (!(node instanceof Node)) return -1;
109
115
  return candidates.findIndex((candidate) => candidate.contains(node));
@@ -112,31 +118,78 @@ function ownerOf(candidates, node) {
112
118
  return candidates[ownerIndex(candidates, node)] ?? null;
113
119
  }
114
120
 
115
- // src/utils/roving_tabindex.ts
121
+ var ListenerSet = class {
122
+ #abort = new AbortController();
123
+ add(target, type, handler, options) {
124
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
125
+ }
126
+ dispose() {
127
+ this.#abort.abort();
128
+ this.#abort = new AbortController();
129
+ }
130
+ };
131
+
132
+ var MicrotaskCoalescer = class {
133
+ #run;
134
+ #queued = false;
135
+ #active = false;
136
+ #generation = 0;
137
+ constructor(run) {
138
+ this.#run = run;
139
+ }
140
+ activate() {
141
+ this.#active = true;
142
+ }
143
+ cancel() {
144
+ this.#active = false;
145
+ this.#queued = false;
146
+ this.#generation += 1;
147
+ }
148
+ schedule() {
149
+ if (!this.#active || this.#queued) return;
150
+ this.#queued = true;
151
+ const generation = this.#generation;
152
+ queueMicrotask(() => {
153
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
154
+ this.#queued = false;
155
+ this.#run();
156
+ });
157
+ }
158
+ };
159
+
160
+ var MorphRenderWatcher = class {
161
+ #listeners = new ListenerSet();
162
+ #pass;
163
+ #element = null;
164
+ #onMorph = () => this.#pass.schedule();
165
+ constructor(run) {
166
+ this.#pass = new MicrotaskCoalescer(run);
167
+ }
168
+ observe(element) {
169
+ if (this.#element === element) return;
170
+ this.disconnect();
171
+ this.#element = element;
172
+ this.#pass.activate();
173
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
174
+ }
175
+ schedule() {
176
+ this.#pass.schedule();
177
+ }
178
+ disconnect() {
179
+ this.#listeners.dispose();
180
+ this.#pass.cancel();
181
+ this.#element = null;
182
+ }
183
+ };
184
+
116
185
  var RovingTabindex = class {
117
- /** Returns the current ordered item elements; called on every operation. */
118
186
  #getItems;
119
- /**
120
- * @param getItems - Returns the current ordered item elements. Called on every
121
- * operation so the live target list is always used.
122
- */
123
187
  constructor(getItems) {
124
188
  this.#getItems = getItems;
125
189
  }
126
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
127
190
  get activeIndex() {
128
191
  return this.#getItems().findIndex((item) => item.tabIndex === 0);
129
192
  }
130
- /**
131
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
132
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
133
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
134
- * "nothing is currently tabbable".
135
- *
136
- * @param index - Position of the item to make tabbable.
137
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
138
- * and `items` to reuse an event-scoped collection snapshot.
139
- */
140
193
  setActive(index, options = {}) {
141
194
  const { focus = false } = options;
142
195
  const items = options.items ?? this.#getItems();
@@ -147,43 +200,24 @@ var RovingTabindex = class {
147
200
  }
148
201
  };
149
202
 
150
- // src/utils/safe_timeout.ts
151
203
  var TimerRegistry = class {
152
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
153
204
  ids = /* @__PURE__ */ new Set();
154
- /**
155
- * Cancels a single tracked timer.
156
- *
157
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
158
- * registry), so callers can clear defensively without guarding.
159
- */
160
205
  clear(id) {
161
206
  if (this.ids.delete(id)) {
162
207
  this.cancel(id);
163
208
  }
164
209
  }
165
- /**
166
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
167
- * to guarantee no timer outlives the element.
168
- */
169
210
  clearAll() {
170
211
  for (const id of this.ids) {
171
212
  this.cancel(id);
172
213
  }
173
214
  this.ids.clear();
174
215
  }
175
- /** Number of timers currently tracked (pending). */
176
216
  get size() {
177
217
  return this.ids.size;
178
218
  }
179
219
  };
180
220
  var SafeTimeout = class extends TimerRegistry {
181
- /**
182
- * Schedules `callback` after `delay` ms and returns the timer id.
183
- *
184
- * The id is removed from the registry automatically when the timeout fires,
185
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
186
- */
187
221
  set(callback, delay) {
188
222
  const id = this.schedule(() => {
189
223
  this.ids.delete(id);
@@ -200,29 +234,22 @@ var SafeTimeout = class extends TimerRegistry {
200
234
  }
201
235
  };
202
236
 
203
- // src/utils/typeahead.ts
237
+ function targetSelector(identifier, name) {
238
+ return `[data-${identifier}-target~="${name}"]`;
239
+ }
240
+
204
241
  var TYPEAHEAD_RESET_MS = 500;
205
242
  var Typeahead = class {
206
- /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
207
243
  #timers = new SafeTimeout();
208
- /** Idle window before the query resets, in milliseconds. */
209
244
  #resetMs;
210
- /** The accumulated lowercase query, empty when idle. */
211
245
  #query = "";
212
- /** Id of the pending reset timer, `0` when none is scheduled. */
213
246
  #timerId = 0;
214
- /** @param options - Overrides for the idle window. */
215
247
  constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
216
248
  this.#resetMs = resetMs;
217
249
  }
218
- /** The query a search would currently run with; empty while idle. */
219
250
  get query() {
220
251
  return this.#query;
221
252
  }
222
- /**
223
- * Folds `key` into the query, restarts the idle window, and returns the query to
224
- * search with. A repeated character collapses the query to that one character.
225
- */
226
253
  push(key) {
227
254
  const char = key.toLowerCase();
228
255
  const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
@@ -231,7 +258,6 @@ var Typeahead = class {
231
258
  this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
232
259
  return this.#query;
233
260
  }
234
- /** Clears the query and cancels the pending idle reset. */
235
261
  reset() {
236
262
  this.#query = "";
237
263
  this.#timers.clear(this.#timerId);
@@ -258,70 +284,38 @@ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel
258
284
  return -1;
259
285
  }
260
286
 
261
- // src/controllers/menubar_controller.ts
262
287
  var STATE_ATTRIBUTES = ["disabled", "hidden"];
263
288
  var MenubarController = class extends Controller {
264
289
  static targets = ["top", "menu", "item"];
265
290
  static actions = ["activate", "onItemKeydown", "onTopKeydown", "toggle"];
266
- /** Roving tabindex across the top-level menuitems (one Tab stop). */
291
+ static events = ["close", "open"];
267
292
  #roving = new RovingTabindex(() => this.topTargets);
268
- /** Typeahead query and its idle-reset timer (scoped to the open menu). */
269
293
  #typeahead = new Typeahead();
270
294
  #timers = new SafeTimeout();
271
- /** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */
272
295
  #escapeLayer = new EscapeLayer();
273
- /** Whether {@link #escapeLayer} is currently on the stack (see {@link #syncEscapeLayer}). */
274
296
  #layerActive = false;
275
- /** Watches `disabled`/`hidden` under the menubar; `null` while disconnected. */
276
297
  #observer = null;
277
- /**
278
- * Live between `connect()` and `disconnect()`. Stimulus reports the *initial*
279
- * targets before `connect()` and re-reports them as disconnected afterwards;
280
- * gating on this keeps the target callbacks from clobbering the authored Tab
281
- * stop on mount and from resurrecting one after teardown.
282
- */
283
298
  #connected = false;
284
- /** The element inside the menubar that last took DOM focus, if any. */
299
+ #reporting = false;
285
300
  #focused = null;
286
- /**
287
- * Records where DOM focus sits inside the menubar. `focusin` bubbles, so one
288
- * listener covers the top items and everything nested in their menus. The
289
- * record is needed because a top or menu becomes unreachable *before* this
290
- * controller hears about it, by which point the browser has already reset
291
- * `document.activeElement`.
292
- */
293
301
  #onFocusIn = (event) => {
294
302
  this.#focused = event.target;
295
303
  };
296
- /**
297
- * Forgets the record once focus genuinely lands on something else, so a later
298
- * mutation cannot pull focus back in. A `relatedTarget` of `null` is the case
299
- * that must be kept: it is what "focus went nowhere" looks like, which is
300
- * exactly the state the rescue exists for.
301
- */
302
304
  #onFocusOut = (event) => {
303
305
  const next = event.relatedTarget;
304
306
  if (next && !this.element.contains(next)) this.#focused = null;
305
307
  };
306
- /**
307
- * Establishes the closed baseline and the single tab stop: keep an existing tab
308
- * stop when it is still navigable (so a Turbo cache restore preserves the user's
309
- * position), else fall back to the first navigable top item.
310
- *
311
- * The outside-click listener is registered in the **capture** phase: in the
312
- * bubble phase an inside handler that removes its own click target detaches the
313
- * node before `contains()` runs, so an *inside* click would read as outside and
314
- * close the menu.
315
- */
308
+ #morph = new MorphRenderWatcher(() => this.#reconcile());
316
309
  connect() {
317
- this.#closeAllMenus();
310
+ this.#morph.observe(this.element);
311
+ this.#closeAllMenus("api");
318
312
  this.#reconcile();
319
313
  this.element.addEventListener("click", this.#onDisabledClickCapture, true);
320
314
  this.element.addEventListener("focusin", this.#onFocusIn);
321
315
  this.element.addEventListener("focusout", this.#onFocusOut);
322
316
  document.addEventListener("click", this.#onOutsideClick, true);
323
317
  if (typeof MutationObserver !== "undefined") {
324
- this.#observer = new MutationObserver(() => this.#reconcile());
318
+ this.#observer = new MutationObserver(() => this.#morph.schedule());
325
319
  this.#observer.observe(this.element, {
326
320
  subtree: true,
327
321
  childList: true,
@@ -330,10 +324,12 @@ var MenubarController = class extends Controller {
330
324
  });
331
325
  }
332
326
  this.#connected = true;
327
+ this.#reporting = true;
333
328
  }
334
- /** Removes the listeners, stack membership, and any pending timer (typeahead / Tab close). */
335
329
  disconnect() {
330
+ this.#morph.disconnect();
336
331
  this.#connected = false;
332
+ this.#reporting = false;
337
333
  this.#escapeLayer.deactivate();
338
334
  this.#layerActive = false;
339
335
  this.element.removeEventListener("click", this.#onDisabledClickCapture, true);
@@ -346,62 +342,39 @@ var MenubarController = class extends Controller {
346
342
  this.#typeahead.reset();
347
343
  this.#timers.clearAll();
348
344
  }
349
- /**
350
- * A top item added at runtime is dropped out of the Tab sequence first — a fresh
351
- * `<button>` is tabbable by default, which would leave the menubar with two Tab
352
- * stops — before the lone stop is re-established.
353
- */
354
345
  topTargetConnected(top) {
355
346
  if (!this.#connected) return;
356
347
  top.tabIndex = -1;
357
348
  this.#reconcile();
358
349
  }
359
- /**
360
- * Removing a top item can strand two things: the Tab stop it held, and the menu
361
- * it owned (which nothing could close afterwards).
362
- *
363
- * The departing element is handed back in a neutral state first. A target can
364
- * leave without leaving the document — a morph that only drops the
365
- * `data-*-target` token keeps the node — and it would then sit in the page
366
- * carrying this controller's `tabindex="0"` (a second Tab stop next to the one
367
- * re-established below) and an `aria-expanded="true"` nothing can collapse.
368
- * When the node really is gone these writes are harmless no-ops.
369
- */
370
350
  topTargetDisconnected(top) {
371
351
  if (!this.#connected) return;
372
352
  top.tabIndex = -1;
373
353
  if (this.#isExpanded(top)) top.setAttribute("aria-expanded", "false");
374
354
  this.#reconcile();
375
355
  }
376
- /** See {@link MenubarController.menuTargetDisconnected}. */
377
356
  menuTargetConnected() {
378
357
  if (!this.#connected) return;
379
358
  this.#reconcile();
380
359
  }
381
- /**
382
- * A menu removed while open leaves its owner claiming `aria-expanded="true"` for
383
- * a popup that no longer exists, and the menubar holding an Escape layer that
384
- * would swallow presses meant for something else.
385
- *
386
- * As with {@link MenubarController.topTargetDisconnected}, the departing menu is
387
- * closed first: a token-only removal leaves a visible popup in the page that no
388
- * key and no click can dismiss any more.
389
- */
390
360
  menuTargetDisconnected(menu) {
391
361
  if (!this.#connected) return;
392
362
  if (!menu.hidden) menu.hidden = true;
393
363
  this.#reconcile();
394
364
  }
395
- /** Toggles a top item's menu. Bound via `data-action` (click on the top item). */
396
- toggle(event) {
397
- const top = event.currentTarget;
365
+ toggle(source) {
366
+ const { event, host, origin, reason } = actionSource(source);
367
+ const top = host?.closest(targetSelector(this.identifier, "top"));
368
+ if (!top || !this.topTargets.includes(top)) return;
369
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
370
+ return;
371
+ const moveFocus = event !== null || this.element.contains(document.activeElement);
398
372
  if (this.#isExpanded(top)) {
399
- this.#closeMenu(top);
373
+ this.#closeMenu(top, reason);
400
374
  } else {
401
- this.#openMenu(top, "first");
375
+ this.#openMenu(top, "first", reason, moveFocus);
402
376
  }
403
377
  }
404
- /** Keyboard handling while focus is on a top item. */
405
378
  onTopKeydown(event) {
406
379
  if (event.defaultPrevented) return;
407
380
  if (isReservedArrowChord(event)) return;
@@ -414,34 +387,33 @@ var MenubarController = class extends Controller {
414
387
  switch (event.key) {
415
388
  case "ArrowRight":
416
389
  event.preventDefault();
417
- this.#gotoTop(tops[(index + step + length) % length], anyOpen);
390
+ this.#gotoTop(tops[(index + step + length) % length], anyOpen, "user");
418
391
  break;
419
392
  case "ArrowLeft":
420
393
  event.preventDefault();
421
- this.#gotoTop(tops[(index - step + length) % length], anyOpen);
394
+ this.#gotoTop(tops[(index - step + length) % length], anyOpen, "user");
422
395
  break;
423
396
  case "ArrowDown":
424
397
  event.preventDefault();
425
- this.#openMenu(tops[index], "first");
398
+ this.#openMenu(tops[index], "first", "user");
426
399
  break;
427
400
  case "ArrowUp":
428
401
  event.preventDefault();
429
- this.#openMenu(tops[index], "last");
402
+ this.#openMenu(tops[index], "last", "user");
430
403
  break;
431
404
  case "Home":
432
405
  event.preventDefault();
433
- this.#gotoTop(tops[0], anyOpen);
406
+ this.#gotoTop(tops[0], anyOpen, "user");
434
407
  break;
435
408
  case "End":
436
409
  event.preventDefault();
437
- this.#gotoTop(tops[length - 1], anyOpen);
410
+ this.#gotoTop(tops[length - 1], anyOpen, "user");
438
411
  break;
439
412
  case "Tab":
440
413
  this.#closeMenusSoon();
441
414
  break;
442
415
  }
443
416
  }
444
- /** Keyboard handling while focus is on a menu item. */
445
417
  onItemKeydown(event) {
446
418
  if (event.defaultPrevented) return;
447
419
  if (isReservedArrowChord(event)) return;
@@ -487,138 +459,109 @@ var MenubarController = class extends Controller {
487
459
  break;
488
460
  }
489
461
  }
490
- /** Closes the owning menu after an item is activated and refocuses its top. */
491
- activate(event) {
492
- const item = event.currentTarget;
462
+ activate(source) {
463
+ const { event, host, origin } = actionSource(source);
464
+ const item = host?.closest(targetSelector(this.identifier, "item"));
465
+ if (!item || !this.itemTargets.includes(item)) return;
466
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
467
+ return;
468
+ const moveFocus = event !== null || this.element.contains(document.activeElement);
493
469
  const menu = item.closest("[role='menu']");
494
470
  const top = this.#topFor(menu);
495
- this.#closeAllMenus();
496
- this.#focusTop(top);
471
+ this.#closeAllMenus("select");
472
+ this.#focusTop(
473
+ top,
474
+ moveFocus && (event !== null || this.element.contains(document.activeElement))
475
+ );
497
476
  }
498
- /** Moves the roving focus to `top`, opening its menu when one was open. */
499
- #gotoTop(top, reopen) {
500
- if (!top) return;
477
+ #gotoTop(destination, reopen, reason) {
478
+ if (!destination) return;
501
479
  if (reopen) {
502
- this.#openMenu(top, "first");
480
+ this.#openMenu(destination, "first", reason);
503
481
  } else {
504
- this.#roving.setActive(this.topTargets.indexOf(top), { focus: true });
482
+ this.#roving.setActive(this.topTargets.indexOf(destination), { focus: true });
505
483
  }
506
484
  }
507
- /**
508
- * Opens `top`'s menu (closing others) and focuses its first/last item.
509
- *
510
- * Three kinds of top item never open a menu:
511
- * - **`aria-disabled`** — focusable but never activated, and opening a popup is
512
- * activation.
513
- * - **plain command** (no `aria-controls` at all) — a legitimate top item that
514
- * simply has no popup.
515
- * - **dangling `aria-controls`** (names a menu that is not a target) — broken
516
- * markup.
517
- *
518
- * The first two still take the roving focus, and any open menu closes: the APG
519
- * makes closing unconditional for the horizontal move ("closes the submenu,
520
- * moves focus to the next menubar item, and *optionally* opens that item's
521
- * submenu"), so only the opening half is skipped. Leaving the old menu open
522
- * would strand a popup that no longer contains focus.
523
- *
524
- * The dangling case instead leaves the open/closed state and focus untouched
525
- * rather than closing everything and dropping focus to the body — but only on
526
- * this "open a menu" path. A dangling top is still an ordinary roving
527
- * destination while nothing is open.
528
- */
529
- #openMenu(top, focus) {
530
- if (!top) return;
531
- const menu = this.#menuFor(top);
532
- if (!menu || this.#isActivationBlocked(top)) {
533
- if (menu || !top.hasAttribute("aria-controls")) {
534
- this.#closeAllMenus();
535
- this.#focusTop(top);
485
+ #openMenu(opener, focus, reason, moveFocus = true) {
486
+ if (!opener) return;
487
+ const menu = this.#menuFor(opener);
488
+ if (!menu || this.#isActivationBlocked(opener)) {
489
+ if (menu || !opener.hasAttribute("aria-controls")) {
490
+ this.#closeAllMenus(reason);
491
+ this.#focusTop(
492
+ opener,
493
+ moveFocus && (reason !== "api" || this.element.contains(document.activeElement))
494
+ );
536
495
  }
537
496
  return;
538
497
  }
539
498
  this.#timers.clearAll();
540
- this.#closeAllMenus();
499
+ const was = this.#isExpanded(opener);
500
+ this.#closeAllMenus(reason, opener);
541
501
  menu.hidden = false;
542
- top.setAttribute("aria-expanded", "true");
502
+ opener.setAttribute("aria-expanded", "true");
503
+ if (!was) this.#report("open", opener, menu, reason);
504
+ if (!this.#isExpanded(opener)) return;
543
505
  this.#escapeLayer.activate(document, {
544
506
  onDismiss: () => this.#dismissOpenMenu(),
545
507
  claims: claimsWhileFocusWithin(this.element)
546
508
  });
547
509
  this.#layerActive = true;
548
- this.#roving.setActive(this.topTargets.indexOf(top));
510
+ this.#roving.setActive(this.topTargets.indexOf(opener));
549
511
  const items = this.#itemsIn(menu);
550
- this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
551
- }
552
- /**
553
- * Hides `top`'s menu and reflects the collapsed state. A top item that controls
554
- * no menu (a plain command in an otherwise popup-bearing menubar) is left alone
555
- * — stamping `aria-expanded="false"` on it would announce a popup it lacks.
556
- */
557
- #closeMenu(top) {
512
+ if (moveFocus && (reason !== "api" || this.element.contains(document.activeElement))) {
513
+ this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
514
+ }
515
+ }
516
+ #closeMenu(top, reason, silent = false) {
558
517
  if (!top) return;
559
518
  const menu = this.#menuFor(top);
560
519
  if (!menu) return;
520
+ const was = this.#isExpanded(top);
561
521
  menu.hidden = true;
562
522
  top.setAttribute("aria-expanded", "false");
523
+ if (was && !silent) this.#report("close", top, menu, reason);
563
524
  this.#syncEscapeLayer();
564
525
  }
565
- /** Closes every menu and resets the typeahead query. */
566
- #closeAllMenus() {
567
- for (const top of this.topTargets) this.#closeMenu(top);
526
+ #closeAllMenus(reason, except = null) {
527
+ for (const top of this.topTargets) this.#closeMenu(top, reason, top === except);
568
528
  this.#typeahead.reset();
569
529
  }
570
- /**
571
- * Closes every menu on the next task instead of synchronously.
572
- *
573
- * Used by `Tab`: closing right away removes the focused element before the
574
- * browser performs its own Tab move, which can restart traversal at the
575
- * document head and lose the user's place in the Tab order.
576
- */
530
+ #report(name, top, menu, reason) {
531
+ if (!this.#reporting) return;
532
+ const detail = { reason, index: this.topTargets.indexOf(top), menu };
533
+ if (name === "open") this.dispatch("open", { detail, cancelable: false });
534
+ else this.dispatch("close", { detail, cancelable: false });
535
+ }
577
536
  #closeMenusSoon() {
578
537
  if (!this.#isAnyOpen) return;
579
538
  this.#timers.clearAll();
580
- this.#timers.set(() => this.#closeAllMenus(), 0);
581
- }
582
- /**
583
- * Escape path, invoked by the shared resolver: pressed inside an open menu it
584
- * closes that menu and returns focus to its top item (the APG behavior);
585
- * pressed while focus is on a top item (or fell to the body) it closes every
586
- * menu without moving focus.
587
- */
539
+ this.#timers.set(() => this.#closeAllMenus("focus"), 0);
540
+ }
588
541
  #dismissOpenMenu() {
589
542
  const menu = ownerOf(this.menuTargets, document.activeElement);
590
543
  if (menu) {
591
544
  const top = this.#topFor(menu);
592
- this.#closeMenu(top);
545
+ this.#closeMenu(top, "escape");
593
546
  this.#focusTop(top);
594
547
  return;
595
548
  }
596
- this.#closeAllMenus();
549
+ this.#closeAllMenus("escape");
597
550
  }
598
- /** Opens the menu of the navigable top item `delta` steps from `menu`'s owner. */
599
551
  #moveToAdjacentMenu(menu, delta) {
600
- const top = this.#topFor(menu);
601
- if (!top) return;
552
+ const ownerTop = this.#topFor(menu);
553
+ if (!ownerTop) return;
602
554
  const tops = this.#navigableTops;
603
- const current = tops.indexOf(top);
555
+ const current = tops.indexOf(ownerTop);
604
556
  if (current === -1) return;
605
557
  const next = (current + delta + tops.length) % tops.length;
606
- this.#openMenu(tops[next], "first");
558
+ this.#openMenu(tops[next], "first", "user");
607
559
  }
608
- /**
609
- * Closes when a click lands outside the controller's element. Focus is left on
610
- * whatever the user clicked — see the focus-restoration contract in the class
611
- * docs.
612
- */
613
560
  #onOutsideClick = (event) => {
614
- if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAllMenus();
561
+ if (this.#isAnyOpen && !this.element.contains(event.target)) {
562
+ this.#closeAllMenus("outside");
563
+ }
615
564
  };
616
- /**
617
- * Captures clicks so `aria-disabled` top items and commands cannot reach
618
- * consumer handlers (nor `toggle`/`activate`). Native Enter/Space activation
619
- * also synthesizes a click and is blocked here; natively `disabled` buttons
620
- * dispatch no click at all.
621
- */
622
565
  #onDisabledClickCapture = (event) => {
623
566
  const target = event.target;
624
567
  if (!(target instanceof Node)) return;
@@ -629,18 +572,6 @@ var MenubarController = class extends Controller {
629
572
  event.preventDefault();
630
573
  event.stopImmediatePropagation();
631
574
  };
632
- /**
633
- * Re-derives every piece of state this controller owns from the live targets:
634
- * expanded flags, menu visibility, the single Tab stop, and Escape-stack
635
- * membership. Runtime DOM edits can leave those out of step with each other — a
636
- * top can claim `aria-expanded="true"` after its menu target was removed or after
637
- * being hidden itself, a menu can stay visible after its owning top was removed,
638
- * an open pair can appear from a morph with no layer registered for it, and the
639
- * Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
640
- * all. The only state carried between calls is the tracked focus record and which side
641
- * of the Escape stack this layer is on (which the stack itself does not expose), so the outcome is the
642
- * same whichever mutation arrived and calling it more often than needed is free.
643
- */
644
575
  #reconcile() {
645
576
  for (const top of this.topTargets) {
646
577
  const own = this.#menuFor(top);
@@ -657,20 +588,6 @@ var MenubarController = class extends Controller {
657
588
  this.#rescueFocus();
658
589
  this.#syncEscapeLayer();
659
590
  }
660
- /**
661
- * Returns DOM focus to the menubar when the element holding it became
662
- * unreachable — hidden, natively disabled, or removed — and the document had
663
- * nowhere to put it. The destination is the top item that now owns the Tab
664
- * stop, which is where `Escape` from a closed menu would have left the user.
665
- *
666
- * Focus is only *restored*, never *stolen*: it moves solely when the tracked
667
- * element can no longer take it **and** focus is either already gone or still
668
- * sitting on that element. The second half is what makes a `hidden` ancestor
669
- * work — a browser blurs the element it hides on its next style pass, not when
670
- * the attribute is written, so waiting to observe `<body>` here would always
671
- * come too early. Removal is the other side of the same condition: the browser
672
- * has already fallen back to the body, or left a detached `activeElement`.
673
- */
674
591
  #rescueFocus() {
675
592
  const focused = this.#focused;
676
593
  if (!focused) return;
@@ -686,22 +603,6 @@ var MenubarController = class extends Controller {
686
603
  const top = index === -1 ? void 0 : this.topTargets[index];
687
604
  if (top && top !== focused && !focused.contains(top)) top.focus();
688
605
  }
689
- /**
690
- * Brings Escape-stack membership back in line with what the DOM now says is
691
- * open, in both directions.
692
- *
693
- * The deactivate half is the common one (something closed). The activate half
694
- * covers a menu that became open *without* going through {@link #openMenu} —
695
- * a Turbo morph that patches `aria-expanded` and the menu's `hidden` in place,
696
- * which is legitimate here because the DOM is this controller's only source of
697
- * truth. Without it the popup is visible but `Escape` does nothing, since no
698
- * layer is registered to claim the press.
699
- *
700
- * `#layerActive` exists because re-activating an already-active layer moves it
701
- * to the top of the stack, which would reshuffle nested layers on every
702
- * unrelated mutation. Registering only on the false→true edge keeps activation
703
- * ordered by when each layer actually opened.
704
- */
705
606
  #syncEscapeLayer() {
706
607
  const open = this.#isAnyOpen;
707
608
  if (open === this.#layerActive) return;
@@ -715,13 +616,6 @@ var MenubarController = class extends Controller {
715
616
  }
716
617
  this.#layerActive = open;
717
618
  }
718
- /**
719
- * Re-establishes the single Tab stop: keep the current one while it is still
720
- * navigable, else hand it to the first navigable top item. Every other top is
721
- * explicitly removed from the Tab sequence, so a top that arrived tabbable
722
- * cannot leave two stops behind. No Tab stop at all (`-1`) happens only when
723
- * every top is inert, and is recovered from as soon as one becomes navigable.
724
- */
725
619
  #ensureTabStop() {
726
620
  const active = this.#roving.activeIndex;
727
621
  const activeTop = active === -1 ? void 0 : this.topTargets[active];
@@ -732,58 +626,40 @@ var MenubarController = class extends Controller {
732
626
  const first = this.#navigableTops[0];
733
627
  this.#roving.setActive(first ? this.topTargets.indexOf(first) : -1);
734
628
  }
735
- /** The menu element controlled by `top` (matched by `aria-controls`/`id`). */
736
629
  #menuFor(top) {
737
630
  const id = top.getAttribute("aria-controls");
738
631
  return id ? this.menuTargets.find((menu) => menu.id === id) ?? null : null;
739
632
  }
740
- /** The top item that controls `menu` (reverse of `#menuFor`). */
741
633
  #topFor(menu) {
742
634
  if (!menu) return null;
743
635
  return this.topTargets.find((top) => top.getAttribute("aria-controls") === menu.id) ?? null;
744
636
  }
745
- /** The navigable item targets that live inside `menu`, in DOM order. */
746
637
  #itemsIn(menu) {
747
638
  return this.itemTargets.filter((item) => menu.contains(item) && this.#isNavigable(item));
748
639
  }
749
- /** Top items eligible for the roving tab stop and the arrow keys. */
750
640
  get #navigableTops() {
751
641
  return this.topTargets.filter((top) => this.#isNavigable(top));
752
642
  }
753
- /**
754
- * Whether `element` can take roving focus: `hidden` and natively `disabled`
755
- * controls are out of reach and are skipped. `aria-disabled` is deliberately
756
- * **not** checked — the APG keeps such items focusable so they stay
757
- * discoverable, and {@link MenubarController.#isActivationBlocked} is what
758
- * suppresses acting on them. CSS-only visibility is not detectable headlessly
759
- * and stays the consumer's responsibility.
760
- */
761
643
  #isNavigable(element) {
762
644
  if (element.hasAttribute("hidden")) return false;
763
645
  return !element.disabled;
764
646
  }
765
- /** Whether `element` is `aria-disabled`: reachable, but never activated. */
766
647
  #isActivationBlocked(element) {
767
648
  return element.getAttribute("aria-disabled") === "true";
768
649
  }
769
- /** Moves DOM focus to the item at `index` (no-op if out of range). */
770
650
  #focusAt(items, index) {
771
651
  items[index]?.focus();
772
652
  }
773
- /** Returns roving focus to a top item (and makes it the single tab stop). */
774
- #focusTop(top) {
653
+ #focusTop(top, focus = true) {
775
654
  if (!top) return;
776
- this.#roving.setActive(this.topTargets.indexOf(top), { focus: true });
655
+ this.#roving.setActive(this.topTargets.indexOf(top), { focus });
777
656
  }
778
- /** Whether `top`'s menu is currently expanded. */
779
657
  #isExpanded(top) {
780
658
  return top.getAttribute("aria-expanded") === "true";
781
659
  }
782
- /** Whether any top item's menu is currently open. */
783
660
  get #isAnyOpen() {
784
661
  return this.topTargets.some((top) => this.#isExpanded(top));
785
662
  }
786
- /** Advances focus to the next item in `items` matching the accumulated query. */
787
663
  #typeaheadTo(items, current, key) {
788
664
  const index = findTypeaheadMatch(items, current, this.#typeahead.push(key));
789
665
  if (index !== -1) items[index]?.focus();
@@ -791,5 +667,4 @@ var MenubarController = class extends Controller {
791
667
  };
792
668
 
793
669
  export { MenubarController };
794
- //# sourceMappingURL=menubar_controller.js.map
795
670
  //# sourceMappingURL=menubar_controller.js.map