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/spinner_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,124 +16,209 @@ 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/default_attribute.ts
52
166
  function setDefaultAttribute(element, name, value) {
53
167
  if (element.hasAttribute(name)) return false;
54
168
  element.setAttribute(name, value);
55
169
  return true;
56
170
  }
57
171
 
58
- // src/utils/detach_gate.ts
59
172
  var DetachGate = class _DetachGate {
60
- /** Set while a probe is queued, waiting for a reconnect to cancel it. */
61
173
  #pending = false;
62
- /**
63
- * True while a probe is queued — the last disconnect was ambiguous and no
64
- * reconnect has cancelled it yet. Read it from `connect()` to tell the
65
- * reconnect half of an in-page move from a first connect: a controller whose
66
- * initialisation restarts a measurement (a min-duration floor, an elapsed
67
- * counter) must skip it for the move, where nothing actually restarted.
68
- */
69
174
  get pending() {
70
175
  return this.#pending;
71
176
  }
72
- /**
73
- * True when the disconnect is definitely a real detach — the element left
74
- * the document, or `data-controller` no longer lists the identifier. False
75
- * means ambiguous (in-page move or observed-root exit), NOT "alive".
76
- */
77
177
  static isDetached(host) {
78
- if (!host.element.isConnected) return true;
79
- const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
80
- return !tokens.includes(host.identifier);
81
- }
82
- /**
83
- * Call from `disconnect()`: runs `teardown` synchronously on a definite
84
- * detach (fast path), otherwise defers it one microtask — a reconnect
85
- * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
86
- * One microtask is the whole probe window: Stimulus reconnects a moved
87
- * element within the same mutation batch, before the checkpoint drains.
88
- */
178
+ return !host.element.isConnected || !_DetachGate.#listed(host);
179
+ }
89
180
  disconnected(host, teardown) {
90
- if (_DetachGate.isDetached(host)) {
181
+ const carried = _DetachGate.#carried(host);
182
+ if (!carried && _DetachGate.isDetached(host)) {
91
183
  this.#pending = false;
92
184
  teardown();
93
185
  return;
94
186
  }
95
187
  this.#pending = true;
96
- queueMicrotask(() => {
188
+ const probe = () => {
97
189
  if (!this.#pending) return;
98
190
  this.#pending = false;
99
191
  teardown();
100
- });
192
+ };
193
+ if (carried) setTimeout(probe, 0);
194
+ else queueMicrotask(probe);
195
+ }
196
+ static #carried(host) {
197
+ const element = host.element;
198
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
199
+ }
200
+ static #listed(host) {
201
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
202
+ return tokens.includes(host.identifier);
101
203
  }
102
- /**
103
- * Disarms a pending probe. Call from `connect()` (the reconnect that proves
104
- * an in-page move) and from the head of any teardown path not routed through
105
- * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
106
- * never run the teardown a second time.
107
- */
108
204
  cancel() {
109
205
  this.#pending = false;
110
206
  }
111
207
  };
112
208
 
