stimeo-ui 0.13.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -5
  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 +51 -10
  8. data/dist/controllers/carousel_controller.js +8 -6
  9. data/dist/controllers/checkbox_controller.js +27 -2
  10. data/dist/controllers/conditional_fields_controller.js +36 -7
  11. data/dist/controllers/currency_input_controller.js +77 -25
  12. data/dist/controllers/date_range_picker_controller.js +43 -10
  13. data/dist/controllers/direct_upload_controller.js +21 -10
  14. data/dist/controllers/dirty_form_controller.js +32 -12
  15. data/dist/controllers/flash_controller.js +145 -43
  16. data/dist/controllers/form_field_controller.js +7 -1
  17. data/dist/controllers/frame_loading_controller.js +29 -6
  18. data/dist/controllers/highlight_controller.js +63 -23
  19. data/dist/controllers/input_mask_controller.js +33 -12
  20. data/dist/controllers/intersection_controller.js +10 -5
  21. data/dist/controllers/lazy_frame_controller.js +10 -5
  22. data/dist/controllers/local_time_controller.js +43 -11
  23. data/dist/controllers/masonry_controller.js +32 -14
  24. data/dist/controllers/menu_controller.js +10 -2
  25. data/dist/controllers/menubar_controller.js +10 -2
  26. data/dist/controllers/navigation_menu_controller.js +8 -2
  27. data/dist/controllers/optimistic_controller.js +28 -4
  28. data/dist/controllers/otp_controller.js +23 -10
  29. data/dist/controllers/overflow_indicator_controller.js +31 -8
  30. data/dist/controllers/overflow_menu_controller.js +39 -4
  31. data/dist/controllers/pointer_drag_controller.js +10 -3
  32. data/dist/controllers/portal_controller.js +21 -10
  33. data/dist/controllers/radio_group_controller.js +11 -3
  34. data/dist/controllers/read_more_controller.js +31 -5
  35. data/dist/controllers/reading_progress_controller.js +65 -19
  36. data/dist/controllers/relative_time_controller.js +40 -9
  37. data/dist/controllers/reset_before_cache_controller.js +23 -10
  38. data/dist/controllers/resizable_controller.js +7 -2
  39. data/dist/controllers/roving_controller.js +7 -3
  40. data/dist/controllers/scroll_area_controller.js +70 -26
  41. data/dist/controllers/scroll_restore_controller.js +37 -16
  42. data/dist/controllers/scroll_visibility_controller.js +71 -42
  43. data/dist/controllers/scrollspy_controller.js +76 -32
  44. data/dist/controllers/smart_sticky_header_controller.js +72 -37
  45. data/dist/controllers/stick_to_bottom_controller.js +29 -3
  46. data/dist/controllers/sticky_observer_controller.js +10 -5
  47. data/dist/controllers/submit_once_controller.js +29 -6
  48. data/dist/controllers/textarea_autosize_controller.js +29 -3
  49. data/dist/controllers/theme_controller.js +23 -13
  50. data/dist/controllers/toast_controller.js +136 -73
  51. data/dist/controllers/toggle_group_controller.js +11 -3
  52. data/dist/controllers/toolbar_controller.js +7 -3
  53. data/dist/index.js +629 -597
  54. data/lib/stimeo/ui/version.rb +1 -1
  55. 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
 
@@ -23,6 +23,26 @@ function hasModifierChord(event) {
23
23
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
24
24
  }
25
25
 
26
+ // src/utils/declared_value.ts
27
+ function parseDeclared(raw, parse, fallback) {
28
+ try {
29
+ return parse(raw);
30
+ } catch {
31
+ return fallback;
32
+ }
33
+ }
34
+ function validSelector(element, raw, fallback) {
35
+ if (raw.length === 0) return fallback;
36
+ return parseDeclared(
37
+ raw,
38
+ (selector) => {
39
+ element.matches(selector);
40
+ return selector;
41
+ },
42
+ fallback
43
+ );
44
+ }
45
+
26
46
  // src/utils/roving_tabindex.ts
