stimeo-ui 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +27 -11
  4. data/dist/cable/index.js +384 -449
  5. data/dist/controllers/accordion_controller.js +174 -55
  6. data/dist/controllers/alert_dialog_controller.js +903 -235
  7. data/dist/controllers/announcer_controller.js +238 -205
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +414 -145
  10. data/dist/controllers/avatar_controller.js +185 -107
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +282 -58
  13. data/dist/controllers/calendar_controller.js +228 -260
  14. data/dist/controllers/carousel_controller.js +536 -436
  15. data/dist/controllers/character_counter_controller.js +493 -207
  16. data/dist/controllers/checkbox_controller.js +38 -74
  17. data/dist/controllers/clipboard_controller.js +261 -149
  18. data/dist/controllers/collapsible_controller.js +304 -120
  19. data/dist/controllers/color_picker_controller.js +178 -146
  20. data/dist/controllers/combobox_controller.js +522 -156
  21. data/dist/controllers/command_palette_controller.js +1156 -395
  22. data/dist/controllers/conditional_fields_controller.js +300 -171
  23. data/dist/controllers/confirm_controller.js +980 -262
  24. data/dist/controllers/context_menu_controller.js +346 -153
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +281 -158
  27. data/dist/controllers/currency_input_controller.js +575 -225
  28. data/dist/controllers/data_grid_controller.js +248 -214
  29. data/dist/controllers/date_range_picker_controller.js +338 -235
  30. data/dist/controllers/dialog_controller.js +899 -237
  31. data/dist/controllers/direct_upload_controller.js +97 -167
  32. data/dist/controllers/dirty_form_controller.js +72 -69
  33. data/dist/controllers/dismissible_controller.js +196 -24
  34. data/dist/controllers/drawer_controller.js +974 -355
  35. data/dist/controllers/dropdown_controller.js +339 -96
  36. data/dist/controllers/editable_controller.js +315 -88
  37. data/dist/controllers/empty_state_controller.js +79 -58
  38. data/dist/controllers/file_dropzone_controller.js +322 -211
  39. data/dist/controllers/filter_controller.js +77 -42
  40. data/dist/controllers/flash_controller.js +238 -347
  41. data/dist/controllers/focus_controller.js +679 -240
  42. data/dist/controllers/form_field_controller.js +156 -132
  43. data/dist/controllers/form_validation_controller.js +31 -97
  44. data/dist/controllers/frame_loading_controller.js +275 -217
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +327 -130
  47. data/dist/controllers/idle_controller.js +271 -101
  48. data/dist/controllers/input_mask_controller.js +483 -97
  49. data/dist/controllers/intersection_controller.js +150 -106
  50. data/dist/controllers/lazy_frame_controller.js +24 -68
  51. data/dist/controllers/listbox_controller.js +523 -240
  52. data/dist/controllers/local_time_controller.js +102 -77
  53. data/dist/controllers/masonry_controller.js +80 -96
  54. data/dist/controllers/menu_controller.js +302 -188
  55. data/dist/controllers/menubar_controller.js +232 -345
  56. data/dist/controllers/meter_controller.js +417 -87
  57. data/dist/controllers/multi_select_controller.js +785 -393
  58. data/dist/controllers/navigation_menu_controller.js +190 -250
  59. data/dist/controllers/nested_form_controller.js +396 -192
  60. data/dist/controllers/network_status_controller.js +268 -62
  61. data/dist/controllers/number_input_controller.js +610 -303
  62. data/dist/controllers/optimistic_controller.js +45 -87
  63. data/dist/controllers/otp_controller.js +658 -255
  64. data/dist/controllers/overflow_indicator_controller.js +122 -113
  65. data/dist/controllers/overflow_menu_controller.js +470 -295
  66. data/dist/controllers/pagination_controller.js +332 -123
  67. data/dist/controllers/password_reveal_controller.js +392 -121
  68. data/dist/controllers/password_strength_controller.js +111 -158
  69. data/dist/controllers/persist_controller.js +165 -111
  70. data/dist/controllers/pointer_drag_controller.js +434 -188
  71. data/dist/controllers/popover_controller.js +269 -113
  72. data/dist/controllers/portal_controller.js +215 -76
  73. data/dist/controllers/preview_guard_controller.js +243 -116
  74. data/dist/controllers/progress_controller.js +391 -78
  75. data/dist/controllers/radio_group_controller.js +278 -211
  76. data/dist/controllers/range_slider_controller.js +220 -113
  77. data/dist/controllers/rating_controller.js +458 -211
  78. data/dist/controllers/read_more_controller.js +138 -63
  79. data/dist/controllers/reading_progress_controller.js +166 -137
  80. data/dist/controllers/relative_time_controller.js +211 -112
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
  82. data/dist/controllers/resizable_controller.js +299 -150
  83. data/dist/controllers/roving_controller.js +49 -88
  84. data/dist/controllers/scroll_area_controller.js +490 -246
  85. data/dist/controllers/scroll_restore_controller.js +14 -55
  86. data/dist/controllers/scroll_visibility_controller.js +432 -149
  87. data/dist/controllers/scrollspy_controller.js +193 -248
  88. data/dist/controllers/separator_controller.js +285 -142
  89. data/dist/controllers/sidebar_controller.js +1069 -404
  90. data/dist/controllers/skeleton_controller.js +259 -101
  91. data/dist/controllers/slider_controller.js +187 -86
  92. data/dist/controllers/smart_sticky_header_controller.js +173 -66
  93. data/dist/controllers/sortable_controller.js +26 -125
  94. data/dist/controllers/spinner_controller.js +288 -173
  95. data/dist/controllers/step_indicator_controller.js +105 -46
  96. data/dist/controllers/stepper_controller.js +120 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +275 -140
  98. data/dist/controllers/sticky_observer_controller.js +220 -49
  99. data/dist/controllers/submit_once_controller.js +376 -227
  100. data/dist/controllers/switch_controller.js +201 -84
  101. data/dist/controllers/tabs_controller.js +41 -30
  102. data/dist/controllers/tags_input_controller.js +293 -173
  103. data/dist/controllers/textarea_autosize_controller.js +108 -74
  104. data/dist/controllers/theme_controller.js +140 -116
  105. data/dist/controllers/time_picker_controller.js +331 -127
  106. data/dist/controllers/toast_controller.js +244 -324
  107. data/dist/controllers/toggle_group_controller.js +252 -191
  108. data/dist/controllers/toolbar_controller.js +219 -120
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +283 -369
  112. data/dist/index.js +8573 -10410
  113. data/dist/positioning/index.js +154 -47
  114. data/lib/generators/stimeo/install/install_generator.rb +7 -2
  115. data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
  116. data/lib/stimeo/ui/version.rb +1 -1
  117. metadata +3 -3
