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,49 +1,195 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/sidebar_controller.ts
3
+ function sharedRegistry(key, create) {
4
+ const symbol = Symbol.for(key);
5
+ const scope = globalThis;
6
+ const existing = scope[symbol];
7
+ if (existing !== void 0) return existing;
8
+ const registry = create();
9
+ Object.defineProperty(globalThis, symbol, { value: registry });
10
+ return registry;
11
+ }
4
12
 
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;
13
+ function isAttributeValue(value) {
14
+ return value === null || typeof value === "string";
15
+ }
16
+ function readLeaseRecord(element, record) {
17
+ try {
18
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
19
+ if (!Array.isArray(parsed)) return void 0;
20
+ const [original, written, ...rest] = parsed;
21
+ const linked = rest[0] !== false;
22
+ const beneath = linked ? rest : rest.slice(1);
23
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
24
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
25
+ } catch {
26
+ return void 0;
17
27
  }
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);
28
+ }
29
+ var AttributeLease = class _AttributeLease {
30
+ static #registry = sharedRegistry(
31
+ "stimeo-ui.attribute-lease.registry.v1",
32
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
33
+ );
34
+ #attribute;
35
+ #record;
36
+ #base;
37
+ #held = /* @__PURE__ */ new Set();
38
+ constructor(attribute, owner, options = {}) {
39
+ this.#attribute = attribute;
40
+ this.#record = `data-${owner}-${attribute}-lease`;
41
+ this.#base = options.base === true;
42
+ const names = _AttributeLease.#registry.names;
43
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
44
+ }
45
+ write(element, value) {
46
+ const stack = this.#stack(element);
47
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
48
+ this.#held.add(element);
49
+ const shows = !this.#covered(element, stack, hold);
50
+ hold.written = value;
51
+ this.#mark(element, stack);
52
+ if (shows) this.#reflect(element, value);
53
+ }
54
+ return(element) {
55
+ const held = this.#held.delete(element);
56
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
57
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
58
+ const hold = this.#take(stack);
59
+ if (!hold) return false;
60
+ const carries = element.getAttribute(this.#attribute) === hold.written;
61
+ const owns = carries && !this.#covered(element, stack, hold);
62
+ const index = stack.indexOf(hold);
63
+ stack.splice(index, 1);
64
+ const above = stack[index];
65
+ if (above) above.linked &&= hold.linked;
66
+ this.#mark(element, stack);
67
+ if (!owns) return true;
68
+ const below = hold.linked ? stack[index - 1] : void 0;
69
+ this.#reflect(element, below ? below.written : hold.original);
70
+ return true;
71
+ }
72
+ returnAll() {
73
+ for (const element of Array.from(this.#held)) this.return(element);
74
+ }
75
+ #stack(element) {
76
+ const stacks = _AttributeLease.#registry.stacks;
77
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
78
+ stacks.set(element, byAttribute);
79
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
80
+ byAttribute.set(this.#attribute, stack);
81
+ return stack;
82
+ }
83
+ #seed(element) {
84
+ const found = [];
85
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
86
+ const read = readLeaseRecord(element, record);
87
+ if (read) found.push({ ...read, record });
88
+ }
89
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
90
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
91
+ lease: null,
92
+ record,
93
+ written,
94
+ original,
95
+ linked: linked && index > 0
96
+ }));
97
+ }
98
+ #take(stack) {
99
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
100
+ if (hold) hold.lease = this;
101
+ return hold;
102
+ }
103
+ #acquire(element, stack) {
104
+ const current = element.getAttribute(this.#attribute);
105
+ const top = stack.at(-1);
106
+ const bottom = stack[0];
107
+ const over = top?.written === current ? top : void 0;
108
+ const from = this.#base ? bottom : over;
109
+ const hold = {
110
+ lease: this,
111
+ record: this.#record,
112
+ written: current,
113
+ original: from ? from.original : current,
114
+ linked: !this.#base && over !== void 0
115
+ };
116
+ if (this.#base && bottom) {
117
+ bottom.linked = true;
118
+ stack.unshift(hold);
119
+ } else stack.push(hold);
120
+ return hold;
121
+ }
122
+ #covered(element, stack, hold) {
123
+ const current = element.getAttribute(this.#attribute);
124
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
125
+ }
126
+ #mark(element, stack) {
127
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
128
+ stack.forEach((hold, index) => {
129
+ const names = stack.slice(0, index).map((under) => under.record);
130
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
131
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
132
+ records.set(
133
+ hold.record,
134
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
135
+ );
136
+ });
137
+ for (const [name, recorded] of records) {
138
+ if (element.getAttribute(name) === recorded) continue;
139
+ if (recorded === null) element.removeAttribute(name);
140
+ else element.setAttribute(name, recorded);
24
141
  }
25
142
  }
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);
143
+ #reflect(element, value) {
144
+ if (element.getAttribute(this.#attribute) === value) return;
145
+ if (value === null) element.removeAttribute(this.#attribute);
146
+ else element.setAttribute(this.#attribute, value);
147
+ }
148
+ };
149
+
150
+ var DetachGate = class _DetachGate {
151
+ #pending = false;
152
+ get pending() {
153
+ return this.#pending;
154
+ }
155
+ static isDetached(host) {
156
+ return !host.element.isConnected || !_DetachGate.#listed(host);
157
+ }
158
+ disconnected(host, teardown) {
159
+ const carried = _DetachGate.#carried(host);
160
+ if (!carried && _DetachGate.isDetached(host)) {
161
+ this.#pending = false;
162
+ teardown();
163
+ return;
164
+ }
165
+ this.#pending = true;
166
+ const probe = () => {
167
+ if (!this.#pending) return;
168
+ this.#pending = false;
169
+ teardown();
170
+ };
171
+ if (carried) setTimeout(probe, 0);
172
+ else queueMicrotask(probe);
173
+ }
174
+ static #carried(host) {
175
+ const element = host.element;
176
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
177
+ }
178
+ static #listed(host) {
179
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
180
+ return tokens.includes(host.identifier);
181
+ }
182
+ cancel() {
183
+ this.#pending = false;
31
184
  }
32
185
  };
33
186
 
34
- // src/utils/escape_layer.ts
35
187
  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
- */
188
+ static #registries = sharedRegistry(
189
+ "stimeo-ui.escape-layer.registry.v1",
190
+ () => /* @__PURE__ */ new WeakMap()
191
+ );
192
+ #active = null;
47
193
  activate(ownerDocument = document, options) {
48
194
  this.deactivate();
49
195
  let registry = _EscapeLayer.#registries.get(ownerDocument);
@@ -52,44 +198,31 @@ var EscapeLayer = class _EscapeLayer {
52
198
  _EscapeLayer.#registries.set(ownerDocument, registry);
53
199
  ownerDocument.addEventListener("keydown", registry.onKeydown);
54
200
  }
55
- registry.stack.push(this);
56
- this.#ownerDocument = ownerDocument;
57
- this.#onDismiss = options.onDismiss;
58
- this.#claims = options.claims ?? null;
201
+ const entry = { onDismiss: options.onDismiss, claims: options.claims ?? null };
202
+ registry.stack.push(entry);
203
+ this.#active = { document: ownerDocument, entry };
59
204
  }
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
205
  deactivate() {
65
- const ownerDocument = this.#ownerDocument;
66
- if (!ownerDocument) return;
67
- const registry = _EscapeLayer.#registries.get(ownerDocument);
206
+ const active = this.#active;
207
+ if (!active) return;
208
+ const registry = _EscapeLayer.#registries.get(active.document);
68
209
  if (registry) {
69
- const index = registry.stack.lastIndexOf(this);
210
+ const index = registry.stack.lastIndexOf(active.entry);
70
211
  if (index >= 0) registry.stack.splice(index, 1);
71
212
  if (registry.stack.length === 0) {
72
- ownerDocument.removeEventListener("keydown", registry.onKeydown);
73
- _EscapeLayer.#registries.delete(ownerDocument);
213
+ active.document.removeEventListener("keydown", registry.onKeydown);
214
+ _EscapeLayer.#registries.delete(active.document);
74
215
  }
75
216
  }
76
- this.#ownerDocument = null;
77
- this.#onDismiss = null;
78
- this.#claims = null;
217
+ this.#active = null;
79
218
  }
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
- */
85
219
  get ownsEscape() {
86
- const ownerDocument = this.#ownerDocument;
87
- if (!ownerDocument) return false;
88
- const registry = _EscapeLayer.#registries.get(ownerDocument);
220
+ const active = this.#active;
221
+ if (!active) return false;
222
+ const registry = _EscapeLayer.#registries.get(active.document);
89
223
  if (!registry) return false;
90
- return _EscapeLayer.#resolveOwner(registry.stack) === this;
224
+ return _EscapeLayer.#resolveOwner(registry.stack) === active.entry;
91
225
  }
92
- /** Builds a document's registry with its shared resolver listener. */
93
226
  static #createRegistry() {
