stimeo-ui 0.16.0 → 0.18.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +27 -11
  4. data/dist/cable/index.js +384 -449
  5. data/dist/controllers/accordion_controller.js +174 -55
  6. data/dist/controllers/alert_dialog_controller.js +903 -235
  7. data/dist/controllers/announcer_controller.js +238 -205
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +414 -145
  10. data/dist/controllers/avatar_controller.js +185 -107
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +282 -58
  13. data/dist/controllers/calendar_controller.js +228 -260
  14. data/dist/controllers/carousel_controller.js +536 -436
  15. data/dist/controllers/character_counter_controller.js +493 -207
  16. data/dist/controllers/checkbox_controller.js +38 -74
  17. data/dist/controllers/clipboard_controller.js +261 -149
  18. data/dist/controllers/collapsible_controller.js +304 -120
  19. data/dist/controllers/color_picker_controller.js +178 -146
  20. data/dist/controllers/combobox_controller.js +522 -156
  21. data/dist/controllers/command_palette_controller.js +1156 -395
  22. data/dist/controllers/conditional_fields_controller.js +300 -171
  23. data/dist/controllers/confirm_controller.js +980 -262
  24. data/dist/controllers/context_menu_controller.js +346 -153
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +281 -158
  27. data/dist/controllers/currency_input_controller.js +575 -225
  28. data/dist/controllers/data_grid_controller.js +248 -214
  29. data/dist/controllers/date_range_picker_controller.js +338 -235
  30. data/dist/controllers/dialog_controller.js +899 -237
  31. data/dist/controllers/direct_upload_controller.js +97 -167
  32. data/dist/controllers/dirty_form_controller.js +72 -69
  33. data/dist/controllers/dismissible_controller.js +196 -24
  34. data/dist/controllers/drawer_controller.js +974 -355
  35. data/dist/controllers/dropdown_controller.js +339 -96
  36. data/dist/controllers/editable_controller.js +315 -88
  37. data/dist/controllers/empty_state_controller.js +79 -58
  38. data/dist/controllers/file_dropzone_controller.js +322 -211
  39. data/dist/controllers/filter_controller.js +77 -42
  40. data/dist/controllers/flash_controller.js +238 -347
  41. data/dist/controllers/focus_controller.js +679 -240
  42. data/dist/controllers/form_field_controller.js +156 -132
  43. data/dist/controllers/form_validation_controller.js +31 -97
  44. data/dist/controllers/frame_loading_controller.js +275 -217
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +327 -130
  47. data/dist/controllers/idle_controller.js +271 -101
  48. data/dist/controllers/input_mask_controller.js +483 -97
  49. data/dist/controllers/intersection_controller.js +150 -106
  50. data/dist/controllers/lazy_frame_controller.js +24 -68
  51. data/dist/controllers/listbox_controller.js +523 -240
  52. data/dist/controllers/local_time_controller.js +102 -77
  53. data/dist/controllers/masonry_controller.js +80 -96
  54. data/dist/controllers/menu_controller.js +302 -188
  55. data/dist/controllers/menubar_controller.js +232 -345
  56. data/dist/controllers/meter_controller.js +417 -87
  57. data/dist/controllers/multi_select_controller.js +785 -393
  58. data/dist/controllers/navigation_menu_controller.js +190 -250
  59. data/dist/controllers/nested_form_controller.js +396 -192
  60. data/dist/controllers/network_status_controller.js +268 -62
  61. data/dist/controllers/number_input_controller.js +610 -303
  62. data/dist/controllers/optimistic_controller.js +45 -87
  63. data/dist/controllers/otp_controller.js +658 -255
  64. data/dist/controllers/overflow_indicator_controller.js +122 -113
  65. data/dist/controllers/overflow_menu_controller.js +470 -295
  66. data/dist/controllers/pagination_controller.js +332 -123
  67. data/dist/controllers/password_reveal_controller.js +392 -121
  68. data/dist/controllers/password_strength_controller.js +111 -158
  69. data/dist/controllers/persist_controller.js +165 -111
  70. data/dist/controllers/pointer_drag_controller.js +434 -188
  71. data/dist/controllers/popover_controller.js +269 -113
  72. data/dist/controllers/portal_controller.js +215 -76
  73. data/dist/controllers/preview_guard_controller.js +243 -116
  74. data/dist/controllers/progress_controller.js +391 -78
  75. data/dist/controllers/radio_group_controller.js +278 -211
  76. data/dist/controllers/range_slider_controller.js +220 -113
  77. data/dist/controllers/rating_controller.js +458 -211
  78. data/dist/controllers/read_more_controller.js +138 -63
  79. data/dist/controllers/reading_progress_controller.js +166 -137
  80. data/dist/controllers/relative_time_controller.js +211 -112
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
  82. data/dist/controllers/resizable_controller.js +299 -150
  83. data/dist/controllers/roving_controller.js +49 -88
  84. data/dist/controllers/scroll_area_controller.js +490 -246
  85. data/dist/controllers/scroll_restore_controller.js +14 -55
  86. data/dist/controllers/scroll_visibility_controller.js +432 -149
  87. data/dist/controllers/scrollspy_controller.js +193 -248
  88. data/dist/controllers/separator_controller.js +285 -142
  89. data/dist/controllers/sidebar_controller.js +1069 -404
  90. data/dist/controllers/skeleton_controller.js +259 -101
  91. data/dist/controllers/slider_controller.js +187 -86
  92. data/dist/controllers/smart_sticky_header_controller.js +173 -66
  93. data/dist/controllers/sortable_controller.js +26 -125
  94. data/dist/controllers/spinner_controller.js +288 -173
  95. data/dist/controllers/step_indicator_controller.js +105 -46
  96. data/dist/controllers/stepper_controller.js +120 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +275 -140
  98. data/dist/controllers/sticky_observer_controller.js +220 -49
  99. data/dist/controllers/submit_once_controller.js +376 -227
  100. data/dist/controllers/switch_controller.js +201 -84
  101. data/dist/controllers/tabs_controller.js +41 -30
  102. data/dist/controllers/tags_input_controller.js +293 -173
  103. data/dist/controllers/textarea_autosize_controller.js +108 -74
  104. data/dist/controllers/theme_controller.js +140 -116
  105. data/dist/controllers/time_picker_controller.js +331 -127
  106. data/dist/controllers/toast_controller.js +244 -324
  107. data/dist/controllers/toggle_group_controller.js +252 -191
  108. data/dist/controllers/toolbar_controller.js +219 -120
  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 +283 -369
  112. data/dist/index.js +8573 -10410
  113. data/dist/positioning/index.js +154 -47
  114. data/lib/generators/stimeo/install/install_generator.rb +7 -2
  115. data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
  116. data/lib/stimeo/ui/version.rb +1 -1
  117. metadata +3 -3
