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,30 +1,167 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/scroll_visibility_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/blur_deferral.ts
6
150
  var BlurDeferral = class {
7
- /** Elements currently holding an update back, mapped to their `blur` listener. */
8
151
  #pending = /* @__PURE__ */ new Map();
9
- /** Called after a pending element blurs and has been detached. */
10
152
  #onRelease;
11
- /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
12
153
  constructor(onRelease) {
13
154
  this.#onRelease = onRelease;
14
155
  }
15
- /** Number of elements currently holding an update back. */
16
156
  get size() {
17
157
  return this.#pending.size;
18
158
  }
19
- /** Snapshot of the pending elements, safe to iterate while releasing them. */
20
159
  get elements() {
21
160
  return [...this.#pending.keys()];
22
161
  }
23
- /** Whether `element` is currently holding an update back. */
24
162
  has(element) {
25
163
  return this.#pending.has(element);
26
164
  }
27
- /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
28
165
  defer(element) {
29
166
  if (this.#pending.has(element)) return;
30
167
  const onBlur = () => {
@@ -34,22 +171,18 @@ var BlurDeferral = class {
34
171
  this.#pending.set(element, onBlur);
35
172
  element.addEventListener("blur", onBlur);
36
173
  }
37
- /** Defers `element` as the only pending entry, cancelling any others. */
38
174
  deferOnly(element) {
39
175
  for (const pending of this.elements) {
40
176
  if (pending !== element) this.#detach(pending);
41
177
  }
42
178
  this.defer(element);
43
179
  }
44
- /** Cancels `element`'s deferral without completing it; no-ops when not pending. */
45
180
  release(element) {
46
181
  this.#detach(element);
47
182
  }
48
- /** Cancels every deferral without completing any of them. */
49
183
  releaseAll() {
50
184
  for (const element of this.elements) this.#detach(element);
51
185
  }
52
- /** Removes the `blur` listener for `element` and forgets it. */
53
186
  #detach(element) {
54
187
  const onBlur = this.#pending.get(element);
55
188
  if (onBlur) element.removeEventListener("blur", onBlur);
@@ -57,7 +190,6 @@ var BlurDeferral = class {
57
190
  }
58
191
  };
59
192
 
60
- // src/utils/declared_value.ts
61
193
  function parseDeclared(raw, parse, fallback) {
62
194
  try {
63
195
  return parse(raw);
@@ -77,14 +209,8 @@ function validSelector(element, raw, fallback) {
77
209
  );
78
210
  }
79
211
 
80
- // src/utils/frame_coalescer.ts
81
212
  var FrameCoalescer = class {
82
213
  #frame = null;
83
- /**
84
- * Runs `run` on the next frame, unless a frame is already pending — the first
85
- * request of a burst wins and the rest are dropped. The pending frame is
86
- * released before `run`, so `run` may request the next one.
87
- */
88
214
  schedule(run) {
89
215
  if (this.#frame !== null) return;
90
216
  this.#frame = requestAnimationFrame(() => {
@@ -92,14 +218,6 @@ var FrameCoalescer = class {
92
218
  run();
93
219
  });
94
220
  }
95
- /**
96
- * Drops the pending frame, and reaches the platform only when there is one.
97
- *
98
- * There is no handle value that stands for "nothing pending":
99
- * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
100
- * arrives as a large positive number that the same allocator can hand out, and
101
- * an idle cancel would then drop a frame belonging to someone else.
102
- */
103
221
  cancel() {
104
222
  if (this.#frame === null) return;
105
223
  cancelAnimationFrame(this.#frame);
@@ -107,12 +225,118 @@ var FrameCoalescer = class {
107
225
  }
108
226
  };
109
227
 
110
- // src/utils/reduced_motion.ts
228
+ var ListenerSet = class {
229
+ #abort = new AbortController();
230
+ add(target, type, handler, options) {
231
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
232
+ }
233
+ dispose() {
234
+ this.#abort.abort();
235
+ this.#abort = new AbortController();
236
+ }
237
+ };
238
+
239
+ var MicrotaskCoalescer = class {
240
+ #run;
241
+ #queued = false;
242
+ #active = false;
243
+ #generation = 0;
244
+ constructor(run) {
245
+ this.#run = run;
246
+ }
247
+ activate() {
248
+ this.#active = true;
249
+ }
250
+ cancel() {
251
+ this.#active = false;
252
+ this.#queued = false;
253
+ this.#generation += 1;
254
+ }
255
+ schedule() {
256
+ if (!this.#active || this.#queued) return;
257
+ this.#queued = true;
258
+ const generation = this.#generation;
259
+ queueMicrotask(() => {
260
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
261
+ this.#queued = false;
262
+ this.#run();
263
+ });
264
+ }
265
+ };
266
+
267
+ var MorphRenderWatcher = class {
268
+ #listeners = new ListenerSet();
269
+ #pass;
270
+ #element = null;
271
+ #onMorph = () => this.#pass.schedule();
272
+ constructor(run) {
273
+ this.#pass = new MicrotaskCoalescer(run);
274
+ }
275
+ observe(element) {
276
+ if (this.#element === element) return;
277
+ this.disconnect();
278
+ this.#element = element;
279
+ this.#pass.activate();
280
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
281
+ }
282
+ schedule() {
283
+ this.#pass.schedule();
284
+ }
285
+ disconnect() {
286
+ this.#listeners.dispose();
287
+ this.#pass.cancel();
288
+ this.#element = null;
289
+ }
290
+ };
291
+
292
+ var NUMBER_BOUNDS = {
293
+ finite: { finite: true }};
294
+ function matchesNumberBounds(value, bounds) {
295
+ if (!Number.isFinite(value)) {
296
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
297
+ if (direction === null) return false;
298
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
299
+ }
300
+ if (bounds.min !== void 0 && value < bounds.min) return false;
301
+ if (bounds.max !== void 0 && value > bounds.max) return false;
302
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
303
+ if (bounds.integer && !Number.isInteger(value)) return false;
304
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
305
+ return true;
306
+ }
307
+
308
+ function readNumber(raw, fallback, bounds) {
309
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
310
+ }
311
+
312
+ var NumberValueReader = class {
313
+ #lastRejected = /* @__PURE__ */ new Map();
314
+ read(owner, name, raw, fallback, bounds) {
315
+ const resolved = readNumber(raw, fallback, bounds);
316
+ if (matchesNumberBounds(raw, bounds)) {
317
+ this.#lastRejected.delete(name);
318
+ return resolved;
319
+ }
320
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
321
+ const literal = owner.element.getAttribute(attribute);
322
+ if (literal === null) {
323
+ this.#lastRejected.delete(name);
324
+ return resolved;
325
+ }
326
+ if (this.#lastRejected.get(name) !== literal) {
327
+ this.#lastRejected.set(name, literal);
328
+ console.warn(
329
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
330
+ );
331
+ }
332
+ return resolved;
333
+ }
334
+ };
335
+
111
336
  function prefersReducedMotion() {
112
337
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
113
338
  }
114
339
 
115
- // src/utils/scroll_source.ts
116
340
  function resolveScrollContainer(selector) {
117
341
  const match = selector ? document.querySelector(selector) : null;
118
342
  return match instanceof HTMLElement ? match : null;
@@ -124,69 +348,114 @@ function scrollOffset(source) {
124
348
  return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
125
349
  }
126
350
 
127
- // src/utils/before_cache_reset.ts
128
- var BeforeCacheReset = class _BeforeCacheReset {
129
- /** Every subscribed instance, iterated by the one shared document listener. */
130
- static #subscribers = /* @__PURE__ */ new Set();
131
- /** The shared listener; installed while at least one instance is subscribed. */
132
- static #onBeforeCache = () => {
133
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
134
- };
135
- #rewind;
136
- /** @param rewind - the pass that returns this controller's state to its initial form. */
137
- constructor(rewind) {
138
- this.#rewind = rewind;
139
- }
140
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
141
- activate() {
142
- const first = _BeforeCacheReset.#subscribers.size === 0;
143
- _BeforeCacheReset.#subscribers.add(this);
144
- if (first) {
145
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
351
+ function queryOpenDescendants(root, selector) {
352
+ const matches = [];
353
+ const roots = [root];
354
+ for (const scope of roots) {
355
+ matches.push(...scope.querySelectorAll(selector));
356
+ if (scope.nodeType === Node.ELEMENT_NODE) {
357
+ const shadow = scope.shadowRoot;
358
+ if (shadow) roots.push(shadow);
359
+ }
360
+ for (const element of scope.querySelectorAll("*")) {
361
+ if (element.shadowRoot) roots.push(element.shadowRoot);
146
362
  }
147
363
  }
148
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
149
- deactivate() {
150
- _BeforeCacheReset.#subscribers.delete(this);
151
- if (_BeforeCacheReset.#subscribers.size > 0) return;
152
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
153
- }
154
- };
364
+ return matches;
365
+ }
155
366
 
156
- // src/utils/tabindex_loan.ts
157
- var TabindexLoan = class {
367
+ var TabindexLoan = class _TabindexLoan {
368
+ static #registry = sharedRegistry(
369
+ "stimeo-ui.tabindex-loan.registry.v1",
370
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
371
+ );
158
372
  #value;
373
+ #record;
159
374
  #lent = /* @__PURE__ */ new Set();
160
- /** Returns live loans before Turbo can copy them into its page snapshot. */
161
- #beforeCache = new BeforeCacheReset(() => this.returnAll());
162
- /**
163
- * @param value - the `tabindex` to lend. `"-1"` (the default) is
164
- * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
165
- * which a scroll region with no focusable content of its own needs.
166
- */
167
- constructor(value = "-1") {
375
+ constructor(value, owner) {
168
376
  this.#value = value;
377
+ this.#record = `data-${owner}-tabindex-loan`;
378
+ _TabindexLoan.#registry.names.add(this.#record);
169
379
  }
170
- /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
171
380
  lend(element) {
172
- if (element.hasAttribute("tabindex")) return;
173
- element.setAttribute("tabindex", this.#value);
381
+ const stack = this.#stack(element);
382
+ const current = element.getAttribute("tabindex");
383
+ const held = stack.find((hold) => hold.loan === this) ?? stack.find(
384
+ (hold) => hold.loan === null && hold.record === this.#record && hold.value === this.#value
385
+ );
386
+ const linked = stack.at(-1)?.value === current;
387
+ if (!held && current !== null && !linked) return;
174
388
  this.#lent.add(element);
175
- this.#beforeCache.activate();
389
+ if (held) held.loan = this;
390
+ else stack.push({ loan: this, record: this.#record, value: this.#value, linked });
391
+ this.#mark(element, stack);
392
+ if (current === null || !held && current !== this.#value) {
393
+ element.setAttribute("tabindex", this.#value);
394
+ }
176
395
  }
177
- /** Takes back every loan whose value is still the one that was lent. */
178
396
  returnAll() {
179
397
  for (const element of this.#lent) {
180
- if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
398
+ const stack = this.#stack(element);
399
+ const hold = stack.find((held) => held.loan === this);
400
+ if (hold) this.#release(element, stack, hold);
181
401
  }
182
402
  this.#lent.clear();
183
- this.#beforeCache.deactivate();
403
+ }
404
+ reclaim(element) {
405
+ if (element.getAttribute(this.#record) !== this.#value) return;
406
+ const stack = this.#stack(element);
407
+ const hold = stack.find((held) => held.loan === null && held.record === this.#record);
408
+ if (hold && !element.matches(":focus")) this.#release(element, stack, hold);
409
+ }
410
+ reclaimWithin(root) {
411
+ for (const element of queryOpenDescendants(root, `[${this.#record}]`)) {
412
+ this.reclaim(element);
413
+ }
414
+ }
415
+ #stack(element) {
416
+ const stacks = _TabindexLoan.#registry.stacks;
417
+ const stack = stacks.get(element) ?? this.#seed(element);
418
+ stacks.set(element, stack);
419
+ return stack;
420
+ }
421
+ #seed(element) {
422
+ const current = element.getAttribute("tabindex");
423
+ const holds = [];
424
+ for (const record of _TabindexLoan.#registry.names) {
425
+ const value = element.getAttribute(record);
426
+ if (value !== null) holds.push({ loan: null, record, value, linked: true });
427
+ }
428
+ holds.sort((one, other) => Number(one.value === current) - Number(other.value === current));
429
+ for (const [index, hold] of holds.entries()) hold.linked = index > 0;
430
+ return holds;
431
+ }
432
+ #release(element, stack, hold) {
433
+ const current = element.getAttribute("tabindex");
434
+ const index = stack.indexOf(hold);
435
+ const owns = current === hold.value && !stack.slice(index + 1).some((above2) => above2.value === current);
436
+ stack.splice(index, 1);
437
+ const above = stack[index];
438
+ if (above) above.linked &&= hold.linked;
439
+ this.#mark(element, stack);
440
+ if (!owns) return;
441
+ const below = hold.linked ? stack[index - 1] : void 0;
442
+ if (!below) element.removeAttribute("tabindex");
443
+ else if (below.value !== current) element.setAttribute("tabindex", below.value);
444
+ }
445
+ #mark(element, stack) {
446
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
447
+ for (const hold of stack) records.set(hold.record, hold.value);
448
+ for (const [name, recorded] of records) {
449
+ if (element.getAttribute(name) === recorded) continue;
450
+ if (recorded === null) element.removeAttribute(name);
451
+ else element.setAttribute(name, recorded);
452
+ }
184
453
  }
185
454
  };
186
455
 
187
- // src/controllers/scroll_visibility_controller.ts
188
456
  var DEFAULT_OFFSET = 400;
189
- var ScrollVisibilityController = class extends Controller {
457
+ var ScrollVisibilityController = class _ScrollVisibilityController extends Controller {
458
+ #numbers = new NumberValueReader();
190
459
  static targets = ["element"];
191
460
  static values = {
192
461
  offset: { type: Number, default: DEFAULT_OFFSET },
@@ -194,54 +463,38 @@ var ScrollVisibilityController = class extends Controller {
194
463
  focusSelector: { type: String, default: "" },
195
464
  root: { type: String, default: "" }
196
465
  };
466
+ static valueConstraints = {
467
+ offset: NUMBER_BOUNDS.finite
468
+ };
197
469
  static actions = ["toTop"];
198
470
  static events = ["change"];
199
- /** Coalesces scroll bursts into one measurement per frame. */
200
471
  #frames = new FrameCoalescer();
201
- /** Previous scroll position, for `direction` mode delta detection. */
202
472
  #lastScrollY = 0;
203
- /** Current visibility, tracked to dispatch `change` only on real transitions. */
204
473
  #visible = null;
205
- /**
206
- * The observed scroll source: a container element when `root` resolves, else
207
- * the window. Captured on connect so teardown detaches from the same source.
208
- */
209
474
  #scrollSource = window;
210
- /**
211
- * Gates the declaration callbacks to the connected window.
212
- *
213
- * Stimulus delivers a Value callback ahead of `connect()` and again for every
214
- * runtime change; without the gate, merely connecting would evaluate — and
215
- * announce — before `connect()` runs its own first reflection.
216
- */
217
475
  #connected = false;
218
- /** Validated threshold; a non-finite declaration reads as the default. */
219
476
  #offset = DEFAULT_OFFSET;
220
- /** Validated `root` selector; an unparsable declaration reads as absent. */
221
477
  #rootSelector = "";
222
- /** Validated `focusSelector`; an unparsable declaration reads as absent. */
223
478
  #focusSelector = "";
224
- /** Focus targets this instance lent a `tabindex` to. */
225
- #tabindex = new TabindexLoan();
226
- /**
227
- * The hide held back while the control itself owns focus.
228
- *
229
- * Completing the deferral re-runs the ordinary evaluation rather than applying
230
- * the stale decision: by the time focus leaves, the scroll position may have
231
- * moved back past the threshold.
232
- */
479
+ #tabindex = new TabindexLoan("-1", this.identifier);
480
+ #hidden = new AttributeLease("hidden", this.identifier);
233
481
  #pendingHide = new BlurDeferral(() => {
234
482
  if (this.#connected) this.#evaluate();
235
483
  });
236
484
  #onScroll = () => this.#frames.schedule(() => this.#evaluate());
485
+ #morphRender = new MorphRenderWatcher(() => {
486
+ this.#followSource();
487
+ this.#evaluate();
488
+ });
237
489
  connect() {
238
- this.#scrollSource = resolveScrollSource(this.#rootSelector);
239
- this.#lastScrollY = scrollOffset(this.#scrollSource);
240
- this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
490
+ this.#tabindex.reclaimWithin(document);
491
+ this.#morphRender.observe(this.element);
492
+ this.#listenTo(resolveScrollSource(this.#rootSelector));
241
493
  this.#evaluate(false);
242
494
  this.#connected = true;
243
495
  }
244
496
  disconnect() {
497
+ this.#morphRender.disconnect();
245
498
  this.#connected = false;
246
499
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
247
500
  this.#frames.cancel();
@@ -249,40 +502,30 @@ var ScrollVisibilityController = class extends Controller {
249
502
  this.#tabindex.returnAll();
250
503
  this.#visible = null;
251
504
  }
252
- /** Writes the current visibility onto a control that arrives after connect. */
253
505
  elementTargetConnected(element) {
254
- if (this.#visible !== null) element.hidden = !this.#visible;
506
+ if (this.#visible !== null) this.#show(element, this.#visible);
255
507
  }
256
- /** Drops a held-back hide together with the control it was waiting on. */
257
- elementTargetDisconnected() {
508
+ elementTargetDisconnected(element) {
258
509
  this.#pendingHide.releaseAll();
510
+ if (!this.elementTargets.includes(element)) this.#hidden.return(element);
511
+ if (this.#visible !== null && this.hasElementTarget) {
512
+ this.#show(this.elementTarget, this.#visible);
513
+ }
259
514
  }
260
- /**
261
- * Validates `offset` once, then re-renders.
262
- *
263
- * Re-renders when application code (or a Turbo morph) changes `offset` at
264
- * runtime. A declaration that is not a finite number reads as the default, so
265
- * the comparison path never sees `NaN` — which would answer `false` to every
266
- * comparison and strand the element (in `direction` mode, even the guarantee
267
- * that the very top always reveals).
268
- */
269
515
  offsetValueChanged() {
270
- this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
271
- if (this.#connected) this.#evaluate();
516
+ this.#offset = this.#safeOffset;
517
+ this.#morphRender.schedule();
272
518
  }
273
- /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */
274
519
  modeValueChanged() {
275
- if (this.#connected) this.#evaluate();
520
+ this.#morphRender.schedule();
276
521
  }
277
- /** Validates `root` once so connect never parses a selector that throws. */
278
522
  rootValueChanged() {
279
523
  this.#rootSelector = validSelector(this.element, this.rootValue, "");
524
+ this.#morphRender.schedule();
280
525
  }
281
- /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
282
526
  focusSelectorValueChanged() {
283
527
  this.#focusSelector = validSelector(this.element, this.focusSelectorValue, "");
284
528
  }
285
- /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
286
529
  toTop() {
287
530
  const behavior = prefersReducedMotion() ? "instant" : "smooth";
288
531
  this.#scrollSource.scrollTo({ top: 0, behavior });
@@ -294,14 +537,17 @@ var ScrollVisibilityController = class extends Controller {
294
537
  }
295
538
  }
296
539
  }
297
- /**
298
- * Decides the next visibility from the current scroll state and applies it.
299
- *
300
- * @param notify - whether a transition announces itself. The reflection
301
- * `connect()` performs is the current state, not a change.
302
- *
303
- * @stimeoRenderRoot
304
- */
540
+ #followSource() {
541
+ const source = resolveScrollSource(this.#rootSelector);
542
+ if (source === this.#scrollSource) return;
543
+ this.#scrollSource.removeEventListener("scroll", this.#onScroll);
544
+ this.#listenTo(source);
545
+ }
546
+ #listenTo(source) {
547
+ this.#scrollSource = source;
548
+ this.#lastScrollY = scrollOffset(source);
549
+ source.addEventListener("scroll", this.#onScroll, { passive: true });
550
+ }
305
551
  #evaluate(notify = true) {
306
552
  const y = scrollOffset(this.#scrollSource);
307
553
  let nextVisible;
@@ -309,6 +555,7 @@ var ScrollVisibilityController = class extends Controller {
309
555
  if (y <= this.#offset) {
310
556
  nextVisible = true;
311
557
  } else if (y === this.#lastScrollY) {
558
+ this.#setVisible(this.#visible ?? true, notify);
312
559
  return;
313
560
  } else {
314
561
  nextVisible = y < this.#lastScrollY;
@@ -319,33 +566,36 @@ var ScrollVisibilityController = class extends Controller {
319
566
  this.#lastScrollY = y;
320
567
  this.#setVisible(nextVisible, notify);
321
568
  }
322
- /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
323
569
  #setVisible(next, notify) {
324
- if (next === this.#visible) return;
570
+ const changed = next !== this.#visible;
325
571
  const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;
326
572
  if (focused) {
327
573
  this.#pendingHide.deferOnly(focused);
328
574
  return;
329
575
  }
330
576
  this.#visible = next;
331
- if (this.hasElementTarget) this.elementTarget.hidden = !next;
577
+ if (this.hasElementTarget) this.#show(this.elementTarget, next);
332
578
  this.element.setAttribute("data-state", next ? "visible" : "hidden");
333
- if (notify) this.dispatch("change", { detail: { visible: next } });
334
- }
335
- /**
336
- * The focus owner inside the target, or `null` when focus is elsewhere.
337
- *
338
- * `blur` does not bubble, so the deferral has to ride the focused element
339
- * itself: waiting on a container that never receives the event would hold the
340
- * hide forever.
341
- */
579
+ if (notify && changed) this.dispatch("change", { detail: { visible: next } });
580
+ }
581
+ #show(control, visible) {
582
+ this.#hidden.write(control, visible ? null : "");
583
+ }
342
584
  #focusedWithin() {
343
585
  const focused = document.activeElement;
344
586
  if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
345
587
  return null;
346
588
  }
589
+ get #safeOffset() {
590
+ return this.#numbers.read(
591
+ this,
592
+ "offset",
593
+ this.offsetValue,
594
+ _ScrollVisibilityController.values.offset.default,
595
+ _ScrollVisibilityController.valueConstraints.offset
596
+ );
597
+ }
347
598
  };
348
599
 
349
600
  export { ScrollVisibilityController };
350
- //# sourceMappingURL=scroll_visibility_controller.js.map
351
601
  //# sourceMappingURL=scroll_visibility_controller.js.map