stimeo-ui 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,8 +1,152 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/popover_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
+ }
12
+
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;
27
+ }
28
+ }
29
+ var AttributeLease = class _AttributeLease {
30
+ static #registry = sharedRegistry(
31
+ "stimeo-ui.attribute-lease.registry.v1",
32
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
33
+ );
34
+ #attribute;
35
+ #record;
36
+ #base;
37
+ #held = /* @__PURE__ */ new Set();
38
+ constructor(attribute, owner, options = {}) {
39
+ this.#attribute = attribute;
40
+ this.#record = `data-${owner}-${attribute}-lease`;
41
+ this.#base = options.base === true;
42
+ const names = _AttributeLease.#registry.names;
43
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
44
+ }
45
+ write(element, value) {
46
+ const stack = this.#stack(element);
47
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
48
+ this.#held.add(element);
49
+ const shows = !this.#covered(element, stack, hold);
50
+ hold.written = value;
51
+ this.#mark(element, stack);
52
+ if (shows) this.#reflect(element, value);
53
+ }
54
+ return(element) {
55
+ const held = this.#held.delete(element);
56
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
57
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
58
+ const hold = this.#take(stack);
59
+ if (!hold) return false;
60
+ const carries = element.getAttribute(this.#attribute) === hold.written;
61
+ const owns = carries && !this.#covered(element, stack, hold);
62
+ const index = stack.indexOf(hold);
63
+ stack.splice(index, 1);
64
+ const above = stack[index];
65
+ if (above) above.linked &&= hold.linked;
66
+ this.#mark(element, stack);
67
+ if (!owns) return true;
68
+ const below = hold.linked ? stack[index - 1] : void 0;
69
+ this.#reflect(element, below ? below.written : hold.original);
70
+ return true;
71
+ }
72
+ returnAll() {
73
+ for (const element of Array.from(this.#held)) this.return(element);
74
+ }
75
+ #stack(element) {
76
+ const stacks = _AttributeLease.#registry.stacks;
77
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
78
+ stacks.set(element, byAttribute);
79
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
80
+ byAttribute.set(this.#attribute, stack);
81
+ return stack;
82
+ }
83
+ #seed(element) {
84
+ const found = [];
85
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
86
+ const read = readLeaseRecord(element, record);
87
+ if (read) found.push({ ...read, record });
88
+ }
89
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
90
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
91
+ lease: null,
92
+ record,
93
+ written,
94
+ original,
95
+ linked: linked && index > 0
96
+ }));
97
+ }
98
+ #take(stack) {
99
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
100
+ if (hold) hold.lease = this;
101
+ return hold;
102
+ }
103
+ #acquire(element, stack) {
104
+ const current = element.getAttribute(this.#attribute);
105
+ const top = stack.at(-1);
106
+ const bottom = stack[0];
107
+ const over = top?.written === current ? top : void 0;
108
+ const from = this.#base ? bottom : over;
109
+ const hold = {
110
+ lease: this,
111
+ record: this.#record,
112
+ written: current,
113
+ original: from ? from.original : current,
114
+ linked: !this.#base && over !== void 0
115
+ };
116
+ if (this.#base && bottom) {
117
+ bottom.linked = true;
118
+ stack.unshift(hold);
119
+ } else stack.push(hold);
120
+ return hold;
121
+ }
122
+ #covered(element, stack, hold) {
123
+ const current = element.getAttribute(this.#attribute);
124
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
125
+ }
126
+ #mark(element, stack) {
127
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
128
+ stack.forEach((hold, index) => {
129
+ const names = stack.slice(0, index).map((under) => under.record);
130
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
131
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
132
+ records.set(
133
+ hold.record,
134
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
135
+ );
136
+ });
137
+ for (const [name, recorded] of records) {
138
+ if (element.getAttribute(name) === recorded) continue;
139
+ if (recorded === null) element.removeAttribute(name);
140
+ else element.setAttribute(name, recorded);
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
+ };
4
149
 
