stimeo-ui 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +209 -0
  3. data/README.md +128 -1
  4. data/dist/cable/index.js +392 -417
  5. data/dist/controllers/accordion_controller.js +228 -28
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -191
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +295 -110
  10. data/dist/controllers/avatar_controller.js +172 -106
  11. data/dist/controllers/breadcrumb_controller.js +0 -129
  12. data/dist/controllers/bulk_select_controller.js +264 -53
  13. data/dist/controllers/calendar_controller.js +346 -180
  14. data/dist/controllers/carousel_controller.js +587 -300
  15. data/dist/controllers/character_counter_controller.js +271 -143
  16. data/dist/controllers/checkbox_controller.js +77 -75
  17. data/dist/controllers/clipboard_controller.js +187 -108
  18. data/dist/controllers/collapsible_controller.js +374 -108
  19. data/dist/controllers/color_picker_controller.js +183 -127
  20. data/dist/controllers/combobox_controller.js +426 -109
  21. data/dist/controllers/command_palette_controller.js +994 -350
  22. data/dist/controllers/conditional_fields_controller.js +212 -125
  23. data/dist/controllers/confirm_controller.js +939 -245
  24. data/dist/controllers/context_menu_controller.js +275 -127
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +235 -97
  27. data/dist/controllers/currency_input_controller.js +241 -118
  28. data/dist/controllers/data_grid_controller.js +229 -135
  29. data/dist/controllers/date_range_picker_controller.js +359 -165
  30. data/dist/controllers/dialog_controller.js +887 -225
  31. data/dist/controllers/direct_upload_controller.js +80 -142
  32. data/dist/controllers/dirty_form_controller.js +26 -47
  33. data/dist/controllers/dismissible_controller.js +175 -20
  34. data/dist/controllers/drawer_controller.js +963 -336
  35. data/dist/controllers/dropdown_controller.js +342 -85
  36. data/dist/controllers/editable_controller.js +182 -58
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +426 -229
  39. data/dist/controllers/filter_controller.js +78 -36
  40. data/dist/controllers/flash_controller.js +328 -211
  41. data/dist/controllers/focus_controller.js +654 -239
  42. data/dist/controllers/form_field_controller.js +153 -136
  43. data/dist/controllers/form_validation_controller.js +29 -96
  44. data/dist/controllers/frame_loading_controller.js +263 -198
  45. data/dist/controllers/highlight_controller.js +119 -70
  46. data/dist/controllers/hover_card_controller.js +335 -118
  47. data/dist/controllers/idle_controller.js +316 -65
  48. data/dist/controllers/input_mask_controller.js +116 -61
  49. data/dist/controllers/intersection_controller.js +147 -103
  50. data/dist/controllers/lazy_frame_controller.js +9 -56
  51. data/dist/controllers/listbox_controller.js +413 -153
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -95
  54. data/dist/controllers/menu_controller.js +254 -167
  55. data/dist/controllers/menubar_controller.js +206 -331
  56. data/dist/controllers/meter_controller.js +159 -71
  57. data/dist/controllers/multi_select_controller.js +797 -388
  58. data/dist/controllers/navigation_menu_controller.js +156 -231
  59. data/dist/controllers/nested_form_controller.js +373 -174
  60. data/dist/controllers/network_status_controller.js +241 -45
  61. data/dist/controllers/number_input_controller.js +387 -227
  62. data/dist/controllers/optimistic_controller.js +28 -75
  63. data/dist/controllers/otp_controller.js +344 -196
  64. data/dist/controllers/overflow_indicator_controller.js +167 -93
  65. data/dist/controllers/overflow_menu_controller.js +408 -277
  66. data/dist/controllers/pagination_controller.js +342 -120
  67. data/dist/controllers/password_reveal_controller.js +424 -99
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +252 -96
  70. data/dist/controllers/pointer_drag_controller.js +263 -164
  71. data/dist/controllers/popover_controller.js +267 -94
  72. data/dist/controllers/portal_controller.js +209 -72
  73. data/dist/controllers/preview_guard_controller.js +243 -112
  74. data/dist/controllers/progress_controller.js +132 -59
  75. data/dist/controllers/radio_group_controller.js +138 -133
  76. data/dist/controllers/range_slider_controller.js +245 -112
  77. data/dist/controllers/rating_controller.js +429 -194
  78. data/dist/controllers/read_more_controller.js +185 -47
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +294 -118
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -221
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -146
  87. data/dist/controllers/scrollspy_controller.js +181 -236
  88. data/dist/controllers/separator_controller.js +288 -132
  89. data/dist/controllers/sidebar_controller.js +1071 -375
  90. data/dist/controllers/skeleton_controller.js +248 -92
  91. data/dist/controllers/slider_controller.js +211 -96
  92. data/dist/controllers/smart_sticky_header_controller.js +163 -54
  93. data/dist/controllers/sortable_controller.js +13 -110
  94. data/dist/controllers/spinner_controller.js +277 -161
  95. data/dist/controllers/step_indicator_controller.js +107 -53
  96. data/dist/controllers/stepper_controller.js +171 -48
  97. data/dist/controllers/stick_to_bottom_controller.js +310 -116
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -192
  100. data/dist/controllers/switch_controller.js +107 -43
  101. data/dist/controllers/tabs_controller.js +50 -25
  102. data/dist/controllers/tags_input_controller.js +290 -149
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +148 -80
  105. data/dist/controllers/time_picker_controller.js +162 -81
  106. data/dist/controllers/toast_controller.js +365 -193
  107. data/dist/controllers/toggle_group_controller.js +194 -99
  108. data/dist/controllers/toolbar_controller.js +92 -90
  109. data/dist/controllers/tooltip_controller.js +185 -125
  110. data/dist/controllers/transition_controller.js +123 -115
  111. data/dist/controllers/tree_view_controller.js +248 -290
  112. data/dist/index.js +8649 -8572
  113. data/dist/positioning/index.js +148 -45
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/frame_loading_controller.ts
4
-
5
- // src/utils/announce.ts
6
3
  function announce(message, options = {}) {
7
4
  const text = message.trim();
8
5
  if (text.length === 0) return;
@@ -19,141 +16,214 @@ function fillTemplate(template, values) {
19
16
  });
