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/drawer_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 });
24
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
+ }));
25
97
  }
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);
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);
141
+ }
142
+ }
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,225 +340,699 @@ 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
+ }
335
873
  }
336
- node = parent;
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;
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();
365
957
  }
366
958
  };
367
959
 
368
- // src/utils/safe_timeout.ts
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 LivedMark = class {
986
+ #name;
987
+ #lived = false;
988
+ #element = null;
989
+ #morph = new MorphRenderWatcher(() => this.#write());
990
+ constructor(identifier) {
991
+ this.#name = `data-${identifier}-lived`;
992
+ }
993
+ connect(element) {
994
+ const connection = this.#lived ? "reconnect" : element.hasAttribute(this.#name) ? "restored" : "fresh";
995
+ this.#lived = true;
996
+ this.#element = element;
997
+ this.#write();
998
+ this.#morph.observe(element);
999
+ return connection;
1000
+ }
1001
+ disconnect() {
1002
+ this.#morph.disconnect();
1003
+ }
1004
+ #write() {
1005
+ this.#element?.setAttribute(this.#name, "");
1006
+ }
1007
+ };
1008
+
1009
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
1010
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
1011
+ function stateReasonFor(event) {
1012
+ if (!event) return "api";
1013
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
1014
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
1015
+ return "user";
1016
+ }
1017
+
369
1018
  var TimerRegistry = class {
370
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
371
1019
  ids = /* @__PURE__ */ new Set();
372
- /**
373
- * Cancels a single tracked timer.
374
- *
375
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
376
- * registry), so callers can clear defensively without guarding.
377
- */
378
1020
  clear(id) {
379
1021
  if (this.ids.delete(id)) {
380
1022
  this.cancel(id);
381
1023
  }
382
1024
  }
383
- /**
384
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
385
- * to guarantee no timer outlives the element.
386
- */
387
1025
  clearAll() {
388
1026
  for (const id of this.ids) {
389
1027
  this.cancel(id);
390
1028
  }
391
1029
  this.ids.clear();
392
1030
  }
393
- /** Number of timers currently tracked (pending). */
394
1031
  get size() {
395
1032
  return this.ids.size;
396
1033
  }
397
1034
  };