5
- // src/utils/escape_layer.ts
6
150
  function claimsWhileFocusWithin(element) {
7
151
  return () => {
8
152
  const active = element.ownerDocument.activeElement;
@@ -10,17 +154,11 @@ function claimsWhileFocusWithin(element) {
10
154
  };
11
155
  }
12
156
  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
- */
157
+ static #registries = sharedRegistry(
158
+ "stimeo-ui.escape-layer.registry.v1",
159
+ () => /* @__PURE__ */ new WeakMap()
160
+ );
161
+ #active = null;
24
162
  activate(ownerDocument = document, options) {
25
163
  this.deactivate();
26
164
  let registry = _EscapeLayer.#registries.get(ownerDocument);
@@ -29,44 +167,31 @@ var EscapeLayer = class _EscapeLayer {
29
167
  _EscapeLayer.#registries.set(ownerDocument, registry);
30
168
  ownerDocument.addEventListener("keydown", registry.onKeydown);
31
169
  }
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
- */
170
+ const entry = { onDismiss: options.onDismiss, claims: options.claims ?? null };
171
+ registry.stack.push(entry);
172
+ this.#active = { document: ownerDocument, entry };
173
+ }
41
174
  deactivate() {
42
- const ownerDocument = this.#ownerDocument;
43
- if (!ownerDocument) return;
44
- const registry = _EscapeLayer.#registries.get(ownerDocument);
175
+ const active = this.#active;
176
+ if (!active) return;
177
+ const registry = _EscapeLayer.#registries.get(active.document);
45
178
  if (registry) {
46
- const index = registry.stack.lastIndexOf(this);
179
+ const index = registry.stack.lastIndexOf(active.entry);
47
180
  if (index >= 0) registry.stack.splice(index, 1);
48
181
  if (registry.stack.length === 0) {
49
- ownerDocument.removeEventListener("keydown", registry.onKeydown);
50
- _EscapeLayer.#registries.delete(ownerDocument);
182
+ active.document.removeEventListener("keydown", registry.onKeydown);
183
+ _EscapeLayer.#registries.delete(active.document);
51
184
  }
52
185
  }
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
- */
186
+ this.#active = null;
187
+ }
62
188
  get ownsEscape() {
63
- const ownerDocument = this.#ownerDocument;
64
- if (!ownerDocument) return false;
65
- const registry = _EscapeLayer.#registries.get(ownerDocument);
189
+ const active = this.#active;
190
+ if (!active) return false;
191
+ const registry = _EscapeLayer.#registries.get(active.document);
66
192
  if (!registry) return false;
67
- return _EscapeLayer.#resolveOwner(registry.stack) === this;
193
+ return _EscapeLayer.#resolveOwner(registry.stack) === active.entry;
68
194
  }
69
- /** Builds a document's registry with its shared resolver listener. */
70
195
  static #createRegistry() {
71
196
  const registry = {
72
197
  stack: [],
@@ -75,24 +200,22 @@ var EscapeLayer = class _EscapeLayer {
75
200
  const owner = _EscapeLayer.#resolveOwner(registry.stack);
76
201
  if (!owner) return;
77
202
  event.preventDefault();
78
- owner.#onDismiss?.();
203
+ owner.onDismiss();
79
204
  }
80
205
  };
81
206
  return registry;
82
207
  }
83
- /** The topmost stack layer whose claims predicate passes, or `null`. */
84
208
  static #resolveOwner(stack) {
85
209
  for (let index = stack.length - 1; index >= 0; index--) {
86
210
  const layer = stack[index];
87
211
  if (!layer) continue;
88
- if (layer.#claims && !layer.#claims()) continue;
212
+ if (layer.claims && !layer.claims()) continue;
89
213
  return layer;
90
214
  }
91
215
  return null;
92
216
  }
93
217
  };
94
218
 