@@ -1,8 +1,12 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/spinner_controller.ts
3
+ function fillTemplate(template, values) {
4
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
5
+ const replacement = Object.hasOwn(values, name) ? values[name] : void 0;
6
+ return replacement === void 0 ? match : String(replacement);
7
+ });
8
+ }
4
9
 
5
- // src/utils/announce.ts
6
10
  function announce(message, options = {}) {
7
11
  const text = message.trim();
8
12
  if (text.length === 0) return;
@@ -12,131 +16,216 @@ function announce(message, options = {}) {
12
16
  })
13
17
  );
14
18
  }
15
- function fillTemplate(template, values) {
16
- return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
- const replacement = values[name];
18
- return replacement === void 0 ? match : String(replacement);
19
- });
19
+
20
+ function setAttributeIfChanged(element, name, value, beforeWrite) {
21
+ if (element.getAttribute(name) === value) return false;
22
+ if (value === null) element.removeAttribute(name);
23
+ else element.setAttribute(name, value);
24
+ return true;
20
25
  }
21
26
 
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);
27
+ function sharedRegistry(key, create) {
28
+ const symbol = Symbol.for(key);
29
+ const scope = globalThis;
30
+ const existing = scope[symbol];
31
+ if (existing !== void 0) return existing;
32
+ const registry2 = create();
33
+ Object.defineProperty(globalThis, symbol, { value: registry2 });
34
+ return registry2;
35
+ }
36
+
37
+ var registry = sharedRegistry(
38
+ "stimeo-ui.attribute-lease.registry.v1",
39
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
40
+ );
41
+ function isAttributeValue(value) {
42
+ return value === null || typeof value === "string";
43
+ }
44
+ function readLeaseRecord(element, record) {
45
+ try {
46
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
47
+ if (!Array.isArray(parsed)) return void 0;
48
+ const [original, written, ...rest] = parsed;
49
+ const linked = rest[0] !== false;
50
+ const beneath = linked ? rest : rest.slice(1);
51
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
52
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
53
+ } catch {
54
+ return void 0;
55
+ }
56
+ }
57
+ function attributeStack(element, attribute) {
58
+ const byAttribute = registry.stacks.get(element) ?? /* @__PURE__ */ new Map();
59
+ registry.stacks.set(element, byAttribute);
60
+ const known = byAttribute.get(attribute);
61
+ if (known) return known;
62
+ const found = [];
63
+ for (const record of registry.names.get(attribute) ?? []) {
64
+ const read = readLeaseRecord(element, record);
65
+ if (read) found.push({ ...read, record });
66
+ }
67
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
68
+ const stack = found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
69
+ lease: null,
70
+ record,
71
+ written,
72
+ original,
73
+ linked: linked && index > 0
74
+ }));
75
+ byAttribute.set(attribute, stack);
76
+ return stack;
77
+ }
78
+ function findHold(stack, lease, record) {
79
+ return stack.find((held) => held.lease === lease) ?? stack.find((held) => held.lease === null && held.record === record);
80
+ }
81
+ var AttributeLease = class {
82
+ #attribute;
83
+ #record;
84
+ #base;
85
+ #preserveAuthor;
86
+ #held = /* @__PURE__ */ new Set();
87
+ constructor(attribute, owner, options = {}) {
88
+ this.#attribute = attribute;
89
+ this.#record = `data-${owner}-${attribute}-lease`;
90
+ this.#base = options.base === true;
91
+ this.#preserveAuthor = options.preserveAuthor === true;
92
+ const names = registry.names;
93
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
94
+ }
95
+ write(element, value) {
96
+ const stack = attributeStack(element, this.#attribute);
97
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
98
+ this.#held.add(element);
99
+ const shows = !this.#covered(element, stack, hold);
100
+ hold.written = value;
101
+ this.#mark(element, stack);
102
+ if (shows) this.#reflect(element, value);
103
+ }
104
+ return(element) {
105
+ const held = this.#held.delete(element);
106
+ const known = registry.stacks.get(element)?.has(this.#attribute);
107
+ const stack = held || known || element.hasAttribute(this.#record) ? attributeStack(element, this.#attribute) : [];
108
+ const hold = this.#take(stack);
109
+ if (!hold) return false;
110
+ const carries = element.getAttribute(this.#attribute) === hold.written;
111
+ const owns = carries && !this.#covered(element, stack, hold);
112
+ const index = stack.indexOf(hold);
113
+ stack.splice(index, 1);
114
+ const above = stack[index];
115
+ if (above) above.linked &&= hold.linked;
116
+ this.#mark(element, stack);
117
+ if (!owns) return true;
118
+ const below = hold.linked ? stack[index - 1] : void 0;
119
+ this.#reflect(element, below ? below.written : hold.original);
120
+ return true;
121
+ }
122
+ returnAll() {
123
+ for (const element of Array.from(this.#held)) this.return(element);
124
+ }
125
+ #take(stack) {
126
+ const hold = findHold(stack, this, this.#record);
127
+ if (hold) hold.lease = this;
128
+ return hold;
129
+ }
130
+ #acquire(element, stack) {
131
+ const current = element.getAttribute(this.#attribute);
132
+ const top = stack.at(-1);
133
+ const bottom = stack[0];
134
+ const over = top?.written === current ? top : void 0;
135
+ const from = this.#base ? this.#preserveAuthor && !stack.some((held) => held.written === current) ? void 0 : bottom : over;
136
+ const hold = {
137
+ lease: this,
138
+ record: this.#record,
139
+ written: current,
140
+ original: from ? from.original : current,
141
+ linked: !this.#base && over !== void 0
142
+ };
143
+ if (this.#base && bottom) {
144
+ bottom.linked = true;
145
+ stack.unshift(hold);
146
+ } else stack.push(hold);
147
+ return hold;
148
+ }
149
+ #covered(element, stack, hold) {
150
+ const current = element.getAttribute(this.#attribute);
151
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
152
+ }
153
+ #mark(element, stack) {
154
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
155
+ stack.forEach((hold, index) => {
156
+ const names = stack.slice(0, index).map((under) => under.record);
157
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
158
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
159
+ records.set(
160
+ hold.record,
161
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
162
+ );
163
+ });
164
+ for (const [name, recorded] of records) {
165
+ setAttributeIfChanged(element, name, recorded);
41
166
  }
42
167
  }
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);
168
+ #reflect(element, value) {
169
+ setAttributeIfChanged(element, this.#attribute, value);
48
170
  }
49
171
  };
50
172
 
51
- // src/utils/default_attribute.ts
52
173
  function setDefaultAttribute(element, name, value) {
53
174
  if (element.hasAttribute(name)) return false;
54
175
  element.setAttribute(name, value);
55
176
  return true;
56
177
  }
57
178
 
58
- // src/utils/detach_gate.ts
59
179
  var DetachGate = class _DetachGate {
60
- /** Set while a probe is queued, waiting for a reconnect to cancel it. */
61
180
  #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
181
  get pending() {
70
182
  return this.#pending;
71
183
  }
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
184
  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
- */
185
+ return !host.element.isConnected || !_DetachGate.#listed(host);
186
+ }
89
187
  disconnected(host, teardown) {
90
- if (_DetachGate.isDetached(host)) {
188
+ const carried = _DetachGate.#carried(host);
189
+ if (!carried && _DetachGate.isDetached(host)) {
91
190
  this.#pending = false;
92
191
  teardown();
93
192
  return;
94
193
  }
95
194
  this.#pending = true;
96
- queueMicrotask(() => {
195
+ const probe = () => {
97
196
  if (!this.#pending) return;
98
197
  this.#pending = false;
99
198
  teardown();
100
- });
199
+ };
200
+ if (carried) setTimeout(probe, 0);
201
+ else queueMicrotask(probe);
202
+ }
203
+ static #carried(host) {
204
+ const element = host.element;
205
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
206
+ }
207
+ static #listed(host) {
208
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
209
+ return tokens.includes(host.identifier);
101
210
  }
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
211
  cancel() {
109
212
  this.#pending = false;
110
213
  }
111
214
  };
112
215
 
113
- // src/utils/min_duration_floor.ts
114
216
  var MinDurationFloor = class {
115
217
  #timers;
116
- /** Pending finish timer id, or `null` when nothing is held back. */
117
218
  #timerId = null;
118
- /** Epoch ms the floor is measured from. */
119
219
  #since = 0;
120
- /** @param timers - the controller's registry; the floor schedules into it. */
121
220
  constructor(timers) {
122
221
  this.#timers = timers;
123
222
  }
124
- /** Starts the floor: call when the state being held becomes visible. */
125
223
  begin() {
126
224
  this.#since = Date.now();
127
225
  }
128
- /** True while a finish is held back waiting for the floor to elapse. */
129
226
  get pending() {
130
227
  return this.#timerId !== null;
131
228
  }
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
229
  schedule(minDuration, finish) {
141
230
  this.cancel();
142
231
  const remaining = minDuration - (Date.now() - this.#since);
@@ -149,7 +238,6 @@ var MinDurationFloor = class {
149
238
  finish();
150
239
  }
151
240
  }
152
- /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
153
241
  cancel() {
154
242
  if (this.#timerId !== null) {
155
243
  this.#timers.clear(this.#timerId);
@@ -158,43 +246,70 @@ var MinDurationFloor = class {
158
246
  }
159
247
  };
160
248
 
161
- // src/utils/safe_timeout.ts
249
+ var MAX_TIMER_DELAY_MS = 2147483647;
250
+
251
+ var NUMBER_BOUNDS = {
252
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
253
+ function matchesNumberBounds(value, bounds) {
254
+ if (!Number.isFinite(value)) {
255
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
256
+ if (direction === null) return false;
257
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
258
+ }
259
+ if (bounds.min !== void 0 && value < bounds.min) return false;
260
+ if (bounds.max !== void 0 && value > bounds.max) return false;
261
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
262
+ if (bounds.integer && !Number.isInteger(value)) return false;
263
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
264
+ return true;
265
+ }
266
+
267
+ function readNumber(raw, fallback, bounds) {
268
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
269
+ }
270
+
271
+ var NumberValueReader = class {
272
+ #lastRejected = /* @__PURE__ */ new Map();
273
+ read(owner, name, raw, fallback, bounds) {
274
+ const resolved = readNumber(raw, fallback, bounds);
275
+ if (matchesNumberBounds(raw, bounds)) {
276
+ this.#lastRejected.delete(name);
277
+ return resolved;
278
+ }
279
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
280
+ const literal = owner.element.getAttribute(attribute);
281
+ if (literal === null) {
282
+ this.#lastRejected.delete(name);
283
+ return resolved;
284
+ }
285
+ if (this.#lastRejected.get(name) !== literal) {
286
+ this.#lastRejected.set(name, literal);
287
+ console.warn(
288
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
289
+ );
290
+ }
291
+ return resolved;
292
+ }
293
+ };
294
+
162
295
  var TimerRegistry = class {
163
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
164
296
  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
297
  clear(id) {
172
298
  if (this.ids.delete(id)) {
173
299
  this.cancel(id);
174
300
  }
175
301
  }
176
- /**
177
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
178
- * to guarantee no timer outlives the element.
179
- */
180
302
  clearAll() {
181
303
  for (const id of this.ids) {
182
304
  this.cancel(id);
183
305
  }
184
306
  this.ids.clear();
185
307
  }
186
- /** Number of timers currently tracked (pending). */
187
308
  get size() {
188
309
  return this.ids.size;
189
310
  }
190
311
  };
191
312
  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
313
  set(callback, delay) {
199
314
  const id = this.schedule(() => {
200
315
  this.ids.delete(id);
@@ -211,8 +326,8 @@ var SafeTimeout = class extends TimerRegistry {
211
326
  }
212
327
  };
213
328
 
214
- // src/controllers/spinner_controller.ts
215
- var SpinnerController = class extends Controller {
329
+ var SpinnerController = class _SpinnerController extends Controller {
330
+ #numbers = new NumberValueReader();
216
331
  static targets = ["indicator", "region", "message"];
217
332
  static values = {
218
333
  announceText: { type: String, default: "" },
@@ -221,42 +336,52 @@ var SpinnerController = class extends Controller {
221
336
  minDuration: { type: Number, default: 0 },
222
337
  timeout: { type: Number, default: 0 }
223
338
  };
339
+ static valueConstraints = {
340
+ delay: NUMBER_BOUNDS.timer,
341
+ minDuration: NUMBER_BOUNDS.timer,
342
+ timeout: NUMBER_BOUNDS.timer
343
+ };
224
344
  static actions = ["start", "stop"];
225
345
  static events = ["hide", "show", "timeout", "reconcile"];
226
346
  #timers = new SafeTimeout();
227
347
  #floor = new MinDurationFloor(this.#timers);
228
348
  #gate = new DetachGate();
229
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
230
- /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
349
+ #busyLease = new AttributeLease("aria-busy", this.identifier);
350
+ #hiddenLease = new AttributeLease("hidden", this.identifier);
351
+ #connected = false;
352
+ #cycling = false;
231
353
  #delayTimerId = null;
232
- /** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
233
354
  #timeoutTimerId = null;
234
355
  connect() {
356
+ this.#connected = true;
235
357
  this.#gate.cancel();
236
- this.#beforeCache.activate();
237
- if (this.#state === "pending" && this.#delayTimerId === null) {
358
+ if (this.#state !== "idle" && !this.#cycling) {
238
359
  this.#setBusy(false);
360
+ this.#showIndicator(false);
239
361
  this.element.setAttribute("data-state", "idle");
362
+ this.dispatch("reconcile", { detail: {} });
240
363
  return;
241
364
  }
242
365
  setDefaultAttribute(this.element, "data-state", "idle");
243
366
  }
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
367
  indicatorTargetConnected(target) {
253
- target.hidden = this.#state !== "loading";
368
+ this.#hiddenLease.write(target, this.#state === "loading" ? null : "");
369
+ }
370
+ indicatorTargetDisconnected(indicator) {
371
+ if (!this.indicatorTargets.includes(indicator)) this.#hiddenLease.return(indicator);
372
+ if (this.#connected) this.#showIndicator(this.#state === "loading");
373
+ }
374
+ regionTargetConnected(region) {
375
+ if (this.#connected) this.#busyLease.write(region, String(this.#busy));
376
+ }
377
+ regionTargetDisconnected(region) {
378
+ if (!this.regionTargets.includes(region)) this.#busyLease.return(region);
379
+ if (this.#connected) this.#setBusy(this.#busy);
254
380
  }
255
381
  disconnect() {
256
- this.#beforeCache.deactivate();
382
+ this.#connected = false;
257
383
  this.#gate.disconnected(this, () => this.#teardown());
258
384
  }
259
- /** Begins loading. Honors `delay` before the spinner actually appears. */
260
385
  start() {
261
386
  if (this.#state === "loading") {
262
387
  this.#setBusy(true);
@@ -265,107 +390,68 @@ var SpinnerController = class extends Controller {
265
390
  return;
266
391
  }
267
392
  if (this.#state !== "idle") return;
393
+ this.#cycling = true;
268
394
  this.#setBusy(true);
269
395
  this.#floor.cancel();
270
396
  this.#armTimeout();
271
- if (this.delayValue > 0) {
397
+ if (this.#safeDelay > 0) {
272
398
  this.element.setAttribute("data-state", "pending");
273
399
  this.#delayTimerId = this.#timers.set(() => {
274
400
  this.#delayTimerId = null;
275
401
  this.#show();
276
- }, this.delayValue);
402
+ }, this.#safeDelay);
277
403
  } else {
278
404
  this.#show();
279
405
  }
280
406
  }
281
- /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
282
407
  stop() {
283
408
  const state = this.#state;
284
409
  this.#cancelTimeout();
285
410
  if (state === "pending") {
286
411
  this.#cancelDelay();
412
+ this.#cycling = false;
287
413
  this.#setBusy(false);
288
414
  this.element.setAttribute("data-state", "idle");
289
415
  return;
290
416
  }
291
417
  if (state !== "loading") return;
292
418
  this.#setBusy(false);
293
- this.#floor.schedule(this.minDurationValue, () => this.#hide());
419
+ this.#floor.schedule(this.#safeMinDuration, () => this.#hide());
294
420
  }
295
- /**
296
- * Reveals the indicator, marks the moment shown, and announces via the live region.
297
- *
298
- * @stimeoRuntimeOnly `announceText` is the wording of the one announcement this show makes.
299
- */
300
421
  #show() {
301
422
  this.#floor.begin();
302
- this.#setBusy(true);
303
- if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
423
+ this.#showIndicator(true);
304
424
  this.element.setAttribute("data-state", "loading");
305
425
  this.dispatch("show", { detail: {} });
306
426
  announce(fillTemplate(this.announceTextValue, {}));
307
427
  }
308
- /**
309
- * Hides the indicator and returns to the idle state.
310
- *
311
- * @stimeoRuntimeOnly `announceReadyText` is the wording of the one announcement this hide makes.
312
- */
313
428
  #hide() {
314
- if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
429
+ this.#cycling = false;
430
+ this.#showIndicator(false);
315
431
  this.element.setAttribute("data-state", "idle");
316
432
  this.dispatch("hide", { detail: {} });
317
433
  announce(fillTemplate(this.announceReadyTextValue, {}));
318
434
  }
319
- /**
320
- * Drops both timers on a real detach. The markup keeps whatever it last held: an
321
- * element on its way out of the document has no reader left, and one whose
322
- * `data-controller` dropped the identifier no longer resolves its own targets, so
323
- * the rollback could only ever be partial. The snapshot is rewound where it is
324
- * still whole, on `turbo:before-cache`.
325
- */
326
435
  #teardown() {
327
- this.#gate.cancel();
328
436
  this.#timers.clearAll();
329
437
  this.#delayTimerId = null;
330
438
  this.#timeoutTimerId = null;
331
- this.#floor.cancel();
439
+ this.#cycling = false;
332
440
  }
333
- /**
334
- * Returns the loading state to idle for the snapshot Turbo is about to take,
335
- * so a page reached with the Back button is not restored mid-load with a
336
- * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
337
- * and `aria-busy`. No `hide` is dispatched — that would claim the load finished.
338
- * The rewind reports itself as `reconcile` instead, so a consumer painting from
339
- * `show` can drop it before the snapshot is taken. The live page keeps its
340
- * timers, so a navigation that never completes leaves the running cycle intact.
341
- */
342
- #rewindForCache() {
343
- const running = this.element.getAttribute("data-state") !== "idle";
344
- this.#cancelTimeout();
345
- this.#setBusy(false);
346
- if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
347
- this.element.setAttribute("data-state", "idle");
348
- if (running) this.dispatch("reconcile", { detail: {} });
441
+ #showIndicator(shown) {
442
+ if (this.hasIndicatorTarget) this.#hiddenLease.write(this.indicatorTarget, shown ? null : "");
349
443
  }
350
- /** Reflects busy state onto the controlled region (if present). */
351
444
  #setBusy(busy) {
352
- if (this.hasRegionTarget) {
353
- this.regionTarget.setAttribute("aria-busy", String(busy));
354
- }
445
+ if (this.hasRegionTarget) this.#busyLease.write(this.regionTarget, String(busy));
355
446
  }
356
- /**
357
- * Arms the safety net so a `stop` that never arrives cannot strand the spinner.
358
- * Off by default: the consumer owns the async work, so only it knows whether a
359
- * ceiling makes sense. Re-arming on a restart measures from the newest start.
360
- */
361
447
  #armTimeout() {
362
448
  this.#cancelTimeout();
363
- if (this.timeoutValue <= 0) return;
449
+ if (this.#safeTimeout <= 0) return;
364
450
  this.#timeoutTimerId = this.#timers.set(() => {
365
451
  this.#timeoutTimerId = null;
366
452
  this.dispatch("timeout", { detail: {} });
367
453
  this.stop();
368
- }, this.timeoutValue);
454
+ }, this.#safeTimeout);
369
455
  }
370
456
  #cancelTimeout() {
371
457
  if (this.#timeoutTimerId !== null) {
@@ -379,12 +465,41 @@ var SpinnerController = class extends Controller {
379
465
  this.#delayTimerId = null;
380
466
  }
381
467
  }
382
- /** Current lifecycle phase as reflected on `data-state`. */
383
468
  get #state() {
384
469
  return this.element.getAttribute("data-state") ?? "idle";
385
470
  }
471
+ get #busy() {
472
+ const state = this.#state;
473
+ return state === "pending" || state === "loading" && !this.#floor.pending;
474
+ }
475
+ get #safeDelay() {
476
+ return this.#numbers.read(
477
+ this,
478
+ "delay",
479
+ this.delayValue,
480
+ _SpinnerController.values.delay.default,
481
+ _SpinnerController.valueConstraints.delay
482
+ );
483
+ }
484
+ get #safeMinDuration() {
485
+ return this.#numbers.read(
486
+ this,
487
+ "minDuration",
488
+ this.minDurationValue,
489
+ _SpinnerController.values.minDuration.default,
490
+ _SpinnerController.valueConstraints.minDuration
491
+ );
492
+ }
493
+ get #safeTimeout() {
494
+ return this.#numbers.read(
495
+ this,
496
+ "timeout",
497
+ this.timeoutValue,
498
+ _SpinnerController.values.timeout.default,
499
+ _SpinnerController.valueConstraints.timeout
500
+ );
501
+ }
386
502
  };
387
503
 
388
504
  export { SpinnerController };
389
- //# sourceMappingURL=spinner_controller.js.map
390
505
  //# sourceMappingURL=spinner_controller.js.map