@@ -1,8 +1,158 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/popover_controller.ts
3
+ function setAttributeIfChanged(element, name, value, beforeWrite) {
4
+ if (element.getAttribute(name) === value) return false;
5
+ if (value === null) element.removeAttribute(name);
6
+ else element.setAttribute(name, value);
7
+ return true;
8
+ }
9
+
10
+ function sharedRegistry(key, create) {
11
+ const symbol = Symbol.for(key);
12
+ const scope = globalThis;
13
+ const existing = scope[symbol];
14
+ if (existing !== void 0) return existing;
15
+ const registry2 = create();
16
+ Object.defineProperty(globalThis, symbol, { value: registry2 });
17
+ return registry2;
18
+ }
19
+
20
+ var registry = sharedRegistry(
21
+ "stimeo-ui.attribute-lease.registry.v1",
22
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
23
+ );
24
+ function isAttributeValue(value) {
25
+ return value === null || typeof value === "string";
26
+ }
27
+ function readLeaseRecord(element, record) {
28
+ try {
29
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
30
+ if (!Array.isArray(parsed)) return void 0;
31
+ const [original, written, ...rest] = parsed;
32
+ const linked = rest[0] !== false;
33
+ const beneath = linked ? rest : rest.slice(1);
34
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
35
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
36
+ } catch {
37
+ return void 0;
38
+ }
39
+ }
40
+ function attributeStack(element, attribute) {
41
+ const byAttribute = registry.stacks.get(element) ?? /* @__PURE__ */ new Map();
42
+ registry.stacks.set(element, byAttribute);
43
+ const known = byAttribute.get(attribute);
44
+ if (known) return known;
45
+ const found = [];
46
+ for (const record of registry.names.get(attribute) ?? []) {
47
+ const read = readLeaseRecord(element, record);
48
+ if (read) found.push({ ...read, record });
49
+ }
50
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
51
+ const stack = found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
52
+ lease: null,
53
+ record,
54
+ written,
55
+ original,
56
+ linked: linked && index > 0
57
+ }));
58
+ byAttribute.set(attribute, stack);
59
+ return stack;
60
+ }
61
+ function findHold(stack, lease, record) {
62
+ return stack.find((held) => held.lease === lease) ?? stack.find((held) => held.lease === null && held.record === record);
63
+ }
64
+ var AttributeLease = class {
65
+ #attribute;
66
+ #record;
67
+ #base;
68
+ #preserveAuthor;
69
+ #held = /* @__PURE__ */ new Set();
70
+ constructor(attribute, owner, options = {}) {
71
+ this.#attribute = attribute;
72
+ this.#record = `data-${owner}-${attribute}-lease`;
73
+ this.#base = options.base === true;
74
+ this.#preserveAuthor = options.preserveAuthor === true;
75
+ const names = registry.names;
76
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
77
+ }
78
+ write(element, value) {
79
+ const stack = attributeStack(element, this.#attribute);
80
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
81
+ this.#held.add(element);
82
+ const shows = !this.#covered(element, stack, hold);
83
+ hold.written = value;
84
+ this.#mark(element, stack);
85
+ if (shows) this.#reflect(element, value);
86
+ }
87
+ return(element) {
88
+ const held = this.#held.delete(element);
89
+ const known = registry.stacks.get(element)?.has(this.#attribute);
90
+ const stack = held || known || element.hasAttribute(this.#record) ? attributeStack(element, this.#attribute) : [];
91
+ const hold = this.#take(stack);
92
+ if (!hold) return false;
93
+ const carries = element.getAttribute(this.#attribute) === hold.written;
94
+ const owns = carries && !this.#covered(element, stack, hold);
95
+ const index = stack.indexOf(hold);
96
+ stack.splice(index, 1);
97
+ const above = stack[index];
98
+ if (above) above.linked &&= hold.linked;
99
+ this.#mark(element, stack);
100
+ if (!owns) return true;
101
+ const below = hold.linked ? stack[index - 1] : void 0;
102
+ this.#reflect(element, below ? below.written : hold.original);
103
+ return true;
104
+ }
105
+ returnAll() {
106
+ for (const element of Array.from(this.#held)) this.return(element);
107
+ }
108
+ #take(stack) {
109
+ const hold = findHold(stack, this, this.#record);
110
+ if (hold) hold.lease = this;
111
+ return hold;
112
+ }
113
+ #acquire(element, stack) {
114
+ const current = element.getAttribute(this.#attribute);
115
+ const top = stack.at(-1);
116
+ const bottom = stack[0];
117
+ const over = top?.written === current ? top : void 0;
118
+ const from = this.#base ? this.#preserveAuthor && !stack.some((held) => held.written === current) ? void 0 : bottom : over;
119
+ const hold = {
120
+ lease: this,
121
+ record: this.#record,
122
+ written: current,
123
+ original: from ? from.original : current,
124
+ linked: !this.#base && over !== void 0
125
+ };
126
+ if (this.#base && bottom) {
127
+ bottom.linked = true;
128
+ stack.unshift(hold);
129
+ } else stack.push(hold);
130
+ return hold;
131
+ }
132
+ #covered(element, stack, hold) {
133
+ const current = element.getAttribute(this.#attribute);
134
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
135
+ }
136
+ #mark(element, stack) {
137
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
138
+ stack.forEach((hold, index) => {
139
+ const names = stack.slice(0, index).map((under) => under.record);
140
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
141
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
142
+ records.set(
143
+ hold.record,
144
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
145
+ );
146
+ });
147
+ for (const [name, recorded] of records) {
148
+ setAttributeIfChanged(element, name, recorded);
149
+ }
150
+ }
151
+ #reflect(element, value) {
152
+ setAttributeIfChanged(element, this.#attribute, value);
153
+ }
154
+ };
4
155
 
5
- // src/utils/escape_layer.ts
6
156
  function claimsWhileFocusWithin(element) {
7
157
  return () => {
8
158
  const active = element.ownerDocument.activeElement;
@@ -10,89 +160,68 @@ function claimsWhileFocusWithin(element) {
10
160
  };
11
161
  }
12
162
  var EscapeLayer = class _EscapeLayer {
13
- static #registries = /* @__PURE__ */ new WeakMap();
14
- #ownerDocument = null;
15
- /** Dismissal callback while active; `null` when inactive. */
16
- #onDismiss = null;
17
- /** Live predicate deciding whether the layer claims a press; `null` = always. */
18
- #claims = null;
19
- /**
20
- * Activates this layer at the top of its document's Escape stack, installing
21
- * the document's shared resolver listener if this is its first layer.
22
- * Re-activating an already-active layer moves it to the top.
23
- */
163
+ static #registries = sharedRegistry(
164
+ "stimeo-ui.escape-layer.registry.v1",
165
+ () => /* @__PURE__ */ new WeakMap()
166
+ );
167
+ #active = null;
24
168
  activate(ownerDocument = document, options) {
25
169
  this.deactivate();
26
- let registry = _EscapeLayer.#registries.get(ownerDocument);
27
- if (!registry) {
28
- registry = _EscapeLayer.#createRegistry();
29
- _EscapeLayer.#registries.set(ownerDocument, registry);
30
- ownerDocument.addEventListener("keydown", registry.onKeydown);
170
+ let registry2 = _EscapeLayer.#registries.get(ownerDocument);
171
+ if (!registry2) {
172
+ registry2 = _EscapeLayer.#createRegistry();
173
+ _EscapeLayer.#registries.set(ownerDocument, registry2);
174
+ ownerDocument.addEventListener("keydown", registry2.onKeydown);
31
175
  }
32
- registry.stack.push(this);
33
- this.#ownerDocument = ownerDocument;
34
- this.#onDismiss = options.onDismiss;
35
- this.#claims = options.claims ?? null;
36
- }
37
- /**
38
- * Removes this layer from its document's Escape stack, uninstalling the
39
- * shared listener when the stack empties. Safe to call when inactive.
40
- */
176
+ const entry = { onDismiss: options.onDismiss, claims: options.claims ?? null };
177
+ registry2.stack.push(entry);
178
+ this.#active = { document: ownerDocument, entry };
179
+ }
41
180
  deactivate() {
42
- const ownerDocument = this.#ownerDocument;
43
- if (!ownerDocument) return;
44
- const registry = _EscapeLayer.#registries.get(ownerDocument);
45
- if (registry) {
46
- const index = registry.stack.lastIndexOf(this);
47
- if (index >= 0) registry.stack.splice(index, 1);
48
- if (registry.stack.length === 0) {
49
- ownerDocument.removeEventListener("keydown", registry.onKeydown);
50
- _EscapeLayer.#registries.delete(ownerDocument);
181
+ const active = this.#active;
182
+ if (!active) return;
183
+ const registry2 = _EscapeLayer.#registries.get(active.document);
184
+ if (registry2) {
185
+ const index = registry2.stack.lastIndexOf(active.entry);
186
+ if (index >= 0) registry2.stack.splice(index, 1);
187
+ if (registry2.stack.length === 0) {
188
+ active.document.removeEventListener("keydown", registry2.onKeydown);
189
+ _EscapeLayer.#registries.delete(active.document);
51
190
  }
52
191
  }
53
- this.#ownerDocument = null;
54
- this.#onDismiss = null;
55
- this.#claims = null;
56
- }
57
- /**
58
- * Whether this active layer would own a press right now: it is the topmost
59
- * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
60
- * and diagnostics — production dismissal goes through the shared listener.
61
- */
192
+ this.#active = null;
193
+ }
62
194
  get ownsEscape() {
63
- const ownerDocument = this.#ownerDocument;
64
- if (!ownerDocument) return false;
65
- const registry = _EscapeLayer.#registries.get(ownerDocument);
66
- if (!registry) return false;
67
- return _EscapeLayer.#resolveOwner(registry.stack) === this;
195
+ const active = this.#active;
196
+ if (!active) return false;
197
+ const registry2 = _EscapeLayer.#registries.get(active.document);
198
+ if (!registry2) return false;
199
+ return _EscapeLayer.#resolveOwner(registry2.stack) === active.entry;
68
200
  }
69
- /** Builds a document's registry with its shared resolver listener. */
70
201
  static #createRegistry() {
71
- const registry = {
202
+ const registry2 = {
72
203
  stack: [],
73
204
  onKeydown: (event) => {
74
205
  if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
75
- const owner = _EscapeLayer.#resolveOwner(registry.stack);
206
+ const owner = _EscapeLayer.#resolveOwner(registry2.stack);
76
207
  if (!owner) return;
77
208
  event.preventDefault();
78
- owner.#onDismiss?.();
209
+ owner.onDismiss();
79
210
  }
80
211
  };
81
- return registry;
212
+ return registry2;
82
213
  }
83
- /** The topmost stack layer whose claims predicate passes, or `null`. */
84
214
  static #resolveOwner(stack) {
85
215
  for (let index = stack.length - 1; index >= 0; index--) {
86
216
  const layer = stack[index];
87
217
  if (!layer) continue;
88
- if (layer.#claims && !layer.#claims()) continue;
218
+ if (layer.claims && !layer.claims()) continue;
89
219
  return layer;
90
220
  }
91
221
  return null;
92
222
  }
93
223
  };
94
224
 
95
- // src/utils/focus_candidate.ts
96
225
  function inheritsFieldsetDisabled(control) {
97
226
  let fieldset = control.closest("fieldset[disabled]");
98
227
  while (fieldset) {
@@ -102,8 +231,19 @@ function inheritsFieldsetDisabled(control) {
102
231
  }
103
232
  return false;
104
233
  }
234
+ function unslotted(element) {
235
+ return element.assignedSlot === null && element.parentElement?.shadowRoot != null;
236
+ }
237
+ function flatTreeParent(element) {
238
+ if (unslotted(element)) return null;
239
+ const parent = element.assignedSlot ?? element.parentNode;
240
+ if (parent instanceof ShadowRoot) return parent.host;
241
+ return parent instanceof Element ? parent : null;
242
+ }
105
243
  function canTakeFocus(element) {
106
- if (element.closest("[hidden], [inert]")) return false;
244
+ for (let node = element; node; node = flatTreeParent(node)) {
245
+ if (unslotted(node) || node.matches("[hidden], [inert]")) return false;
246
+ }
107
247
  if (element instanceof HTMLInputElement && element.type === "hidden") return false;
108
248
  if (!("disabled" in element)) return true;
109
249
  if (element.disabled) return false;
@@ -167,7 +307,17 @@ function firstTabStop(root) {
167
307
  return null;
168
308
  }
169
309
 
170
- // src/utils/scroll_dismiss.ts
310
+ var ListenerSet = class {
311
+ #abort = new AbortController();
312
+ add(target, type, handler, options) {
313
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
314
+ }
315
+ dispose() {
316
+ this.#abort.abort();
317
+ this.#abort = new AbortController();
318
+ }
319
+ };
320
+
171
321
  function observeScrollDismiss(element, onScroll) {
172
322
  const targets = [...scrollParents(element), window];
173
323
  const handler = () => onScroll();
@@ -192,7 +342,6 @@ function isScrollable(element) {
192
342
  return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
193
343
  }
194
344
 
195
- // src/utils/state_reason.ts
196
345
  var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
197
346
  var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
198
347
  function stateReasonFor(event) {
@@ -202,7 +351,6 @@ function stateReasonFor(event) {
202
351
  return "user";
203
352
  }
204
353
 
205
- // src/controllers/popover_controller.ts
206
354
  var PopoverController = class extends Controller {
207
355
  static targets = ["trigger", "panel"];
208
356
  static values = {
@@ -210,34 +358,43 @@ var PopoverController = class extends Controller {
210
358
  };
211
359
  static actions = ["close", "open", "toggle"];
212
360
  static events = ["close", "open"];
213
- /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
214
361
  #stopScrollDismiss = null;
215
- /** Escape-stack membership while open; the shared resolver dismisses via it. */
216
362
  #escapeLayer = new EscapeLayer();
217
- /** Whether state moves are reported: set once `connect()` settled the baseline. */
363
+ #expanded = new AttributeLease("aria-expanded", this.identifier);
364
+ #shownPanel = null;
365
+ #connected = false;
218
366
  #reporting = false;
219
- /** Starts closed and registers the standing dismissal listeners. */
367
+ #listeners = new ListenerSet();
220
368
  connect() {
221
369
  this.#close("api");
222
- document.addEventListener("click", this.#onOutsideClick, true);
223
- this.element.addEventListener("focusout", this.#onFocusOut);
370
+ this.#listeners.add(document, "click", this.#onOutsideClick, { capture: true });
371
+ this.#listeners.add(this.element, "focusout", this.#onFocusOut);
372
+ this.#connected = true;
224
373
  this.#reporting = true;
225
374
  }
226
- /**
227
- * Removes every standing listener registered in {@link connect} plus any active
228
- * dismiss-on-scroll observers. `removeEventListener` is a no-op when it was
229
- * never added, so this is safe in the closed state too — no listener outlives
230
- * the element after a Turbo navigation.
231
- */
232
375
  disconnect() {
376
+ this.#connected = false;
233
377
  this.#reporting = false;
234
- this.#escapeLayer.deactivate();
235
- document.removeEventListener("click", this.#onOutsideClick, true);
236
- this.element.removeEventListener("focusout", this.#onFocusOut);
237
- this.#stopScrollDismiss?.();
238
- this.#stopScrollDismiss = null;
378
+ this.#release();
379
+ this.#listeners.dispose();
380
+ }
381
+ closeOnScrollValueChanged() {
382
+ if (this.#shownPanel) this.#syncScrollDismiss();
383
+ }
384
+ panelTargetDisconnected(panel) {
385
+ if (panel !== this.#shownPanel) return;
386
+ if (this.panelTargets.includes(panel)) return;
387
+ this.#release();
388
+ panel.hidden = true;
389
+ this.#reflectClosed();
390
+ }
391
+ triggerTargetConnected() {
392
+ if (this.#connected) this.#reflectTrigger();
393
+ }
394
+ triggerTargetDisconnected(trigger) {
395
+ if (!this.triggerTargets.includes(trigger)) this.#expanded.return(trigger);
396
+ if (this.#connected) this.#reflectTrigger();
239
397
  }
240
- /** Toggles the popover. Bound via `data-action` (click on the trigger). */
241
398
  toggle(event) {
242
399
  if (this.#isOpen) {
243
400
  this.close(event);
@@ -245,46 +402,54 @@ var PopoverController = class extends Controller {
245
402
  this.open(event);
246
403
  }
247
404
  }
248
- /** Opens the panel, reflects state, and moves focus inside it. */
249
405
  open(event) {
250
406
  this.#open(stateReasonFor(event));
251
407
  }
252
- /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
253
408
  close(event) {
254
409
  this.#close(stateReasonFor(event));
255
410
  }
256
- /**
257
- * Opens the panel, reflects state, reports a move, and moves focus inside it.
258
- *
259
- * @stimeoRuntimeOnly `closeOnScroll` decides whether this opening wires the scroll dismissal;
260
- * what is shown does not depend on it.
261
- */
262
411
  #open(reason) {
263
412
  if (!this.hasPanelTarget || this.#isOpen) return;
264
- this.panelTarget.hidden = false;
265
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
413
+ const panel = this.panelTarget;
414
+ panel.hidden = false;
415
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, "true");
416
+ this.#shownPanel = panel;
266
417
  if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
267
- if (!this.#isOpen) return;
418
+ if (this.#shownPanel !== panel || !this.#isOpen) return;
268
419
  this.#escapeLayer.activate(document, {
269
420
  onDismiss: () => this.#closeAndRestore(),
270
421
  claims: claimsWhileFocusWithin(this.element)
271
422
  });
272
- if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
273
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#close("scroll"));
274
- }
423
+ this.#syncScrollDismiss();
275
424
  this.#focusFirst();
276
425
  }
277
- /** Closes the panel, reflects the collapsed state, and reports a move. */
278
426
  #close(reason) {
279
427
  const was = this.#isOpen;
428
+ this.#release();
429
+ this.#reflectClosed();
430
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
431
+ }
432
+ #reflectClosed() {
433
+ if (this.hasPanelTarget) this.panelTarget.hidden = true;
434
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, "false");
435
+ }
436
+ #reflectTrigger() {
437
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, String(this.#isOpen));
438
+ }
439
+ #release() {
440
+ this.#shownPanel = null;
280
441
  this.#escapeLayer.deactivate();
281
442
  this.#stopScrollDismiss?.();
282
443
  this.#stopScrollDismiss = null;
283
- if (this.hasPanelTarget) this.panelTarget.hidden = true;
284
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
285
- if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
286
444
  }
287
- /** Moves focus to the first focusable element in the panel, or the panel itself. */
445
+ #syncScrollDismiss() {
446
+ if (this.closeOnScrollValue) {
447
+ this.#stopScrollDismiss ??= observeScrollDismiss(this.element, () => this.#close("scroll"));
448
+ return;
449
+ }
450
+ this.#stopScrollDismiss?.();
451
+ this.#stopScrollDismiss = null;
452
+ }
288
453
  #focusFirst() {
289
454
  const first = firstTabStop(this.panelTarget);
290
455
  if (first) {
@@ -294,38 +459,29 @@ var PopoverController = class extends Controller {
294
459
  if (!this.panelTarget.hasAttribute("tabindex")) this.panelTarget.tabIndex = -1;
295
460
  this.panelTarget.focus();
296
461
  }
297
- /** Closes and restores focus to the trigger for explicit keyboard dismissal. */
298
462
  #closeAndRestore() {
299
463
  this.#close("escape");
300
464
  if (this.hasTriggerTarget) this.triggerTarget.focus();
301
465
  }
302
- /** Closes without moving focus when a click lands outside the controller element. */
303
466
  #onOutsideClick = (event) => {
304
467
  const target = event.target;
305
- if (this.#isOpen && target instanceof Node && !this.element.contains(target)) {
468
+ if (this.#closable && target instanceof Node && !this.element.contains(target)) {
306
469
  this.#close("outside");
307
470
  }
308
471
  };
309
- /**
310
- * Closes when focus leaves the controller for a known external destination
311
- * (e.g. forward Tab past the panel or reverse Tab past the trigger). Focus is
312
- * not restored — the natural destination is kept, which is the modeless
313
- * contract. A null/non-Node destination is indeterminate: browsers use it for
314
- * clicks on non-focusable content and window deactivation, so the later outside
315
- * click handler decides pointer dismissal instead.
316
- */
317
472
  #onFocusOut = (event) => {
318
- if (!this.#isOpen) return;
473
+ if (!this.#closable) return;
319
474
  const next = event.relatedTarget;
320
475
  if (!(next instanceof Node) || this.element.contains(next)) return;
321
476
  this.#close("focus");
322
477
  };
323
- /** Whether the panel is currently visible. */
324
478
  get #isOpen() {
325
479
  return this.hasPanelTarget && !this.panelTarget.hidden;
326
480
  }
481
+ get #closable() {
482
+ return this.#isOpen || this.#shownPanel !== null;
483
+ }
327
484
  };
328
485
 
329
486
  export { PopoverController };
330
- //# sourceMappingURL=popover_controller.js.map
331
487
  //# sourceMappingURL=popover_controller.js.map