398
1035
  var SafeTimeout = class extends TimerRegistry {
399
- /**
400
- * Schedules `callback` after `delay` ms and returns the timer id.
401
- *
402
- * The id is removed from the registry automatically when the timeout fires,
403
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
404
- */
405
1036
  set(callback, delay) {
406
1037
  const id = this.schedule(() => {
407
1038
  this.ids.delete(id);
@@ -418,7 +1049,6 @@ var SafeTimeout = class extends TimerRegistry {
418
1049
  }
419
1050
  };
420
1051
 
421
- // src/utils/transition_completion.ts
422
1052
  function timeMs(value) {
423
1053
  const trimmed = value.trim();
424
1054
  const amount = Number.parseFloat(trimmed);
@@ -454,13 +1084,6 @@ var TransitionCompletion = class {
454
1084
  #complete = null;
455
1085
  #pendingProperties = null;
456
1086
  #deadline = 0;
457
- /**
458
- * Replaces any prior wait and invokes `complete` synchronously for a 0ms
459
- * transition (including when `getComputedStyle` is unavailable).
460
- *
461
- * With a positive `options.timeoutMs` the synchronous fast-path is skipped and
462
- * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
463
- */
464
1087
  wait(element, complete, options = {}) {
465
1088
  this.cancel();
466
1089
  const requested = options.timeoutMs ?? 0;
@@ -480,18 +1103,10 @@ var TransitionCompletion = class {
480
1103
  element.addEventListener("transitioncancel", this.#onTerminal);
481
1104
  this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
482
1105
  }
483
- /** Cancels the pending wait without invoking its completion callback. */
484
1106
  cancel() {
485
1107
  this.#complete = null;
486
1108
  this.#teardown();
487
1109
  }
488
- /**
489
- * Handles terminal events from the observed element only.
490
- *
491
- * For explicit property lists, every declared positive-time property must
492
- * settle. For `all`, no reliable property set exists, so an event can finish
493
- * only after the computed maximum time; the safety timeout owns the usual path.
494
- */
495
1110
  #onTerminal = (event) => {
496
1111
  if (event.target !== this.#element) return;
497
1112
  const transitionEvent = event;
@@ -511,10 +1126,6 @@ var TransitionCompletion = class {
511
1126
  }
512
1127
  if (Date.now() >= this.#deadline) this.#finish();
513
1128
  };
514
- /**
515
- * Returns active CSS transition properties expanded to the names reported by
516
- * terminal events. CSS animations and pseudo-element effects are excluded.
517
- */
518
1129
  #activeTransitionProperties(element) {
519
1130
  if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
520
1131
  try {
@@ -532,7 +1143,6 @@ var TransitionCompletion = class {
532
1143
  return /* @__PURE__ */ new Set();
533
1144
  }
534
1145
  }
535
- /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
536
1146
  #explicitPendingProperties(timings) {
537
1147
  if (timings.some(({ property }) => property === "all")) return null;
538
1148
  const pending = new Set(
@@ -540,7 +1150,6 @@ var TransitionCompletion = class {
540
1150
  );
541
1151
  return pending.size > 0 ? pending : null;
542
1152
  }
543
- /** Completes exactly once, releasing listeners and the fallback before the callback. */
544
1153
  #finish() {
545
1154
  const complete = this.#complete;
546
1155
  if (!complete) return;
@@ -548,7 +1157,6 @@ var TransitionCompletion = class {
548
1157
  this.#teardown();
549
1158
  complete();
550
1159
  }
551
- /** Releases the exact element listeners and timer owned by the current wait. */
552
1160
  #teardown() {
553
1161
  this.#timers.clearAll();
554
1162
  this.#element?.removeEventListener("transitionend", this.#onTerminal);
@@ -559,7 +1167,6 @@ var TransitionCompletion = class {
559
1167
  }
560
1168
  };
561
1169
 
562
- // src/controllers/drawer_controller.ts
563
1170
  var DrawerController = class extends Controller {
564
1171
  static targets = ["trigger", "overlay", "panel"];
565
1172
  static values = {
@@ -567,172 +1174,192 @@ var DrawerController = class extends Controller {
567
1174
  open: { type: Boolean, default: false }
568
1175
  };
569
1176
  static actions = ["close", "closeOnBackdrop", "open"];
570
- /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
1177
+ static events = ["close", "open"];
571
1178
  #activePanel = null;
572
- /** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
1179
+ #openState = false;
573
1180
  #trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
574
- onEscape: () => this.close(),
1181
+ onEscape: () => this.#close("escape"),
575
1182
  fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
576
1183
  });
577
- /** Owns the cancellable close-transition wait and its bounded fallback. */
578
1184
  #transition = new TransitionCompletion();
579
- /** Distinguishes dynamic target churn from callbacks around controller teardown. */
580
1185
  #connected = false;
581
- /**
582
- * Reflects placement and establishes the initial open/closed state.
583
- *
584
- * The DOM is the source of truth on reconnect (Turbo cache restore / morph): a
585
- * restored snapshot whose panel is already `data-state="open"` stays open
586
- * rather than being re-derived from the declarative `open` Value (which would
587
- * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
588
- * render. We normalize to a clean closed baseline first so {@link open} runs its
589
- * full reveal + trap activation — the `FocusTrap` is inactive after a
590
- * disconnect and must be re-activated.
591
- */
1186
+ #reporting = false;
1187
+ #lived = new LivedMark(this.identifier);
1188
+ #morphRender = new MorphRenderWatcher(() => this.#repair());
1189
+ #stateLease = new AttributeLease("data-state", this.identifier);
1190
+ #hiddenLease = new AttributeLease("hidden", this.identifier);
1191
+ #placementLease = new AttributeLease("data-placement", this.identifier);
592
1192
  connect() {
1193
+ const restored = this.#lived.connect(this.element) === "restored";
1194
+ this.#trap.connect();
1195
+ this.#morphRender.observe(this.element);
593
1196
  this.#connected = true;
594
1197
  this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
595
1198
  this.#reflectPlacement();
596
- const shouldOpen = this.#isOpen || this.openValue;
1199
+ const shouldOpen = !restored && (this.#isOpen || this.openValue);
597
1200
  this.#applyClosedState();
598
- if (shouldOpen) this.open();
1201
+ if (restored) this.openValue = false;
1202
+ if (shouldOpen) this.#open("api");
1203
+ this.#reporting = true;
599
1204
  }
600
- /** Reverts the modal side effects and pending hide if torn down while open. */
601
1205
  disconnect() {
1206
+ this.#lived.disconnect();
1207
+ this.#morphRender.disconnect();
602
1208
  this.#connected = false;
1209
+ this.#reporting = false;
603
1210
  this.#transition.cancel();
604
- this.#trap.deactivate({ restoreFocus: false });
1211
+ this.#trap.disconnect(this);
605
1212
  this.#activePanel = null;
606
1213
  }
607
- /** Adopts a panel target added by a Turbo morph after the controller connected. */
608
1214
  panelTargetConnected(panel) {
609
- if (!this.#connected || this.#activePanel?.isConnected && this.#activePanel !== panel) return;
1215
+ if (!this.#connected || this.#activePanel?.isConnected) return;
610
1216
  this.#adoptPanel(panel);
611
1217
  }
612
- /** Closes and releases modal side effects when the actively trapped panel disappears. */
613
1218
  panelTargetDisconnected(panel) {
614
- if (panel !== this.#activePanel) return;
1219
+ const stays = this.panelTargets.includes(panel);
1220
+ if (!stays) {
1221
+ this.#stateLease.return(panel);
1222
+ this.#hiddenLease.return(panel);
1223
+ this.#placementLease.return(panel);
1224
+ }
1225
+ if (panel !== this.#activePanel || stays && this.panelTarget === panel) return;
615
1226
  this.#transition.cancel();
616
1227
  this.#activePanel = null;
617
- if (!this.#connected) return;
618
- panel.setAttribute("data-state", "closed");
619
- panel.hidden = true;
620
- if (this.hasOverlayTarget) {
621
- this.overlayTarget.setAttribute("data-state", "closed");
622
- this.overlayTarget.hidden = true;
1228
+ if (!this.#connected) {
1229
+ this.#trap.deactivate({ restoreFocus: false });
1230
+ return;
1231
+ }
1232
+ this.#openState = false;
1233
+ if (stays) {
1234
+ this.#writeState(panel, "closed");
1235
+ this.#writeHidden(panel, true);
623
1236
  }
1237
+ this.#writeOverlayState("closed");
1238
+ this.#writeOverlayHidden(true);
624
1239
  this.openValue = false;
625
- this.#trap.deactivate();
626
1240
  if (this.hasPanelTarget) this.#adoptPanel(this.panelTarget);
1241
+ else this.#trap.deactivate();
1242
+ }
1243
+ overlayTargetConnected() {
1244
+ this.#morphRender.schedule();
1245
+ }
1246
+ overlayTargetDisconnected(overlay) {
1247
+ if (!this.overlayTargets.includes(overlay)) {
1248
+ this.#stateLease.return(overlay);
1249
+ this.#hiddenLease.return(overlay);
1250
+ }
1251
+ this.#morphRender.schedule();
627
1252
  }
628
- /** Keeps `data-placement` in sync if the value changes at runtime. */
629
1253
  placementValueChanged() {
630
- this.#reflectPlacement();
1254
+ if (this.#connected) this.#repair();
1255
+ else this.#reflectPlacement();
1256
+ }
1257
+ open(event) {
1258
+ if (!this.#connected) return;
1259
+ this.#open(stateReasonFor(event));
631
1260
  }
632
- /** Opens the drawer: reveals it, syncs `data-state`, traps focus. */
633
- open() {
1261
+ #open(reason) {
634
1262
  if (!this.hasPanelTarget || this.#isOpen) return;
635
1263
  this.#transition.cancel();
636
1264
  this.#activePanel = this.panelTarget;
637
- this.panelTarget.hidden = false;
638
- if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
1265
+ this.#writeHidden(this.panelTarget, false);
1266
+ this.#writeOverlayHidden(false);
639
1267
  void this.panelTarget.offsetWidth;
640
1268
  this.#setState("open");
641
1269
  this.openValue = true;
1270
+ if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
1271
+ if (!this.#isOpen) return;
642
1272
  this.#trap.activate();
643
1273
  }
644
- /**
645
- * Closes the drawer: syncs `data-state` to start the exit transition, then
646
- * defers both `hidden` *and* the modal teardown (scroll lock / background
647
- * `inert` / focus restore) until the transition finishes — see
648
- * `#applyHidden`. This keeps the background inert and focus trapped while
649
- * the drawer is still visually on screen, preserving the modal contract during
650
- * the exit animation.
651
- */
652
- close() {
1274
+ close(event) {
1275
+ if (!this.#connected) return;
1276
+ this.#close(stateReasonFor(event));
1277
+ }
1278
+ closeOnBackdrop(event) {
1279
+ if (!this.#connected) return;
1280
+ if (this.hasOverlayTarget && event.target === this.overlayTarget) this.#close("outside");
1281
+ }
1282
+ #close(reason) {
653
1283
  if (!this.hasPanelTarget || !this.#isOpen) return;
654
1284
  this.openValue = false;
655
1285
  this.#setState("closed");
1286
+ if (this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
1287
+ if (this.#isOpen) return;
656
1288
  this.#hideAfterTransition();
657
1289
  }
658
- /** Closes only when the overlay itself (not its contents) is clicked. */
659
- closeOnBackdrop(event) {
660
- if (this.hasOverlayTarget && event.target === this.overlayTarget) this.close();
661
- }
662
- /** Writes `data-placement` from the current `placement` value. */
663
1290
  #reflectPlacement() {
664
- if (this.hasPanelTarget) this.panelTarget.setAttribute("data-placement", this.#placement);
1291
+ if (this.hasPanelTarget) this.#placementLease.write(this.panelTarget, this.#placement);
665
1292
  }
666
- /** Reconciles a replacement panel and companion overlay from its explicit DOM state. */
667
1293
  #adoptPanel(panel) {
668
1294
  this.#transition.cancel();
669
- const trapWasActive = this.#trap.active;
670
1295
  this.#activePanel = panel;
671
- panel.setAttribute("data-placement", this.#placement);
1296
+ this.#placementLease.write(panel, this.#placement);
672
1297
  if (panel.getAttribute("data-state") === "open") {
673
- panel.hidden = false;
674
- if (this.hasOverlayTarget) {
675
- this.overlayTarget.setAttribute("data-state", "open");
676
- this.overlayTarget.hidden = false;
677
- }
1298
+ this.#openState = true;
1299
+ this.#writeHidden(panel, false);
1300
+ this.#writeOverlayState("open");
1301
+ this.#writeOverlayHidden(false);
678
1302
  this.openValue = true;
679
- if (trapWasActive) this.#trap.deactivate({ restoreFocus: false });
680
- this.#trap.activate();
1303
+ if (this.#trap.active) this.#trap.refreshContainer();
1304
+ else this.#trap.activate();
681
1305
  return;
682
1306
  }
683
- panel.setAttribute("data-state", "closed");
684
- panel.hidden = true;
685
- if (this.hasOverlayTarget) {
686
- this.overlayTarget.setAttribute("data-state", "closed");
687
- this.overlayTarget.hidden = true;
688
- }
1307
+ this.#openState = false;
1308
+ this.#writeState(panel, "closed");
1309
+ this.#writeHidden(panel, true);
1310
+ this.#writeOverlayState("closed");
1311
+ this.#writeOverlayHidden(true);
689
1312
  this.openValue = false;
690
1313
  this.#trap.deactivate();
691
1314
  }
692
- /** Validated placement (`left`/`right`/`top`/`bottom`), defaulting to `right`. */
1315
+ #repair() {
1316
+ this.#reflectPlacement();
1317
+ if (!this.hasPanelTarget) return;
1318
+ this.#setState(this.#openState ? "open" : "closed");
1319
+ const hidden = !this.#openState && !this.#trap.active;
1320
+ this.#writeHidden(this.panelTarget, hidden);
1321
+ this.#writeOverlayHidden(hidden);
1322
+ }
693
1323
  get #placement() {
694
1324
  const value = this.placementValue;
695
1325
  return value === "left" || value === "top" || value === "bottom" ? value : "right";
696
1326
  }
697
- /** Syncs `data-state` on the panel and overlay together. */
698
1327
  #setState(state) {
699
- if (this.hasPanelTarget) this.panelTarget.setAttribute("data-state", state);
700
- if (this.hasOverlayTarget) this.overlayTarget.setAttribute("data-state", state);
1328
+ this.#openState = state === "open";
1329
+ if (this.hasPanelTarget) this.#writeState(this.panelTarget, state);
1330
+ this.#writeOverlayState(state);
1331
+ }
1332
+ #writeState(element, state) {
1333
+ this.#stateLease.write(element, state);
1334
+ }
1335
+ #writeHidden(element, hidden) {
1336
+ this.#hiddenLease.write(element, hidden ? "" : null);
1337
+ }
1338
+ #writeOverlayState(state) {
1339
+ if (this.hasOverlayTarget) this.#writeState(this.overlayTarget, state);
1340
+ }
1341
+ #writeOverlayHidden(hidden) {
1342
+ if (this.hasOverlayTarget) this.#writeHidden(this.overlayTarget, hidden);
701
1343
  }
702
- /** Fully reflects the closed state up front (used on connect when not open). */
703
1344
  #applyClosedState() {
704
1345
  this.#setState("closed");
705
- if (this.hasPanelTarget) this.panelTarget.hidden = true;
706
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
707
- }
708
- /**
709
- * Applies `hidden` once the panel's close transition ends, so the exit slide
710
- * can play. The shared waiter hides synchronously for 0ms transitions and
711
- * supplies a bounded fallback when the browser emits no terminal event.
712
- */
1346
+ if (this.hasPanelTarget) this.#writeHidden(this.panelTarget, true);
1347
+ this.#writeOverlayHidden(true);
1348
+ }
713
1349
  #hideAfterTransition() {
714
1350
  const panel = this.panelTarget;
715
1351
  this.#activePanel = panel;
716
1352
  this.#transition.wait(panel, () => this.#applyHidden(panel));
717
1353
  }
718
- /**
719
- * Runs once the close transition has finished: applies `hidden` to the panel
720
- * and overlay, then reverts the modal side effects (scroll lock, background
721
- * `inert`, keydown listener) and restores focus to the opener. Deferring the
722
- * `FocusTrap` teardown to here — rather than at {@link close} time — keeps
723
- * the background unreachable and focus trapped for the whole exit animation.
724
- */
725
1354
  #applyHidden(panel) {
726
- panel.hidden = true;
727
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
1355
+ this.#writeHidden(panel, true);
1356
+ this.#writeOverlayHidden(true);
728
1357
  this.#trap.deactivate();
729
1358
  }
730
- /** Whether the drawer is open (tracked via `data-state`, not `hidden`). */
731
1359
  get #isOpen() {
732
1360
  return this.hasPanelTarget && this.panelTarget.getAttribute("data-state") === "open";
733
1361
  }
734
1362
  };
735
1363
 
736
1364
  export { DrawerController };
737
- //# sourceMappingURL=drawer_controller.js.map
738
1365
  //# sourceMappingURL=drawer_controller.js.map