stimeo-ui 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,49 +1,58 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/focus_controller.ts
4
-
5
- // src/utils/before_cache_reset.ts
6
- var BeforeCacheReset = class _BeforeCacheReset {
7
- /** Every subscribed instance, iterated by the one shared document listener. */
8
- static #subscribers = /* @__PURE__ */ new Set();
9
- /** The shared listener; installed while at least one instance is subscribed. */
10
- static #onBeforeCache = () => {
11
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
- };
13
- #rewind;
14
- /** @param rewind - the pass that returns this controller's state to its initial form. */
15
- constructor(rewind) {
16
- this.#rewind = rewind;
3
+ var DetachGate = class _DetachGate {
4
+ #pending = false;
5
+ get pending() {
6
+ return this.#pending;
17
7
  }
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);
8
+ static isDetached(host) {
9
+ return !host.element.isConnected || !_DetachGate.#listed(host);
10
+ }
11
+ disconnected(host, teardown) {
12
+ const carried = _DetachGate.#carried(host);
13
+ if (!carried && _DetachGate.isDetached(host)) {
14
+ this.#pending = false;
15
+ teardown();
16
+ return;
24
17
  }
18
+ this.#pending = true;
19
+ const probe = () => {
20
+ if (!this.#pending) return;
21
+ this.#pending = false;
22
+ teardown();
23
+ };
24
+ if (carried) setTimeout(probe, 0);
25
+ else queueMicrotask(probe);
25
26
  }
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);
27
+ static #carried(host) {
28
+ const element = host.element;
29
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
30
+ }
31
+ static #listed(host) {
32
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
33
+ return tokens.includes(host.identifier);
34
+ }
35
+ cancel() {
36
+ this.#pending = false;
31
37
  }
32
38
  };
33
39
 
34
- // src/utils/escape_layer.ts
40
+ function sharedRegistry(key, create) {
41
+ const symbol = Symbol.for(key);
42
+ const scope = globalThis;
43
+ const existing = scope[symbol];
44
+ if (existing !== void 0) return existing;
45
+ const registry = create();
46
+ Object.defineProperty(globalThis, symbol, { value: registry });
47
+ return registry;
48
+ }
49
+
35
50
  var EscapeLayer = class _EscapeLayer {
36
- static #registries = /* @__PURE__ */ new WeakMap();
37
- #ownerDocument = null;
38
- /** Dismissal callback while active; `null` when inactive. */
39
- #onDismiss = null;
40
- /** Live predicate deciding whether the layer claims a press; `null` = always. */
41
- #claims = null;
42
- /**
43
- * Activates this layer at the top of its document's Escape stack, installing
44
- * the document's shared resolver listener if this is its first layer.
45
- * Re-activating an already-active layer moves it to the top.
46
- */
51
+ static #registries = sharedRegistry(
52
+ "stimeo-ui.escape-layer.registry.v1",
53
+ () => /* @__PURE__ */ new WeakMap()
54
+ );
55
+ #active = null;
47
56
  activate(ownerDocument = document, options) {
48
57
  this.deactivate();
49
58
  let registry = _EscapeLayer.#registries.get(ownerDocument);
@@ -52,44 +61,31 @@ var EscapeLayer = class _EscapeLayer {
52
61
  _EscapeLayer.#registries.set(ownerDocument, registry);
53
62
  ownerDocument.addEventListener("keydown", registry.onKeydown);
54
63
  }
55
- registry.stack.push(this);
56
- this.#ownerDocument = ownerDocument;
57
- this.#onDismiss = options.onDismiss;
58
- this.#claims = options.claims ?? null;
59
- }
60
- /**
61
- * Removes this layer from its document's Escape stack, uninstalling the
62
- * shared listener when the stack empties. Safe to call when inactive.
63
- */
64
+ const entry = { onDismiss: options.onDismiss, claims: options.claims ?? null };
65
+ registry.stack.push(entry);
66
+ this.#active = { document: ownerDocument, entry };
67
+ }
64
68
  deactivate() {
65
- const ownerDocument = this.#ownerDocument;
66
- if (!ownerDocument) return;
67
- const registry = _EscapeLayer.#registries.get(ownerDocument);
69
+ const active = this.#active;
70
+ if (!active) return;
71
+ const registry = _EscapeLayer.#registries.get(active.document);
68
72
  if (registry) {
69
- const index = registry.stack.lastIndexOf(this);
73
+ const index = registry.stack.lastIndexOf(active.entry);
70
74
  if (index >= 0) registry.stack.splice(index, 1);
71
75
  if (registry.stack.length === 0) {
72
- ownerDocument.removeEventListener("keydown", registry.onKeydown);
73
- _EscapeLayer.#registries.delete(ownerDocument);
76
+ active.document.removeEventListener("keydown", registry.onKeydown);
77
+ _EscapeLayer.#registries.delete(active.document);
74
78
  }
75
79
  }
76
- this.#ownerDocument = null;
77
- this.#onDismiss = null;
78
- this.#claims = null;
79
- }
80
- /**
81
- * Whether this active layer would own a press right now: it is the topmost
82
- * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
83
- * and diagnostics — production dismissal goes through the shared listener.
84
- */
80
+ this.#active = null;
81
+ }
85
82
  get ownsEscape() {
86
- const ownerDocument = this.#ownerDocument;
87
- if (!ownerDocument) return false;
88
- const registry = _EscapeLayer.#registries.get(ownerDocument);
83
+ const active = this.#active;
84
+ if (!active) return false;
85
+ const registry = _EscapeLayer.#registries.get(active.document);
89
86
  if (!registry) return false;
90
- return _EscapeLayer.#resolveOwner(registry.stack) === this;
87
+ return _EscapeLayer.#resolveOwner(registry.stack) === active.entry;
91
88
  }