94
227
  const registry = {
95
228
  stack: [],
@@ -98,24 +231,22 @@ var EscapeLayer = class _EscapeLayer {
98
231
  const owner = _EscapeLayer.#resolveOwner(registry.stack);
99
232
  if (!owner) return;
100
233
  event.preventDefault();
101
- owner.#onDismiss?.();
234
+ owner.onDismiss();
102
235
  }
103
236
  };
104
237
  return registry;
105
238
  }
106
- /** The topmost stack layer whose claims predicate passes, or `null`. */
107
239
  static #resolveOwner(stack) {
108
240
  for (let index = stack.length - 1; index >= 0; index--) {
109
241
  const layer = stack[index];
110
242
  if (!layer) continue;
111
- if (layer.#claims && !layer.#claims()) continue;
243
+ if (layer.claims && !layer.claims()) continue;
112
244
  return layer;
113
245
  }
114
246
  return null;
115
247
  }
116
248
  };
117
249
 
118
- // src/utils/focus_candidate.ts
119
250
  function inheritsFieldsetDisabled(control) {
120
251
  let fieldset = control.closest("fieldset[disabled]");
121
252
  while (fieldset) {
@@ -125,8 +256,34 @@ function inheritsFieldsetDisabled(control) {
125
256
  }
126
257
  return false;
127
258
  }
259
+ function unslotted(element) {
260
+ return element.assignedSlot === null && element.parentElement?.shadowRoot != null;
261
+ }
262
+ function flatTreeParent(element) {
263
+ if (unslotted(element)) return null;
264
+ const parent = element.assignedSlot ?? element.parentNode;
265
+ if (parent instanceof ShadowRoot) return parent.host;
266
+ return parent instanceof Element ? parent : null;
267
+ }
268
+ function flatTreeContains(ancestor, element) {
269
+ for (let node = element; node; node = flatTreeParent(node)) {
270
+ if (node === ancestor) return true;
271
+ }
272
+ return false;
273
+ }
274
+ function closestInFlatTree(element, selector) {
275
+ for (let node = element; node; node = flatTreeParent(node)) {
276
+ if (node.matches(selector)) return node;
277
+ }
278
+ return null;
279
+ }
280
+ function flatTreeChildren(element) {
281
+ return ownedScope(element) ?? Array.from(element.children);
282
+ }
128
283
  function canTakeFocus(element) {
129
- if (element.closest("[hidden], [inert]")) return false;
284
+ for (let node = element; node; node = flatTreeParent(node)) {
285
+ if (unslotted(node) || node.matches("[hidden], [inert]")) return false;
286
+ }
130
287
  if (element instanceof HTMLInputElement && element.type === "hidden") return false;
131
288
  if (!("disabled" in element)) return true;
132
289
  if (element.disabled) return false;
@@ -183,189 +340,692 @@ function isTabStop(element) {
183
340
  if (tabindex !== null) return tabindex >= 0;
184
341
  return hasNativeTabStop(element) || hasEditableTabStop(element);
185
342
  }
186
- function tabStopsWithin(root) {
187
- return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
188
- isTabStop
343
+ function isRadio(element) {
344
+ return element instanceof HTMLInputElement && element.type === "radio";
345
+ }
346
+ function sameRadioGroup(a, b) {
347
+ return a.name !== "" && a.name === b.name && a.form === b.form && a.getRootNode() === b.getRootNode();
348
+ }
349
+ function isInEditableContent(element) {
350
+ for (let node = element.parentElement; node; node = node.parentElement) {
351
+ if (node.getAttribute("contenteditable")?.toLowerCase() === "false") return false;
352
+ if (hasEditableTabStop(node)) return true;
353
+ }
354
+ return false;
355
+ }
356
+ var XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
357
+ function isSvgLink(element) {
358
+ return element instanceof SVGElement && element.localName === "a" && (element.hasAttribute("href") || element.hasAttributeNS(XLINK_NAMESPACE, "href"));
359
+ }
360
+ function hasRenderedImage(area) {
361
+ const map = area.closest("map");
362
+ const key = map?.name || map?.id;
363
+ if (!map || !key) return false;
364
+ const root = map.getRootNode();
365
+ return Array.from(root.querySelectorAll("img[usemap]")).some(
366
+ (image) => image.getAttribute("usemap") === `#${key}` && isRenderedForFocus(image)
189
367
  );
190
368
  }
369
+ function isFocusableInOrder(element) {
370
+ const tabindex = authoredTabindex(element);
371
+ return (tabindex === null || tabindex >= 0) && canTakeFocus(element);
372
+ }
373
+ function extraStop(element) {
374
+ if (element instanceof HTMLAreaElement) {
375
+ const focusable = element.hasAttribute("href") || authoredTabindex(element) !== null;
376
+ return focusable && isFocusableInOrder(element) && hasRenderedImage(element);
377
+ }
378
+ if (element instanceof HTMLObjectElement) {
379
+ return isFocusableInOrder(element) && isRenderedForFocus(element) && element.contentWindow !== null;
380
+ }
381
+ if (isSvgLink(element))
382
+ return isFocusableInOrder(element) && isRenderedForFocus(element);
383
+ return null;
384
+ }
385
+ function isSequentialStop(element) {
386
+ const stop = extraStop(element) ?? (element.matches(TAB_STOP_CANDIDATE_SELECTOR) && isTabStop(element));
387
+ if (!stop) return false;
388
+ const link = element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement || isSvgLink(element);
389
+ return !(link && authoredTabindex(element) === null && isInEditableContent(element));
390
+ }
391
+ function ownedScope(element) {
392
+ if (element.shadowRoot) return Array.from(element.shadowRoot.children);
393
+ if (element instanceof HTMLSlotElement) {
394
+ const assigned = element.assignedElements();
395
+ return assigned.length > 0 ? assigned : Array.from(element.children);
396
+ }
397
+ return null;
398
+ }
399
+ function orderScope(elements, walk) {
400
+ const entries = [];
401
+ const visit = (element) => {
402
+ if (element === walk.from) walk.fromAt = walk.tree.length;
403
+ const tabindex = authoredTabindex(element) ?? 0;
404
+ const stop = (element instanceof HTMLElement || element instanceof SVGElement) && isSequentialStop(element) ? element : null;
405
+ if (stop) walk.tree.push(stop);
406
+ const owned = ownedScope(element);
407
+ if (owned) {
408
+ if (tabindex >= 0) {
409
+ entries.push({ tabindex, stops: [...stop ? [stop] : [], ...orderScope(owned, walk)] });
410
+ }
411
+ return;
412
+ }
413
+ if (stop) entries.push({ tabindex, stops: [stop] });
414
+ for (const child of Array.from(element.children)) visit(child);
415
+ };
416
+ for (const element of elements) visit(element);
417
+ const positive = entries.filter((entry) => entry.tabindex > 0);
418
+ positive.sort((a, b) => a.tabindex - b.tabindex);
419
+ const rest = entries.filter((entry) => entry.tabindex === 0);
420
+ return [...positive, ...rest].flatMap((entry) => entry.stops);
421
+ }
422
+ function passesRadioRule(element, tree, focusedRadio) {
423
+ if (!isRadio(element) || element.checked) return true;
424
+ const group = tree.filter(
425
+ (stop) => stop === element || isRadio(stop) && sameRadioGroup(stop, element)
426
+ );
427
+ if (group.some((radio) => radio.checked)) return false;
428
+ if (group[0] !== element) return false;
429
+ return focusedRadio === null || !sameRadioGroup(focusedRadio, element);
430
+ }
431
+ function startIndex(order, walk, backward, isStop) {
432
+ if (walk.fromAt < 0) return backward ? order.length - 1 : 0;
433
+ const at = order.indexOf(walk.from);
434
+ if (at >= 0) return backward ? at - 1 : at + 1;
435
+ const neighbour = backward ? walk.tree.slice(0, walk.fromAt).reverse().find(isStop) : walk.tree.slice(walk.fromAt).find(isStop);
436
+ if (neighbour) return order.indexOf(neighbour);
437
+ return backward ? order.length - 1 : 0;
438
+ }
439
+ function sequentialTabStops(root, from = null, backward = false) {
440
+ const walk = { from, tree: [], fromAt: from === root ? 0 : -1 };
441
+ const order = orderScope(ownedScope(root) ?? Array.from(root.children), walk);
442
+ const focusedRadio = walk.fromAt >= 0 && isRadio(from) ? from : null;
443
+ const isStop = (element) => passesRadioRule(element, walk.tree, focusedRadio);
444
+ const start = startIndex(order, walk, backward, isStop);
445
+ const visited = backward ? [...order.slice(0, start + 1).reverse(), ...order.slice(start + 1).reverse()] : [...order.slice(start), ...order.slice(0, start)];
446
+ return visited.filter((element) => element !== from && isStop(element));
447
+ }
448
+ function deepActiveElement(ownerDocument) {
449
+ let active = ownerDocument.activeElement;
450
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
451
+ return active;
452
+ }
453
+
454
+ var MicrotaskCoalescer = class {
455
+ #run;
456
+ #queued = false;
457
+ #active = false;
458
+ #generation = 0;
459
+ constructor(run) {
460
+ this.#run = run;
461
+ }
462
+ activate() {
463
+ this.#active = true;
464
+ }
465
+ cancel() {
466
+ this.#active = false;
467
+ this.#queued = false;
468
+ this.#generation += 1;
469
+ }
470
+ schedule() {
471
+ if (!this.#active || this.#queued) return;
472
+ this.#queued = true;
473
+ const generation = this.#generation;
474
+ queueMicrotask(() => {
475
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
476
+ this.#queued = false;
477
+ this.#run();
478
+ });
479
+ }
480
+ };
481
+
482
+ function queryOpenDescendants(root, selector) {
483
+ const matches = [];
484
+ const roots = [root];
485
+ for (const scope of roots) {
486
+ matches.push(...scope.querySelectorAll(selector));
487
+ if (scope.nodeType === Node.ELEMENT_NODE) {
488
+ const shadow = scope.shadowRoot;
489
+ if (shadow) roots.push(shadow);
490
+ }
491
+ for (const element of scope.querySelectorAll("*")) {
492
+ if (element.shadowRoot) roots.push(element.shadowRoot);
493
+ }
494
+ }
495
+ return matches;
496
+ }
497
+
498
+ var TabindexLoan = class _TabindexLoan {
499
+ static #registry = sharedRegistry(
500
+ "stimeo-ui.tabindex-loan.registry.v1",
501
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
502
+ );
503
+ #value;
504
+ #record;
505
+ #lent = /* @__PURE__ */ new Set();
506
+ constructor(value, owner) {
507
+ this.#value = value;
508
+ this.#record = `data-${owner}-tabindex-loan`;
509
+ _TabindexLoan.#registry.names.add(this.#record);
510
+ }
511
+ lend(element) {
512
+ const stack = this.#stack(element);
513
+ const current = element.getAttribute("tabindex");
514
+ const held = stack.find((hold) => hold.loan === this) ?? stack.find(
515
+ (hold) => hold.loan === null && hold.record === this.#record && hold.value === this.#value
516
+ );
517
+ const linked = stack.at(-1)?.value === current;
518
+ if (!held && current !== null && !linked) return;
519
+ this.#lent.add(element);
520
+ if (held) held.loan = this;
521
+ else stack.push({ loan: this, record: this.#record, value: this.#value, linked });
522
+ this.#mark(element, stack);
523
+ if (current === null || !held && current !== this.#value) {
524
+ element.setAttribute("tabindex", this.#value);
525
+ }
526
+ }
527
+ returnAll() {
528
+ for (const element of this.#lent) {
529
+ const stack = this.#stack(element);
530
+ const hold = stack.find((held) => held.loan === this);
531
+ if (hold) this.#release(element, stack, hold);
532
+ }
533
+ this.#lent.clear();
534
+ }
535
+ reclaim(element) {
536
+ if (element.getAttribute(this.#record) !== this.#value) return;
537
+ const stack = this.#stack(element);
538
+ const hold = stack.find((held) => held.loan === null && held.record === this.#record);
539
+ if (hold && !element.matches(":focus")) this.#release(element, stack, hold);
540
+ }
541
+ reclaimWithin(root) {
542
+ for (const element of queryOpenDescendants(root, `[${this.#record}]`)) {
543
+ this.reclaim(element);
544
+ }
545
+ }
546
+ #stack(element) {
547
+ const stacks = _TabindexLoan.#registry.stacks;
548
+ const stack = stacks.get(element) ?? this.#seed(element);
549
+ stacks.set(element, stack);
550
+ return stack;
551
+ }
552
+ #seed(element) {
553
+ const current = element.getAttribute("tabindex");
554
+ const holds = [];
555
+ for (const record of _TabindexLoan.#registry.names) {
556
+ const value = element.getAttribute(record);
557
+ if (value !== null) holds.push({ loan: null, record, value, linked: true });
558
+ }
559
+ holds.sort((one, other) => Number(one.value === current) - Number(other.value === current));
560
+ for (const [index, hold] of holds.entries()) hold.linked = index > 0;
561
+ return holds;
562
+ }
563
+ #release(element, stack, hold) {
564
+ const current = element.getAttribute("tabindex");
565
+ const index = stack.indexOf(hold);
566
+ const owns = current === hold.value && !stack.slice(index + 1).some((above2) => above2.value === current);
567
+ stack.splice(index, 1);
568
+ const above = stack[index];
569
+ if (above) above.linked &&= hold.linked;
570
+ this.#mark(element, stack);
571
+ if (!owns) return;
572
+ const below = hold.linked ? stack[index - 1] : void 0;
573
+ if (!below) element.removeAttribute("tabindex");
574
+ else if (below.value !== current) element.setAttribute("tabindex", below.value);
575
+ }
576
+ #mark(element, stack) {
577
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
578
+ for (const hold of stack) records.set(hold.record, hold.value);
579
+ for (const [name, recorded] of records) {
580
+ if (element.getAttribute(name) === recorded) continue;
581
+ if (recorded === null) element.removeAttribute(name);
582
+ else element.setAttribute(name, recorded);
583
+ }
584
+ }
585
+ };
191
586
 
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. */
587
+ var FOCUS_TRAP_OWNER = "stimeo-focus-trap";
588
+ var INERT_MARK = `data-${FOCUS_TRAP_OWNER}-inert`;
589
+ var OVERFLOW_MARK = `data-${FOCUS_TRAP_OWNER}-overflow`;
590
+ var FocusTrap = class _FocusTrap {
591
+ static #registries = sharedRegistry(
592
+ "stimeo-ui.focus-trap.registry.v2",
593
+ () => /* @__PURE__ */ new WeakMap()
594
+ );
595
+ static #TEXT_ENTRY_TYPES = /* @__PURE__ */ new Set([
596
+ "text",
597
+ "search",
598
+ "email",
599
+ "url",
600
+ "tel",
601
+ "password",
602
+ "number"
603
+ ]);
604
+ #openers = [];
605
+ #entry;
606
+ #tabindexLoans = /* @__PURE__ */ new Map();
203
607
  #activeState = false;
204
- /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
205
608
  #escapeLayer = new EscapeLayer();
206
- /** Returns the trapped element; called on every operation for the live target. */
609
+ #gate = new DetachGate();
207
610
  #getContainer;
208
- /** Closing/focus hooks; see {@link FocusTrapOptions}. */
209
611
  #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
612
  constructor(getContainer, options = {}) {
216
613
  this.#getContainer = getContainer;
217
614
  this.#options = options;
615
+ this.#entry = {
616
+ container: () => this.#readContainer(),
617
+ moveFocus: (backward) => this.#moveFocus(backward),
618
+ refocus: () => this.#refocus(),
619
+ isolated: false,
620
+ lockedBody: null,
621
+ openedFrom: null,
622
+ handedBack: []
623
+ };
218
624
  }
219
- /** Whether the trap is currently active. */
220
625
  get active() {
221
626
  return this.#activeState;
222
627
  }
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
628
  activate() {
229
629
  if (this.#activeState) return;
630
+ _FocusTrap.#sweep();
230
631
  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();
632
+ this.#openers = _FocusTrap.#focusPath();
633
+ this.#entry.openedFrom = _FocusTrap.#trapHolding(this.#openers[0] ?? null);
634
+ if (this.#flag(this.#options.lockScroll, true)) this.#lockScroll();
635
+ this.#entry.isolated = this.#flag(this.#options.isolate, true);
636
+ this.#joinStack();
637
+ _FocusTrap.#reconcileBackground(document);
241
638
  const onEscape = this.#options.onEscape;
242
639
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
243
640
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
244
641
  }
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
642
  deactivate({ restoreFocus = true } = {}) {
253
- if (!this.#activeState) return;
643
+ if (!this.#activeState) {
644
+ this.#returnTabindexes();
645
+ return;
646
+ }
254
647
  this.#activeState = false;
255
648
  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
- }
649
+ const above = this.#leaveStack();
650
+ this.#unlockScroll();
651
+ _FocusTrap.#reconcileBackground(document);
652
+ const wayBack = this.#handOver(above);
653
+ if (restoreFocus) this.#restore(wayBack, above);
654
+ this.#returnTabindexes();
655
+ }
656
+ connect() {
657
+ this.#gate.cancel();
658
+ _FocusTrap.#sweep();
659
+ new TabindexLoan("-1", FOCUS_TRAP_OWNER).reclaimWithin(document);
660
+ }
661
+ disconnect(host) {
662
+ if (this.#activeState) this.deactivate({ restoreFocus: false });
663
+ this.#gate.disconnected(host, () => this.#returnTabindexes());
664
+ }
665
+ refreshIsolation() {
666
+ if (!this.#activeState) return;
667
+ const isolate = this.#flag(this.#options.isolate, true);
668
+ if (isolate === this.#entry.isolated) return;
669
+ this.#entry.isolated = isolate;
670
+ const stranded = _FocusTrap.#reconcileBackground(document);
671
+ const active = deepActiveElement(document);
672
+ if (stranded.some((element) => flatTreeContains(element, active))) this.#focusInitial();
673
+ }
674
+ refreshContainer() {
675
+ if (!this.#activeState) return;
676
+ _FocusTrap.#reconcileBackground(document);
677
+ this.#returnTabindexes();
678
+ if (this.#ownsTab()) this.#refocus();
267
679
  }
268
- /** Resolves a boolean-or-getter option, defaulting when it was not provided. */
269
680
  #flag(option, fallback) {
270
681
  if (option === void 0) return fallback;
271
682
  return typeof option === "function" ? option() : option;
272
683
  }
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;
684
+ #joinStack() {
685
+ const registry = _FocusTrap.#registry(document);
686
+ if (registry.stack.length === 0) {
687
+ document.addEventListener("keydown", registry.onKeydown);
688
+ document.addEventListener("turbo:morph", registry.onMorph);
689
+ document.addEventListener("turbo:morph-element", registry.onMorph);
296
690
  }
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;
691
+ registry.stack.push(this.#entry);
692
+ }
693
+ #leaveStack() {
694
+ const registry = _FocusTrap.#registry(document);
695
+ const index = registry.stack.indexOf(this.#entry);
696
+ const above = registry.stack.slice(index + 1);
697
+ registry.stack.splice(index, 1);
698
+ if (registry.stack.length === 0) {
699
+ document.removeEventListener("keydown", registry.onKeydown);
700
+ document.removeEventListener("turbo:morph", registry.onMorph);
701
+ document.removeEventListener("turbo:morph-element", registry.onMorph);
304
702
  }
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();
311
- }
312
- }
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);
703
+ return above;
704
+ }
705
+ static #focusPath() {
706
+ const path = [];
707
+ for (let active = document.activeElement; active; active = active.shadowRoot?.activeElement ?? null) {
708
+ if (active instanceof HTMLElement && active !== document.body) path.unshift(active);
709
+ }
710
+ return path;
711
+ }
712
+ static #trapHolding(element) {
713
+ for (const trap of [..._FocusTrap.#registry(document).stack].reverse()) {
714
+ const container = trap.container();
715
+ if (container && flatTreeContains(container, element)) return trap;
716
+ }
717
+ return null;
718
+ }
719
+ #handOver(above) {
720
+ const entry = this.#entry;
721
+ const wayBack = [
722
+ ...this.#openers,
723
+ this.#options.fallbackFocus?.() ?? null,
724
+ ...entry.handedBack
725
+ ].filter((element) => element !== null);
726
+ for (const trap of above) {
727
+ if (trap.openedFrom !== entry) continue;
728
+ trap.handedBack = [...trap.handedBack, ...wayBack];
729
+ trap.openedFrom = entry.openedFrom;
730
+ }
731
+ this.#openers = [];
732
+ entry.openedFrom = null;
733
+ entry.handedBack = [];
734
+ return wayBack;
735
+ }
736
+ #restore(wayBack, above) {
737
+ const active = deepActiveElement(document);
738
+ const released = this.#readContainer();
739
+ const holds = (trap) => {
740
+ const container = trap.container();
741
+ if (!container || !_FocusTrap.#takesTab(trap) || !flatTreeContains(container, active)) {
742
+ return false;
743
+ }
744
+ return !released || !flatTreeContains(released, active) || flatTreeContains(released, container);
745
+ };
746
+ if (above.some(holds)) return;
747
+ for (const element of wayBack) {
748
+ if (this.#activeState) return;
749
+ if (!element.isConnected || !canTakeFocus(element) || !isRenderedForFocus(element)) continue;
750
+ const before = deepActiveElement(document);
751
+ element.focus();
752
+ const after = deepActiveElement(document);
753
+ if (after === element || after !== before) return;
754
+ }
755
+ _FocusTrap.#tabOwner(_FocusTrap.#registry(document).stack)?.refocus();
756
+ }
757
+ #refocus() {
758
+ if (!this.#flag(this.#options.autoFocus, true)) return;
759
+ if (flatTreeContains(this.#getContainer(), deepActiveElement(document))) return;
760
+ this.#focusInitial();
761
+ }
762
+ #lendTabindex(container) {
763
+ const loan = this.#tabindexLoans.get(container) ?? new TabindexLoan("-1", FOCUS_TRAP_OWNER);
764
+ loan.lend(container);
765
+ this.#tabindexLoans.set(container, loan);
766
+ }
767
+ #returnTabindexes() {
768
+ const focused = deepActiveElement(document);
769
+ for (const [element, loan] of [...this.#tabindexLoans]) {
770
+ if (element === focused) continue;
771
+ loan.returnAll();
772
+ this.#tabindexLoans.delete(element);
773
+ }
774
+ }
775
+ #lockScroll() {
776
+ const registry = _FocusTrap.#registry(document);
777
+ const body = document.body;
778
+ if (!body.hasAttribute(OVERFLOW_MARK)) body.setAttribute(OVERFLOW_MARK, body.style.overflow);
779
+ registry.scrollLocks.add(this.#entry);
780
+ this.#entry.lockedBody = body;
781
+ body.style.overflow = "hidden";
782
+ }
783
+ #unlockScroll() {
784
+ const registry = _FocusTrap.#registry(document);
785
+ const body = this.#entry.lockedBody;
786
+ this.#entry.lockedBody = null;
787
+ if (!registry.scrollLocks.delete(this.#entry) || !body) return;
788
+ if (!_FocusTrap.#isLocked(registry, body)) _FocusTrap.#unlock(body);
789
+ }
790
+ static #isLocked(registry, body) {
791
+ return [...registry.scrollLocks].some((trap) => trap.lockedBody === body);
792
+ }
793
+ static #unlock(body) {
794
+ const saved = body.getAttribute(OVERFLOW_MARK);
795
+ if (saved === null) return;
796
+ body.removeAttribute(OVERFLOW_MARK);
797
+ body.style.overflow = saved;
798
+ }
799
+ static #sweep() {
800
+ const registry = _FocusTrap.#registry(document);
801
+ for (const element of queryOpenDescendants(document, `[${INERT_MARK}]`)) {
802
+ if (registry.inerted.has(element)) continue;
803
+ element.removeAttribute(INERT_MARK);
804
+ element.inert = false;
805
+ }
806
+ if (!_FocusTrap.#isLocked(registry, document.body)) _FocusTrap.#unlock(document.body);
807
+ }
808
+ static #registry(ownerDocument) {
809
+ const existing = _FocusTrap.#registries.get(ownerDocument);
810
+ if (existing) return existing;
811
+ const morphs = new MicrotaskCoalescer(() => _FocusTrap.#reapply(registry));
812
+ morphs.activate();
813
+ const registry = {
814
+ stack: [],
815
+ onKeydown: (event) => _FocusTrap.#resolveTab(registry.stack, event),
816
+ onMorph: () => morphs.schedule(),
817
+ inerted: /* @__PURE__ */ new Set(),
818
+ scrollLocks: /* @__PURE__ */ new Set()
819
+ };
820
+ _FocusTrap.#registries.set(ownerDocument, registry);
821
+ return registry;
822
+ }
823
+ static #reapply(registry) {
824
+ _FocusTrap.#reconcileBackground(document);
825
+ const body = document.body;
826
+ if (!_FocusTrap.#isLocked(registry, body)) return;
827
+ if (!body.hasAttribute(OVERFLOW_MARK)) body.setAttribute(OVERFLOW_MARK, body.style.overflow);
828
+ body.style.overflow = "hidden";
829
+ }
830
+ static #reconcileBackground(ownerDocument) {
831
+ const registry = _FocusTrap.#registry(ownerDocument);
832
+ for (const element of registry.inerted) {
833
+ if (!element.hasAttribute(INERT_MARK)) registry.inerted.delete(element);
834
+ }
835
+ const background = _FocusTrap.#backgroundOf(registry);
836
+ for (const element of registry.inerted) {
837
+ if (background.has(element)) continue;
838
+ element.removeAttribute(INERT_MARK);
839
+ element.inert = false;
840
+ registry.inerted.delete(element);
841
+ }
842
+ const added = [];
843
+ for (const element of background) {
844
+ if (registry.inerted.has(element)) continue;
845
+ element.setAttribute(INERT_MARK, "");
846
+ element.inert = true;
847
+ registry.inerted.add(element);
848
+ added.push(element);
849
+ }
850
+ return added;
851
+ }
852
+ static #backgroundOf(registry) {
853
+ const containers = registry.stack.map((trap) => trap.container());
854
+ let top = -1;
855
+ registry.stack.forEach((trap, index) => {
856
+ if (trap.isolated && containers[index]) top = index;
857
+ });
858
+ const background = /* @__PURE__ */ new Set();
859
+ if (top < 0) return background;
860
+ const kept = /* @__PURE__ */ new Set();
861
+ const keep = (path) => {
862
+ for (const node of path) {
863
+ kept.add(node);
864
+ background.delete(node);
865
+ }
866
+ for (const node of path) {
867
+ const parent = flatTreeParent(node);
868
+ for (const sibling of parent ? flatTreeChildren(parent) : []) {
869
+ if (!(sibling instanceof HTMLElement) || kept.has(sibling)) continue;
870
+ if (sibling.inert && !registry.inerted.has(sibling)) continue;
871
+ background.add(sibling);
872
+ }
873
+ }
874
+ };
875
+ containers.slice(top).forEach((container, index) => {
876
+ const path = [];
877
+ for (let node = container; node && node !== document.body && !kept.has(node); ) {
878
+ path.push(node);
879
+ node = flatTreeParent(node);
335
880
  }
336
- node = parent;
881
+ if (index === 0 || path.some((node) => background.has(node))) keep(path);
882
+ });
883
+ return background;
884
+ }
885
+ #readContainer() {
886
+ try {
887
+ return this.#getContainer();
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+ static #resolveTab(stack, event) {
893
+ if (event.key !== "Tab" || event.defaultPrevented) return;
894
+ if (event.isComposing || event.keyCode === 229) return;
895
+ if (event.ctrlKey || event.metaKey) return;
896
+ const owner = _FocusTrap.#tabOwner(stack);
897
+ if (!owner) return;
898
+ event.preventDefault();
899
+ owner.moveFocus(event.shiftKey);
900
+ }
901
+ static #tabOwner(stack) {
902
+ for (const trap of [...stack].reverse()) {
903
+ if (_FocusTrap.#takesTab(trap)) return trap;
904
+ }
905
+ return null;
906
+ }
907
+ static #takesTab(trap) {
908
+ const container = trap.container();
909
+ if (!container) return false;
910
+ return container.isConnected && !closestInFlatTree(container, "[hidden]") && isRenderedForFocus(container);
911
+ }
912
+ #ownsTab() {
913
+ return _FocusTrap.#tabOwner(_FocusTrap.#registry(document).stack) === this.#entry;
914
+ }
915
+ #moveFocus(backward) {
916
+ const from = deepActiveElement(document);
917
+ for (const stop of sequentialTabStops(this.#getContainer(), from, backward)) {
918
+ if (!this.#ownsTab()) return;
919
+ const before = deepActiveElement(document);
920
+ stop.focus();
921
+ const after = deepActiveElement(document);
922
+ if (after === before) continue;
923
+ if (after === stop) _FocusTrap.#selectOnEntry(stop);
924
+ return;
337
925
  }
338
926
  }
339
- /** Reverts the `inert` flags applied by `#isolateBackground`. */
340
- #releaseBackground() {
341
- for (const sibling of this.#inertedSiblings) {
342
- sibling.inert = false;
927
+ static #selectOnEntry(stop) {
928
+ if (stop instanceof HTMLInputElement && _FocusTrap.#TEXT_ENTRY_TYPES.has(stop.type)) {
929
+ stop.select();
343
930
  }
344
- this.#inertedSiblings = [];
345
931
  }
346
- /** Moves focus to the initial target, the first focusable, or the container. */
347
932
  #focusInitial() {
348
933
  const preferred = this.#options.initialFocus?.();
349
934
  if (preferred) {
350
935
  preferred.focus();
351
936
  return;
352
937
  }
353
- const focusable = this.#focusableElements();
354
- if (focusable[0]) {
355
- focusable[0].focus();
938
+ const container = this.#getContainer();
939
+ const [first] = sequentialTabStops(container);
940
+ if (first) {
941
+ first.focus();
356
942
  return;
357
943
  }
358
- const container = this.#getContainer();
359
- container.tabIndex = -1;
944
+ this.#lendTabindex(container);
360
945
  container.focus();
361
946
  }
362
- /** Collects the container's currently focusable descendants in DOM order. */
363
- #focusableElements() {
364
- return tabStopsWithin(this.#getContainer());
947
+ };
948
+
949
+ var ListenerSet = class {
950
+ #abort = new AbortController();
951
+ add(target, type, handler, options) {
952
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
953
+ }
954
+ dispose() {
955
+ this.#abort.abort();
956
+ this.#abort = new AbortController();
957
+ }
958
+ };
959
+
960
+ var MorphRenderWatcher = class {
961
+ #listeners = new ListenerSet();
962
+ #pass;
963
+ #element = null;
964
+ #onMorph = () => this.#pass.schedule();
965
+ constructor(run) {
966
+ this.#pass = new MicrotaskCoalescer(run);
967
+ }
968
+ observe(element) {
969
+ if (this.#element === element) return;
970
+ this.disconnect();
971
+ this.#element = element;
972
+ this.#pass.activate();
973
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
974
+ }
975
+ schedule() {
976
+ this.#pass.schedule();
977
+ }
978
+ disconnect() {
979
+ this.#listeners.dispose();
980
+ this.#pass.cancel();
981
+ this.#element = null;
982
+ }
983
+ };
984
+
985
+ var NUMBER_BOUNDS = {
986
+ nonNegative: { finite: true, min: 0 }};
987
+ function matchesNumberBounds(value, bounds) {
988
+ if (!Number.isFinite(value)) {
989
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
990
+ if (direction === null) return false;
991
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
992
+ }
993
+ if (bounds.min !== void 0 && value < bounds.min) return false;
994
+ if (bounds.max !== void 0 && value > bounds.max) return false;
995
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
996
+ if (bounds.integer && !Number.isInteger(value)) return false;
997
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
998
+ return true;
999
+ }
1000
+
1001
+ function readNumber(raw, fallback, bounds) {
1002
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
1003
+ }
1004
+
1005
+ var NumberValueReader = class {
1006
+ #lastRejected = /* @__PURE__ */ new Map();
1007
+ read(owner, name, raw, fallback, bounds) {
1008
+ const resolved = readNumber(raw, fallback, bounds);
1009
+ if (matchesNumberBounds(raw, bounds)) {
1010
+ this.#lastRejected.delete(name);
1011
+ return resolved;
1012
+ }
1013
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
1014
+ const literal = owner.element.getAttribute(attribute);
1015
+ if (literal === null) {
1016
+ this.#lastRejected.delete(name);
1017
+ return resolved;
1018
+ }
1019
+ if (this.#lastRejected.get(name) !== literal) {
1020
+ this.#lastRejected.set(name, literal);
1021
+ console.warn(
1022
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
1023
+ );
1024
+ }
1025
+ return resolved;
365
1026
  }
366
1027
  };
367
1028
 
368
- // src/utils/safe_storage.ts
369
1029
  function readLocalStorage(key) {
370
1030
  try {
371
1031
  return { ok: true, value: window.localStorage.getItem(key) };
@@ -382,43 +1042,33 @@ function writeLocalStorage(key, value) {
382
1042
  }
383
1043
  }
384
1044
 
385
- // src/utils/safe_timeout.ts
1045
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
1046
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
1047
+ function stateReasonFor(event) {
1048
+ if (!event) return "api";
1049
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
1050
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
1051
+ return "user";
1052
+ }
1053
+
386
1054
  var TimerRegistry = class {
387
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
388
1055
  ids = /* @__PURE__ */ new Set();
389
- /**
390
- * Cancels a single tracked timer.
391
- *
392
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
393
- * registry), so callers can clear defensively without guarding.
394
- */
395
1056
  clear(id) {
396
1057
  if (this.ids.delete(id)) {
397
1058
  this.cancel(id);
398
1059
  }
399
1060
  }
400
- /**
401
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
402
- * to guarantee no timer outlives the element.
403
- */
404
1061
  clearAll() {
405
1062
  for (const id of this.ids) {
406
1063
  this.cancel(id);
407
1064
  }
408
1065
  this.ids.clear();
409
1066
  }
410
- /** Number of timers currently tracked (pending). */
411
1067
  get size() {
412
1068
  return this.ids.size;
413
1069
  }
414
1070
  };
415
1071
  var SafeTimeout = class extends TimerRegistry {
416
- /**
417
- * Schedules `callback` after `delay` ms and returns the timer id.
418
- *
419
- * The id is removed from the registry automatically when the timeout fires,
420
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
421
- */
422
1072
  set(callback, delay) {
423
1073
  const id = this.schedule(() => {
424
1074
  this.ids.delete(id);
@@ -435,7 +1085,6 @@ var SafeTimeout = class extends TimerRegistry {
435
1085
  }
436
1086
  };
437
1087
 
438
- // src/utils/transition_completion.ts
439
1088
  function timeMs(value) {
440
1089
  const trimmed = value.trim();
441
1090
  const amount = Number.parseFloat(trimmed);
@@ -471,13 +1120,6 @@ var TransitionCompletion = class {
471
1120
  #complete = null;
472
1121
  #pendingProperties = null;
473
1122
  #deadline = 0;
474
- /**
475
- * Replaces any prior wait and invokes `complete` synchronously for a 0ms
476
- * transition (including when `getComputedStyle` is unavailable).
477
- *
478
- * With a positive `options.timeoutMs` the synchronous fast-path is skipped and
479
- * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
480
- */
481
1123
  wait(element, complete, options = {}) {
482
1124
  this.cancel();
483
1125
  const requested = options.timeoutMs ?? 0;
@@ -497,18 +1139,10 @@ var TransitionCompletion = class {
497
1139
  element.addEventListener("transitioncancel", this.#onTerminal);
498
1140
  this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
499
1141
  }
500
- /** Cancels the pending wait without invoking its completion callback. */
501
1142
  cancel() {
502
1143
  this.#complete = null;
503
1144
  this.#teardown();
504
1145
  }
505
- /**
506
- * Handles terminal events from the observed element only.
507
- *
508
- * For explicit property lists, every declared positive-time property must
509
- * settle. For `all`, no reliable property set exists, so an event can finish
510
- * only after the computed maximum time; the safety timeout owns the usual path.
511
- */
512
1146
  #onTerminal = (event) => {
513
1147
  if (event.target !== this.#element) return;
514
1148
  const transitionEvent = event;
@@ -528,10 +1162,6 @@ var TransitionCompletion = class {
528
1162
  }
529
1163
  if (Date.now() >= this.#deadline) this.#finish();
530
1164
  };
531
- /**
532
- * Returns active CSS transition properties expanded to the names reported by
533
- * terminal events. CSS animations and pseudo-element effects are excluded.
534
- */
535
1165
  #activeTransitionProperties(element) {
536
1166
  if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
537
1167
  try {
@@ -549,7 +1179,6 @@ var TransitionCompletion = class {
549
1179
  return /* @__PURE__ */ new Set();
550
1180
  }
551
1181
  }
552
- /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
553
1182
  #explicitPendingProperties(timings) {
554
1183
  if (timings.some(({ property }) => property === "all")) return null;
555
1184
  const pending = new Set(
@@ -557,7 +1186,6 @@ var TransitionCompletion = class {
557
1186
  );
558
1187
  return pending.size > 0 ? pending : null;
559
1188
  }
560
- /** Completes exactly once, releasing listeners and the fallback before the callback. */
561
1189
  #finish() {
562
1190
  const complete = this.#complete;
563
1191
  if (!complete) return;
@@ -565,7 +1193,6 @@ var TransitionCompletion = class {
565
1193
  this.#teardown();
566
1194
  complete();
567
1195
  }
568
- /** Releases the exact element listeners and timer owned by the current wait. */
569
1196
  #teardown() {
570
1197
  this.#timers.clearAll();
571
1198
  this.#element?.removeEventListener("transitionend", this.#onTerminal);
@@ -576,79 +1203,112 @@ var TransitionCompletion = class {
576
1203
  }
577
1204
  };
578
1205
 
579
- // src/controllers/sidebar_controller.ts
580
- var SidebarController = class extends Controller {
1206
+ var SidebarController = class _SidebarController extends Controller {
1207
+ #numbers = new NumberValueReader();
581
1208
  static targets = ["trigger", "panel", "backdrop"];
582
1209
  static values = {
583
1210
  breakpoint: { type: Number, default: 768 },
584
1211
  key: { type: String, default: "" },
585
1212
  collapsed: { type: Boolean, default: false }
586
1213
  };
1214
+ static valueConstraints = {
1215
+ breakpoint: NUMBER_BOUNDS.nonNegative
1216
+ };
587
1217
  static actions = ["close", "open", "toggle"];
588
- /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
1218
+ static events = ["close", "open", "reconcile"];
589
1219
  #activePanel = null;
590
- /** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
591
1220
  #trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
592
- onEscape: () => this.close(),
1221
+ onEscape: () => this.#closeOverlay("escape"),
593
1222
  fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
594
1223
  });
595
- /** Current responsive mode. */
596
1224
  #mode = "inline";
597
- /** Persisted inline preference: whether the rail is collapsed. */
598
1225
  #collapsed = false;
599
- /** The matched media query (`min-width: breakpoint`), watched for mode changes. */
600
1226
  #mql = null;
601
- /** Exact normalized query currently represented by the active media-query listener. */
602
1227
  #mqlQuery = null;
603
- /** Owns the cancellable close-transition wait and its bounded fallback. */
604
1228
  #transition = new TransitionCompletion();
605
- /** Distinguishes dynamic target churn from callbacks around controller teardown. */
606
1229
  #connected = false;
1230
+ #storageKeyRead = null;
1231
+ #expanded = false;
1232
+ #backdrop = {
1233
+ state: "closed",
1234
+ hidden: true
1235
+ };
1236
+ #expandedLease = new AttributeLease("aria-expanded", this.identifier);
1237
+ #stateLease = new AttributeLease("data-state", this.identifier);
1238
+ #hiddenLease = new AttributeLease("hidden", this.identifier);
1239
+ #modeLease = new AttributeLease("data-mode", this.identifier);
1240
+ #morphRender = new MorphRenderWatcher(() => this.#repair());
1241
+ #lived = false;
607
1242
  connect() {
1243
+ this.#trap.connect();
608
1244
  this.#connected = true;
609
- this.#beforeCache.activate();
610
1245
  this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
1246
+ this.#storageKeyRead = this.#storageKey;
611
1247
  this.#collapsed = this.#restoreCollapsed();
612
1248
  this.#mqlQuery = this.#breakpointQuery;
613
1249
  this.#mql = this.#matchBreakpoint(this.#mqlQuery);
614
1250
  this.#mql?.addEventListener("change", this.#onMediaChange);
615
- this.#applyMode(this.#computeMode());
1251
+ const mode = this.#computeMode();
1252
+ const resume = this.#lived && mode === "overlay" && this.#isOverlayOpen;
1253
+ this.#lived = true;
1254
+ if (resume) this.#trap.activate();
1255
+ else this.#applyMode(mode, false);
1256
+ this.#morphRender.observe(this.element);
616
1257
  }
617
1258
  disconnect() {
618
1259
  this.#connected = false;
619
- this.#beforeCache.deactivate();
1260
+ this.#morphRender.disconnect();
620
1261
  this.#mql?.removeEventListener("change", this.#onMediaChange);
621
1262
  this.#mql = null;
622
1263
  this.#mqlQuery = null;
623
1264
  this.#transition.cancel();
624
- this.#trap.deactivate({ restoreFocus: false });
1265
+ this.#trap.disconnect(this);
625
1266
  this.#activePanel = null;
626
1267
  }
627
- /** Adopts a panel target added by a Turbo morph after the controller connected. */
628
1268
  panelTargetConnected(panel) {
629
- if (!this.#connected || this.#activePanel?.isConnected && this.#activePanel !== panel) return;
1269
+ if (!this.#connected || this.#activePanel?.isConnected) return;
630
1270
  this.#adoptPanel(panel);
631
1271
  }
632
- /** Closes and releases overlay side effects when the actively trapped panel disappears. */
633
1272
  panelTargetDisconnected(panel) {
634
- if (panel !== this.#activePanel) return;
1273
+ const stays = this.panelTargets.includes(panel);
1274
+ if (!stays) {
1275
+ this.#modeLease.return(panel);
1276
+ this.#stateLease.return(panel);
1277
+ this.#hiddenLease.return(panel);
1278
+ }
1279
+ if (panel !== this.#activePanel || stays && this.panelTarget === panel) return;
635
1280
  this.#transition.cancel();
636
1281
  this.#activePanel = null;
637
- if (!this.#connected) return;
638
- panel.setAttribute("data-state", "closed");
639
- panel.hidden = true;
1282
+ if (stays) {
1283
+ this.#writeState(panel, "closed");
1284
+ this.#writeHidden(panel, true);
1285
+ }
640
1286
  this.#hideBackdrop();
641
1287
  this.#setExpandedAttr(false);
642
- this.#trap.deactivate();
1288
+ if (!this.#connected) {
1289
+ this.#trap.deactivate({ restoreFocus: false });
1290
+ return;
1291
+ }
643
1292
  if (this.hasPanelTarget) this.#adoptPanel(this.panelTarget);
1293
+ else this.#trap.deactivate();
1294
+ }
1295
+ triggerTargetConnected() {
1296
+ if (this.#connected) this.#reflectExpanded();
1297
+ }
1298
+ triggerTargetDisconnected(trigger) {
1299
+ if (!this.triggerTargets.includes(trigger)) this.#expandedLease.return(trigger);
1300
+ if (this.#connected) this.#reflectExpanded();
1301
+ }
1302
+ backdropTargetConnected() {
1303
+ if (this.#connected) this.#reflectBackdrop();
1304
+ }
1305
+ backdropTargetDisconnected(backdrop) {
1306
+ if (!this.backdropTargets.includes(backdrop)) {
1307
+ this.#stateLease.return(backdrop);
1308
+ this.#hiddenLease.return(backdrop);
1309
+ }
1310
+ if (this.#connected) this.#reflectBackdrop();
644
1311
  }
645
- /**
646
- * Rebinds responsive observation when `breakpoint` changes at runtime.
647
- *
648
- * Stimulus calls value callbacks before `connect()`, so the connected guard
649
- * prevents an eager subscription. Equivalent normalized queries retain the
650
- * existing listener instead of allocating duplicate `MediaQueryList` objects.
651
- */
652
1312
  breakpointValueChanged() {
653
1313
  if (!this.#connected) return;
654
1314
  const query = this.#breakpointQuery;
@@ -658,91 +1318,108 @@ var SidebarController = class extends Controller {
658
1318
  this.#mql = this.#matchBreakpoint(query);
659
1319
  this.#mql?.addEventListener("change", this.#onMediaChange);
660
1320
  const next = this.#computeMode();
661
- if (next !== this.#mode) this.#applyMode(next);
662
- }
663
- /**
664
- * Sanitizes overlay markup before Turbo snapshots the page.
665
- *
666
- * Inline state is durable and remains untouched. Overlay state is transient:
667
- * pending transitions are cancelled, controller-owned open attributes are
668
- * closed immediately, and modal side effects are released without moving
669
- * focus during navigation.
670
- */
671
- #beforeCache = new BeforeCacheReset(() => {
672
- if (!this.#connected || !this.#isOverlay) return;
673
- this.#transition.cancel();
674
- this.#trap.deactivate({ restoreFocus: false });
675
- this.#setOverlayClosedImmediate();
676
- });
677
- /** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
678
- toggle() {
1321
+ if (next !== this.#mode) this.#applyMode(next, true);
1322
+ }
1323
+ keyValueChanged() {
1324
+ if (!this.#connected) return;
1325
+ const key = this.#storageKey;
1326
+ if (key === this.#storageKeyRead) return;
1327
+ this.#storageKeyRead = key;
1328
+ const stored = this.#readStoredCollapsed(key);
1329
+ if (stored === null || stored === this.#collapsed) return;
1330
+ this.#collapsed = stored;
1331
+ if (this.#isOverlay) return;
1332
+ this.#applyInlineState(stored);
1333
+ this.#reportReconcile();
1334
+ }
1335
+ toggle(event) {
1336
+ if (!this.#connected) return;
1337
+ const reason = stateReasonFor(event);
679
1338
  if (this.#isOverlay) {
680
- this.#isOverlayOpen ? this.#closeOverlay() : this.#openOverlay();
1339
+ this.#isOverlayOpen ? this.#closeOverlay(reason) : this.#openOverlay(reason);
681
1340
  } else {
682
- this.#setCollapsed(!this.#collapsed);
1341
+ this.#setCollapsed(!this.#collapsed, reason);
683
1342
  }
684
1343
  }
685
- /** Shows the panel (inline: expand; overlay: open). */
686
- open() {
687
- if (this.#isOverlay) this.#openOverlay();
688
- else this.#setCollapsed(false);
1344
+ open(event) {
1345
+ if (!this.#connected) return;
1346
+ const reason = stateReasonFor(event);
1347
+ if (this.#isOverlay) this.#openOverlay(reason);
1348
+ else this.#setCollapsed(false, reason);
1349
+ }
1350
+ close(event) {
1351
+ if (!this.#connected) return;
1352
+ const reason = this.#closeReason(event);
1353
+ if (this.#isOverlay) this.#closeOverlay(reason);
1354
+ else this.#setCollapsed(true, reason);
689
1355
  }
690
- /** Hides the panel (inline: collapse; overlay: close). */
691
- close() {
692
- if (this.#isOverlay) this.#closeOverlay();
693
- else this.#setCollapsed(true);
1356
+ #closeReason(event) {
1357
+ if (this.hasBackdropTarget && event?.currentTarget === this.backdropTarget) return "outside";
1358
+ return stateReasonFor(event);
694
1359
  }
695
- // --- Mode handling ---------------------------------------------------------
696
- /** Re-renders the closed/default state for `mode` and records it. */
697
- #applyMode(mode) {
1360
+ #applyMode(mode, report) {
698
1361
  this.#mode = mode;
699
- if (this.hasPanelTarget) this.panelTarget.setAttribute("data-mode", mode);
1362
+ if (this.hasPanelTarget) this.#modeLease.write(this.panelTarget, mode);
700
1363
  if (mode === "inline") {
701
1364
  this.#transition.cancel();
702
- this.#trap.deactivate({ restoreFocus: false });
703
- if (this.hasPanelTarget) this.panelTarget.hidden = false;
1365
+ if (report) this.#trap.deactivate({ restoreFocus: false });
1366
+ if (this.hasPanelTarget) this.#writeHidden(this.panelTarget, false);
704
1367
  this.#hideBackdrop();
705
1368
  this.#applyInlineState(this.#collapsed);
706
1369
  } else {
707
- this.#transition.cancel();
708
- this.#trap.deactivate({ restoreFocus: false });
709
1370
  this.#setOverlayClosedImmediate();
710
1371
  }
1372
+ if (report) this.#reportReconcile();
1373
+ }
1374
+ #repair() {
1375
+ if (!this.hasPanelTarget) return;
1376
+ if (this.#mode === "inline") {
1377
+ this.#applyMode("inline", false);
1378
+ return;
1379
+ }
1380
+ const panel = this.panelTarget;
1381
+ this.#modeLease.write(panel, "overlay");
1382
+ this.#setOverlayState(this.#expanded ? "open" : "closed");
1383
+ const hidden = !this.#expanded && !this.#trap.active;
1384
+ this.#writeHidden(panel, hidden);
1385
+ this.#writeBackdropHidden(hidden);
1386
+ this.#setExpandedAttr(this.#expanded);
1387
+ }
1388
+ #reportReconcile() {
1389
+ this.dispatch("reconcile", {
1390
+ detail: { mode: this.#mode, open: this.#isExpanded },
1391
+ cancelable: false
1392
+ });
711
1393
  }
712
- /** Reconciles a replacement panel with the current responsive mode and DOM state. */
713
1394
  #adoptPanel(panel) {
714
1395
  this.#transition.cancel();
715
- const trapWasActive = this.#trap.active;
716
1396
  this.#activePanel = panel;
717
- panel.setAttribute("data-mode", this.#mode);
1397
+ this.#modeLease.write(panel, this.#mode);
718
1398
  if (this.#mode === "inline") {
719
- panel.hidden = false;
720
- panel.setAttribute("data-state", this.#collapsed ? "collapsed" : "expanded");
1399
+ this.#writeHidden(panel, false);
1400
+ this.#writeState(panel, this.#collapsed ? "collapsed" : "expanded");
721
1401
  this.#hideBackdrop();
722
1402
  this.#setExpandedAttr(!this.#collapsed);
723
- this.#trap.deactivate({ restoreFocus: false });
724
1403
  return;
725
1404
  }
726
1405
  if (panel.getAttribute("data-state") === "open") {
727
- panel.hidden = false;
728
- if (this.hasBackdropTarget) {
729
- this.backdropTarget.setAttribute("data-state", "open");
730
- this.backdropTarget.hidden = false;
731
- }
1406
+ this.#writeHidden(panel, false);
1407
+ this.#writeBackdropState("open");
1408
+ this.#writeBackdropHidden(false);
732
1409
  this.#setExpandedAttr(true);
733
- if (trapWasActive) this.#trap.deactivate({ restoreFocus: false });
734
- this.#trap.activate();
1410
+ if (this.#trap.active) this.#trap.refreshContainer();
1411
+ else this.#trap.activate();
735
1412
  return;
736
1413
  }
737
- panel.setAttribute("data-state", "closed");
738
- panel.hidden = true;
1414
+ this.#writeState(panel, "closed");
1415
+ this.#writeHidden(panel, true);
739
1416
  this.#hideBackdrop();
740
1417
  this.#setExpandedAttr(false);
741
1418
  this.#trap.deactivate();
742
1419
  }
743
1420
  #onMediaChange = (event) => {
744
1421
  const next = event.matches ? "inline" : "overlay";
745
- if (next !== this.#mode) this.#applyMode(next);
1422
+ if (next !== this.#mode) this.#applyMode(next, true);
746
1423
  };
747
1424
  #computeMode() {
748
1425
  return this.#mql?.matches ?? true ? "inline" : "overlay";
@@ -751,101 +1428,113 @@ var SidebarController = class extends Controller {
751
1428
  if (typeof window.matchMedia !== "function") return null;
752
1429
  return window.matchMedia(query);
753
1430
  }
754
- // --- Inline (rail) ---------------------------------------------------------
755
- /** Sets, reflects, and persists the inline collapsed preference. */
756
- #setCollapsed(collapsed) {
1431
+ #setCollapsed(collapsed, reason) {
757
1432
  if (collapsed === this.#collapsed) return;
758
1433
  this.#collapsed = collapsed;
759
1434
  this.#applyInlineState(collapsed);
1435
+ this.#report(!collapsed, reason);
760
1436
  this.#persistCollapsed(collapsed);
761
1437
  }
762
- /** Reflects the inline rail state onto the panel and trigger. */
763
1438
  #applyInlineState(collapsed) {
764
1439
  if (this.hasPanelTarget) {
765
- this.panelTarget.setAttribute("data-state", collapsed ? "collapsed" : "expanded");
1440
+ this.#writeState(this.panelTarget, collapsed ? "collapsed" : "expanded");
766
1441
  }
767
1442
  this.#setExpandedAttr(!collapsed);
768
1443
  }
769
- // --- Overlay (off-canvas modal) -------------------------------------------
770
- /** Opens the overlay: reveal it, commit a starting frame, then trap focus. */
771
- #openOverlay() {
1444
+ #openOverlay(reason) {
772
1445
  if (!this.hasPanelTarget || this.#isOverlayOpen) return;
773
1446
  this.#transition.cancel();
774
1447
  this.#activePanel = this.panelTarget;
775
- this.panelTarget.hidden = false;
776
- if (this.hasBackdropTarget) this.backdropTarget.hidden = false;
1448
+ this.#writeHidden(this.panelTarget, false);
1449
+ this.#writeBackdropHidden(false);
777
1450
  void this.panelTarget.offsetWidth;
778
1451
  this.#setOverlayState("open");
779
1452
  this.#setExpandedAttr(true);
1453
+ this.#report(true, reason);
1454
+ if (!this.#isOverlayOpen) return;
780
1455
  this.#trap.activate();
781
1456
  }
782
- /** Closes the overlay: start the exit transition, defer hide + trap teardown. */
783
- #closeOverlay() {
1457
+ #closeOverlay(reason) {
784
1458
  if (!this.hasPanelTarget || !this.#isOverlayOpen) return;
785
1459
  this.#setOverlayState("closed");
786
1460
  this.#setExpandedAttr(false);
1461
+ this.#report(false, reason);
1462
+ if (this.#isOverlayOpen) return;
787
1463
  this.#hideAfterTransition();
788
1464
  }
789
- /** Renders the overlay closed state up front (used when entering overlay mode). */
790
1465
  #setOverlayClosedImmediate() {
791
1466
  this.#setOverlayState("closed");
792
1467
  this.#setExpandedAttr(false);
793
- if (this.hasPanelTarget) this.panelTarget.hidden = true;
1468
+ if (this.hasPanelTarget) this.#writeHidden(this.panelTarget, true);
794
1469
  this.#hideBackdrop();
795
1470
  }
796
- /** Syncs `data-state` on the panel and backdrop together (overlay). */
797
1471
  #setOverlayState(state) {
798
- if (this.hasPanelTarget) this.panelTarget.setAttribute("data-state", state);
799
- if (this.hasBackdropTarget) this.backdropTarget.setAttribute("data-state", state);
800
- }
801
- /**
802
- * Applies `hidden` once the close transition ends so the exit slide can play,
803
- * then reverts the modal side effects. The shared waiter completes
804
- * synchronously for 0ms transitions and supplies a bounded fallback when the
805
- * browser emits no terminal event.
806
- */
1472
+ if (this.hasPanelTarget) this.#writeState(this.panelTarget, state);
1473
+ this.#writeBackdropState(state);
1474
+ }
807
1475
  #hideAfterTransition() {
808
1476
  const panel = this.panelTarget;
809
1477
  this.#activePanel = panel;
810
1478
  this.#transition.wait(panel, () => this.#applyOverlayHidden(panel));
811
1479
  }
812
- /** Hides the panel/backdrop and tears down the trap after the exit transition. */
813
1480
  #applyOverlayHidden(panel) {
814
- panel.hidden = true;
1481
+ this.#writeHidden(panel, true);
815
1482
  this.#hideBackdrop();
816
1483
  this.#trap.deactivate();
817
1484
  }
818
1485
  #hideBackdrop() {
819
- if (!this.hasBackdropTarget) return;
820
- this.backdropTarget.setAttribute("data-state", "closed");
821
- this.backdropTarget.hidden = true;
1486
+ this.#writeBackdropState("closed");
1487
+ this.#writeBackdropHidden(true);
1488
+ }
1489
+ #writeState(element, state) {
1490
+ this.#stateLease.write(element, state);
1491
+ }
1492
+ #writeHidden(element, hidden) {
1493
+ this.#hiddenLease.write(element, hidden ? "" : null);
1494
+ }
1495
+ #writeBackdropState(state) {
1496
+ this.#backdrop.state = state;
1497
+ if (this.hasBackdropTarget) this.#writeState(this.backdropTarget, state);
1498
+ }
1499
+ #writeBackdropHidden(hidden) {
1500
+ this.#backdrop.hidden = hidden;
1501
+ if (this.hasBackdropTarget) this.#writeHidden(this.backdropTarget, hidden);
1502
+ }
1503
+ #reflectBackdrop() {
1504
+ this.#writeBackdropState(this.#backdrop.state);
1505
+ this.#writeBackdropHidden(this.#backdrop.hidden);
822
1506
  }
823
- // --- Shared helpers --------------------------------------------------------
824
1507
  #setExpandedAttr(expanded) {
1508
+ this.#expanded = expanded;
1509
+ this.#reflectExpanded();
1510
+ }
1511
+ #reflectExpanded() {
825
1512
  if (this.hasTriggerTarget) {
826
- this.triggerTarget.setAttribute("aria-expanded", expanded ? "true" : "false");
1513
+ this.#expandedLease.write(this.triggerTarget, this.#expanded ? "true" : "false");
827
1514
  }
828
1515
  }
829
- /**
830
- * Resolves the inline collapsed preference, in priority order:
831
- * 1. the persisted value (when a `key` is set and storage is readable),
832
- * 2. the current DOM `data-state` — so a Turbo cache restore / morph that
833
- * reconnects over already-rendered markup keeps the live state (the DOM is
834
- * the source of truth) even when no `key` / localStorage is available,
835
- * 3. the declared `collapsed` value.
836
- */
1516
+ #report(open, reason) {
1517
+ const detail = { reason, mode: this.#mode };
1518
+ if (open) this.dispatch("open", { detail, cancelable: false });
1519
+ else this.dispatch("close", { detail, cancelable: false });
1520
+ }
1521
+ get #isExpanded() {
1522
+ return this.#isOverlay ? this.#isOverlayOpen : !this.#collapsed;
1523
+ }
837
1524
  #restoreCollapsed() {
838
- const key = this.#storageKey;
839
- if (key) {
840
- const result = readLocalStorage(key);
841
- if (result.ok && result.value !== null) return result.value === "1";
842
- }
1525
+ const stored = this.#readStoredCollapsed(this.#storageKey);
1526
+ if (stored !== null) return stored;
843
1527
  const domState = this.hasPanelTarget ? this.panelTarget.getAttribute("data-state") : null;
844
1528
  if (domState === "collapsed") return true;
845
1529
  if (domState === "expanded") return false;
846
1530
  return this.collapsedValue;
847
1531
  }
848
- /** Persists the collapsed preference when a key is configured. */
1532
+ #readStoredCollapsed(key) {
1533
+ if (!key) return null;
1534
+ const result = readLocalStorage(key);
1535
+ if (!result.ok || result.value === null) return null;
1536
+ return result.value === "1";
1537
+ }
849
1538
  #persistCollapsed(collapsed) {
850
1539
  const key = this.#storageKey;
851
1540
  if (!key) return;
@@ -854,10 +1543,9 @@ var SidebarController = class extends Controller {
854
1543
  get #storageKey() {
855
1544
  return this.keyValue ? `stimeo--sidebar:${this.keyValue}` : "";
856
1545
  }
857
- /** Valid breakpoint CSS query, defaulting malformed or negative values to 768px. */
858
1546
  get #breakpointQuery() {
859
- const value = this.breakpointValue;
860
- const breakpoint = Number.isFinite(value) && value >= 0 ? value : 768;
1547
+ const value = this.#safeBreakpoint;
1548
+ const breakpoint = value;
861
1549
  return `(min-width: ${breakpoint}px)`;
862
1550
  }
863
1551
  get #isOverlay() {
@@ -866,8 +1554,16 @@ var SidebarController = class extends Controller {
866
1554
  get #isOverlayOpen() {
867
1555
  return this.#isOverlay && this.hasPanelTarget && this.panelTarget.getAttribute("data-state") === "open";
868
1556
  }
1557
+ get #safeBreakpoint() {
1558
+ return this.#numbers.read(
1559
+ this,
1560
+ "breakpoint",
1561
+ this.breakpointValue,
1562
+ _SidebarController.values.breakpoint.default,
1563
+ _SidebarController.valueConstraints.breakpoint
1564
+ );
1565
+ }
869
1566
  };
870
1567
 
871
1568
  export { SidebarController };
872
- //# sourceMappingURL=sidebar_controller.js.map
873
1569
  //# sourceMappingURL=sidebar_controller.js.map