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/collapsible_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/state_reason.ts
6
156
  var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
7
157
  var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
8
158
  function stateReasonFor(event) {
@@ -12,16 +162,20 @@ function stateReasonFor(event) {
12
162
  return "user";
13
163
  }
14
164
 
15
- // src/utils/state_regions.ts
16
- var StateRegions = class {
165
+ var StateRegions = class _StateRegions {
166
+ static #registry = sharedRegistry(
167
+ "stimeo-ui.state-regions.registry.v1",
168
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
169
+ );
17
170
  #whenTrue;
18
171
  #whenFalse;
19
- #taken = /* @__PURE__ */ new WeakSet();
20
- constructor(options) {
172
+ #record;
173
+ constructor(options, owner) {
21
174
  this.#whenTrue = options.whenTrue;
22
175
  this.#whenFalse = options.whenFalse ?? null;
176
+ this.#record = `data-${owner}-hidden-region`;
177
+ _StateRegions.#registry.names.add(this.#record);
23
178
  }
24
- /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
25
179
  reflect(host, isTrue) {
26
180
  const shown = this.#inside(host, this.#whenTrue());
27
181
  if (!this.#whenFalse) {
@@ -37,59 +191,137 @@ var StateRegions = class {
37
191
  for (const region of shown) this.#write(region, !isTrue);
38
192
  for (const region of hiddenSide) this.#write(region, isTrue);
39
193
  }
40
- /** Writes `hidden` where it moves, noting which regions it takes out of view. */
194
+ release(host) {
195
+ for (const region of [host, ...host.querySelectorAll("*")]) {
196
+ if (!(region instanceof HTMLElement)) continue;
197
+ const stack = this.#held(region);
198
+ const hold = stack && this.#take(stack);
199
+ if (!stack || !hold) continue;
200
+ const owns = region.hidden === hold.written && !this.#covered(region, stack, hold);
201
+ const index = stack.indexOf(hold);
202
+ stack.splice(index, 1);
203
+ const above = stack[index];
204
+ if (above) above.linked &&= hold.linked;
205
+ if (owns) {
206
+ const below = hold.linked ? stack[index - 1] : void 0;
207
+ if (below) {
208
+ if (region.hidden !== below.written) region.hidden = below.written;
209
+ } else {
210
+ setAttributeIfChanged(region, "hidden", hold.authored);
211
+ }
212
+ }
213
+ this.#mark(region, stack);
214
+ }
215
+ }
41
216
  #write(region, hidden) {
42
- if (hidden) this.#taken.add(region);
43
- else this.#taken.delete(region);
44
- if (region.hidden !== hidden) region.hidden = hidden;
217
+ const stack = this.#stack(region);
218
+ const hold = this.#take(stack) ?? this.#acquire(region, stack);
219
+ const shows = !this.#covered(region, stack, hold);
220
+ hold.written = hidden;
221
+ if (shows && region.hidden !== hidden) region.hidden = hidden;
222
+ this.#mark(region, stack);
45
223
  }
46
- /** Returns a region this instance hid; one it never hid keeps what it carries. */
47
224
  #give(region) {
48
- if (this.#taken.delete(region)) region.hidden = false;
225
+ const stack = this.#held(region);
226
+ if (stack && this.#take(stack)?.written) this.#write(region, false);
227
+ }
228
+ #held(region) {
229
+ return _StateRegions.#registry.stacks.has(region) || region.hasAttribute(this.#record) ? this.#stack(region) : void 0;
230
+ }
231
+ #stack(region) {
232
+ const stacks = _StateRegions.#registry.stacks;
233
+ const stack = stacks.get(region) ?? this.#seed(region);
234
+ stacks.set(region, stack);
235
+ return stack;
236
+ }
237
+ #seed(region) {
238
+ const found = [];
239
+ for (const record of _StateRegions.#registry.names) {
240
+ const read = this.#recorded(region, record);
241
+ if (read) found.push({ ...read, record });
242
+ }
243
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
244
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, authored, written, linked }, index) => ({
245
+ instance: null,
246
+ record,
247
+ authored,
248
+ written,
249
+ linked: linked && index > 0
250
+ }));
251
+ }
252
+ #take(stack) {
253
+ const hold = stack.find((held) => held.instance === this) ?? stack.find((held) => held.instance === null && held.record === this.#record);
254
+ if (hold) hold.instance = this;
255
+ return hold;
256
+ }
257
+ #covered(region, stack, hold) {
258
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === region.hidden);
259
+ }
260
+ #acquire(region, stack) {
261
+ const top = stack.at(-1);
262
+ const over = top && region.hidden === top.written ? top : void 0;
263
+ const hold = {
264
+ instance: this,
265
+ record: this.#record,
266
+ authored: over ? over.authored : region.getAttribute("hidden"),
267
+ written: false,
268
+ linked: over !== void 0
269
+ };
270
+ stack.push(hold);
271
+ return hold;
272
+ }
273
+ #mark(region, stack) {
274
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
275
+ stack.forEach((hold, index) => {
276
+ const names = stack.slice(0, index).map((under) => under.record);
277
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
278
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
279
+ records.set(
280
+ hold.record,
281
+ region.getAttribute("hidden") === hold.authored && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.authored, hold.written, ...marker, ...beneath])
282
+ );
283
+ });
284
+ for (const [name, recorded] of records) {
285
+ setAttributeIfChanged(region, name, recorded);
286
+ }
287
+ }
288
+ #recorded(region, name) {
289
+ try {
290
+ const parsed = JSON.parse(region.getAttribute(name) ?? "null");
291
+ if (!Array.isArray(parsed)) return null;
292
+ const [authored, written, ...rest] = parsed;
293
+ if (authored !== null && typeof authored !== "string") return null;
294
+ const linked = rest[0] !== false;
295
+ const beneath = linked ? rest : rest.slice(1);
296
+ if (!beneath.every((under) => typeof under === "string")) return null;
297
+ return typeof written === "boolean" ? { authored, written, linked, beneath } : null;
298
+ } catch {
299
+ return null;
300
+ }
49
301
  }