20
17
  }
21
18
 
22
- // src/utils/before_cache_reset.ts
23
- var BeforeCacheReset = class _BeforeCacheReset {
24
- /** Every subscribed instance, iterated by the one shared document listener. */
25
- static #subscribers = /* @__PURE__ */ new Set();
26
- /** The shared listener; installed while at least one instance is subscribed. */
27
- static #onBeforeCache = () => {
28
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
29
- };
30
- #rewind;
31
- /** @param rewind - the pass that returns this controller's state to its initial form. */
32
- constructor(rewind) {
33
- this.#rewind = rewind;
34
- }
35
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
36
- activate() {
37
- const first = _BeforeCacheReset.#subscribers.size === 0;
38
- _BeforeCacheReset.#subscribers.add(this);
39
- if (first) {
40
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
19
+ function sharedRegistry(key, create) {
20
+ const symbol = Symbol.for(key);
21
+ const scope = globalThis;
22
+ const existing = scope[symbol];
23
+ if (existing !== void 0) return existing;
24
+ const registry = create();
25
+ Object.defineProperty(globalThis, symbol, { value: registry });
26
+ return registry;
27
+ }
28
+
29
+ function isAttributeValue(value) {
30
+ return value === null || typeof value === "string";
31
+ }
32
+ function readLeaseRecord(element, record) {
33
+ try {
34
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
35
+ if (!Array.isArray(parsed)) return void 0;
36
+ const [original, written, ...rest] = parsed;
37
+ const linked = rest[0] !== false;
38
+ const beneath = linked ? rest : rest.slice(1);
39
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
40
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
41
+ } catch {
42
+ return void 0;
43
+ }
44
+ }
45
+ var AttributeLease = class _AttributeLease {
46
+ static #registry = sharedRegistry(
47
+ "stimeo-ui.attribute-lease.registry.v1",
48
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
49
+ );
50
+ #attribute;
51
+ #record;
52
+ #base;
53
+ #held = /* @__PURE__ */ new Set();
54
+ constructor(attribute, owner, options = {}) {
55
+ this.#attribute = attribute;
56
+ this.#record = `data-${owner}-${attribute}-lease`;
57
+ this.#base = options.base === true;
58
+ const names = _AttributeLease.#registry.names;
59
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
60
+ }
61
+ write(element, value) {
62
+ const stack = this.#stack(element);
63
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
64
+ this.#held.add(element);
65
+ const shows = !this.#covered(element, stack, hold);
66
+ hold.written = value;
67
+ this.#mark(element, stack);
68
+ if (shows) this.#reflect(element, value);
69
+ }
70
+ return(element) {
71
+ const held = this.#held.delete(element);
72
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
73
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
74
+ const hold = this.#take(stack);
75
+ if (!hold) return false;
76
+ const carries = element.getAttribute(this.#attribute) === hold.written;
77
+ const owns = carries && !this.#covered(element, stack, hold);
78
+ const index = stack.indexOf(hold);
79
+ stack.splice(index, 1);
80
+ const above = stack[index];
81
+ if (above) above.linked &&= hold.linked;
82
+ this.#mark(element, stack);
83
+ if (!owns) return true;
84
+ const below = hold.linked ? stack[index - 1] : void 0;
85
+ this.#reflect(element, below ? below.written : hold.original);
86
+ return true;
87
+ }
88
+ returnAll() {
89
+ for (const element of Array.from(this.#held)) this.return(element);
90
+ }
91
+ #stack(element) {
92
+ const stacks = _AttributeLease.#registry.stacks;
93
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
94
+ stacks.set(element, byAttribute);
95
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
96
+ byAttribute.set(this.#attribute, stack);
97
+ return stack;
98
+ }
99
+ #seed(element) {
100
+ const found = [];
101
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
102
+ const read = readLeaseRecord(element, record);
103
+ if (read) found.push({ ...read, record });
104
+ }
105
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
106
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
107
+ lease: null,
108
+ record,
109
+ written,
110
+ original,
111
+ linked: linked && index > 0
112
+ }));
113
+ }
114
+ #take(stack) {
115
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
116
+ if (hold) hold.lease = this;
117
+ return hold;
118
+ }
119
+ #acquire(element, stack) {
120
+ const current = element.getAttribute(this.#attribute);
121
+ const top = stack.at(-1);
122
+ const bottom = stack[0];
123
+ const over = top?.written === current ? top : void 0;
124
+ const from = this.#base ? bottom : over;
125
+ const hold = {
126
+ lease: this,
127
+ record: this.#record,
128
+ written: current,
129
+ original: from ? from.original : current,
130
+ linked: !this.#base && over !== void 0
131
+ };
132
+ if (this.#base && bottom) {
133
+ bottom.linked = true;
134
+ stack.unshift(hold);
135
+ } else stack.push(hold);
136
+ return hold;
137
+ }
138
+ #covered(element, stack, hold) {
139
+ const current = element.getAttribute(this.#attribute);
140
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
141
+ }
142
+ #mark(element, stack) {
143
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
144
+ stack.forEach((hold, index) => {
145
+ const names = stack.slice(0, index).map((under) => under.record);
146
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
147
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
148
+ records.set(
149
+ hold.record,
150
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
151
+ );
152
+ });
153
+ for (const [name, recorded] of records) {
154
+ if (element.getAttribute(name) === recorded) continue;
155
+ if (recorded === null) element.removeAttribute(name);
156
+ else element.setAttribute(name, recorded);
41
157
  }
