stimeo-ui 0.14.0 → 0.15.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/dist/cable/index.js +60 -14
  4. data/dist/controllers/announcer_controller.js +88 -61
  5. data/dist/controllers/auto_submit_controller.js +40 -5
  6. data/dist/controllers/breadcrumb_controller.js +29 -3
  7. data/dist/controllers/calendar_controller.js +20 -3
  8. data/dist/controllers/checkbox_controller.js +27 -2
  9. data/dist/controllers/conditional_fields_controller.js +29 -6
  10. data/dist/controllers/currency_input_controller.js +16 -9
  11. data/dist/controllers/date_range_picker_controller.js +12 -3
  12. data/dist/controllers/dirty_form_controller.js +32 -12
  13. data/dist/controllers/flash_controller.js +145 -43
  14. data/dist/controllers/frame_loading_controller.js +29 -6
  15. data/dist/controllers/highlight_controller.js +63 -23
  16. data/dist/controllers/local_time_controller.js +10 -5
  17. data/dist/controllers/masonry_controller.js +31 -13
  18. data/dist/controllers/optimistic_controller.js +28 -4
  19. data/dist/controllers/overflow_indicator_controller.js +31 -8
  20. data/dist/controllers/overflow_menu_controller.js +29 -3
  21. data/dist/controllers/read_more_controller.js +31 -5
  22. data/dist/controllers/reading_progress_controller.js +65 -19
  23. data/dist/controllers/relative_time_controller.js +10 -5
  24. data/dist/controllers/scroll_area_controller.js +70 -26
  25. data/dist/controllers/scroll_restore_controller.js +37 -16
  26. data/dist/controllers/scroll_visibility_controller.js +49 -29
  27. data/dist/controllers/scrollspy_controller.js +65 -26
  28. data/dist/controllers/smart_sticky_header_controller.js +49 -25
  29. data/dist/controllers/stick_to_bottom_controller.js +29 -3
  30. data/dist/controllers/submit_once_controller.js +29 -6
  31. data/dist/controllers/textarea_autosize_controller.js +29 -3
  32. data/dist/controllers/toast_controller.js +136 -73
  33. data/dist/index.js +473 -435
  34. data/lib/stimeo/ui/version.rb +1 -1
  35. metadata +2 -2
@@ -8,10 +8,15 @@ var LayoutObserver = class {
8
8
  #resizeObserverFactory;
9
9
  #resizeObserver = null;
10
10
  #observingViewport = false;
11
+ #loadContainer = null;
11
12
  /** Stable bound handler so add/removeEventListener target the same reference. */
12
13
  #handleViewportResize = () => {
13
14
  this.#callback();
14
15
  };