95
- // src/utils/focus_candidate.ts
96
219
  function inheritsFieldsetDisabled(control) {
97
220
  let fieldset = control.closest("fieldset[disabled]");
98
221
  while (fieldset) {
@@ -102,8 +225,19 @@ function inheritsFieldsetDisabled(control) {
102
225
  }
103
226
  return false;
104
227
  }
228
+ function unslotted(element) {
229
+ return element.assignedSlot === null && element.parentElement?.shadowRoot != null;
230
+ }
231
+ function flatTreeParent(element) {
232
+ if (unslotted(element)) return null;
233
+ const parent = element.assignedSlot ?? element.parentNode;
234
+ if (parent instanceof ShadowRoot) return parent.host;
235
+ return parent instanceof Element ? parent : null;
236
+ }
105
237
  function canTakeFocus(element) {
106
- if (element.closest("[hidden], [inert]")) return false;
238
+ for (let node = element; node; node = flatTreeParent(node)) {
239
+ if (unslotted(node) || node.matches("[hidden], [inert]")) return false;
240
+ }
107
241
  if (element instanceof HTMLInputElement && element.type === "hidden") return false;
108
242
  if (!("disabled" in element)) return true;
109
243
  if (element.disabled) return false;
@@ -167,7 +301,6 @@ function firstTabStop(root) {
167
301
  return null;
168
302
  }
169
303
 
170
- // src/utils/scroll_dismiss.ts
171
304
  function observeScrollDismiss(element, onScroll) {
172
305
  const targets = [...scrollParents(element), window];
173
306
  const handler = () => onScroll();
@@ -192,7 +325,6 @@ function isScrollable(element) {
192
325
  return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
193
326
  }
194
327
 
195
- // src/utils/state_reason.ts
196
328
  var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
197
329
  var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
198
330
  function stateReasonFor(event) {
@@ -202,7 +334,6 @@ function stateReasonFor(event) {
202
334
  return "user";
203
335
  }
204
336
 
205
- // src/controllers/popover_controller.ts
206
337
  var PopoverController = class extends Controller {
207
338
  static targets = ["trigger", "panel"];
208
339
  static values = {
@@ -210,34 +341,43 @@ var PopoverController = class extends Controller {
210
341
  };
211
342
  static actions = ["close", "open", "toggle"];
212
343
  static events = ["close", "open"];
213
- /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
214
344
  #stopScrollDismiss = null;
215
- /** Escape-stack membership while open; the shared resolver dismisses via it. */
216
345
  #escapeLayer = new EscapeLayer();
217
- /** Whether state moves are reported: set once `connect()` settled the baseline. */
346
+ #expanded = new AttributeLease("aria-expanded", this.identifier);
347
+ #shownPanel = null;
348
+ #connected = false;
218
349
  #reporting = false;
219
- /** Starts closed and registers the standing dismissal listeners. */
220
350
  connect() {
221
351
  this.#close("api");
222
352
  document.addEventListener("click", this.#onOutsideClick, true);
223
353
  this.element.addEventListener("focusout", this.#onFocusOut);
354
+ this.#connected = true;
224
355
  this.#reporting = true;
225
356
  }
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
357
  disconnect() {
358
+ this.#connected = false;
233
359
  this.#reporting = false;
234
- this.#escapeLayer.deactivate();
360
+ this.#release();
235
361
  document.removeEventListener("click", this.#onOutsideClick, true);
236
362
  this.element.removeEventListener("focusout", this.#onFocusOut);
237
- this.#stopScrollDismiss?.();
238
- this.#stopScrollDismiss = null;
239
363
  }
240
- /** Toggles the popover. Bound via `data-action` (click on the trigger). */
364
+ closeOnScrollValueChanged() {
365
+ if (this.#shownPanel) this.#syncScrollDismiss();
366
+ }
367
+ panelTargetDisconnected(panel) {
368
+ if (panel !== this.#shownPanel) return;
369
+ if (this.panelTargets.includes(panel)) return;
370
+ this.#release();
371
+ panel.hidden = true;
372
+ this.#reflectClosed();
373
+ }
374
+ triggerTargetConnected() {
375
+ if (this.#connected) this.#reflectTrigger();
376
+ }
377
+ triggerTargetDisconnected(trigger) {
378
+ if (!this.triggerTargets.includes(trigger)) this.#expanded.return(trigger);
379
+ if (this.#connected) this.#reflectTrigger();
380
+ }
241
381
  toggle(event) {
242
382
  if (this.#isOpen) {
243
383
  this.close(event);
@@ -245,46 +385,54 @@ var PopoverController = class extends Controller {
245
385
  this.open(event);
246
386
  }
247
387
  }
248
- /** Opens the panel, reflects state, and moves focus inside it. */
249
388
  open(event) {
250
389
  this.#open(stateReasonFor(event));
251
390
  }
252
- /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
253
391
  close(event) {
254
392
  this.#close(stateReasonFor(event));
255
393
  }
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
394
  #open(reason) {
263
395
  if (!this.hasPanelTarget || this.#isOpen) return;
264
- this.panelTarget.hidden = false;
265
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
396
+ const panel = this.panelTarget;
397
+ panel.hidden = false;
398
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, "true");
399
+ this.#shownPanel = panel;
266
400
  if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
267
- if (!this.#isOpen) return;
401
+ if (this.#shownPanel !== panel || !this.#isOpen) return;
268
402
  this.#escapeLayer.activate(document, {
269
403
  onDismiss: () => this.#closeAndRestore(),
270
404
  claims: claimsWhileFocusWithin(this.element)
271
405
  });
272
- if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
273
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#close("scroll"));
274
- }
406
+ this.#syncScrollDismiss();
275
407
  this.#focusFirst();
276
408
  }
277
- /** Closes the panel, reflects the collapsed state, and reports a move. */
278
409
  #close(reason) {
279
410
  const was = this.#isOpen;
411
+ this.#release();
412
+ this.#reflectClosed();
413
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
414
+ }
415
+ #reflectClosed() {
416
+ if (this.hasPanelTarget) this.panelTarget.hidden = true;
417
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, "false");
418
+ }
419
+ #reflectTrigger() {
420
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, String(this.#isOpen));
421
+ }
422
+ #release() {
423
+ this.#shownPanel = null;
280
424
  this.#escapeLayer.deactivate();
281
425
  this.#stopScrollDismiss?.();
282
426
  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
427
  }
287
- /** Moves focus to the first focusable element in the panel, or the panel itself. */
428
+ #syncScrollDismiss() {
429
+ if (this.closeOnScrollValue) {
430
+ this.#stopScrollDismiss ??= observeScrollDismiss(this.element, () => this.#close("scroll"));
431
+ return;
432
+ }
433
+ this.#stopScrollDismiss?.();
434
+ this.#stopScrollDismiss = null;
435
+ }
288
436
  #focusFirst() {
289
437
  const first = firstTabStop(this.panelTarget);
290
438
  if (first) {
@@ -294,38 +442,29 @@ var PopoverController = class extends Controller {
294
442
  if (!this.panelTarget.hasAttribute("tabindex")) this.panelTarget.tabIndex = -1;
295
443
  this.panelTarget.focus();
296
444
  }
297
- /** Closes and restores focus to the trigger for explicit keyboard dismissal. */
298
445
  #closeAndRestore() {
299
446
  this.#close("escape");
300
447
  if (this.hasTriggerTarget) this.triggerTarget.focus();
301
448
  }
302
- /** Closes without moving focus when a click lands outside the controller element. */
303
449
  #onOutsideClick = (event) => {
304
450
  const target = event.target;
305
- if (this.#isOpen && target instanceof Node && !this.element.contains(target)) {
451
+ if (this.#closable && target instanceof Node && !this.element.contains(target)) {
306
452
  this.#close("outside");
307
453
  }
308
454
  };
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
455
  #onFocusOut = (event) => {
318
- if (!this.#isOpen) return;
456
+ if (!this.#closable) return;
319
457
  const next = event.relatedTarget;
320
458
  if (!(next instanceof Node) || this.element.contains(next)) return;
321
459
  this.#close("focus");
322
460
  };
323
- /** Whether the panel is currently visible. */
324
461
  get #isOpen() {
325
462
  return this.hasPanelTarget && !this.panelTarget.hidden;
326
463
  }
464
+ get #closable() {
465
+ return this.#isOpen || this.#shownPanel !== null;
466
+ }
327
467
  };
328
468
 
329
469
  export { PopoverController };
330
- //# sourceMappingURL=popover_controller.js.map
331
470
  //# sourceMappingURL=popover_controller.js.map