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,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)
367
+ );
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)
189
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;
190
496
  }
191
497
 
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. */
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
+ };
586
+
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;
335
743
  }
336
- node = parent;
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);
337
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);
880
+ }
881
+ if (index === 0 || path.some((node) => background.has(node))) keep(path);
882
+ });
883
+ return background;
338
884
  }
339
- /** Reverts the `inert` flags applied by `#isolateBackground`. */
340
- #releaseBackground() {
341
- for (const sibling of this.#inertedSiblings) {
342
- sibling.inert = false;
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;
925
+ }
926
+ }
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,7 +1042,6 @@ function writeLocalStorage(key, value) {
382
1042
  }
383
1043
  }
384
1044
 
385
- // src/utils/state_reason.ts
386
1045
  var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
387
1046
  var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
388
1047
  function stateReasonFor(event) {
@@ -392,43 +1051,24 @@ function stateReasonFor(event) {
392
1051
  return "user";
393
1052
  }
394
1053
 
395
- // src/utils/safe_timeout.ts
396
1054
  var TimerRegistry = class {
397
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
398
1055
  ids = /* @__PURE__ */ new Set();
399
- /**
400
- * Cancels a single tracked timer.
401
- *
402
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
403
- * registry), so callers can clear defensively without guarding.
404
- */
405
1056
  clear(id) {
406
1057
  if (this.ids.delete(id)) {
407
1058
  this.cancel(id);
408
1059
  }
409
1060
  }
410
- /**
411
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
412
- * to guarantee no timer outlives the element.
413
- */
414
1061
  clearAll() {
415
1062
  for (const id of this.ids) {
416
1063
  this.cancel(id);
417
1064
  }
418
1065
  this.ids.clear();
419
1066
  }
420
- /** Number of timers currently tracked (pending). */
421
1067
  get size() {
422
1068
  return this.ids.size;
423
1069
  }
424
1070
  };
425
1071
  var SafeTimeout = class extends TimerRegistry {
426
- /**
427
- * Schedules `callback` after `delay` ms and returns the timer id.
428
- *
429
- * The id is removed from the registry automatically when the timeout fires,
430
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
431
- */
432
1072
  set(callback, delay) {
433
1073
  const id = this.schedule(() => {
434
1074
  this.ids.delete(id);
@@ -445,7 +1085,6 @@ var SafeTimeout = class extends TimerRegistry {
445
1085
  }
446
1086
  };
447
1087
 
448
- // src/utils/transition_completion.ts
449
1088
  function timeMs(value) {
450
1089
  const trimmed = value.trim();
451
1090
  const amount = Number.parseFloat(trimmed);
@@ -481,13 +1120,6 @@ var TransitionCompletion = class {
481
1120
  #complete = null;
482
1121
  #pendingProperties = null;
483
1122
  #deadline = 0;
484
- /**
485
- * Replaces any prior wait and invokes `complete` synchronously for a 0ms
486
- * transition (including when `getComputedStyle` is unavailable).
487
- *
488
- * With a positive `options.timeoutMs` the synchronous fast-path is skipped and
489
- * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
490
- */
491
1123
  wait(element, complete, options = {}) {
492
1124
  this.cancel();
493
1125
  const requested = options.timeoutMs ?? 0;
@@ -507,18 +1139,10 @@ var TransitionCompletion = class {
507
1139
  element.addEventListener("transitioncancel", this.#onTerminal);
508
1140
  this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
509
1141
  }
510
- /** Cancels the pending wait without invoking its completion callback. */
511
1142
  cancel() {
512
1143
  this.#complete = null;
513
1144
  this.#teardown();
514
1145
  }
515
- /**
516
- * Handles terminal events from the observed element only.
517
- *
518
- * For explicit property lists, every declared positive-time property must
519
- * settle. For `all`, no reliable property set exists, so an event can finish
520
- * only after the computed maximum time; the safety timeout owns the usual path.
521
- */
522
1146
  #onTerminal = (event) => {
523
1147
  if (event.target !== this.#element) return;
524
1148
  const transitionEvent = event;
@@ -538,10 +1162,6 @@ var TransitionCompletion = class {
538
1162
  }
539
1163
  if (Date.now() >= this.#deadline) this.#finish();
540
1164
  };
541
- /**
542
- * Returns active CSS transition properties expanded to the names reported by
543
- * terminal events. CSS animations and pseudo-element effects are excluded.
544
- */
545
1165
  #activeTransitionProperties(element) {
546
1166
  if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
547
1167
  try {
@@ -559,7 +1179,6 @@ var TransitionCompletion = class {
559
1179
  return /* @__PURE__ */ new Set();
560
1180
  }
561
1181
  }
562
- /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
563
1182
  #explicitPendingProperties(timings) {
564
1183
  if (timings.some(({ property }) => property === "all")) return null;
565
1184
  const pending = new Set(
@@ -567,7 +1186,6 @@ var TransitionCompletion = class {
567
1186
  );
568
1187
  return pending.size > 0 ? pending : null;
569
1188
  }
570
- /** Completes exactly once, releasing listeners and the fallback before the callback. */
571
1189
  #finish() {
572
1190
  const complete = this.#complete;
573
1191
  if (!complete) return;
@@ -575,7 +1193,6 @@ var TransitionCompletion = class {
575
1193
  this.#teardown();
576
1194
  complete();
577
1195
  }
578
- /** Releases the exact element listeners and timer owned by the current wait. */
579
1196
  #teardown() {
580
1197
  this.#timers.clearAll();
581
1198
  this.#element?.removeEventListener("transitionend", this.#onTerminal);
@@ -586,84 +1203,112 @@ var TransitionCompletion = class {
586
1203
  }
587
1204
  };
588
1205
 
589
- // src/controllers/sidebar_controller.ts
590
- var SidebarController = class extends Controller {
1206
+ var SidebarController = class _SidebarController extends Controller {
1207
+ #numbers = new NumberValueReader();
591
1208
  static targets = ["trigger", "panel", "backdrop"];
592
1209
  static values = {
593
1210
  breakpoint: { type: Number, default: 768 },
594
1211
  key: { type: String, default: "" },
595
1212
  collapsed: { type: Boolean, default: false }
596
1213
  };
1214
+ static valueConstraints = {
1215
+ breakpoint: NUMBER_BOUNDS.nonNegative
1216
+ };
597
1217
  static actions = ["close", "open", "toggle"];
598
1218
  static events = ["close", "open", "reconcile"];
599
- /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
600
1219
  #activePanel = null;
601
- /** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
602
1220
  #trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
603
1221
  onEscape: () => this.#closeOverlay("escape"),
604
1222
  fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
605
1223
  });
606
- /** Current responsive mode. */
607
1224
  #mode = "inline";
608
- /** Persisted inline preference: whether the rail is collapsed. */
609
1225
  #collapsed = false;
610
- /** The matched media query (`min-width: breakpoint`), watched for mode changes. */
611
1226
  #mql = null;
612
- /** Exact normalized query currently represented by the active media-query listener. */
613
1227
  #mqlQuery = null;
614
- /** Owns the cancellable close-transition wait and its bounded fallback. */
615
1228
  #transition = new TransitionCompletion();
616
- /** Distinguishes dynamic target churn from callbacks around controller teardown. */
617
1229
  #connected = false;
618
- /** Whether state moves are reported: set once `connect()` settled the baseline. */
619
- #reporting = 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;
620
1242
  connect() {
1243
+ this.#trap.connect();
621
1244
  this.#connected = true;
622
- this.#beforeCache.activate();
623
1245
  this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
1246
+ this.#storageKeyRead = this.#storageKey;
624
1247
  this.#collapsed = this.#restoreCollapsed();
625
1248
  this.#mqlQuery = this.#breakpointQuery;
626
1249
  this.#mql = this.#matchBreakpoint(this.#mqlQuery);
627
1250
  this.#mql?.addEventListener("change", this.#onMediaChange);
628
- this.#applyMode(this.#computeMode(), false);
629
- this.#reporting = true;
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);
630
1257
  }
631
1258
  disconnect() {
632
1259
  this.#connected = false;
633
- this.#reporting = false;
634
- this.#beforeCache.deactivate();
1260
+ this.#morphRender.disconnect();
635
1261
  this.#mql?.removeEventListener("change", this.#onMediaChange);
636
1262
  this.#mql = null;
637
1263
  this.#mqlQuery = null;
638
1264
  this.#transition.cancel();
639
- this.#trap.deactivate({ restoreFocus: false });
1265
+ this.#trap.disconnect(this);
640
1266
  this.#activePanel = null;
641
1267
  }
642
- /** Adopts a panel target added by a Turbo morph after the controller connected. */
643
1268
  panelTargetConnected(panel) {
644
- if (!this.#connected || this.#activePanel?.isConnected && this.#activePanel !== panel) return;
1269
+ if (!this.#connected || this.#activePanel?.isConnected) return;
645
1270
  this.#adoptPanel(panel);
646
1271
  }
647
- /** Closes and releases overlay side effects when the actively trapped panel disappears. */
648
1272
  panelTargetDisconnected(panel) {
649
- 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;
650
1280
  this.#transition.cancel();
651
1281
  this.#activePanel = null;
652
- if (!this.#connected) return;
653
- panel.setAttribute("data-state", "closed");
654
- panel.hidden = true;
1282
+ if (stays) {
1283
+ this.#writeState(panel, "closed");
1284
+ this.#writeHidden(panel, true);
1285
+ }
655
1286
  this.#hideBackdrop();
656
1287
  this.#setExpandedAttr(false);
657
- this.#trap.deactivate();
1288
+ if (!this.#connected) {
1289
+ this.#trap.deactivate({ restoreFocus: false });
1290
+ return;
1291
+ }
658
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();
659
1311
  }
660
- /**
661
- * Rebinds responsive observation when `breakpoint` changes at runtime.
662
- *
663
- * Stimulus calls value callbacks before `connect()`, so the connected guard
664
- * prevents an eager subscription. Equivalent normalized queries retain the
665
- * existing listener instead of allocating duplicate `MediaQueryList` objects.
666
- */
667
1312
  breakpointValueChanged() {
668
1313
  if (!this.#connected) return;
669
1314
  const query = this.#breakpointQuery;
@@ -675,22 +1320,20 @@ var SidebarController = class extends Controller {
675
1320
  const next = this.#computeMode();
676
1321
  if (next !== this.#mode) this.#applyMode(next, true);
677
1322
  }
678
- /**
679
- * Sanitizes overlay markup before Turbo snapshots the page.
680
- *
681
- * Inline state is durable and remains untouched. Overlay state is transient:
682
- * pending transitions are cancelled, controller-owned open attributes are
683
- * closed immediately, and modal side effects are released without moving
684
- * focus during navigation.
685
- */
686
- #beforeCache = new BeforeCacheReset(() => {
687
- if (!this.#connected || !this.#isOverlay) return;
688
- this.#transition.cancel();
689
- this.#trap.deactivate({ restoreFocus: false });
690
- this.#setOverlayClosedImmediate();
691
- });
692
- /** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
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
+ }
693
1335
  toggle(event) {
1336
+ if (!this.#connected) return;
694
1337
  const reason = stateReasonFor(event);
695
1338
  if (this.#isOverlay) {
696
1339
  this.#isOverlayOpen ? this.#closeOverlay(reason) : this.#openOverlay(reason);
@@ -698,83 +1341,78 @@ var SidebarController = class extends Controller {
698
1341
  this.#setCollapsed(!this.#collapsed, reason);
699
1342
  }
700
1343
  }
701
- /** Shows the panel (inline: expand; overlay: open). */
702
1344
  open(event) {
1345
+ if (!this.#connected) return;
703
1346
  const reason = stateReasonFor(event);
704
1347
  if (this.#isOverlay) this.#openOverlay(reason);
705
1348
  else this.#setCollapsed(false, reason);
706
1349
  }
707
- /** Hides the panel (inline: collapse; overlay: close). */
708
1350
  close(event) {
1351
+ if (!this.#connected) return;
709
1352
  const reason = this.#closeReason(event);
710
1353
  if (this.#isOverlay) this.#closeOverlay(reason);
711
1354
  else this.#setCollapsed(true, reason);
712
1355
  }
713
- /**
714
- * Reads why a close happened. The backdrop has no action of its own — the
715
- * consumer wires this one onto it — so the shared `"outside"` vocabulary is
716
- * only reachable by recognising that target here.
717
- */
718
1356
  #closeReason(event) {
719
1357
  if (this.hasBackdropTarget && event?.currentTarget === this.backdropTarget) return "outside";
720
1358
  return stateReasonFor(event);
721
1359
  }
722
- // --- Mode handling ---------------------------------------------------------
723
- /**
724
- * Re-renders the closed/default state for `mode` and records it.
725
- *
726
- * @param report - Whether the mode change is one the consumer can observe: a
727
- * viewport change re-derives the state under them, the connect-time baseline
728
- * does not.
729
- */
730
1360
  #applyMode(mode, report) {
731
1361
  this.#mode = mode;
732
- if (this.hasPanelTarget) this.panelTarget.setAttribute("data-mode", mode);
1362
+ if (this.hasPanelTarget) this.#modeLease.write(this.panelTarget, mode);
733
1363
  if (mode === "inline") {
734
1364
  this.#transition.cancel();
735
- this.#trap.deactivate({ restoreFocus: false });
736
- if (this.hasPanelTarget) this.panelTarget.hidden = false;
1365
+ if (report) this.#trap.deactivate({ restoreFocus: false });
1366
+ if (this.hasPanelTarget) this.#writeHidden(this.panelTarget, false);
737
1367
  this.#hideBackdrop();
738
1368
  this.#applyInlineState(this.#collapsed);
739
1369
  } else {
740
- this.#transition.cancel();
741
- this.#trap.deactivate({ restoreFocus: false });
742
1370
  this.#setOverlayClosedImmediate();
743
1371
  }
744
- if (report && this.#reporting) {
745
- this.dispatch("reconcile", {
746
- detail: { mode, open: this.#isExpanded },
747
- cancelable: false
748
- });
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;
749
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
+ });
750
1393
  }
751
- /** Reconciles a replacement panel with the current responsive mode and DOM state. */
752
1394
  #adoptPanel(panel) {
753
1395
  this.#transition.cancel();
754
- const trapWasActive = this.#trap.active;
755
1396
  this.#activePanel = panel;
756
- panel.setAttribute("data-mode", this.#mode);
1397
+ this.#modeLease.write(panel, this.#mode);
757
1398
  if (this.#mode === "inline") {
758
- panel.hidden = false;
759
- panel.setAttribute("data-state", this.#collapsed ? "collapsed" : "expanded");
1399
+ this.#writeHidden(panel, false);
1400
+ this.#writeState(panel, this.#collapsed ? "collapsed" : "expanded");
760
1401
  this.#hideBackdrop();
761
1402
  this.#setExpandedAttr(!this.#collapsed);
762
- this.#trap.deactivate({ restoreFocus: false });
763
1403
  return;
764
1404
  }
765
1405
  if (panel.getAttribute("data-state") === "open") {
766
- panel.hidden = false;
767
- if (this.hasBackdropTarget) {
768
- this.backdropTarget.setAttribute("data-state", "open");
769
- this.backdropTarget.hidden = false;
770
- }
1406
+ this.#writeHidden(panel, false);
1407
+ this.#writeBackdropState("open");
1408
+ this.#writeBackdropHidden(false);
771
1409
  this.#setExpandedAttr(true);
772
- if (trapWasActive) this.#trap.deactivate({ restoreFocus: false });
773
- this.#trap.activate();
1410
+ if (this.#trap.active) this.#trap.refreshContainer();
1411
+ else this.#trap.activate();
774
1412
  return;
775
1413
  }
776
- panel.setAttribute("data-state", "closed");
777
- panel.hidden = true;
1414
+ this.#writeState(panel, "closed");
1415
+ this.#writeHidden(panel, true);
778
1416
  this.#hideBackdrop();
779
1417
  this.#setExpandedAttr(false);
780
1418
  this.#trap.deactivate();
@@ -790,8 +1428,6 @@ var SidebarController = class extends Controller {
790
1428
  if (typeof window.matchMedia !== "function") return null;
791
1429
  return window.matchMedia(query);
792
1430
  }
793
- // --- Inline (rail) ---------------------------------------------------------
794
- /** Sets, reflects, and persists the inline collapsed preference. */
795
1431
  #setCollapsed(collapsed, reason) {
796
1432
  if (collapsed === this.#collapsed) return;
797
1433
  this.#collapsed = collapsed;
@@ -799,21 +1435,18 @@ var SidebarController = class extends Controller {
799
1435
  this.#report(!collapsed, reason);
800
1436
  this.#persistCollapsed(collapsed);
801
1437
  }
802
- /** Reflects the inline rail state onto the panel and trigger. */
803
1438
  #applyInlineState(collapsed) {
804
1439
  if (this.hasPanelTarget) {
805
- this.panelTarget.setAttribute("data-state", collapsed ? "collapsed" : "expanded");
1440
+ this.#writeState(this.panelTarget, collapsed ? "collapsed" : "expanded");
806
1441
  }
807
1442
  this.#setExpandedAttr(!collapsed);
808
1443
  }
809
- // --- Overlay (off-canvas modal) -------------------------------------------
810
- /** Opens the overlay: reveal it, commit a starting frame, then trap focus. */
811
1444
  #openOverlay(reason) {
812
1445
  if (!this.hasPanelTarget || this.#isOverlayOpen) return;
813
1446
  this.#transition.cancel();
814
1447
  this.#activePanel = this.panelTarget;
815
- this.panelTarget.hidden = false;
816
- if (this.hasBackdropTarget) this.backdropTarget.hidden = false;
1448
+ this.#writeHidden(this.panelTarget, false);
1449
+ this.#writeBackdropHidden(false);
817
1450
  void this.panelTarget.offsetWidth;
818
1451
  this.#setOverlayState("open");
819
1452
  this.#setExpandedAttr(true);
@@ -821,7 +1454,6 @@ var SidebarController = class extends Controller {
821
1454
  if (!this.#isOverlayOpen) return;
822
1455
  this.#trap.activate();
823
1456
  }
824
- /** Closes the overlay: start the exit transition, defer hide + trap teardown. */
825
1457
  #closeOverlay(reason) {
826
1458
  if (!this.hasPanelTarget || !this.#isOverlayOpen) return;
827
1459
  this.#setOverlayState("closed");
@@ -830,93 +1462,90 @@ var SidebarController = class extends Controller {
830
1462
  if (this.#isOverlayOpen) return;
831
1463
  this.#hideAfterTransition();
832
1464
  }
833
- /** Renders the overlay closed state up front (used when entering overlay mode). */
834
1465
  #setOverlayClosedImmediate() {
835
1466
  this.#setOverlayState("closed");
836
1467
  this.#setExpandedAttr(false);
837
- if (this.hasPanelTarget) this.panelTarget.hidden = true;
1468
+ if (this.hasPanelTarget) this.#writeHidden(this.panelTarget, true);
838
1469
  this.#hideBackdrop();
839
1470
  }
840
- /** Syncs `data-state` on the panel and backdrop together (overlay). */
841
1471
  #setOverlayState(state) {
842
- if (this.hasPanelTarget) this.panelTarget.setAttribute("data-state", state);
843
- if (this.hasBackdropTarget) this.backdropTarget.setAttribute("data-state", state);
844
- }
845
- /**
846
- * Applies `hidden` once the close transition ends so the exit slide can play,
847
- * then reverts the modal side effects. The shared waiter completes
848
- * synchronously for 0ms transitions and supplies a bounded fallback when the
849
- * browser emits no terminal event.
850
- */
1472
+ if (this.hasPanelTarget) this.#writeState(this.panelTarget, state);
1473
+ this.#writeBackdropState(state);
1474
+ }
851
1475
  #hideAfterTransition() {
852
1476
  const panel = this.panelTarget;
853
1477
  this.#activePanel = panel;
854
1478
  this.#transition.wait(panel, () => this.#applyOverlayHidden(panel));
855
1479
  }
856
- /** Hides the panel/backdrop and tears down the trap after the exit transition. */
857
1480
  #applyOverlayHidden(panel) {
858
- panel.hidden = true;
1481
+ this.#writeHidden(panel, true);
859
1482
  this.#hideBackdrop();
860
1483
  this.#trap.deactivate();
861
1484
  }
862
1485
  #hideBackdrop() {
863
- if (!this.hasBackdropTarget) return;
864
- this.backdropTarget.setAttribute("data-state", "closed");
865
- 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);
866
1506
  }
867
- // --- Shared helpers --------------------------------------------------------
868
1507
  #setExpandedAttr(expanded) {
1508
+ this.#expanded = expanded;
1509
+ this.#reflectExpanded();
1510
+ }
1511
+ #reflectExpanded() {
869
1512
  if (this.hasTriggerTarget) {
870
- this.triggerTarget.setAttribute("aria-expanded", expanded ? "true" : "false");
1513
+ this.#expandedLease.write(this.triggerTarget, this.#expanded ? "true" : "false");
871
1514
  }
872
1515
  }
873
- /** Reports a move of the expanded state, naming the mode it happened in. */
874
1516
  #report(open, reason) {
875
- if (!this.#reporting) return;
876
1517
  const detail = { reason, mode: this.#mode };
877
1518
  if (open) this.dispatch("open", { detail, cancelable: false });
878
1519
  else this.dispatch("close", { detail, cancelable: false });
879
1520
  }
880
- /** Whether the panel currently counts as expanded in the mode it is in. */
881
1521
  get #isExpanded() {
882
1522
  return this.#isOverlay ? this.#isOverlayOpen : !this.#collapsed;
883
1523
  }
884
- /**
885
- * Resolves the inline collapsed preference, in priority order:
886
- * 1. the persisted value (when a `key` is set and storage is readable),
887
- * 2. the current DOM `data-state` — so a Turbo cache restore / morph that
888
- * reconnects over already-rendered markup keeps the live state (the DOM is
889
- * the source of truth) even when no `key` / localStorage is available,
890
- * 3. the declared `collapsed` value.
891
- */
892
1524
  #restoreCollapsed() {
893
- const key = this.#storageKey;
894
- if (key) {
895
- const result = readLocalStorage(key);
896
- if (result.ok && result.value !== null) return result.value === "1";
897
- }
1525
+ const stored = this.#readStoredCollapsed(this.#storageKey);
1526
+ if (stored !== null) return stored;
898
1527
  const domState = this.hasPanelTarget ? this.panelTarget.getAttribute("data-state") : null;
899
1528
  if (domState === "collapsed") return true;
900
1529
  if (domState === "expanded") return false;
901
1530
  return this.collapsedValue;
902
1531
  }
903
- /** 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
+ }
904
1538
  #persistCollapsed(collapsed) {
905
1539
  const key = this.#storageKey;
906
1540
  if (!key) return;
907
1541
  writeLocalStorage(key, collapsed ? "1" : "0");
908
1542
  }
909
- /**
910
- * @stimeoRuntimeOnly `key` names the storage slot the collapsed state is saved under; the state
911
- * itself comes from the user.
912
- */
913
1543
  get #storageKey() {
914
1544
  return this.keyValue ? `stimeo--sidebar:${this.keyValue}` : "";
915
1545
  }
916
- /** Valid breakpoint CSS query, defaulting malformed or negative values to 768px. */
917
1546
  get #breakpointQuery() {
918
- const value = this.breakpointValue;
919
- const breakpoint = Number.isFinite(value) && value >= 0 ? value : 768;
1547
+ const value = this.#safeBreakpoint;
1548
+ const breakpoint = value;
920
1549
  return `(min-width: ${breakpoint}px)`;
921
1550
  }
922
1551
  get #isOverlay() {
@@ -925,8 +1554,16 @@ var SidebarController = class extends Controller {
925
1554
  get #isOverlayOpen() {
926
1555
  return this.#isOverlay && this.hasPanelTarget && this.panelTarget.getAttribute("data-state") === "open";
927
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
+ }
928
1566
  };
929
1567
 
930
1568
  export { SidebarController };
931
- //# sourceMappingURL=sidebar_controller.js.map
932
1569
  //# sourceMappingURL=sidebar_controller.js.map