16
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
17
+ #handleDescendantLoad = () => {
18
+ this.#callback();
19
+ };
15
20
  constructor(callback, options = {}) {
16
21
  this.#callback = callback;
17
22
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -47,14 +52,35 @@ var LayoutObserver = class {
47
52
  window.removeEventListener("resize", this.#handleViewportResize);
48
53
  }
49
54
  /**
50
- * Releases every observation: disconnects the {@link ResizeObserver} and
51
- * removes the viewport listener. Safe to call multiple times. Call this from a
52
- * controller's `disconnect()`.
55
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
56
+ * frame settling changes the box it sits in, and it measures as zero high until
57
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
58
+ * on the container itself and nothing the caller spells.
59
+ *
60
+ * **One container at a time.** A further call moves the observation, so a widget
61
+ * whose content element is swapped at runtime releases the element it let go by
62
+ * naming the new one — there is no second place for the release to drift from.
63
+ */
64
+ observeDescendantLoads(container) {
65
+ this.unobserveDescendantLoads();
66
+ this.#loadContainer = container;
67
+ container.addEventListener("load", this.#handleDescendantLoad, true);
68
+ }
69
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
70
+ unobserveDescendantLoads() {
71
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
72
+ this.#loadContainer = null;
73
+ }
74
+ /**
75
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
76
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
77
+ * this from a controller's `disconnect()`.
53
78
  */
54
79
  disconnect() {
55
80
  this.#resizeObserver?.disconnect();
56
81
  this.#resizeObserver = null;
57
82
  this.unobserveViewport();
83
+ this.unobserveDescendantLoads();
58
84
  }
59
85
  };
60
86
 
@@ -138,6 +138,30 @@ var DetachGate = class _DetachGate {
138
138
  }
139
139
  };
140
140
 
141
+ // src/utils/listener_set.ts
142
+ var ListenerSet = class {
143
+ /** The generation every `add` joins until the next `dispose()`. */
144
+ #abort = new AbortController();
145
+ /**
146
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
147
+ *
148
+ * The set supplies the signal, so `options` carries everything else the DOM
149
+ * accepts — `capture` included, which has to match at release time and no
150
+ * longer has a second place to drift from.
151
+ */
152
+ add(target, type, handler, options) {
153
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
154
+ }
155
+ /**
156
+ * Releases every listener of the open generation, synchronously, and opens the
157
+ * next one. Idempotent, and safe before anything has been added.
158
+ */
159
+ dispose() {
160
+ this.#abort.abort();
161
+ this.#abort = new AbortController();
162
+ }
163
+ };
164
+
141
165
  // src/utils/safe_timeout.ts
142
166
  var TimerRegistry = class {
143
167
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -206,6 +230,7 @@ var SubmitOnceController = class extends Controller {
206
230
  static events = ["start", "end", "reconcile"];
207
231
  #timers = new SafeTimeout();
208
232
  #gate = new DetachGate();
233
+ #listeners = new ListenerSet();
209
234
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
210
235
  #sessions = /* @__PURE__ */ new Map();
211
236
  #disabled = new AttributeLease("disabled");
@@ -230,14 +255,12 @@ var SubmitOnceController = class extends Controller {
230
255
  connect() {
231
256
  this.#gate.cancel();
232
257
  this.#beforeCache.activate();
233
- this.element.addEventListener("submit", this.#onNativeSubmit, true);
234
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
235
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
258
+ this.#listeners.add(this.element, "submit", this.#onNativeSubmit, { capture: true });
259
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
260
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
236
261
  }
237
262
  disconnect() {
238
- this.element.removeEventListener("submit", this.#onNativeSubmit, true);
239
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
240
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
263
+ this.#listeners.dispose();
241
264
  this.#beforeCache.deactivate();
242
265
  this.#gate.disconnected(this, () => this.#teardown());
243
266
  }
@@ -8,10 +8,15 @@ var LayoutObserver = class {
8
8
  #resizeObserverFactory;
9
9
  #resizeObserver = null;
10
10
  #observingViewport = false;
11
+ #loadContainer = null;
11
12
  /** Stable bound handler so add/removeEventListener target the same reference. */
12
13
  #handleViewportResize = () => {
13
14
  this.#callback();
14
15
  };
16
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
17
+ #handleDescendantLoad = () => {
18
+ this.#callback();
19
+ };
15
20
  constructor(callback, options = {}) {
16
21
  this.#callback = callback;
17
22
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -47,14 +52,35 @@ var LayoutObserver = class {
47
52
  window.removeEventListener("resize", this.#handleViewportResize);
48
53
  }
49
54
  /**
50
- * Releases every observation: disconnects the {@link ResizeObserver} and
51
- * removes the viewport listener. Safe to call multiple times. Call this from a
52
- * controller's `disconnect()`.
55
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
56
+ * frame settling changes the box it sits in, and it measures as zero high until
57
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
58
+ * on the container itself and nothing the caller spells.
59
+ *
60
+ * **One container at a time.** A further call moves the observation, so a widget
61
+ * whose content element is swapped at runtime releases the element it let go by
62
+ * naming the new one — there is no second place for the release to drift from.
63
+ */
64
+ observeDescendantLoads(container) {
65
+ this.unobserveDescendantLoads();
66
+ this.#loadContainer = container;
67
+ container.addEventListener("load", this.#handleDescendantLoad, true);
68
+ }
69
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
70
+ unobserveDescendantLoads() {
71
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
72
+ this.#loadContainer = null;
73
+ }
74
+ /**
75
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
76
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
77
+ * this from a controller's `disconnect()`.
53
78
  */
54
79
  disconnect() {
55
80
  this.#resizeObserver?.disconnect();
56
81
  this.#resizeObserver = null;
57
82
  this.unobserveViewport();
83
+ this.unobserveDescendantLoads();
58
84
  }
59
85
  };
60
86
 
@@ -55,6 +55,121 @@ var SafeTimeout = class extends TimerRegistry {
55
55
  }
56
56
  };
57
57
 
58
+ // src/utils/keyed_timers.ts
59
+ var KeyedTimers = class {
60
+ #timers = new SafeTimeout();
61
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
62
+ #pending = /* @__PURE__ */ new Map();
63
+ /**
64
+ * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
65
+ * pending. The entry is dropped before the callback runs, so the callback sees
66
+ * the key unarmed and may arm it again for the next round.
67
+ */
68
+ set(key, callback, delay) {
69
+ this.clear(key);
70
+ const id = this.#timers.set(() => {
71
+ this.#pending.delete(key);
72
+ callback();
73
+ }, delay);
74
+ this.#pending.set(key, id);
75
+ }
76
+ /**
77
+ * Cancels `key`'s pending timer, if it has one.
78
+ *
79
+ * A timer id is a positive integer, so `-1` stands for "nothing pending" and
80
+ * the registry ignores an id it does not own — the unarmed case needs no
81
+ * branch of its own, and no other key's timer can be reached from here.
82
+ */
83
+ clear(key) {
84
+ this.#timers.clear(this.#pending.get(key) ?? -1);
85
+ this.#pending.delete(key);
86
+ }
87
+ /**
88
+ * Cancels every pending timer and forgets every key. Call this from a
89
+ * controller's `disconnect()` so no timer, and no entry, outlives the element.
90
+ */
91
+ clearAll() {
92
+ this.#timers.clearAll();
93
+ this.#pending.clear();
94
+ }
95
+ /** Whether `key` has a timer pending. */
96
+ has(key) {
97
+ return this.#pending.has(key);
98
+ }
99
+ };
100
+
101
+ // src/utils/pausable_timers.ts
102
+ var PausableTimers = class {
103
+ #timers = new KeyedTimers();
104
+ /** Every key armed or held; an entry goes as its timer fires, or with the key. */
105
+ #entries = /* @__PURE__ */ new Map();
106
+ /**
107
+ * Arms `callback` after `delay` ms for `key`, replacing whatever `key` had.
108
+ * A held key stays held and banks `delay` for its resume.
109
+ */
110
+ set(key, callback, delay) {
111
+ const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
112
+ const entry = { callback, startedAt: 0, remaining: delay, reasons };
113
+ this.#entries.set(key, entry);
114
+ if (reasons.size === 0) this.#arm(key, entry);
115
+ }
116
+ /**
117
+ * Holds `key` for `reason` and banks the time left, with a floor of one
118
+ * millisecond. Reports whether this call is the one that stopped a running
119
+ * timer, so the caller can write its held-state hook exactly once.
120
+ */
121
+ pause(key, reason) {
122
+ const entry = this.#entries.get(key);
123
+ if (entry === void 0) return false;
124
+ entry.reasons.add(reason);
125
+ if (!this.#timers.has(key)) return false;
126
+ this.#timers.clear(key);
127
+ entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
128
+ return true;
129
+ }
130
+ /**
131
+ * Releases `reason` on `key`, arming the banked time again once no reason is
132
+ * left. Reports whether this call is the one that started the timer again.
133
+ */
134
+ resume(key, reason) {
135
+ const entry = this.#entries.get(key);
136
+ if (entry === void 0) return false;
137
+ entry.reasons.delete(reason);
138
+ if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
139
+ this.#arm(key, entry);
140
+ return true;
141
+ }
142
+ /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
143
+ tracks(key) {
144
+ return this.#entries.has(key);
145
+ }
146
+ /** Cancels `key`'s timer and drops its hold. */
147
+ clear(key) {
148
+ this.#timers.clear(key);
149
+ this.#entries.delete(key);
150
+ }
151
+ /**
152
+ * Cancels every timer and forgets every key. Call this from a controller's
153
+ * `disconnect()` so neither a timer nor a hold outlives the element.
154
+ */
155
+ clearAll() {
156
+ this.#timers.clearAll();
157
+ this.#entries.clear();
158
+ }
159
+ /** Starts `entry`'s banked time running for `key`, and drops it as it fires. */
160
+ #arm(key, entry) {
161
+ entry.startedAt = Date.now();
162
+ this.#timers.set(
163
+ key,
164
+ () => {
165
+ this.#entries.delete(key);
166
+ entry.callback();
167
+ },
168
+ entry.remaining
169
+ );
170
+ }
171
+ };
172
+
58
173
  // src/utils/transition_completion.ts
59
174
  function timeMs(value) {
60
175
  const trimmed = value.trim();
@@ -99,30 +214,23 @@ var ToastController = class extends Controller {
99
214
  };
100
215
  static actions = ["dismiss", "onKeydown", "pause", "resume", "show"];
101
216
  static events = ["dismiss", "show"];
102
- /**
103
- * Registry for every auto-dismiss and transition-finalize timer the controller
104
- * schedules. `SafeTimeout` owns *registration and teardown only*; the
105
- * pause/resume remaining-time accounting stays in `#activeTimeouts` so the
106
- * per-widget WCAG 2.2.1 semantics are not flattened into the helper.
107
- */
217
+ /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
108
218
  #timers = new SafeTimeout();
219
+ /** Per-toast auto-dismiss, held open while the toast is hovered or focused. */
220
+ #dismiss = new PausableTimers();
109
221
  /**
110
222
  * Pending one-shot `requestAnimationFrame` handles (the entering→visible flip).
111
223
  * Tracked so {@link disconnect} can cancel any that have not fired, preventing a
112
224
  * detached element from being mutated after it leaves the DOM (Turbo).
113
225
  */
114
226
  #rafHandles = /* @__PURE__ */ new Map();
115
- /** Track active timeouts mapped by each toast element for safe cancellation. */
116
- #activeTimeouts = /* @__PURE__ */ new Map();
117
- /** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
118
- #pauseReasons = /* @__PURE__ */ new Map();
119
227
  /** The stable list that owns delegated listeners for dynamically added items. */
120
228
  #delegatedList = null;
121
229
  connect() {
122
230
  this.#connectDelegatedEvents();
123
231
  this.enforceMaxLimit();
124
232
  for (const item of this.itemTargets) {
125
- if (!this.#activeTimeouts.has(item) && item.dataset.state !== "leaving") {
233
+ if (!this.#dismiss.tracks(item) && item.dataset.state !== "leaving") {
126
234
  this.#startTimer(item);
127
235
  }
128
236
  }
@@ -130,12 +238,11 @@ var ToastController = class extends Controller {
130
238
  disconnect() {
131
239
  this.#disconnectDelegatedEvents();
132
240
  this.#timers.clearAll();
241
+ this.#dismiss.clearAll();
133
242
  for (const handle of this.#rafHandles.values()) {
134
243
  window.cancelAnimationFrame(handle);
135
244
  }
136
245
  this.#rafHandles.clear();
137
- this.#activeTimeouts.clear();
138
- this.#pauseReasons.clear();
139
246
  }
140
247
  /** Rebinds delegated interaction when Turbo replaces the list target in place. */
141
248
  listTargetConnected(element) {
@@ -148,18 +255,9 @@ var ToastController = class extends Controller {
148
255
  durationValueChanged() {
149
256
  for (const item of this.itemTargets) {
150
257
  if (item.dataset.state === "leaving") continue;
151
- const pauseReasons = this.#pauseReasons.get(item);
152
- this.#clearTimer(item);
153
258
  if (this.durationValue <= 0) {
259
+ this.#dismiss.clear(item);
154
260
  item.removeAttribute("data-paused");
155
- } else if (pauseReasons && pauseReasons.size > 0) {
156
- this.#pauseReasons.set(item, pauseReasons);
157
- this.#activeTimeouts.set(item, {
158
- id: 0,
159
- startedAt: 0,
160
- remaining: this.durationValue
161
- });
162
- item.setAttribute("data-paused", "true");
163
261
  } else {
164
262
  this.#startTimer(item);
165
263
  }
@@ -188,7 +286,7 @@ var ToastController = class extends Controller {
188
286
  }
189
287
  /** Clears any active timer when a toast is removed from the DOM. */
190
288
  itemTargetDisconnected(element) {
191
- this.#clearTimer(element);
289
+ this.#dismiss.clear(element);
192
290
  this.#cancelAnimation(element);
193
291
  }
194
292
  /**
@@ -260,35 +358,16 @@ var ToastController = class extends Controller {
260
358
  */
261
359
  pause(event) {
262
360
  const item = this.#itemFromEvent(event);
263
- if (!item || this.durationValue <= 0) return;
264
- const timeout = this.#activeTimeouts.get(item);
265
- if (!timeout) return;
266
- const reasons = this.#pauseReasonsFor(item);
267
- const wasActive = reasons.size > 0;
268
- reasons.add(this.#pauseReason(event));
269
- if (wasActive || timeout.id === 0) return;
270
- this.#timers.clear(timeout.id);
271
- const elapsed = Date.now() - timeout.startedAt;
272
- const remaining = Math.max(0, timeout.remaining - elapsed);
273
- if (remaining <= 0) {
274
- this.#removeWithTransition(item, "timeout");
275
- return;
361
+ if (item && this.#dismiss.pause(item, this.#pauseReason(event))) {
362
+ item.setAttribute("data-paused", "true");
276
363
  }
277
- this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
278
- item.setAttribute("data-paused", "true");
279
364
  }
280
365
  /** Resumes the auto-dismiss timer once both hover and focus have been released. */
281
366
  resume(event) {
282
367
  const item = this.#itemFromEvent(event);
283
- if (!item || this.durationValue <= 0) return;
284
- const reasons = this.#pauseReasonsFor(item);
285
- reasons.delete(this.#pauseReason(event));
286
- if (reasons.size > 0) return;
287
- const timeout = this.#activeTimeouts.get(item);
288
- if (!timeout) return;
289
- if (timeout.id !== 0 || timeout.remaining <= 0) return;
290
- item.removeAttribute("data-paused");
291
- this.#startTimer(item, timeout.remaining);
368
+ if (item && this.#dismiss.resume(item, this.#pauseReason(event))) {
369
+ item.removeAttribute("data-paused");
370
+ }
292
371
  }
293
372
  /** Resolves the toast item element a pause/resume event targets. */
294
373
  #itemFromEvent(event) {
@@ -301,36 +380,20 @@ var ToastController = class extends Controller {
301
380
  #pauseReason(event) {
302
381
  return event.type === "focusin" || event.type === "focusout" ? "focus" : "hover";
303
382
  }
304
- /** Lazily creates and returns the active pause-reason set for an item. */
305
- #pauseReasonsFor(item) {
306
- let reasons = this.#pauseReasons.get(item);
307
- if (!reasons) {
308
- reasons = /* @__PURE__ */ new Set();
309
- this.#pauseReasons.set(item, reasons);
310
- }
311
- return reasons;
312
- }
313
- #startTimer(element, duration = this.durationValue) {
314
- if (duration <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
383
+ /** Arms a toast's auto-dismiss; a non-positive `duration` means it never expires. */
384
+ #startTimer(element) {
385
+ if (this.durationValue <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
315
386
  return;
316
387
  }
317
- this.#clearTimer(element);
318
- const id = this.#timers.set(() => {
319
- this.#removeWithTransition(element, "timeout");
320
- }, duration);
321
- this.#activeTimeouts.set(element, { id, startedAt: Date.now(), remaining: duration });
322
- }
323
- #clearTimer(element) {
324
- const timeout = this.#activeTimeouts.get(element);
325
- if (timeout) {
326
- if (timeout.id) this.#timers.clear(timeout.id);
327
- this.#activeTimeouts.delete(element);
328
- }
329
- this.#pauseReasons.delete(element);
388
+ this.#dismiss.set(
389
+ element,
390
+ () => this.#removeWithTransition(element, "timeout"),
391
+ this.durationValue
392
+ );
330
393
  }
331
394
  #removeWithTransition(element, reason) {
332
395
  if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
333
- this.#clearTimer(element);
396
+ this.#dismiss.clear(element);
334
397
  this.#cancelAnimation(element);
335
398
  element.setAttribute("data-state", "leaving");
336
399
  const finalize = () => {