42
158
  }
43
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
44
- deactivate() {
45
- _BeforeCacheReset.#subscribers.delete(this);
46
- if (_BeforeCacheReset.#subscribers.size > 0) return;
47
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
159
+ #reflect(element, value) {
160
+ if (element.getAttribute(this.#attribute) === value) return;
161
+ if (value === null) element.removeAttribute(this.#attribute);
162
+ else element.setAttribute(this.#attribute, value);
48
163
  }
49
164
  };
50
165
 
51
- // src/utils/detach_gate.ts
52
166
  var DetachGate = class _DetachGate {
53
- /** Set while a probe is queued, waiting for a reconnect to cancel it. */
54
167
  #pending = false;
55
- /**
56
- * True while a probe is queued — the last disconnect was ambiguous and no
57
- * reconnect has cancelled it yet. Read it from `connect()` to tell the
58
- * reconnect half of an in-page move from a first connect: a controller whose
59
- * initialisation restarts a measurement (a min-duration floor, an elapsed
60
- * counter) must skip it for the move, where nothing actually restarted.
61
- */
62
168
  get pending() {
63
169
  return this.#pending;
64
170
  }
65
- /**
66
- * True when the disconnect is definitely a real detach — the element left
67
- * the document, or `data-controller` no longer lists the identifier. False
68
- * means ambiguous (in-page move or observed-root exit), NOT "alive".
69
- */
70
171
  static isDetached(host) {
71
- if (!host.element.isConnected) return true;
72
- const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
73
- return !tokens.includes(host.identifier);
74
- }
75
- /**
76
- * Call from `disconnect()`: runs `teardown` synchronously on a definite
77
- * detach (fast path), otherwise defers it one microtask — a reconnect
78
- * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
79
- * One microtask is the whole probe window: Stimulus reconnects a moved
80
- * element within the same mutation batch, before the checkpoint drains.
81
- */
172
+ return !host.element.isConnected || !_DetachGate.#listed(host);
173
+ }
82
174
  disconnected(host, teardown) {
83
- if (_DetachGate.isDetached(host)) {
175
+ const carried = _DetachGate.#carried(host);
176
+ if (!carried && _DetachGate.isDetached(host)) {
84
177
  this.#pending = false;
85
178
  teardown();
86
179
  return;
87
180
  }
88
181
  this.#pending = true;
89
- queueMicrotask(() => {
182
+ const probe = () => {
90
183
  if (!this.#pending) return;
91
184
  this.#pending = false;
92
185
  teardown();
93
- });
186
+ };
187
+ if (carried) setTimeout(probe, 0);
188
+ else queueMicrotask(probe);
189
+ }
190
+ static #carried(host) {
191
+ const element = host.element;
192
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
193
+ }
194
+ static #listed(host) {
195
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
196
+ return tokens.includes(host.identifier);
94
197
  }
95
- /**
96
- * Disarms a pending probe. Call from `connect()` (the reconnect that proves
97
- * an in-page move) and from the head of any teardown path not routed through
98
- * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
99
- * never run the teardown a second time.
100
- */
101
198
  cancel() {
102
199
  this.#pending = false;
103
200
  }
104
201
  };
105
202
 
106
- // src/utils/listener_set.ts
107
203
  var ListenerSet = class {
108
- /** The generation every `add` joins until the next `dispose()`. */
109
204
  #abort = new AbortController();
110
- /**
111
- * Attaches `handler` to the open generation, exactly as the caller spelled it.
112
- *
113
- * The set supplies the signal, so `options` carries everything else the DOM
114
- * accepts — `capture` included, which has to match at release time and no
115
- * longer has a second place to drift from.
116
- */
117
205
  add(target, type, handler, options) {
118
206
  target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
119
207
  }
120
- /**
121
- * Releases every listener of the open generation, synchronously, and opens the
122
- * next one. Idempotent, and safe before anything has been added.
123
- */
124
208
  dispose() {
125
209
  this.#abort.abort();
126
210
  this.#abort = new AbortController();
127
211
  }
128
212
  };
129
213
 
130
- // src/utils/min_duration_floor.ts
131
214
  var MinDurationFloor = class {
132
215
  #timers;
133
- /** Pending finish timer id, or `null` when nothing is held back. */
134
216
  #timerId = null;
135
- /** Epoch ms the floor is measured from. */
136
217
  #since = 0;
137
- /** @param timers - the controller's registry; the floor schedules into it. */
138
218
  constructor(timers) {
139
219
  this.#timers = timers;
140
220
  }
141
- /** Starts the floor: call when the state being held becomes visible. */
142
221
  begin() {
143
222
  this.#since = Date.now();
144
223
  }
145
- /** True while a finish is held back waiting for the floor to elapse. */
146
224
  get pending() {
147
225
  return this.#timerId !== null;
148
226
  }
149
- /**
150
- * Runs `finish` once the floor has elapsed, immediately when it already has.
151
- *
152
- * A held-back finish is **replaced**, never stacked: only the most recently
153
- * queued id is cancellable, so a second timer would outlive every cancel and
154
- * end a state that has since restarted. Controllers that want the first signal
155
- * to win guard on {@link pending} before calling.
156
- */
157
227
  schedule(minDuration, finish) {
158
228
  this.cancel();
159
229
  const remaining = minDuration - (Date.now() - this.#since);
@@ -166,7 +236,6 @@ var MinDurationFloor = class {
166
236
  finish();
167
237
  }
168
238
  }
169
- /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
170
239
  cancel() {
171
240
  if (this.#timerId !== null) {
172
241
  this.#timers.clear(this.#timerId);
@@ -175,43 +244,70 @@ var MinDurationFloor = class {
175
244
  }
176
245
  };
177
246
 
178
- // src/utils/safe_timeout.ts
247
+ var MAX_TIMER_DELAY_MS = 2147483647;
248
+
249
+ var NUMBER_BOUNDS = {
250
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
251
+ function matchesNumberBounds(value, bounds) {
252
+ if (!Number.isFinite(value)) {
253
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
254
+ if (direction === null) return false;
255
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
256
+ }
257
+ if (bounds.min !== void 0 && value < bounds.min) return false;
258
+ if (bounds.max !== void 0 && value > bounds.max) return false;
259
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
260
+ if (bounds.integer && !Number.isInteger(value)) return false;
261
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
262
+ return true;
263
+ }
264
+
265
+ function readNumber(raw, fallback, bounds) {
266
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
267
+ }
268
+
269
+ var NumberValueReader = class {
270
+ #lastRejected = /* @__PURE__ */ new Map();
271
+ read(owner, name, raw, fallback, bounds) {
272
+ const resolved = readNumber(raw, fallback, bounds);
273
+ if (matchesNumberBounds(raw, bounds)) {
274
+ this.#lastRejected.delete(name);
275
+ return resolved;
276
+ }
277
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
278
+ const literal = owner.element.getAttribute(attribute);
279
+ if (literal === null) {
280
+ this.#lastRejected.delete(name);
281
+ return resolved;
282
+ }
283
+ if (this.#lastRejected.get(name) !== literal) {
284
+ this.#lastRejected.set(name, literal);
285
+ console.warn(
286
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
287
+ );
288
+ }
289
+ return resolved;
290
+ }
291
+ };
292
+
179
293
  var TimerRegistry = class {
180
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
181
294
  ids = /* @__PURE__ */ new Set();
182
- /**
183
- * Cancels a single tracked timer.
184
- *
185
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
186
- * registry), so callers can clear defensively without guarding.
187
- */
188
295
  clear(id) {
189
296
  if (this.ids.delete(id)) {
190
297
  this.cancel(id);
191
298
  }
192
299
  }
193
- /**
194
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
195
- * to guarantee no timer outlives the element.
196
- */
197
300
  clearAll() {
198
301
  for (const id of this.ids) {
199
302
  this.cancel(id);
200
303
  }
201
304
  this.ids.clear();
202
305
  }
203
- /** Number of timers currently tracked (pending). */
204
306
  get size() {
205
307
  return this.ids.size;
206
308
  }
207
309
  };
208
310
  var SafeTimeout = class extends TimerRegistry {
209
- /**
210
- * Schedules `callback` after `delay` ms and returns the timer id.
211
- *
212
- * The id is removed from the registry automatically when the timeout fires,
213
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
214
- */
215
311
  set(callback, delay) {
216
312
  const id = this.schedule(() => {
217
313
  this.ids.delete(id);
@@ -228,8 +324,8 @@ var SafeTimeout = class extends TimerRegistry {
228
324
  }
229
325
  };
230
326
 
231
- // src/controllers/frame_loading_controller.ts
232
- var FrameLoadingController = class extends Controller {
327
+ var FrameLoadingController = class _FrameLoadingController extends Controller {
328
+ #numbers = new NumberValueReader();
233
329
  static targets = ["content", "skeleton", "overlay"];
234
330
  static values = {
235
331
  announceText: { type: String, default: "" },
@@ -237,26 +333,23 @@ var FrameLoadingController = class extends Controller {
237
333
  minDuration: { type: Number, default: 0 },
238
334
  restoreFocus: { type: Boolean, default: true }
239
335
  };
336
+ static valueConstraints = {
337
+ minDuration: NUMBER_BOUNDS.timer
338
+ };
240
339
  static events = ["start", "end", "reconcile"];
241
340
  #timeouts = new SafeTimeout();
242
341
  #floor = new MinDurationFloor(this.#timeouts);
243
342
  #gate = new DetachGate();
244
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
245
343
  #listeners = new ListenerSet();
344
+ #busy = new AttributeLease("aria-busy", this.identifier);
345
+ #loadingHook = new AttributeLease("data-frame-loading", this.identifier);
346
+ #hidden = new AttributeLease("hidden", this.identifier);
347
+ #inert = new AttributeLease("inert", this.identifier);
246
348
  #loading = false;
247
- /**
248
- * The optional targets this controller revealed, and the content it marked inert.
249
- * Held as references rather than re-resolved on the way out: a detach that keeps
250
- * the element takes the identifier off `data-controller` first, and a scope
251
- * without its identifier stops resolving targets — the elements to tidy would be
252
- * unreachable exactly when the tidying matters. They double as the ownership
253
- * marker, so a `hidden` or an `inert` the consumer wrote is never taken over.
254
- */
255
349
  #revealedSkeleton = null;
256
350
  #revealedOverlay = null;
257
351
  #inertTarget = null;
258
352
  #previousFocus = null;
259
- /** The id of the retreated element, used to re-find it if the load replaced it. */
260
353
  #previousFocusId = "";
261
354
  #onStart = () => {
262
355
  this.#floor.cancel();
@@ -264,111 +357,85 @@ var FrameLoadingController = class extends Controller {
264
357
  };
265
358
  #onEnd = () => {
266
359
  if (!this.#loading) return;
267
- this.#floor.schedule(this.minDurationValue, () => this.#finish());
360
+ this.#floor.schedule(this.#safeMinDuration, () => this.#finish());
268
361
  };
269
362
  connect() {
270
363
  this.#gate.cancel();
271
- this.#beforeCache.activate();
272
364
  this.#listeners.add(this.element, "turbo:before-fetch-request", this.#onStart);
273
365
  this.#listeners.add(this.element, "turbo:frame-load", this.#onEnd);
274
366
  this.#listeners.add(this.element, "turbo:fetch-request-error", this.#onEnd);
367
+ if (this.#loading) return;
368
+ const stale = this.element.hasAttribute("data-frame-loading");
369
+ this.#returnCopied();
370
+ if (stale) this.dispatch("reconcile", { detail: {} });
371
+ }
372
+ #returnCopied() {
373
+ this.#busy.return(this.element);
374
+ this.#loadingHook.return(this.element);
375
+ for (const region of [...this.skeletonTargets, ...this.overlayTargets]) {
376
+ this.#hidden.return(region);
377
+ }
378
+ for (const content of this.contentTargets) this.#inert.return(content);
275
379
  }
276
380
  disconnect() {
277
381
  this.#listeners.dispose();
278
- this.#beforeCache.deactivate();
279
382
  this.#gate.disconnected(this, () => this.#teardown());
280
383
  }
281
- /**
282
- * Drops the held finish and the loading bookkeeping on a real detach, returning
283
- * the frame to its idle form. No reconnect is coming, so nothing is left that
284
- * could finish the load and clear the hooks — a detach that keeps the element
285
- * (a morph dropping the identifier, an exit from a scoped observed root) would
286
- * otherwise strand it busy and inert. Focus is left where it is: the element is
287
- * leaving this controller's care, and moving it now would be an unexplained jump.
288
- */
289
384
  #teardown() {
290
- this.#gate.cancel();
291
385
  this.#timeouts.clearAll();
292
- this.#floor.cancel();
293
386
  if (this.#loading) this.#rewindHooks();
294
387
  this.#loading = false;
295
388
  this.#previousFocus = null;
296
389
  }
297
- /**
298
- * Returns the frame to its idle form for the snapshot Turbo is about to take, so
299
- * a page reached with the Back button does not restore a frame that is busy and
300
- * inert with nothing left to finish it. State only — no `end` event and no focus
301
- * move, because the load did not actually complete.
302
- *
303
- * The load is abandoned rather than paused, so the flag and any finish the floor
304
- * still holds drop along with the hooks. A kept finish would surface after the
305
- * rewind as exactly the three things this pass exists to avoid — an `end`, a
306
- * completion announcement, and a focus move — and a kept flag would leave the
307
- * next fetch on a page that survives a cancelled visit skipping the loading
308
- * state, its idempotence guard already satisfied.
309
- */
310
- #rewindForCache() {
311
- if (!this.#loading) return;
312
- this.#loading = false;
313
- this.#floor.cancel();
314
- this.#rewindHooks();
315
- this.dispatch("reconcile", { detail: {} });
316
- }
317
- /**
318
- * Clears every hook the loading state writes. Shared by the three ways a load can
319
- * stop — completion, detach, snapshot — so none of them can drift into tidying
320
- * only part of it.
321
- */
322
390
  #rewindHooks() {
323
- this.element.removeAttribute("aria-busy");
324
- this.element.removeAttribute("data-frame-loading");
325
- if (this.#revealedSkeleton) this.#revealedSkeleton.hidden = true;
326
- if (this.#revealedOverlay) this.#revealedOverlay.hidden = true;
391
+ this.#busy.return(this.element);
392
+ this.#loadingHook.return(this.element);
393
+ if (this.#revealedSkeleton) this.#hidden.return(this.#revealedSkeleton);
394
+ if (this.#revealedOverlay) this.#hidden.return(this.#revealedOverlay);
327
395
  this.#revealedSkeleton = null;
328
396
  this.#revealedOverlay = null;
329
397
  this.#clearInert();
330
398
  }
331
- /**
332
- * Re-shows a `skeleton` that arrived mid-load. Turbo's frame renderer empties the
333
- * frame and re-inserts the response's children, so a response's authored (hidden)
334
- * skeleton can land while a later fetch is still running, and only the controller
335
- * knows the frame is still busy.
336
- */
337
399
  skeletonTargetConnected() {
338
400
  if (this.#loading) this.#revealSkeleton();
339
401
  }
340
- /** Re-shows an `overlay` that arrived mid-load — the same swap as the skeleton. */
402
+ skeletonTargetDisconnected(skeleton) {
403
+ if (this.#loading && !this.skeletonTargets.includes(skeleton)) this.#revealSkeleton();
404
+ }
341
405
  overlayTargetConnected() {
342
406
  if (this.#loading) this.#revealOverlay();
343
407
  }
344
- /**
345
- * Re-blocks a `content` that arrived mid-load, so the stale copy stays unusable.
346
- * The element that left is released first and ownership is then decided afresh, so
347
- * an `inert` the replacement authored stays the consumer's.
348
- */
408
+ overlayTargetDisconnected(overlay) {
409
+ if (this.#loading && !this.overlayTargets.includes(overlay)) this.#revealOverlay();
410
+ }
349
411
  contentTargetConnected() {
350
412
  if (!this.#loading) return;
351
413
  this.#clearInert();
352
414
  this.#applyInert();
353
415
  }
354
- /** Reveals the optional `skeleton`, noting it as this controller's to hide again. */
416
+ contentTargetDisconnected(content) {
417
+ if (!this.#loading || this.contentTargets.includes(content)) return;
418
+ this.#clearInert();
419
+ this.#applyInert();
420
+ }
355
421
  #revealSkeleton() {
356
- if (!this.hasSkeletonTarget) return;
357
- this.#revealedSkeleton = this.skeletonTarget;
358
- this.skeletonTarget.hidden = false;
422
+ const next = this.hasSkeletonTarget ? this.skeletonTarget : null;
423
+ this.#revealedSkeleton = this.#reveal(this.#revealedSkeleton, next);
359
424
  }
360
- /** Reveals the optional `overlay`, noting it as this controller's to hide again. */
361
425
  #revealOverlay() {
362
- if (!this.hasOverlayTarget) return;
363
- this.#revealedOverlay = this.overlayTarget;
364
- this.overlayTarget.hidden = false;
426
+ const next = this.hasOverlayTarget ? this.overlayTarget : null;
427
+ this.#revealedOverlay = this.#reveal(this.#revealedOverlay, next);
428
+ }
429
+ #reveal(revealed, next) {
430
+ if (revealed && revealed !== next) this.#hidden.return(revealed);
431
+ if (next) this.#hidden.write(next, null);
432
+ return next;
365
433
  }
366
- /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
367
434
  #begin() {
368
435
  this.#loading = true;
369
436
  this.#floor.begin();
370
- this.element.setAttribute("aria-busy", "true");
371
- this.element.setAttribute("data-frame-loading", "true");
437
+ this.#busy.write(this.element, "true");
438
+ this.#loadingHook.write(this.element, "true");
372
439
  this.#revealSkeleton();
373
440
  this.#revealOverlay();
374
441
  this.#applyInert();
@@ -376,7 +443,6 @@ var FrameLoadingController = class extends Controller {
376
443
  this.dispatch("start", { detail: {} });
377
444
  announce(fillTemplate(this.announceTextValue, {}));
378
445
  }
379
- /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
380
446
  #finish() {
381
447
  this.#loading = false;
382
448
  this.#rewindHooks();
@@ -384,17 +450,15 @@ var FrameLoadingController = class extends Controller {
384
450
  this.dispatch("end", { detail: {} });
385
451
  announce(fillTemplate(this.announceReadyTextValue, {}));
386
452
  }
387
- /** Marks the content inert to block double-submits while stale (if we own it). */
388
453
  #applyInert() {
389
454
  if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
390
- this.contentTarget.setAttribute("inert", "");
455
+ this.#inert.write(this.contentTarget, "");
391
456
  this.#inertTarget = this.contentTarget;
392
457
  }
393
458
  #clearInert() {
394
- this.#inertTarget?.removeAttribute("inert");
459
+ if (this.#inertTarget) this.#inert.return(this.#inertTarget);
395
460
  this.#inertTarget = null;
396
461
  }
397
- /** Saves and blurs focus if it sits inside the frame about to go stale. */
398
462
  #retreatFocus() {
399
463
  this.#previousFocus = null;
400
464
  this.#previousFocusId = "";
@@ -406,13 +470,6 @@ var FrameLoadingController = class extends Controller {
406
470
  active.blur();
407
471
  }
408
472
  }
409
- /**
410
- * Restores focus after the load. The same node when it survived (e.g. a
411
- * non-replacing update), else the element re-rendered with the same id inside the
412
- * frame — Turbo frames typically re-emit the same controls. When neither is present
413
- * (an anonymous control was replaced) focus is left where the browser put it, to
414
- * avoid an unexpected jump (WCAG 3.2.x).
415
- */
416
473
  #restoreFocus() {
417
474
  const target = this.#previousFocus;
418
475
  const id = this.#previousFocusId;
@@ -428,8 +485,16 @@ var FrameLoadingController = class extends Controller {
428
485
  if (replacement && this.element.contains(replacement)) replacement.focus();
429
486
  }
430
487
  }
488
+ get #safeMinDuration() {
489
+ return this.#numbers.read(
490
+ this,
491
+ "minDuration",
492
+ this.minDurationValue,
493
+ _FrameLoadingController.values.minDuration.default,
494
+ _FrameLoadingController.valueConstraints.minDuration
495
+ );
496
+ }
431
497
  };
432
498
 
433
499
  export { FrameLoadingController };
434
- //# sourceMappingURL=frame_loading_controller.js.map
435
500
  //# sourceMappingURL=frame_loading_controller.js.map