50
- /** The declared regions that sit within `host`, which may be the host itself. */
51
302
  #inside(host, regions) {
52
303
  return regions.filter((region) => host.contains(region));
53
304
  }
54
305
  };
55
306
 
56
- // src/utils/safe_timeout.ts
57
307
  var TimerRegistry = class {
58
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
59
308
  ids = /* @__PURE__ */ new Set();
60
- /**
61
- * Cancels a single tracked timer.
62
- *
63
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
64
- * registry), so callers can clear defensively without guarding.
65
- */
66
309
  clear(id) {
67
310
  if (this.ids.delete(id)) {
68
311
  this.cancel(id);
69
312
  }
70
313
  }
71
- /**
72
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
73
- * to guarantee no timer outlives the element.
74
- */
75
314
  clearAll() {
76
315
  for (const id of this.ids) {
77
316
  this.cancel(id);
78
317
  }
79
318
  this.ids.clear();
80
319
  }
81
- /** Number of timers currently tracked (pending). */
82
320
  get size() {
83
321
  return this.ids.size;
84
322
  }
85
323
  };
86
324
  var SafeTimeout = class extends TimerRegistry {
87
- /**
88
- * Schedules `callback` after `delay` ms and returns the timer id.
89
- *
90
- * The id is removed from the registry automatically when the timeout fires,
91
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
92
- */
93
325
  set(callback, delay) {
94
326
  const id = this.schedule(() => {
95
327
  this.ids.delete(id);
@@ -106,7 +338,6 @@ var SafeTimeout = class extends TimerRegistry {
106
338
  }
107
339
  };
108
340
 
109
- // src/utils/transition_completion.ts
110
341
  function timeMs(value) {
111
342
  const trimmed = value.trim();
112
343
  const amount = Number.parseFloat(trimmed);
@@ -142,13 +373,6 @@ var TransitionCompletion = class {
142
373
  #complete = null;
143
374
  #pendingProperties = null;
144
375
  #deadline = 0;
145
- /**
146
- * Replaces any prior wait and invokes `complete` synchronously for a 0ms
147
- * transition (including when `getComputedStyle` is unavailable).
148
- *
149
- * With a positive `options.timeoutMs` the synchronous fast-path is skipped and
150
- * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
151
- */
152
376
  wait(element, complete, options = {}) {
153
377
  this.cancel();
154
378
  const requested = options.timeoutMs ?? 0;
@@ -168,18 +392,10 @@ var TransitionCompletion = class {
168
392
  element.addEventListener("transitioncancel", this.#onTerminal);
169
393
  this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
170
394
  }
171
- /** Cancels the pending wait without invoking its completion callback. */
172
395
  cancel() {
173
396
  this.#complete = null;
174
397
  this.#teardown();
175
398
  }
176
- /**
177
- * Handles terminal events from the observed element only.
178
- *
179
- * For explicit property lists, every declared positive-time property must
180
- * settle. For `all`, no reliable property set exists, so an event can finish
181
- * only after the computed maximum time; the safety timeout owns the usual path.
182
- */
183
399
  #onTerminal = (event) => {
184
400
  if (event.target !== this.#element) return;
185
401
  const transitionEvent = event;
@@ -199,10 +415,6 @@ var TransitionCompletion = class {
199
415
  }
200
416
  if (Date.now() >= this.#deadline) this.#finish();
201
417
  };
202
- /**
203
- * Returns active CSS transition properties expanded to the names reported by
204
- * terminal events. CSS animations and pseudo-element effects are excluded.
205
- */
206
418
  #activeTransitionProperties(element) {
207
419
  if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
208
420
  try {
@@ -220,7 +432,6 @@ var TransitionCompletion = class {
220
432
  return /* @__PURE__ */ new Set();
221
433
  }
222
434
  }
223
- /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
224
435
  #explicitPendingProperties(timings) {
225
436
  if (timings.some(({ property }) => property === "all")) return null;
226
437
  const pending = new Set(
@@ -228,7 +439,6 @@ var TransitionCompletion = class {
228
439
  );
229
440
  return pending.size > 0 ? pending : null;
230
441
  }
231
- /** Completes exactly once, releasing listeners and the fallback before the callback. */
232
442
  #finish() {
233
443
  const complete = this.#complete;
234
444
  if (!complete) return;
@@ -236,7 +446,6 @@ var TransitionCompletion = class {
236
446
  this.#teardown();
237
447
  complete();
238
448
  }
239
- /** Releases the exact element listeners and timer owned by the current wait. */
240
449
  #teardown() {
241
450
  this.#timers.clearAll();
242
451
  this.#element?.removeEventListener("transitionend", this.#onTerminal);
@@ -247,7 +456,6 @@ var TransitionCompletion = class {
247
456
  }
248
457
  };
249
458
 
250
- // src/controllers/collapsible_controller.ts
251
459
  var CollapsibleController = class extends Controller {
252
460
  static targets = ["trigger", "content", "expandedLabel", "collapsedLabel"];
253
461
  static values = {
@@ -255,35 +463,24 @@ var CollapsibleController = class extends Controller {
255
463
  };
256
464
  static actions = ["toggle"];
257
465
  static events = ["close", "open"];
258
- /** Owns the cancellable close-transition wait and its bounded fallback. */
466
+ #expanded = new AttributeLease("aria-expanded", this.identifier);
467
+ #contentHidden = new AttributeLease("hidden", this.identifier);
468
+ #contentState = new AttributeLease("data-state", this.identifier);
259
469
  #transition = new TransitionCompletion();
260
- /** Owns `hidden` on the trigger's label pair, which follows `aria-expanded`. */
261
- #labels = new StateRegions({
262
- whenTrue: () => this.expandedLabelTargets,
263
- whenFalse: () => this.collapsedLabelTargets
264
- });
265
- /** Distinguishes dynamic target churn from the callbacks that precede `connect()`. */
470
+ #labels = new StateRegions(
471
+ {
472
+ whenTrue: () => this.expandedLabelTargets,
473
+ whenFalse: () => this.collapsedLabelTargets
474
+ },
475
+ this.identifier
476
+ );
266
477
  #connected = false;
267
- /** Whether state moves are reported: set once `connect()` settled the baseline. */
268
478
  #reporting = false;
269
- /**
270
- * Establishes the initial open/closed state without waiting for a close transition.
271
- *
272
- * The DOM is the source of truth on reconnect (Turbo cache restore / morph): an
273
- * **explicit** state attribute — `aria-expanded="true"`/`"false"` (or, with no
274
- * trigger, `data-state="open"`/`"closed"`) — is honored verbatim so a region the
275
- * user opened *or* closed survives a back-navigation, even when the declarative
276
- * `open` Value disagrees. An already-open region remains open without a
277
- * close/reopen cycle. The Value only seeds a genuinely fresh render where no
278
- * state attribute is present yet; any opening animation in that case belongs to
279
- * the consumer's CSS.
280
- */
281
479
  connect() {
282
480
  this.#connected = true;
283
481
  this.#apply(this.#initialOpen(), false, "api");
284
482
  this.#reporting = true;
285
483
  }
286
- /** Resolves the connect-time state: explicit DOM state wins, else the `open` Value. */
287
484
  #initialOpen() {
288
485
  if (this.hasTriggerTarget) {
289
486
  const expanded = this.triggerTarget.getAttribute("aria-expanded");
@@ -301,72 +498,66 @@ var CollapsibleController = class extends Controller {
301
498
  this.#reporting = false;
302
499
  this.#transition.cancel();
303
500
  }
304
- /** Reconciles a replacement trigger target with the content's live state. */
305
501
  triggerTargetConnected(trigger) {
306
- if (!this.#connected) return;
502
+ if (this.#connected) this.#reconcileTrigger(trigger);
503
+ }
504
+ triggerTargetDisconnected(trigger) {
505
+ if (!this.triggerTargets.includes(trigger)) {
506
+ this.#expanded.return(trigger);
507
+ this.#labels.release(trigger);
508
+ }
509
+ if (this.#connected && this.hasTriggerTarget) this.#reconcileTrigger(this.triggerTarget);
510
+ }
511
+ #reconcileTrigger(trigger) {
307
512
  if (this.hasContentTarget) {
308
- trigger.setAttribute(
309
- "aria-expanded",
513
+ this.#expanded.write(
514
+ trigger,
310
515
  this.contentTarget.getAttribute("data-state") === "open" ? "true" : "false"
311
516
  );
312
517
  }
313
518
  this.#labels.reflect(trigger, trigger.getAttribute("aria-expanded") === "true");
314
519
  }
315
- /** Settles a label that arrived after the state it belongs to was written. */
316
520
  expandedLabelTargetConnected() {
317
521
  this.#reflectLabels();
318
522
  }
319
- /** Settles a label that arrived after the state it belongs to was written. */
320
523
  collapsedLabelTargetConnected() {
321
524
  this.#reflectLabels();
322
525
  }
323
- /** Shows the label pair's side that belongs to the trigger's expanded state. */
324
526
  #reflectLabels() {
325
527
  if (this.#connected && this.hasTriggerTarget) {
326
528
  this.#labels.reflect(this.triggerTarget, this.#isOpen);
327
529
  }
328
530
  }
329
- /** Reconciles a replacement content target with the disclosure's live state. */
330
531
  contentTargetConnected(content) {
331
532
  if (!this.#connected) return;
332
533
  this.#transition.cancel();
333
- const open = this.hasTriggerTarget ? this.triggerTarget.getAttribute("aria-expanded") === "true" : content.getAttribute("data-state") === "open";
334
- this.#applyContent(content, open, false);
534
+ this.#reconcileContent(content);
335
535
  }
336
- /** Cancels a wait tied to a content target that was removed or replaced. */
337
- contentTargetDisconnected() {
536
+ contentTargetDisconnected(content) {
338
537
  this.#transition.cancel();
538
+ if (!this.contentTargets.includes(content)) {
539
+ this.#contentHidden.return(content);
540
+ this.#contentState.return(content);
541
+ }
542
+ if (this.#connected && this.hasContentTarget) this.#reconcileContent(this.contentTarget);
543
+ }
544
+ #reconcileContent(content) {
545
+ const open = this.hasTriggerTarget ? this.triggerTarget.getAttribute("aria-expanded") === "true" : content.getAttribute("data-state") === "open";
546
+ this.#applyContent(content, open, false);
339
547
  }
340
- /** Toggles the region open/closed. Bound via `data-action` (click). */
341
548
  toggle(event) {
342
549
  this.#apply(!this.#isOpen, true, stateReasonFor(event));
343
550
  }
344
- /**
345
- * Whether the region is logically open. Read from `aria-expanded` (not the
346
- * content's `hidden`) so a click *during* a close transition — when `hidden`
347
- * is still deferred — correctly reopens instead of re-closing.
348
- */
349
551
  get #isOpen() {
350
552
  if (this.hasTriggerTarget) {
351
553
  return this.triggerTarget.getAttribute("aria-expanded") === "true";
352
554
  }
353
555
  return this.hasContentTarget && this.contentTarget.getAttribute("data-state") === "open";
354
556
  }
355
- /**
356
- * Drives the disclosure to `open`, syncing the trigger's `aria-expanded` and
357
- * the content's `hidden` / `data-state` / height variable in an order where
358
- * `hidden` never blocks measurement or the transition.
359
- *
360
- * @param open - Target state.
361
- * @param waitForCloseTransition - When `false` (initial `connect`) the close
362
- * path applies `hidden` immediately. This flag does not suppress consumer CSS
363
- * on the open path.
364
- * @param reason - What drove the move, reported when the state actually moved.
365
- */
366
557
  #apply(open, waitForCloseTransition, reason) {
367
558
  const was = this.#isOpen;
368
559
  if (this.hasTriggerTarget) {
369
- this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false");
560
+ this.#expanded.write(this.triggerTarget, open ? "true" : "false");
370
561
  this.#labels.reflect(this.triggerTarget, open);
371
562
  }
372
563
  if (this.hasContentTarget) {
@@ -378,38 +569,31 @@ var CollapsibleController = class extends Controller {
378
569
  if (open) this.dispatch("open", { detail: { reason }, cancelable: false });
379
570
  else this.dispatch("close", { detail: { reason }, cancelable: false });
380
571
  }
381
- /** Reflects one content target without relying on a later target lookup. */
382
572
  #applyContent(content, open, waitForCloseTransition) {
383
573
  if (open) {
384
- content.hidden = false;
574
+ this.#contentHidden.write(content, null);
385
575
  content.style.setProperty(
386
576
  "--stimeo--collapsible-content-height",
387
577
  `${content.scrollHeight}px`
388
578
  );
389
- content.setAttribute("data-state", "open");
579
+ this.#contentState.write(content, "open");
390
580
  return;
391
581
  }
392
- content.setAttribute("data-state", "closed");
582
+ this.#contentState.write(content, "closed");
393
583
  if (waitForCloseTransition) {
394
584
  this.#applyHiddenAfterTransition(content);
395
585
  } else {
396
- content.hidden = true;
586
+ this.#contentHidden.write(content, "");
397
587
  }
398
588
  }
399
- /**
400
- * Re-applies `hidden` once the close transition settles. Guarded against a
401
- * reopen mid-transition; the shared waiter also guarantees a bounded fallback
402
- * when the browser emits no terminal transition event.
403
- */
404
589
  #applyHiddenAfterTransition(content) {
405
590
  this.#transition.wait(content, () => {
406
591
  if (content.getAttribute("data-state") === "closed") {
407
- content.hidden = true;
592
+ this.#contentHidden.write(content, "");
408
593
  }
409
594
  });
410
595
  }
411
596
  };
412
597
 
413
598
  export { CollapsibleController };
414
- //# sourceMappingURL=collapsible_controller.js.map
415
599
  //# sourceMappingURL=collapsible_controller.js.map