92
- /** Builds a document's registry with its shared resolver listener. */
93
89
  static #createRegistry() {
94
90
  const registry = {
95
91
  stack: [],
@@ -98,24 +94,22 @@ var EscapeLayer = class _EscapeLayer {
98
94
  const owner = _EscapeLayer.#resolveOwner(registry.stack);
99
95
  if (!owner) return;
100
96
  event.preventDefault();
101
- owner.#onDismiss?.();
97
+ owner.onDismiss();
102
98
  }
103
99
  };
104
100
  return registry;
105
101
  }
106
- /** The topmost stack layer whose claims predicate passes, or `null`. */
107
102
  static #resolveOwner(stack) {
108
103
  for (let index = stack.length - 1; index >= 0; index--) {
109
104
  const layer = stack[index];
110
105
  if (!layer) continue;
111
- if (layer.#claims && !layer.#claims()) continue;
106
+ if (layer.claims && !layer.claims()) continue;
112
107
  return layer;
113
108
  }
114
109
  return null;
115
110
  }
116
111
  };
117
112
 
118
- // src/utils/focus_candidate.ts
119
113
  function inheritsFieldsetDisabled(control) {
120
114
  let fieldset = control.closest("fieldset[disabled]");
121
115
  while (fieldset) {
@@ -125,8 +119,34 @@ function inheritsFieldsetDisabled(control) {
125
119
  }
126
120
  return false;
127
121
  }
122
+ function unslotted(element) {
123
+ return element.assignedSlot === null && element.parentElement?.shadowRoot != null;
124
+ }
125
+ function flatTreeParent(element) {
126
+ if (unslotted(element)) return null;
127
+ const parent = element.assignedSlot ?? element.parentNode;
128
+ if (parent instanceof ShadowRoot) return parent.host;
129
+ return parent instanceof Element ? parent : null;
130
+ }
131
+ function flatTreeContains(ancestor, element) {
132
+ for (let node = element; node; node = flatTreeParent(node)) {
133
+ if (node === ancestor) return true;
134
+ }
135
+ return false;
136
+ }
137
+ function closestInFlatTree(element, selector) {
138
+ for (let node = element; node; node = flatTreeParent(node)) {
139
+ if (node.matches(selector)) return node;
140
+ }
141
+ return null;
142
+ }
143
+ function flatTreeChildren(element) {
144
+ return ownedScope(element) ?? Array.from(element.children);
145
+ }
128
146
  function canTakeFocus(element) {
129
- if (element.closest("[hidden], [inert]")) return false;
147
+ for (let node = element; node; node = flatTreeParent(node)) {
148
+ if (unslotted(node) || node.matches("[hidden], [inert]")) return false;
149
+ }
130
150
  if (element instanceof HTMLInputElement && element.type === "hidden") return false;
131
151
  if (!("disabled" in element)) return true;
132
152
  if (element.disabled) return false;
@@ -183,189 +203,612 @@ function isTabStop(element) {
183
203
  if (tabindex !== null) return tabindex >= 0;
184
204
  return hasNativeTabStop(element) || hasEditableTabStop(element);
185
205
  }
186
- function tabStopsWithin(root) {
187
- return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
188
- isTabStop
206
+ function isRadio(element) {
207
+ return element instanceof HTMLInputElement && element.type === "radio";
208
+ }
209
+ function sameRadioGroup(a, b) {
210
+ return a.name !== "" && a.name === b.name && a.form === b.form && a.getRootNode() === b.getRootNode();
211
+ }
212
+ function isInEditableContent(element) {
213
+ for (let node = element.parentElement; node; node = node.parentElement) {
214
+ if (node.getAttribute("contenteditable")?.toLowerCase() === "false") return false;
215
+ if (hasEditableTabStop(node)) return true;
216
+ }
217
+ return false;
218
+ }
219
+ var XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
220
+ function isSvgLink(element) {
221
+ return element instanceof SVGElement && element.localName === "a" && (element.hasAttribute("href") || element.hasAttributeNS(XLINK_NAMESPACE, "href"));
222
+ }
223
+ function hasRenderedImage(area) {
224
+ const map = area.closest("map");
225
+ const key = map?.name || map?.id;
226
+ if (!map || !key) return false;
227
+ const root = map.getRootNode();
228
+ return Array.from(root.querySelectorAll("img[usemap]")).some(
229
+ (image) => image.getAttribute("usemap") === `#${key}` && isRenderedForFocus(image)
230
+ );
231
+ }
232
+ function isFocusableInOrder(element) {
233
+ const tabindex = authoredTabindex(element);
234
+ return (tabindex === null || tabindex >= 0) && canTakeFocus(element);
235
+ }
236
+ function extraStop(element) {
237
+ if (element instanceof HTMLAreaElement) {
238
+ const focusable = element.hasAttribute("href") || authoredTabindex(element) !== null;
239
+ return focusable && isFocusableInOrder(element) && hasRenderedImage(element);
240
+ }
241
+ if (element instanceof HTMLObjectElement) {
242
+ return isFocusableInOrder(element) && isRenderedForFocus(element) && element.contentWindow !== null;
243
+ }
244
+ if (isSvgLink(element))
245
+ return isFocusableInOrder(element) && isRenderedForFocus(element);
246
+ return null;
247
+ }
248
+ function isSequentialStop(element) {
249
+ const stop = extraStop(element) ?? (element.matches(TAB_STOP_CANDIDATE_SELECTOR) && isTabStop(element));
250
+ if (!stop) return false;
251
+ const link = element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement || isSvgLink(element);
252
+ return !(link && authoredTabindex(element) === null && isInEditableContent(element));
253
+ }
254
+ function ownedScope(element) {
255
+ if (element.shadowRoot) return Array.from(element.shadowRoot.children);
256
+ if (element instanceof HTMLSlotElement) {
257
+ const assigned = element.assignedElements();
258
+ return assigned.length > 0 ? assigned : Array.from(element.children);
259
+ }
260
+ return null;
261
+ }
262
+ function orderScope(elements, walk) {
263
+ const entries = [];
264
+ const visit = (element) => {
265
+ if (element === walk.from) walk.fromAt = walk.tree.length;
266
+ const tabindex = authoredTabindex(element) ?? 0;
267
+ const stop = (element instanceof HTMLElement || element instanceof SVGElement) && isSequentialStop(element) ? element : null;
268
+ if (stop) walk.tree.push(stop);
269
+ const owned = ownedScope(element);
270
+ if (owned) {
271
+ if (tabindex >= 0) {
272
+ entries.push({ tabindex, stops: [...stop ? [stop] : [], ...orderScope(owned, walk)] });
273
+ }
274
+ return;
275
+ }
276
+ if (stop) entries.push({ tabindex, stops: [stop] });
277
+ for (const child of Array.from(element.children)) visit(child);
278
+ };
279
+ for (const element of elements) visit(element);
280
+ const positive = entries.filter((entry) => entry.tabindex > 0);
281
+ positive.sort((a, b) => a.tabindex - b.tabindex);
282
+ const rest = entries.filter((entry) => entry.tabindex === 0);
283
+ return [...positive, ...rest].flatMap((entry) => entry.stops);
284
+ }
285
+ function passesRadioRule(element, tree, focusedRadio) {
286
+ if (!isRadio(element) || element.checked) return true;
287
+ const group = tree.filter(
288
+ (stop) => stop === element || isRadio(stop) && sameRadioGroup(stop, element)
189
289
  );
290
+ if (group.some((radio) => radio.checked)) return false;
291
+ if (group[0] !== element) return false;
292
+ return focusedRadio === null || !sameRadioGroup(focusedRadio, element);
293
+ }
294
+ function startIndex(order, walk, backward, isStop) {
295
+ if (walk.fromAt < 0) return backward ? order.length - 1 : 0;
296
+ const at = order.indexOf(walk.from);
297
+ if (at >= 0) return backward ? at - 1 : at + 1;
298
+ const neighbour = backward ? walk.tree.slice(0, walk.fromAt).reverse().find(isStop) : walk.tree.slice(walk.fromAt).find(isStop);
299
+ if (neighbour) return order.indexOf(neighbour);
300
+ return backward ? order.length - 1 : 0;
301
+ }
302
+ function sequentialTabStops(root, from = null, backward = false) {
303
+ const walk = { from, tree: [], fromAt: from === root ? 0 : -1 };
304
+ const order = orderScope(ownedScope(root) ?? Array.from(root.children), walk);
305
+ const focusedRadio = walk.fromAt >= 0 && isRadio(from) ? from : null;
306
+ const isStop = (element) => passesRadioRule(element, walk.tree, focusedRadio);
307
+ const start = startIndex(order, walk, backward, isStop);
308
+ const visited = backward ? [...order.slice(0, start + 1).reverse(), ...order.slice(start + 1).reverse()] : [...order.slice(start), ...order.slice(0, start)];
309
+ return visited.filter((element) => element !== from && isStop(element));
190
310
  }
311
+ function deepActiveElement(ownerDocument) {
312
+ let active = ownerDocument.activeElement;
313
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
314
+ return active;
315
+ }
316
+
317
+ var MicrotaskCoalescer = class {
318
+ #run;
319
+ #queued = false;
320
+ #active = false;
321
+ #generation = 0;
322
+ constructor(run) {
323
+ this.#run = run;
324
+ }
325
+ activate() {
326
+ this.#active = true;
327
+ }
328
+ cancel() {
329
+ this.#active = false;
330
+ this.#queued = false;
331
+ this.#generation += 1;
332
+ }
333
+ schedule() {
334
+ if (!this.#active || this.#queued) return;
335
+ this.#queued = true;
336
+ const generation = this.#generation;
337
+ queueMicrotask(() => {
338
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
339
+ this.#queued = false;
340
+ this.#run();
341
+ });
342
+ }
343
+ };
191
344
 
192
- // src/utils/focus_trap.ts
193
- var FocusTrap = class {
194
- /** The element focused before activation, restored on deactivation. */
195
- #previouslyFocused = null;
196
- /** The body's inline `overflow` before locking, restored on deactivation. */
197
- #previousBodyOverflow = "";
198
- /** Whether scroll was locked this activation (so it is only restored if applied). */
199
- #scrollLocked = false;
200
- /** Background siblings made `inert` while active, restored on deactivation. */
201
- #inertedSiblings = [];
202
- /** Whether the modal side effects are currently applied. */
345
+ function queryOpenDescendants(root, selector) {
346
+ const matches = [];
347
+ const roots = [root];
348
+ for (const scope of roots) {
349
+ matches.push(...scope.querySelectorAll(selector));
350
+ if (scope.nodeType === Node.ELEMENT_NODE) {
351
+ const shadow = scope.shadowRoot;
352
+ if (shadow) roots.push(shadow);
353
+ }
354
+ for (const element of scope.querySelectorAll("*")) {
355
+ if (element.shadowRoot) roots.push(element.shadowRoot);
356
+ }
357
+ }
358
+ return matches;
359
+ }
360
+
361
+ var TabindexLoan = class _TabindexLoan {
362
+ static #registry = sharedRegistry(
363
+ "stimeo-ui.tabindex-loan.registry.v1",
364
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
365
+ );
366
+ #value;
367
+ #record;
368
+ #lent = /* @__PURE__ */ new Set();
369
+ constructor(value, owner) {
370
+ this.#value = value;
371
+ this.#record = `data-${owner}-tabindex-loan`;
372
+ _TabindexLoan.#registry.names.add(this.#record);
373
+ }
374
+ lend(element) {
375
+ const stack = this.#stack(element);
376
+ const current = element.getAttribute("tabindex");
377
+ const held = stack.find((hold) => hold.loan === this) ?? stack.find(
378
+ (hold) => hold.loan === null && hold.record === this.#record && hold.value === this.#value
379
+ );
380
+ const linked = stack.at(-1)?.value === current;
381
+ if (!held && current !== null && !linked) return;
382
+ this.#lent.add(element);
383
+ if (held) held.loan = this;
384
+ else stack.push({ loan: this, record: this.#record, value: this.#value, linked });
385
+ this.#mark(element, stack);
386
+ if (current === null || !held && current !== this.#value) {
387
+ element.setAttribute("tabindex", this.#value);
388
+ }
389
+ }
390
+ returnAll() {
391
+ for (const element of this.#lent) {
392
+ const stack = this.#stack(element);
393
+ const hold = stack.find((held) => held.loan === this);
394
+ if (hold) this.#release(element, stack, hold);
395
+ }
396
+ this.#lent.clear();
397
+ }
398
+ reclaim(element) {
399
+ if (element.getAttribute(this.#record) !== this.#value) return;
400
+ const stack = this.#stack(element);
401
+ const hold = stack.find((held) => held.loan === null && held.record === this.#record);
402
+ if (hold && !element.matches(":focus")) this.#release(element, stack, hold);
403
+ }
404
+ reclaimWithin(root) {
405
+ for (const element of queryOpenDescendants(root, `[${this.#record}]`)) {
406
+ this.reclaim(element);
407
+ }
408
+ }
409
+ #stack(element) {
410
+ const stacks = _TabindexLoan.#registry.stacks;
411
+ const stack = stacks.get(element) ?? this.#seed(element);
412
+ stacks.set(element, stack);
413
+ return stack;
414
+ }
415
+ #seed(element) {
416
+ const current = element.getAttribute("tabindex");
417
+ const holds = [];
418
+ for (const record of _TabindexLoan.#registry.names) {
419
+ const value = element.getAttribute(record);
420
+ if (value !== null) holds.push({ loan: null, record, value, linked: true });
421
+ }
422
+ holds.sort((one, other) => Number(one.value === current) - Number(other.value === current));
423
+ for (const [index, hold] of holds.entries()) hold.linked = index > 0;
424
+ return holds;
425
+ }
426
+ #release(element, stack, hold) {
427
+ const current = element.getAttribute("tabindex");
428
+ const index = stack.indexOf(hold);
429
+ const owns = current === hold.value && !stack.slice(index + 1).some((above2) => above2.value === current);
430
+ stack.splice(index, 1);
431
+ const above = stack[index];
432
+ if (above) above.linked &&= hold.linked;
433
+ this.#mark(element, stack);
434
+ if (!owns) return;
435
+ const below = hold.linked ? stack[index - 1] : void 0;
436
+ if (!below) element.removeAttribute("tabindex");
437
+ else if (below.value !== current) element.setAttribute("tabindex", below.value);
438
+ }
439
+ #mark(element, stack) {
440
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
441
+ for (const hold of stack) records.set(hold.record, hold.value);
442
+ for (const [name, recorded] of records) {
443
+ if (element.getAttribute(name) === recorded) continue;
444
+ if (recorded === null) element.removeAttribute(name);
445
+ else element.setAttribute(name, recorded);
446
+ }
447
+ }
448
+ };
449
+
450
+ var FOCUS_TRAP_OWNER = "stimeo-focus-trap";
451
+ var INERT_MARK = `data-${FOCUS_TRAP_OWNER}-inert`;
452
+ var OVERFLOW_MARK = `data-${FOCUS_TRAP_OWNER}-overflow`;
453
+ var FocusTrap = class _FocusTrap {
454
+ static #registries = sharedRegistry(
455
+ "stimeo-ui.focus-trap.registry.v2",
456
+ () => /* @__PURE__ */ new WeakMap()
457
+ );
458
+ static #TEXT_ENTRY_TYPES = /* @__PURE__ */ new Set([
459
+ "text",
460
+ "search",
461
+ "email",
462
+ "url",
463
+ "tel",
464
+ "password",
465
+ "number"
466
+ ]);
467
+ #openers = [];
468
+ #entry;
469
+ #tabindexLoans = /* @__PURE__ */ new Map();
203
470
  #activeState = false;
204
- /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
205
471
  #escapeLayer = new EscapeLayer();
206
- /** Returns the trapped element; called on every operation for the live target. */
472
+ #gate = new DetachGate();
207
473
  #getContainer;
208
- /** Closing/focus hooks; see {@link FocusTrapOptions}. */
209
474
  #options;
210
- /**
211
- * @param getContainer - Returns the trapped element. Called on every operation
212
- * so the live target is always used.
213
- * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
214
- */
215
475
  constructor(getContainer, options = {}) {
216
476
  this.#getContainer = getContainer;
217
477
  this.#options = options;
478
+ this.#entry = {
479
+ container: () => this.#readContainer(),
480
+ moveFocus: (backward) => this.#moveFocus(backward),
481
+ refocus: () => this.#refocus(),
482
+ isolated: false,
483
+ lockedBody: null,
484
+ openedFrom: null,
485
+ handedBack: []
486
+ };
218
487
  }
219
- /** Whether the trap is currently active. */
220
488
  get active() {
221
489
  return this.#activeState;
222
490
  }
223
- /**
224
- * Applies the trap: records the current focus, optionally locks background scroll
225
- * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
226
- * `autoFocus` is off) moves focus inside. No-ops if already active.
227
- */
228
491
  activate() {
229
492
  if (this.#activeState) return;
493
+ _FocusTrap.#sweep();
230
494
  this.#activeState = true;
231
- const active = document.activeElement;
232
- this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
233
- if (this.#flag(this.#options.lockScroll, true)) {
234
- this.#previousBodyOverflow = document.body.style.overflow;
235
- document.body.style.overflow = "hidden";
236
- this.#scrollLocked = true;
237
- }
238
- if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
239
- document.addEventListener("keydown", this.#onKeydown);
240
- this.#beforeCache.activate();
495
+ this.#openers = _FocusTrap.#focusPath();
496
+ this.#entry.openedFrom = _FocusTrap.#trapHolding(this.#openers[0] ?? null);
497
+ if (this.#flag(this.#options.lockScroll, true)) this.#lockScroll();
498
+ this.#entry.isolated = this.#flag(this.#options.isolate, true);
499
+ this.#joinStack();
500
+ _FocusTrap.#reconcileBackground(document);
241
501
  const onEscape = this.#options.onEscape;
242
502
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
243
503
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
244
504
  }
245
- /**
246
- * Reverts every side effect applied by {@link activate}. No-ops if inactive, so
247
- * a controller can call it defensively from both `close()` and `disconnect()`.
248
- *
249
- * @param restoreFocus - Move focus back to the opener (default `true`). Pass
250
- * `false` on teardown (`disconnect`), where yanking focus is undesirable.
251
- */
252
505
  deactivate({ restoreFocus = true } = {}) {
253
- if (!this.#activeState) return;
506
+ if (!this.#activeState) {
507
+ this.#returnTabindexes();
508
+ return;
509
+ }
254
510
  this.#activeState = false;
255
511
  this.#escapeLayer.deactivate();
256
- document.removeEventListener("keydown", this.#onKeydown);
257
- this.#beforeCache.deactivate();
258
- if (this.#scrollLocked) {
259
- document.body.style.overflow = this.#previousBodyOverflow;
260
- this.#scrollLocked = false;
261
- }
262
- this.#releaseBackground();
263
- if (restoreFocus) {
264
- const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
265
- target?.focus();
266
- }
512
+ const above = this.#leaveStack();
513
+ this.#unlockScroll();
514
+ _FocusTrap.#reconcileBackground(document);
515
+ const wayBack = this.#handOver(above);
516
+ if (restoreFocus) this.#restore(wayBack, above);
517
+ this.#returnTabindexes();
518
+ }
519
+ connect() {
520
+ this.#gate.cancel();
521
+ _FocusTrap.#sweep();
522
+ new TabindexLoan("-1", FOCUS_TRAP_OWNER).reclaimWithin(document);
523
+ }
524
+ disconnect(host) {
525
+ if (this.#activeState) this.deactivate({ restoreFocus: false });
526
+ this.#gate.disconnected(host, () => this.#returnTabindexes());
527
+ }
528
+ refreshIsolation() {
529
+ if (!this.#activeState) return;
530
+ const isolate = this.#flag(this.#options.isolate, true);
531
+ if (isolate === this.#entry.isolated) return;
532
+ this.#entry.isolated = isolate;
533
+ const stranded = _FocusTrap.#reconcileBackground(document);
534
+ const active = deepActiveElement(document);
535
+ if (stranded.some((element) => flatTreeContains(element, active))) this.#focusInitial();
536
+ }
537
+ refreshContainer() {
538
+ if (!this.#activeState) return;
539
+ _FocusTrap.#reconcileBackground(document);
540
+ this.#returnTabindexes();
541
+ if (this.#ownsTab()) this.#refocus();
267
542
  }
268
- /** Resolves a boolean-or-getter option, defaulting when it was not provided. */
269
543
  #flag(option, fallback) {
270
544
  if (option === void 0) return fallback;
271
545
  return typeof option === "function" ? option() : option;
272
546
  }
273
- /**
274
- * Reverts the side effects just before Turbo caches the page snapshot, so an
275
- * overlay left open does not bake the scroll lock into `body[style]` — a
276
- * restored page would feed that locked value back into {@link activate} as the
277
- * baseline, and closing would then never unlock the page. Markup state stays
278
- * untouched (restore-open designs reopen against a clean baseline), and focus
279
- * is left alone mid-navigation. The listener lives only while active.
280
- */
281
- #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
282
- /**
283
- * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
284
- * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
285
- * which layer currently owns Escape.
286
- */
287
- #onKeydown = (event) => {
288
- if (event.key === "Tab") this.#trapTab(event);
289
- };
290
- /** Keeps `Tab` focus cycling within the container's focusable elements. */
291
- #trapTab(event) {
292
- const focusable = this.#focusableElements();
293
- if (focusable.length === 0) {
294
- event.preventDefault();
295
- return;
547
+ #joinStack() {
548
+ const registry = _FocusTrap.#registry(document);
549
+ if (registry.stack.length === 0) {
550
+ document.addEventListener("keydown", registry.onKeydown);
551
+ document.addEventListener("turbo:morph", registry.onMorph);
552
+ document.addEventListener("turbo:morph-element", registry.onMorph);
296
553
  }
297
- const first = focusable[0];
298
- const last = focusable[focusable.length - 1];
299
- const active = document.activeElement;
300
- if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
301
- event.preventDefault();
302
- first?.focus();
303
- return;
554
+ registry.stack.push(this.#entry);
555
+ }
556
+ #leaveStack() {
557
+ const registry = _FocusTrap.#registry(document);
558
+ const index = registry.stack.indexOf(this.#entry);
559
+ const above = registry.stack.slice(index + 1);
560
+ registry.stack.splice(index, 1);
561
+ if (registry.stack.length === 0) {
562
+ document.removeEventListener("keydown", registry.onKeydown);
563
+ document.removeEventListener("turbo:morph", registry.onMorph);
564
+ document.removeEventListener("turbo:morph-element", registry.onMorph);
304
565
  }
305
- if (event.shiftKey && active === first) {
306
- event.preventDefault();
307
- last?.focus();
308
- } else if (!event.shiftKey && active === last) {
309
- event.preventDefault();
310
- first?.focus();
566
+ return above;
567
+ }
568
+ static #focusPath() {
569
+ const path = [];
570
+ for (let active = document.activeElement; active; active = active.shadowRoot?.activeElement ?? null) {
571
+ if (active instanceof HTMLElement && active !== document.body) path.unshift(active);
311
572
  }
573
+ return path;
312
574
  }
313
- /**
314
- * Marks every element outside the container's subtree as `inert` so background
315
- * content cannot be focused or reached by assistive technology, honoring the
316
- * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
- * untracked so `#releaseBackground` does not wrongly clear it.
318
- *
319
- * The walk climbs from the container to `body` and inerts each ancestor's other
320
- * children. Scanning only `body`'s children would skip the branch the container
321
- * sits in — everything beside it inside that branch is background too, and a
322
- * nested container is the ordinary case.
323
- */
324
- #isolateBackground() {
325
- const container = this.#getContainer();
326
- this.#inertedSiblings = [];
327
- for (let node = container; node !== document.body; ) {
328
- const parent = node.parentElement;
329
- if (!parent) break;
330
- for (const sibling of Array.from(parent.children)) {
331
- if (!(sibling instanceof HTMLElement)) continue;
332
- if (sibling === node || sibling.inert) continue;
333
- sibling.inert = true;
334
- this.#inertedSiblings.push(sibling);
575
+ static #trapHolding(element) {
576
+ for (const trap of [..._FocusTrap.#registry(document).stack].reverse()) {
577
+ const container = trap.container();
578
+ if (container && flatTreeContains(container, element)) return trap;
579
+ }
580
+ return null;
581
+ }
582
+ #handOver(above) {
583
+ const entry = this.#entry;
584
+ const wayBack = [
585
+ ...this.#openers,
586
+ this.#options.fallbackFocus?.() ?? null,
587
+ ...entry.handedBack
588
+ ].filter((element) => element !== null);
589
+ for (const trap of above) {
590
+ if (trap.openedFrom !== entry) continue;
591
+ trap.handedBack = [...trap.handedBack, ...wayBack];
592
+ trap.openedFrom = entry.openedFrom;
593
+ }
594
+ this.#openers = [];
595
+ entry.openedFrom = null;
596
+ entry.handedBack = [];
597
+ return wayBack;
598
+ }
599
+ #restore(wayBack, above) {
600
+ const active = deepActiveElement(document);
601
+ const released = this.#readContainer();
602
+ const holds = (trap) => {
603
+ const container = trap.container();
604
+ if (!container || !_FocusTrap.#takesTab(trap) || !flatTreeContains(container, active)) {
605
+ return false;
335
606
  }
336
- node = parent;
607
+ return !released || !flatTreeContains(released, active) || flatTreeContains(released, container);
608
+ };
609
+ if (above.some(holds)) return;
610
+ for (const element of wayBack) {
611
+ if (this.#activeState) return;
612
+ if (!element.isConnected || !canTakeFocus(element) || !isRenderedForFocus(element)) continue;
613
+ const before = deepActiveElement(document);
614
+ element.focus();
615
+ const after = deepActiveElement(document);
616
+ if (after === element || after !== before) return;
337
617
  }
618
+ _FocusTrap.#tabOwner(_FocusTrap.#registry(document).stack)?.refocus();
619
+ }
620
+ #refocus() {
621
+ if (!this.#flag(this.#options.autoFocus, true)) return;
622
+ if (flatTreeContains(this.#getContainer(), deepActiveElement(document))) return;
623
+ this.#focusInitial();
338
624
  }
339
- /** Reverts the `inert` flags applied by `#isolateBackground`. */
340
- #releaseBackground() {
341
- for (const sibling of this.#inertedSiblings) {
342
- sibling.inert = false;
625
+ #lendTabindex(container) {
626
+ const loan = this.#tabindexLoans.get(container) ?? new TabindexLoan("-1", FOCUS_TRAP_OWNER);
627
+ loan.lend(container);
628
+ this.#tabindexLoans.set(container, loan);
629
+ }
630
+ #returnTabindexes() {
631
+ const focused = deepActiveElement(document);
632
+ for (const [element, loan] of [...this.#tabindexLoans]) {
633
+ if (element === focused) continue;
634
+ loan.returnAll();
635
+ this.#tabindexLoans.delete(element);
636
+ }
637
+ }
638
+ #lockScroll() {
639
+ const registry = _FocusTrap.#registry(document);
640
+ const body = document.body;
641
+ if (!body.hasAttribute(OVERFLOW_MARK)) body.setAttribute(OVERFLOW_MARK, body.style.overflow);
642
+ registry.scrollLocks.add(this.#entry);
643
+ this.#entry.lockedBody = body;
644
+ body.style.overflow = "hidden";
645
+ }
646
+ #unlockScroll() {
647
+ const registry = _FocusTrap.#registry(document);
648
+ const body = this.#entry.lockedBody;
649
+ this.#entry.lockedBody = null;
650
+ if (!registry.scrollLocks.delete(this.#entry) || !body) return;
651
+ if (!_FocusTrap.#isLocked(registry, body)) _FocusTrap.#unlock(body);
652
+ }
653
+ static #isLocked(registry, body) {
654
+ return [...registry.scrollLocks].some((trap) => trap.lockedBody === body);
655
+ }
656
+ static #unlock(body) {
657
+ const saved = body.getAttribute(OVERFLOW_MARK);
658
+ if (saved === null) return;
659
+ body.removeAttribute(OVERFLOW_MARK);
660
+ body.style.overflow = saved;
661
+ }
662
+ static #sweep() {
663
+ const registry = _FocusTrap.#registry(document);
664
+ for (const element of queryOpenDescendants(document, `[${INERT_MARK}]`)) {
665
+ if (registry.inerted.has(element)) continue;
666
+ element.removeAttribute(INERT_MARK);
667
+ element.inert = false;
668
+ }
669
+ if (!_FocusTrap.#isLocked(registry, document.body)) _FocusTrap.#unlock(document.body);
670
+ }
671
+ static #registry(ownerDocument) {
672
+ const existing = _FocusTrap.#registries.get(ownerDocument);
673
+ if (existing) return existing;
674
+ const morphs = new MicrotaskCoalescer(() => _FocusTrap.#reapply(registry));
675
+ morphs.activate();
676
+ const registry = {
677
+ stack: [],
678
+ onKeydown: (event) => _FocusTrap.#resolveTab(registry.stack, event),
679
+ onMorph: () => morphs.schedule(),
680
+ inerted: /* @__PURE__ */ new Set(),
681
+ scrollLocks: /* @__PURE__ */ new Set()
682
+ };
683
+ _FocusTrap.#registries.set(ownerDocument, registry);
684
+ return registry;
685
+ }
686
+ static #reapply(registry) {
687
+ _FocusTrap.#reconcileBackground(document);
688
+ const body = document.body;
689
+ if (!_FocusTrap.#isLocked(registry, body)) return;
690
+ if (!body.hasAttribute(OVERFLOW_MARK)) body.setAttribute(OVERFLOW_MARK, body.style.overflow);
691
+ body.style.overflow = "hidden";
692
+ }
693
+ static #reconcileBackground(ownerDocument) {
694
+ const registry = _FocusTrap.#registry(ownerDocument);
695
+ for (const element of registry.inerted) {
696
+ if (!element.hasAttribute(INERT_MARK)) registry.inerted.delete(element);
697
+ }
698
+ const background = _FocusTrap.#backgroundOf(registry);
699
+ for (const element of registry.inerted) {
700
+ if (background.has(element)) continue;
701
+ element.removeAttribute(INERT_MARK);
702
+ element.inert = false;
703
+ registry.inerted.delete(element);
704
+ }
705
+ const added = [];
706
+ for (const element of background) {
707
+ if (registry.inerted.has(element)) continue;
708
+ element.setAttribute(INERT_MARK, "");
709
+ element.inert = true;
710
+ registry.inerted.add(element);
711
+ added.push(element);
712
+ }
713
+ return added;
714
+ }
715
+ static #backgroundOf(registry) {
716
+ const containers = registry.stack.map((trap) => trap.container());
717
+ let top = -1;
718
+ registry.stack.forEach((trap, index) => {
719
+ if (trap.isolated && containers[index]) top = index;
720
+ });
721
+ const background = /* @__PURE__ */ new Set();
722
+ if (top < 0) return background;
723
+ const kept = /* @__PURE__ */ new Set();
724
+ const keep = (path) => {
725
+ for (const node of path) {
726
+ kept.add(node);
727
+ background.delete(node);
728
+ }
729
+ for (const node of path) {
730
+ const parent = flatTreeParent(node);
731
+ for (const sibling of parent ? flatTreeChildren(parent) : []) {
732
+ if (!(sibling instanceof HTMLElement) || kept.has(sibling)) continue;
733
+ if (sibling.inert && !registry.inerted.has(sibling)) continue;
734
+ background.add(sibling);
735
+ }
736
+ }
737
+ };
738
+ containers.slice(top).forEach((container, index) => {
739
+ const path = [];
740
+ for (let node = container; node && node !== document.body && !kept.has(node); ) {
741
+ path.push(node);
742
+ node = flatTreeParent(node);
743
+ }
744
+ if (index === 0 || path.some((node) => background.has(node))) keep(path);
745
+ });
746
+ return background;
747
+ }
748
+ #readContainer() {
749
+ try {
750
+ return this.#getContainer();
751
+ } catch {
752
+ return null;
753
+ }
754
+ }
755
+ static #resolveTab(stack, event) {
756
+ if (event.key !== "Tab" || event.defaultPrevented) return;
757
+ if (event.isComposing || event.keyCode === 229) return;
758
+ if (event.ctrlKey || event.metaKey) return;
759
+ const owner = _FocusTrap.#tabOwner(stack);
760
+ if (!owner) return;
761
+ event.preventDefault();
762
+ owner.moveFocus(event.shiftKey);
763
+ }
764
+ static #tabOwner(stack) {
765
+ for (const trap of [...stack].reverse()) {
766
+ if (_FocusTrap.#takesTab(trap)) return trap;
767
+ }
768
+ return null;
769
+ }
770
+ static #takesTab(trap) {
771
+ const container = trap.container();
772
+ if (!container) return false;
773
+ return container.isConnected && !closestInFlatTree(container, "[hidden]") && isRenderedForFocus(container);
774
+ }
775
+ #ownsTab() {
776
+ return _FocusTrap.#tabOwner(_FocusTrap.#registry(document).stack) === this.#entry;
777
+ }
778
+ #moveFocus(backward) {
779
+ const from = deepActiveElement(document);
780
+ for (const stop of sequentialTabStops(this.#getContainer(), from, backward)) {
781
+ if (!this.#ownsTab()) return;
782
+ const before = deepActiveElement(document);
783
+ stop.focus();
784
+ const after = deepActiveElement(document);
785
+ if (after === before) continue;
786
+ if (after === stop) _FocusTrap.#selectOnEntry(stop);
787
+ return;
788
+ }
789
+ }
790
+ static #selectOnEntry(stop) {
791
+ if (stop instanceof HTMLInputElement && _FocusTrap.#TEXT_ENTRY_TYPES.has(stop.type)) {
792
+ stop.select();
343
793
  }
344
- this.#inertedSiblings = [];
345
794
  }
346
- /** Moves focus to the initial target, the first focusable, or the container. */
347
795
  #focusInitial() {
348
796
  const preferred = this.#options.initialFocus?.();
349
797
  if (preferred) {
350
798
  preferred.focus();
351
799
  return;
352
800
  }
353
- const focusable = this.#focusableElements();
354
- if (focusable[0]) {
355
- focusable[0].focus();
801
+ const container = this.#getContainer();
802
+ const [first] = sequentialTabStops(container);
803
+ if (first) {
804
+ first.focus();
356
805
  return;
357
806
  }
358
- const container = this.#getContainer();
359
- container.tabIndex = -1;
807
+ this.#lendTabindex(container);
360
808
  container.focus();
361
809
  }
362
- /** Collects the container's currently focusable descendants in DOM order. */
363
- #focusableElements() {
364
- return tabStopsWithin(this.#getContainer());
365
- }
366
810
  };
367
811
 
368
- // src/controllers/focus_controller.ts
369
812
  var FocusController = class extends Controller {
370
813
  static targets = ["initial"];
371
814
  static values = {
@@ -377,70 +820,42 @@ var FocusController = class extends Controller {
377
820
  static actions = ["activate", "deactivate"];
378
821
  static events = ["activate", "deactivate"];
379
822
  #trap = new FocusTrap(() => this.element, {
380
- // A focus scope is not a modal: never lock scroll, and only isolate the background
381
- // when `inert` is requested. `auto` gates the initial focus move; Escape releases.
382
823
  lockScroll: false,
383
824
  isolate: () => this.inertValue,
384
825
  autoFocus: () => this.autoValue,
385
826
  initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
386
827
  onEscape: () => this.deactivate()
387
828
  });
388
- /**
389
- * Whether this scope is currently trapping.
390
- *
391
- * Held here rather than read back from the trap: the trap releases itself
392
- * before Turbo caches the page, so its own flag stops answering for the state
393
- * this controller publishes on the element and in its events.
394
- */
395
- #active = false;
396
- /**
397
- * Returns the element to its untrapped form before Turbo copies the page. The
398
- * trap performs its own release on the same event, so what is left is the hook
399
- * this controller owns — carried into the snapshot it would describe a scope
400
- * that is no longer trapping.
401
- *
402
- * Silent, like `disconnect()`: the page is about to be frozen, and a release
403
- * nobody asked for is not a close to report.
404
- */
405
- #beforeCache = new BeforeCacheReset(() => {
406
- this.#active = false;
407
- this.element.removeAttribute("data-focus-trapped");
408
- });
409
- /** Stimulus drives activation from the `trap` value (also fires on connect). */
829
+ inertValueChanged() {
830
+ this.#trap.refreshIsolation();
831
+ }
410
832
  trapValueChanged() {
411
833
  if (this.trapValue) this.#activate();
412
834
  else this.#deactivate();
413
835
  }
414
836
  connect() {
415
- this.#beforeCache.activate();
837
+ this.#trap.connect();
416
838
  }
417
839
  disconnect() {
418
- this.#trap.deactivate({ restoreFocus: false });
419
- this.#beforeCache.deactivate();
420
- this.#active = false;
840
+ this.#trap.disconnect(this);
421
841
  this.element.removeAttribute("data-focus-trapped");
422
842
  }
423
- /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
424
843
  activate() {
425
844
  this.trapValue = true;
426
845
  this.#activate();
427
846
  }
428
- /** Turns the trap off (also wired to Escape). */
429
847
  deactivate() {
430
848
  this.trapValue = false;
431
849
  this.#deactivate();
432
850
  }
433
851
  #activate() {
434
- if (this.#active) return;
435
- this.#active = true;
852
+ if (this.#trap.active) return;
436
853
  this.#trap.activate();
437
854
  this.element.setAttribute("data-focus-trapped", "true");
438
855
  this.dispatch("activate", { detail: {} });
439
856
  }
440
- /** @stimeoRuntimeOnly `restore` decides whether releasing this one trap returns focus. */
441
857
  #deactivate() {
442
- if (!this.#active) return;
443
- this.#active = false;
858
+ if (!this.#trap.active) return;
444
859
  this.#trap.deactivate({ restoreFocus: this.restoreValue });
445
860
  this.element.removeAttribute("data-focus-trapped");
446
861
  this.dispatch("deactivate", { detail: {} });
@@ -448,5 +863,4 @@ var FocusController = class extends Controller {
448
863
  };
449
864
 
450
865
  export { FocusController };
451
- //# sourceMappingURL=focus_controller.js.map
452
866
  //# sourceMappingURL=focus_controller.js.map