113
- // src/utils/min_duration_floor.ts
114
209
  var MinDurationFloor = class {
115
210
  #timers;
116
- /** Pending finish timer id, or `null` when nothing is held back. */
117
211
  #timerId = null;
118
- /** Epoch ms the floor is measured from. */
119
212
  #since = 0;
120
- /** @param timers - the controller's registry; the floor schedules into it. */
121
213
  constructor(timers) {
122
214
  this.#timers = timers;
123
215
  }
124
- /** Starts the floor: call when the state being held becomes visible. */
125
216
  begin() {
126
217
  this.#since = Date.now();
127
218
  }
128
- /** True while a finish is held back waiting for the floor to elapse. */
129
219
  get pending() {
130
220
  return this.#timerId !== null;
131
221
  }
132
- /**
133
- * Runs `finish` once the floor has elapsed, immediately when it already has.
134
- *
135
- * A held-back finish is **replaced**, never stacked: only the most recently
136
- * queued id is cancellable, so a second timer would outlive every cancel and
137
- * end a state that has since restarted. Controllers that want the first signal
138
- * to win guard on {@link pending} before calling.
139
- */
140
222
  schedule(minDuration, finish) {
141
223
  this.cancel();
142
224
  const remaining = minDuration - (Date.now() - this.#since);
@@ -149,7 +231,6 @@ var MinDurationFloor = class {
149
231
  finish();
150
232
  }
151
233
  }
152
- /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
153
234
  cancel() {
154
235
  if (this.#timerId !== null) {
155
236
  this.#timers.clear(this.#timerId);
@@ -158,43 +239,70 @@ var MinDurationFloor = class {
158
239
  }
159
240
  };
160
241
 
161
- // src/utils/safe_timeout.ts
242
+ var MAX_TIMER_DELAY_MS = 2147483647;
243
+
244
+ var NUMBER_BOUNDS = {
245
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
246
+ function matchesNumberBounds(value, bounds) {
247
+ if (!Number.isFinite(value)) {
248
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
249
+ if (direction === null) return false;
250
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
251
+ }
252
+ if (bounds.min !== void 0 && value < bounds.min) return false;
253
+ if (bounds.max !== void 0 && value > bounds.max) return false;
254
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
255
+ if (bounds.integer && !Number.isInteger(value)) return false;
256
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
257
+ return true;
258
+ }
259
+
260
+ function readNumber(raw, fallback, bounds) {
261
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
262
+ }
263
+
264
+ var NumberValueReader = class {
265
+ #lastRejected = /* @__PURE__ */ new Map();
266
+ read(owner, name, raw, fallback, bounds) {
267
+ const resolved = readNumber(raw, fallback, bounds);
268
+ if (matchesNumberBounds(raw, bounds)) {
269
+ this.#lastRejected.delete(name);
270
+ return resolved;
271
+ }
272
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
273
+ const literal = owner.element.getAttribute(attribute);
274
+ if (literal === null) {
275
+ this.#lastRejected.delete(name);
276
+ return resolved;
277
+ }
278
+ if (this.#lastRejected.get(name) !== literal) {
279
+ this.#lastRejected.set(name, literal);
280
+ console.warn(
281
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
282
+ );
283
+ }
284
+ return resolved;
285
+ }
286
+ };
287
+
162
288
  var TimerRegistry = class {
163
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
164
289
  ids = /* @__PURE__ */ new Set();
165
- /**
166
- * Cancels a single tracked timer.
167
- *
168
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
169
- * registry), so callers can clear defensively without guarding.
170
- */
171
290
  clear(id) {
172
291
  if (this.ids.delete(id)) {
173
292
  this.cancel(id);
174
293
  }
175
294
  }
176
- /**
177
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
178
- * to guarantee no timer outlives the element.
179
- */
180
295
  clearAll() {
181
296
  for (const id of this.ids) {
182
297
  this.cancel(id);
183
298
  }
184
299
  this.ids.clear();
185
300
  }
186
- /** Number of timers currently tracked (pending). */
187
301
  get size() {
188
302
  return this.ids.size;
189
303
  }
190
304
  };
191
305
  var SafeTimeout = class extends TimerRegistry {
192
- /**
193
- * Schedules `callback` after `delay` ms and returns the timer id.
194
- *
195
- * The id is removed from the registry automatically when the timeout fires,
196
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
197
- */
198
306
  set(callback, delay) {
199
307
  const id = this.schedule(() => {
200
308
  this.ids.delete(id);
@@ -211,8 +319,8 @@ var SafeTimeout = class extends TimerRegistry {
211
319
  }
212
320
  };
213
321
 
214
- // src/controllers/spinner_controller.ts
215
- var SpinnerController = class extends Controller {
322
+ var SpinnerController = class _SpinnerController extends Controller {
323
+ #numbers = new NumberValueReader();
216
324
  static targets = ["indicator", "region", "message"];
217
325
  static values = {
218
326
  announceText: { type: String, default: "" },
@@ -221,42 +329,52 @@ var SpinnerController = class extends Controller {
221
329
  minDuration: { type: Number, default: 0 },
222
330
  timeout: { type: Number, default: 0 }
223
331
  };
332
+ static valueConstraints = {
333
+ delay: NUMBER_BOUNDS.timer,
334
+ minDuration: NUMBER_BOUNDS.timer,
335
+ timeout: NUMBER_BOUNDS.timer
336
+ };
224
337
  static actions = ["start", "stop"];
225
338
  static events = ["hide", "show", "timeout", "reconcile"];
226
339
  #timers = new SafeTimeout();
227
340
  #floor = new MinDurationFloor(this.#timers);
228
341
  #gate = new DetachGate();
229
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
230
- /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
342
+ #busyLease = new AttributeLease("aria-busy", this.identifier);
343
+ #hiddenLease = new AttributeLease("hidden", this.identifier);
344
+ #connected = false;
345
+ #cycling = false;
231
346
  #delayTimerId = null;
232
- /** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
233
347
  #timeoutTimerId = null;
234
348
  connect() {
349
+ this.#connected = true;
235
350
  this.#gate.cancel();
236
- this.#beforeCache.activate();
237
- if (this.#state === "pending" && this.#delayTimerId === null) {
351
+ if (this.#state !== "idle" && !this.#cycling) {
238
352
  this.#setBusy(false);
353
+ this.#showIndicator(false);
239
354
  this.element.setAttribute("data-state", "idle");
355
+ this.dispatch("reconcile", { detail: {} });
240
356
  return;
241
357
  }
242
358
  setDefaultAttribute(this.element, "data-state", "idle");
243
359
  }
244
- /**
245
- * Re-applies the current phase to an indicator that arrived after `connect()`.
246
- *
247
- * A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
248
- * carries the markup contract's `hidden`. Without this the spinner would vanish
249
- * while `data-state` still says `loading`, and nothing but the next cycle would
250
- * bring it back.
251
- */
252
360
  indicatorTargetConnected(target) {
253
- target.hidden = this.#state !== "loading";
361
+ this.#hiddenLease.write(target, this.#state === "loading" ? null : "");
362
+ }
363
+ indicatorTargetDisconnected(indicator) {
364
+ if (!this.indicatorTargets.includes(indicator)) this.#hiddenLease.return(indicator);
365
+ if (this.#connected) this.#showIndicator(this.#state === "loading");
366
+ }
367
+ regionTargetConnected(region) {
368
+ if (this.#connected) this.#busyLease.write(region, String(this.#busy));
369
+ }
370
+ regionTargetDisconnected(region) {
371
+ if (!this.regionTargets.includes(region)) this.#busyLease.return(region);
372
+ if (this.#connected) this.#setBusy(this.#busy);
254
373
  }
255
374
  disconnect() {
256
- this.#beforeCache.deactivate();
375
+ this.#connected = false;
257
376
  this.#gate.disconnected(this, () => this.#teardown());
258
377
  }
259
- /** Begins loading. Honors `delay` before the spinner actually appears. */
260
378
  start() {
261
379
  if (this.#state === "loading") {
262
380
  this.#setBusy(true);
@@ -265,99 +383,68 @@ var SpinnerController = class extends Controller {
265
383
  return;
266
384
  }
267
385
  if (this.#state !== "idle") return;
386
+ this.#cycling = true;
268
387
  this.#setBusy(true);
269
388
  this.#floor.cancel();
270
389
  this.#armTimeout();
271
- if (this.delayValue > 0) {
390
+ if (this.#safeDelay > 0) {
272
391
  this.element.setAttribute("data-state", "pending");
273
392
  this.#delayTimerId = this.#timers.set(() => {
274
393
  this.#delayTimerId = null;
275
394
  this.#show();
276
- }, this.delayValue);
395
+ }, this.#safeDelay);
277
396
  } else {
278
397
  this.#show();
279
398
  }
280
399
  }
281
- /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
282
400
  stop() {
283
401
  const state = this.#state;
284
402
  this.#cancelTimeout();
285
403
  if (state === "pending") {
286
404
  this.#cancelDelay();
405
+ this.#cycling = false;
287
406
  this.#setBusy(false);
288
407
  this.element.setAttribute("data-state", "idle");
289
408
  return;
290
409
  }
291
410
  if (state !== "loading") return;
292
411
  this.#setBusy(false);
293
- this.#floor.schedule(this.minDurationValue, () => this.#hide());
412
+ this.#floor.schedule(this.#safeMinDuration, () => this.#hide());
294
413
  }
295
- /** Reveals the indicator, marks the moment shown, and announces via the live region. */
296
414
  #show() {
297
415
  this.#floor.begin();
298
- this.#setBusy(true);
299
- if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
416
+ this.#showIndicator(true);
300
417
  this.element.setAttribute("data-state", "loading");
301
418
  this.dispatch("show", { detail: {} });
302
419
  announce(fillTemplate(this.announceTextValue, {}));
303
420
  }
304
- /** Hides the indicator and returns to the idle state. */
305
421
  #hide() {
306
- if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
422
+ this.#cycling = false;
423
+ this.#showIndicator(false);
307
424
  this.element.setAttribute("data-state", "idle");
308
425
  this.dispatch("hide", { detail: {} });
309
426
  announce(fillTemplate(this.announceReadyTextValue, {}));
310
427
  }
311
- /**
312
- * Drops both timers on a real detach. The markup keeps whatever it last held: an
313
- * element on its way out of the document has no reader left, and one whose
314
- * `data-controller` dropped the identifier no longer resolves its own targets, so
315
- * the rollback could only ever be partial. The snapshot is rewound where it is
316
- * still whole, on `turbo:before-cache`.
317
- */
318
428
  #teardown() {
319
- this.#gate.cancel();
320
429
  this.#timers.clearAll();
321
430
  this.#delayTimerId = null;
322
431
  this.#timeoutTimerId = null;
323
- this.#floor.cancel();
432
+ this.#cycling = false;
324
433
  }
325
- /**
326
- * Returns the loading state to idle for the snapshot Turbo is about to take,
327
- * so a page reached with the Back button is not restored mid-load with a
328
- * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
329
- * and `aria-busy`. No `hide` is dispatched — that would claim the load finished.
330
- * The rewind reports itself as `reconcile` instead, so a consumer painting from
331
- * `show` can drop it before the snapshot is taken. The live page keeps its
332
- * timers, so a navigation that never completes leaves the running cycle intact.
333
- */
334
- #rewindForCache() {
335
- const running = this.element.getAttribute("data-state") !== "idle";
336
- this.#cancelTimeout();
337
- this.#setBusy(false);
338
- if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
339
- this.element.setAttribute("data-state", "idle");
340
- if (running) this.dispatch("reconcile", { detail: {} });
434
+ #showIndicator(shown) {
435
+ if (this.hasIndicatorTarget) this.#hiddenLease.write(this.indicatorTarget, shown ? null : "");
341
436
  }
342
- /** Reflects busy state onto the controlled region (if present). */
343
437
  #setBusy(busy) {
344
- if (this.hasRegionTarget) {
345
- this.regionTarget.setAttribute("aria-busy", String(busy));
346
- }
438
+ if (this.hasRegionTarget) this.#busyLease.write(this.regionTarget, String(busy));
347
439
  }
348
- /**
349
- * Arms the safety net so a `stop` that never arrives cannot strand the spinner.
350
- * Off by default: the consumer owns the async work, so only it knows whether a
351
- * ceiling makes sense. Re-arming on a restart measures from the newest start.
352
- */
353
440
  #armTimeout() {
354
441
  this.#cancelTimeout();
355
- if (this.timeoutValue <= 0) return;
442
+ if (this.#safeTimeout <= 0) return;
356
443
  this.#timeoutTimerId = this.#timers.set(() => {
357
444
  this.#timeoutTimerId = null;
358
445
  this.dispatch("timeout", { detail: {} });
359
446
  this.stop();
360
- }, this.timeoutValue);
447
+ }, this.#safeTimeout);
361
448
  }
362
449
  #cancelTimeout() {
363
450
  if (this.#timeoutTimerId !== null) {
@@ -371,12 +458,41 @@ var SpinnerController = class extends Controller {
371
458
  this.#delayTimerId = null;
372
459
  }
373
460
  }
374
- /** Current lifecycle phase as reflected on `data-state`. */
375
461
  get #state() {
376
462
  return this.element.getAttribute("data-state") ?? "idle";
377
463
  }
464
+ get #busy() {
465
+ const state = this.#state;
466
+ return state === "pending" || state === "loading" && !this.#floor.pending;
467
+ }
468
+ get #safeDelay() {
469
+ return this.#numbers.read(
470
+ this,
471
+ "delay",
472
+ this.delayValue,
473
+ _SpinnerController.values.delay.default,
474
+ _SpinnerController.valueConstraints.delay
475
+ );
476
+ }
477
+ get #safeMinDuration() {
478
+ return this.#numbers.read(
479
+ this,
480
+ "minDuration",
481
+ this.minDurationValue,
482
+ _SpinnerController.values.minDuration.default,
483
+ _SpinnerController.valueConstraints.minDuration
484
+ );
485
+ }
486
+ get #safeTimeout() {
487
+ return this.#numbers.read(
488
+ this,
489
+ "timeout",
490
+ this.timeoutValue,
491
+ _SpinnerController.values.timeout.default,
492
+ _SpinnerController.valueConstraints.timeout
493
+ );
494
+ }
378
495
  };
379
496
 
380
497
  export { SpinnerController };
381
- //# sourceMappingURL=spinner_controller.js.map
382
498
  //# sourceMappingURL=spinner_controller.js.map