27
47
  var RovingTabindex = class {
28
48
  /** Returns the current ordered item elements; called on every operation. */
@@ -167,16 +187,7 @@ var ThemeController = class extends Controller {
167
187
  }
168
188
  /** Validates the `target` declaration once, so the render path never parses. */
169
189
  targetValueChanged() {
170
- const selector = this.targetValue;
171
- if (selector.length > 0) {
172
- try {
173
- this.element.matches(selector);
174
- this.#targetSelector = selector;
175
- return;
176
- } catch {
177
- }
178
- }
179
- this.#targetSelector = DEFAULT_TARGET;
190
+ this.#targetSelector = validSelector(this.element, this.targetValue, DEFAULT_TARGET);
180
191
  }
181
192
  /** Re-derives the single Tab stop and ARIA for an option set that changed. */
182
193
  optionTargetConnected() {
@@ -189,9 +200,8 @@ var ThemeController = class extends Controller {
189
200
  /**
190
201
  * Selects the mode the activated option declares.
191
202
  *
192
- * Read through {@link ThemeController.#optionMode}, the same lane that decides
193
- * which option is checked, so the two can never disagree about what an option
194
- * declares.
203
+ * Read through the same lane that decides which option is checked, so the two
204
+ * can never disagree about what an option declares.
195
205
  */
196
206
  set(event) {
197
207
  const option = event.currentTarget;
@@ -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 = () => {
@@ -20,6 +20,15 @@ function isReservedArrowChord(event, allow = []) {
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
22
 
23
+ // src/utils/event_owner.ts
24
+ function ownerIndex(candidates, node) {
25
+ if (!(node instanceof Node)) return -1;
26
+ return candidates.findIndex((candidate) => candidate.contains(node));
27
+ }
28
+ function ownerOf(candidates, node) {
29
+ return candidates[ownerIndex(candidates, node)] ?? null;
30
+ }
31
+
23
32
  // src/utils/focus_candidate.ts
24
33
  function inheritsFieldsetDisabled(control) {
25
34
  let fieldset = control.closest("fieldset[disabled]");
@@ -363,9 +372,8 @@ var ToggleGroupController = class extends Controller {
363
372
  }
364
373
  /** Finds this group's item containing an event target, excluding nested groups. */
365
374
  #itemForEventTarget(target) {
366
- if (!this.#ownsEventTarget(target)) return void 0;
367
- const node = target;
368
- return this.itemTargets.find((item) => item === node || item.contains(node));
375
+ if (!this.#ownsEventTarget(target)) return null;
376
+ return ownerOf(this.itemTargets, target);
369
377
  }
370
378
  /** Whether the closest Toggle Group scope around a target is this instance. */
371
379
  #ownsEventTarget(target) {
@@ -60,6 +60,12 @@ var CompositionTracker = class {
60
60
  };
61
61
  };
62
62
 
63
+ // src/utils/event_owner.ts
64
+ function ownerIndex(candidates, node) {
65
+ if (!(node instanceof Node)) return -1;
66
+ return candidates.findIndex((candidate) => candidate.contains(node));
67
+ }
68
+
63
69
  // src/utils/focus_candidate.ts
64
70
  function inheritsFieldsetDisabled(control) {
65
71
  let fieldset = control.closest("fieldset[disabled]");
@@ -268,9 +274,7 @@ var ToolbarController = class extends Controller {
268
274
  }
269
275
  /** Index in `controlTargets` of the control owning `target` (it or a descendant). */
270
276
  #indexOf(target) {
271
- const node = target;
272
- if (!node) return -1;
273
- return this.controlTargets.findIndex((control) => control === node || control.contains(node));
277
+ return ownerIndex(this.controlTargets, target);
274
278
  }
275
279
  /** Controls eligible for the roving tab stop (excludes native disabled / hidden). */
276
280
  get #navigableControls() {