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,44 +1,164 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/toast_controller.ts
3
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
4
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
5
+ function stateReasonFor(event) {
6
+ if (!event) return "api";
7
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
8
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
9
+ return "user";
10
+ }
11
+
12
+ function actionSource(source) {
13
+ if (source instanceof HTMLElement) {
14
+ return { event: null, host: source, origin: source, reason: "api" };
15
+ }
16
+ const event = source ?? null;
17
+ return {
18
+ event,
19
+ host: event?.currentTarget instanceof Element ? event.currentTarget : null,
20
+ origin: event?.target instanceof Element ? event.target : null,
21
+ reason: stateReasonFor(event)
22
+ };
23
+ }
24
+
25
+ function ownerIndex(candidates, node) {
26
+ if (!(node instanceof Node)) return -1;
27
+ return candidates.findIndex((candidate) => candidate.contains(node));
28
+ }
29
+ function ownerOf(candidates, node) {
30
+ return candidates[ownerIndex(candidates, node)] ?? null;
31
+ }
32
+
33
+ var ListenerSet = class {
34
+ #abort = new AbortController();
35
+ add(target, type, handler, options) {
36
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
37
+ }
38
+ dispose() {
39
+ this.#abort.abort();
40
+ this.#abort = new AbortController();
41
+ }
42
+ };
43
+
44
+ var MicrotaskCoalescer = class {
45
+ #run;
46
+ #queued = false;
47
+ #active = false;
48
+ #generation = 0;
49
+ constructor(run) {
50
+ this.#run = run;
51
+ }
52
+ activate() {
53
+ this.#active = true;
54
+ }
55
+ cancel() {
56
+ this.#active = false;
57
+ this.#queued = false;
58
+ this.#generation += 1;
59
+ }
60
+ schedule() {
61
+ if (!this.#active || this.#queued) return;
62
+ this.#queued = true;
63
+ const generation = this.#generation;
64
+ queueMicrotask(() => {
65
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
66
+ this.#queued = false;
67
+ this.#run();
68
+ });
69
+ }
70
+ };
71
+
72
+ var MorphRenderWatcher = class {
73
+ #listeners = new ListenerSet();
74
+ #pass;
75
+ #element = null;
76
+ #onMorph = () => this.#pass.schedule();
77
+ constructor(run) {
78
+ this.#pass = new MicrotaskCoalescer(run);
79
+ }
80
+ observe(element) {
81
+ if (this.#element === element) return;
82
+ this.disconnect();
83
+ this.#element = element;
84
+ this.#pass.activate();
85
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
86
+ }
87
+ schedule() {
88
+ this.#pass.schedule();
89
+ }
90
+ disconnect() {
91
+ this.#listeners.dispose();
92
+ this.#pass.cancel();
93
+ this.#element = null;
94
+ }
95
+ };
96
+
97
+ var MAX_TIMER_DELAY_MS = 2147483647;
98
+
99
+ var NUMBER_BOUNDS = {
100
+ finite: { finite: true },
101
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
102
+ function matchesNumberBounds(value, bounds) {
103
+ if (!Number.isFinite(value)) {
104
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
105
+ if (direction === null) return false;
106
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
107
+ }
108
+ if (bounds.min !== void 0 && value < bounds.min) return false;
109
+ if (bounds.max !== void 0 && value > bounds.max) return false;
110
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
111
+ if (bounds.integer && !Number.isInteger(value)) return false;
112
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
113
+ return true;
114
+ }
115
+
116
+ function readNumber(raw, fallback, bounds) {
117
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
118
+ }
119
+
120
+ var NumberValueReader = class {
121
+ #lastRejected = /* @__PURE__ */ new Map();
122
+ read(owner, name, raw, fallback, bounds) {
123
+ const resolved = readNumber(raw, fallback, bounds);
124
+ if (matchesNumberBounds(raw, bounds)) {
125
+ this.#lastRejected.delete(name);
126
+ return resolved;
127
+ }
128
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
129
+ const literal = owner.element.getAttribute(attribute);
130
+ if (literal === null) {
131
+ this.#lastRejected.delete(name);
132
+ return resolved;
133
+ }
134
+ if (this.#lastRejected.get(name) !== literal) {
135
+ this.#lastRejected.set(name, literal);
136
+ console.warn(
137
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
138
+ );
139
+ }
140
+ return resolved;
141
+ }
142
+ };
4
143
 
5
- // src/utils/safe_timeout.ts
6
144
  var TimerRegistry = class {
7
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
145
  ids = /* @__PURE__ */ new Set();
9
- /**
10
- * Cancels a single tracked timer.
11
- *
12
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
- * registry), so callers can clear defensively without guarding.
14
- */
15
146
  clear(id) {
16
147
  if (this.ids.delete(id)) {
17
148
  this.cancel(id);
18
149
  }
19
150
  }
20
- /**
21
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
- * to guarantee no timer outlives the element.
23
- */
24
151
  clearAll() {
25
152
  for (const id of this.ids) {
26
153
  this.cancel(id);
27
154
  }
28
155
  this.ids.clear();
29
156
  }
30
- /** Number of timers currently tracked (pending). */
31
157
  get size() {
32
158
  return this.ids.size;
33
159
  }
34
160
  };
35
161
  var SafeTimeout = class extends TimerRegistry {
36
- /**
37
- * Schedules `callback` after `delay` ms and returns the timer id.
38
- *
39
- * The id is removed from the registry automatically when the timeout fires,
40
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
- */
42
162
  set(callback, delay) {
43
163
  const id = this.schedule(() => {
44
164
  this.ids.delete(id);
@@ -55,16 +175,9 @@ var SafeTimeout = class extends TimerRegistry {
55
175
  }
56
176
  };
57
177
 
58
- // src/utils/keyed_timers.ts
59
178
  var KeyedTimers = class {
60
179
  #timers = new SafeTimeout();
61
- /** The pending timer of each key; an entry lives exactly as long as its timer. */
62
180
  #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
181
  set(key, callback, delay) {
69
182
  this.clear(key);
70
183
  const id = this.#timers.set(() => {
@@ -73,104 +186,89 @@ var KeyedTimers = class {
73
186
  }, delay);
74
187
  this.#pending.set(key, id);
75
188
  }
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
189
  clear(key) {
84
190
  this.#timers.clear(this.#pending.get(key) ?? -1);
85
191
  this.#pending.delete(key);
86
192
  }
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
193
  clearAll() {
92
194
  this.#timers.clearAll();
93
195
  this.#pending.clear();
94
196
  }
95
- /** Whether `key` has a timer pending. */
96
197
  has(key) {
97
198
  return this.#pending.has(key);
98
199
  }
99
200
  };
100
201
 
101
- // src/utils/pausable_timers.ts
102
202
  var PausableTimers = class {
103
203
  #timers = new KeyedTimers();
104
- /** Every key armed or held; an entry goes as its timer fires, or with the key. */
105
204
  #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
205
  set(key, callback, delay) {
111
206
  const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
112
207
  const entry = { callback, startedAt: 0, remaining: delay, reasons };
113
208
  this.#entries.set(key, entry);
114
- if (reasons.size === 0) this.#arm(key, entry);
209
+ if (reasons.size === 0) this.#arm(key, entry, callback);
115
210
  }
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
211
  pause(key, reason) {
122
- const entry = this.#entries.get(key);
123
- if (entry === void 0) return false;
212
+ let entry = this.#entries.get(key);
213
+ if (entry === void 0) {
214
+ entry = { callback: null, startedAt: 0, remaining: 0, reasons: /* @__PURE__ */ new Set() };
215
+ this.#entries.set(key, entry);
216
+ }
124
217
  entry.reasons.add(reason);
125
218
  if (!this.#timers.has(key)) return false;
126
219
  this.#timers.clear(key);
127
220
  entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
128
221
  return true;
129
222
  }
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
223
  resume(key, reason) {
135
224
  const entry = this.#entries.get(key);
136
225
  if (entry === void 0) return false;
137
226
  entry.reasons.delete(reason);
138
227
  if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
139
- this.#arm(key, entry);
228
+ const { callback } = entry;
229
+ if (callback === null) {
230
+ this.#entries.delete(key);
231
+ return false;
232
+ }
233
+ this.#arm(key, entry, callback);
140
234
  return true;
141
235
  }
142
- /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
236
+ isHeld(key) {
237
+ return (this.#entries.get(key)?.reasons.size ?? 0) > 0;
238
+ }
143
239
  tracks(key) {
144
240
  return this.#entries.has(key);
145
241
  }
146
- /** Cancels `key`'s timer and drops its hold. */
147
242
  clear(key) {
148
243
  this.#timers.clear(key);
149
244
  this.#entries.delete(key);
150
245
  }
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
246
  clearAll() {
156
247
  this.#timers.clearAll();
157
248
  this.#entries.clear();
158
249
  }
159
- /** Starts `entry`'s banked time running for `key`, and drops it as it fires. */
160
- #arm(key, entry) {
250
+ #arm(key, entry, callback) {
161
251
  entry.startedAt = Date.now();
162
252
  this.#timers.set(
163
253
  key,
164
254
  () => {
165
255
  this.#entries.delete(key);
166
- entry.callback();
256
+ callback();
167
257
  },
168
258
  entry.remaining
169
259
  );
170
260
  }
171
261
  };
172
262
 
173
- // src/utils/transition_completion.ts
263
+ function targetSelector(identifier, name) {
264
+ return `[data-${identifier}-target~="${name}"]`;
265
+ }
266
+
267
+ function cloneTemplateRoot(template) {
268
+ const root = template.content.firstElementChild;
269
+ return root instanceof HTMLElement ? root.cloneNode(true) : null;
270
+ }
271
+
174
272
  function timeMs(value) {
175
273
  const trimmed = value.trim();
176
274
  const amount = Number.parseFloat(trimmed);
@@ -204,123 +302,136 @@ function maxTransitionTotalMs(style) {
204
302
  return maxTotalMs(transitionTimings(style));
205
303
  }
206
304
 
207
- // src/controllers/toast_controller.ts
208
305
  var DELEGATED_EVENTS = ["click", "focusin", "focusout", "keydown", "mouseover", "mouseout"];
209
- var ToastController = class extends Controller {
306
+ var ToastController = class _ToastController extends Controller {
307
+ #numbers = new NumberValueReader();
210
308
  static targets = ["list", "template", "item"];
211
309
  static values = {
212
310
  duration: { type: Number, default: 0 },
213
311
  max: { type: Number, default: 3 }
214
312
  };
313
+ static valueConstraints = {
314
+ duration: NUMBER_BOUNDS.timer,
315
+ max: NUMBER_BOUNDS.finite
316
+ };
215
317
  static actions = ["dismiss", "onKeydown", "pause", "resume", "show"];
216
318
  static events = ["dismiss", "show"];
217
- /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
218
319
  #timers = new SafeTimeout();
219
- /** Per-toast auto-dismiss, held open while the toast is hovered or focused. */
220
320
  #dismiss = new PausableTimers();
221
- /**
222
- * Pending one-shot `requestAnimationFrame` handles (the entering→visible flip).
223
- * Tracked so {@link disconnect} can cancel any that have not fired, preventing a
224
- * detached element from being mutated after it leaves the DOM (Turbo).
225
- */
321
+ #taken = /* @__PURE__ */ new Set();
322
+ #outside = /* @__PURE__ */ new WeakSet();
323
+ #phases = /* @__PURE__ */ new WeakMap();
324
+ #reapply = new MorphRenderWatcher(() => this.#reapplyLimit());
325
+ #spared = [];
326
+ #pointer = new ListenerSet();
327
+ #awaitingPointer = /* @__PURE__ */ new Set();
226
328
  #rafHandles = /* @__PURE__ */ new Map();
227
- /** The stable list that owns delegated listeners for dynamically added items. */
228
329
  #delegatedList = null;
330
+ #connected = false;
331
+ #arrivals = /* @__PURE__ */ new Set();
332
+ #appliedMax = null;
229
333
  connect() {
334
+ this.#connected = true;
335
+ const current = this.itemTargets;
336
+ for (const item of this.#arrivals) {
337
+ if (!current.includes(item)) this.#arrivals.delete(item);
338
+ }
339
+ this.#reapply.observe(this.element);
230
340
  this.#connectDelegatedEvents();
231
- this.enforceMaxLimit();
232
341
  for (const item of this.itemTargets) {
233
342
  if (!this.#dismiss.tracks(item) && item.dataset.state !== "leaving") {
234
343
  this.#startTimer(item);
235
344
  }
236
345
  }
346
+ if (!Object.is(this.#appliedMax, this.#safeMax)) {
347
+ this.#appliedMax = this.#safeMax;
348
+ this.#enforceMax();
349
+ }
237
350
  }
238
351
  disconnect() {
352
+ this.#connected = false;
239
353
  this.#disconnectDelegatedEvents();
240
354
  this.#timers.clearAll();
241
355
  this.#dismiss.clearAll();
356
+ this.#taken.clear();
357
+ this.#reapply.disconnect();
358
+ this.#spared = [];
359
+ this.#pointer.dispose();
360
+ this.#awaitingPointer.clear();
242
361
  for (const handle of this.#rafHandles.values()) {
243
362
  window.cancelAnimationFrame(handle);
244
363
  }
245
364
  this.#rafHandles.clear();
246
365
  }
247
- /** Rebinds delegated interaction when Turbo replaces the list target in place. */
248
- listTargetConnected(element) {
249
- if (this.#delegatedList !== element) this.#connectDelegatedEvents(element);
366
+ listTargetConnected() {
367
+ if (this.#delegatedList !== this.listTarget) this.#connectDelegatedEvents();
250
368
  }
251
- /** Releases delegation only when the removed target is its current owner. */
252
369
  listTargetDisconnected(element) {
253
370
  if (this.#delegatedList === element) this.#disconnectDelegatedEvents();
254
- }
255
- durationValueChanged() {
371
+ if (!this.#connected || !this.hasListTarget || this.listTarget === element) return;
372
+ if (this.#delegatedList !== this.listTarget) this.#connectDelegatedEvents();
256
373
  for (const item of this.itemTargets) {
257
- if (item.dataset.state === "leaving") continue;
258
- if (this.durationValue <= 0) {
259
- this.#dismiss.clear(item);
260
- item.removeAttribute("data-paused");
261
- } else {
262
- this.#startTimer(item);
263
- }
374
+ if (this.#outside.has(item)) this.#takeIn(item);
264
375
  }
265
376
  }
266
377
  maxValueChanged() {
378
+ if (!this.#connected) return;
379
+ this.#appliedMax = this.#safeMax;
267
380
  this.enforceMaxLimit();
268
381
  }
269
- /**
270
- * Stimulus lifecycle callback triggered automatically when a new item target
271
- * enters the DOM, from a client-side injection or a Turbo Stream append alike. An
272
- * item already leaving, or parented outside `list`, is skipped.
273
- */
274
382
  itemTargetConnected(element) {
275
- this.enforceMaxLimit();
276
- if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
383
+ if (this.#takeIn(element) && this.#connected) this.#enforceMax([element]);
384
+ }
385
+ #takeIn(element) {
386
+ if (this.#taken.has(element) || element.dataset.state === "leaving") return false;
387
+ if (element.parentNode !== this.listTarget) {
388
+ this.#outside.add(element);
389
+ return false;
390
+ }
391
+ this.#taken.add(element);
392
+ this.#arrivals.add(element);
393
+ this.#readHolds(element);
277
394
  this.#startTimer(element);
278
- element.setAttribute("data-state", "entering");
279
- this.#cancelAnimation(element);
395
+ this.#setPhase(element, "entering");
280
396
  const handle = window.requestAnimationFrame(() => {
281
397
  this.#rafHandles.delete(element);
282
398
  if (element.parentNode !== this.listTarget || element.dataset.state === "leaving") return;
283
- element.setAttribute("data-state", "visible");
399
+ this.#setPhase(element, "visible");
284
400
  });
285
401
  this.#rafHandles.set(element, handle);
402
+ return true;
286
403
  }
287
- /** Clears any active timer when a toast is removed from the DOM. */
288
404
  itemTargetDisconnected(element) {
289
- this.#dismiss.clear(element);
405
+ const moved = this.#isShown(element) && element.matches(targetSelector(this.identifier, "item"));
406
+ if (moved) {
407
+ this.#afterMove(element);
408
+ return;
409
+ }
410
+ this.#taken.delete(element);
411
+ this.#phases.delete(element);
412
+ if (this.#connected) this.#arrivals.delete(element);
413
+ element.removeAttribute("data-paused");
414
+ this.#letGo(element);
290
415
  this.#cancelAnimation(element);
291
416
  }
292
- /**
293
- * Shows a new toast. Accepts its content from either a Stimulus action param
294
- * (attribute-only trigger) or a programmatic `show` CustomEvent `detail`
295
- * (remote / Turbo trigger); the action param wins when both are present.
296
- *
297
- * <button data-action="click->stimeo--toast#show"
298
- * data-stimeo--toast-body-param="Saved"
299
- * data-stimeo--toast-type-param="status">Show</button>
300
- *
301
- * element.dispatchEvent(new CustomEvent("show", { detail: { body, type } }))
302
- *
303
- * Clones the template slot, interpolates the body text, and appends to the list.
304
- */
305
417
  show(event) {
306
418
  if (!this.hasTemplateTarget || !this.hasListTarget) return;
307
- const body = this.#readField(event, "body");
308
- if (!body) return;
309
- const clone = this.templateTarget.content.cloneNode(true);
310
- const item = clone.querySelector("[data-stimeo--toast-target='item']");
311
- if (!item) return;
312
- const bodySlot = item.querySelector("[data-toast-slot='body']");
313
- if (!bodySlot) return;
314
- bodySlot.textContent = body;
315
- bodySlot.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
419
+ const message = this.#readField(event, "message");
420
+ if (!message) return;
421
+ const template = this.templateTarget;
422
+ const item = cloneTemplateRoot(template);
423
+ if (template.content.children.length !== 1) return;
424
+ if (!item?.matches(targetSelector(this.identifier, "item"))) return;
425
+ const messageSlot = item.querySelector("[data-toast-slot='message']");
426
+ if (!messageSlot) return;
427
+ messageSlot.textContent = message;
428
+ messageSlot.setAttribute(
429
+ "role",
430
+ this.#readField(event, "type") === "alert" ? "alert" : "status"
431
+ );
316
432
  this.listTarget.appendChild(item);
317
433
  this.dispatch("show", { detail: { item } });
318
434
  }
319
- /**
320
- * Reads a string field from a Stimulus action param or a CustomEvent `detail`,
321
- * preferring the action param. Returns null unless a non-empty string is found,
322
- * so untrusted runtime payloads cannot inject non-string values.
323
- */
324
435
  #readField(event, key) {
325
436
  const params = event.params;
326
437
  const fromParams = params?.[key];
@@ -332,70 +443,126 @@ var ToastController = class extends Controller {
332
443
  }
333
444
  return null;
334
445
  }
335
- /** Dismisses the toast that contained the trigger. */
336
- dismiss(event) {
337
- const item = this.#itemFromEvent(event);
446
+ dismiss(input) {
447
+ const source = actionSource(input);
448
+ const item = this.#itemFromSource(source);
338
449
  if (!item) return;
339
- this.#removeWithTransition(item, "user");
450
+ this.#removeWithTransition(item, source.reason);
340
451
  }
341
- /** Dismisses the focused toast when Escape is pressed. */
342
452
  onKeydown(event) {
343
453
  if (event.key === "Escape") {
344
454
  if (event.defaultPrevented || event.isComposing) return;
345
- const item = this.#itemFromEvent(event);
455
+ const item = this.#itemFromSource(actionSource(event));
346
456
  if (!item) return;
347
457
  event.preventDefault();
348
458
  this.#removeWithTransition(item, "user");
349
459
  }
350
460
  }
351
- /**
352
- * Pauses the auto-dismiss timer on mouse entry or keyboard focus.
353
- *
354
- * Hover and focus are tracked as independent reasons: the timer is only
355
- * snapshotted on the first active reason, and only resumed once *every*
356
- * reason has been released (see {@link resume}). This keeps a toast paused
357
- * while it is still hovered *or* focused, per WCAG 2.2.1.
358
- */
359
- pause(event) {
360
- const item = this.#itemFromEvent(event);
361
- if (item && this.#dismiss.pause(item, this.#pauseReason(event))) {
461
+ pause(input) {
462
+ const source = actionSource(input);
463
+ const item = this.#itemFromSource(source);
464
+ if (item && this.#dismiss.pause(item, this.#pauseReason(source.event))) {
362
465
  item.setAttribute("data-paused", "true");
363
466
  }
364
467
  }
365
- /** Resumes the auto-dismiss timer once both hover and focus have been released. */
366
- resume(event) {
367
- const item = this.#itemFromEvent(event);
368
- if (item && this.#dismiss.resume(item, this.#pauseReason(event))) {
369
- item.removeAttribute("data-paused");
468
+ resume(input) {
469
+ const source = actionSource(input);
470
+ const item = this.#itemFromSource(source);
471
+ if (item) {
472
+ this.#release(
473
+ item,
474
+ this.#pauseReason(source.event),
475
+ ownerOf(this.itemTargets, source.event?.relatedTarget)
476
+ );
370
477
  }
371
478
  }
372
- /** Resolves the toast item element a pause/resume event targets. */
373
- #itemFromEvent(event) {
374
- const target = event.target instanceof Element ? event.target : event.currentTarget;
375
- if (!(target instanceof Element) || !this.hasListTarget) return null;
376
- const item = target.closest("[data-stimeo--toast-target='item']");
377
- return item && this.listTarget.contains(item) ? item : null;
479
+ #release(item, reason, spare) {
480
+ const held = this.#dismiss.isHeld(item);
481
+ if (this.#dismiss.resume(item, reason)) item.removeAttribute("data-paused");
482
+ if (held && !this.#dismiss.isHeld(item)) this.#limitLater(spare);
483
+ }
484
+ #limitLater(spare) {
485
+ if (spare) this.#spared.push(spare);
486
+ this.#reapply.schedule();
487
+ }
488
+ #setPhase(item, phase) {
489
+ this.#phases.set(item, phase);
490
+ item.setAttribute("data-state", phase);
491
+ }
492
+ #reapplyLimit() {
493
+ for (const item of this.itemTargets) {
494
+ if (!this.#taken.has(item) || !this.hasListTarget || item.parentNode !== this.listTarget)
495
+ continue;
496
+ const phase = this.#phases.get(item);
497
+ if (item.dataset.state === "leaving") this.#phases.set(item, "leaving");
498
+ else if (phase) item.setAttribute("data-state", phase);
499
+ if (this.#dismiss.isHeld(item) && this.#dismiss.tracks(item))
500
+ item.setAttribute("data-paused", "true");
501
+ else item.removeAttribute("data-paused");
502
+ }
503
+ const spared = this.#spared;
504
+ this.#spared = [];
505
+ this.#enforceMax(spared);
506
+ }
507
+ #afterMove(item) {
508
+ if (!item.contains(document.activeElement)) this.#release(item, "focus", null);
509
+ if (this.#dismiss.isHeld(item)) this.#awaitPointer(item);
510
+ }
511
+ #readHolds(item) {
512
+ if (item.contains(document.activeElement)) this.#dismiss.pause(item, "focus");
513
+ if (!item.matches(":hover")) return;
514
+ this.#dismiss.pause(item, "hover");
515
+ this.#awaitPointer(item);
516
+ }
517
+ #awaitPointer(item) {
518
+ this.#awaitingPointer.add(item);
519
+ this.#pointer.add(document, "pointermove", this.#onPointerMove, {
520
+ capture: true,
521
+ passive: true
522
+ });
523
+ }
524
+ #onPointerMove = () => {
525
+ this.#pointer.dispose();
526
+ const waiting = [...this.#awaitingPointer];
527
+ this.#awaitingPointer.clear();
528
+ for (const item of waiting) {
529
+ if (!item.matches(":hover")) this.#release(item, "hover", null);
530
+ }
531
+ };
532
+ #letGo(item) {
533
+ if (this.#dismiss.isHeld(item)) this.#limitLater(null);
534
+ this.#dismiss.clear(item);
535
+ this.#awaitingPointer.delete(item);
536
+ if (this.#awaitingPointer.size === 0) this.#pointer.dispose();
537
+ }
538
+ #itemFromSource(source) {
539
+ const target = source.origin ?? source.host;
540
+ if (!target || !this.hasListTarget) return null;
541
+ if (target.closest(`[data-controller~="${this.identifier}"]`) !== this.element) return null;
542
+ const item = target.closest(targetSelector(this.identifier, "item"));
543
+ return item && this.itemTargets.includes(item) && this.listTarget.contains(item) ? item : null;
378
544
  }
379
- /** Classifies a pause/resume event as a hover or focus reason. */
380
545
  #pauseReason(event) {
381
- return event.type === "focusin" || event.type === "focusout" ? "focus" : "hover";
546
+ return event?.type === "focusin" || event?.type === "focusout" ? "focus" : "hover";
382
547
  }
383
- /** Arms a toast's auto-dismiss; a non-positive `duration` means it never expires. */
384
548
  #startTimer(element) {
385
- if (this.durationValue <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
386
- return;
549
+ if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
550
+ const armed = this.#safeDuration > 0;
551
+ if (armed) {
552
+ this.#dismiss.set(
553
+ element,
554
+ () => this.#removeWithTransition(element, "timeout"),
555
+ this.#safeDuration
556
+ );
387
557
  }
388
- this.#dismiss.set(
389
- element,
390
- () => this.#removeWithTransition(element, "timeout"),
391
- this.durationValue
392
- );
558
+ if (armed && this.#dismiss.isHeld(element)) element.setAttribute("data-paused", "true");
559
+ else element.removeAttribute("data-paused");
393
560
  }
394
561
  #removeWithTransition(element, reason) {
395
562
  if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
396
- this.#dismiss.clear(element);
563
+ this.#letGo(element);
397
564
  this.#cancelAnimation(element);
398
- element.setAttribute("data-state", "leaving");
565
+ this.#setPhase(element, "leaving");
399
566
  const finalize = () => {
400
567
  if (element.parentNode !== this.listTarget) return;
401
568
  this.listTarget.removeChild(element);
@@ -408,26 +575,24 @@ var ToastController = class extends Controller {
408
575
  finalize();
409
576
  }
410
577
  }
411
- /**
412
- * Removes the oldest toasts when the list exceeds `maxValue`.
413
- *
414
- * Public (not `#private`) as a deterministic seam: enforcement normally runs
415
- * from `itemTargetConnected`, a Stimulus callback delivered through a
416
- * MutationObserver, which a DOM-only environment does not reliably fire — so
417
- * it can also be invoked directly. It is not a user-wired action.
418
- */
419
578
  enforceMaxLimit() {
420
- const currentItems = this.itemTargets;
421
- const max = Math.max(0, this.maxValue);
422
- if (currentItems.length > max) {
423
- const excessCount = currentItems.length - max;
424
- for (let i = 0; i < excessCount; i++) {
425
- const oldest = currentItems[i];
426
- if (oldest) this.#removeWithTransition(oldest, "timeout");
579
+ this.#enforceMax();
580
+ }
581
+ #enforceMax(spared = []) {
582
+ if (this.#safeMax <= 0) return;
583
+ for (const item of [...this.#arrivals]) {
584
+ if (this.#shownCount() <= this.#safeMax) return;
585
+ if (this.#isShown(item) && !spared.includes(item) && !this.#dismiss.isHeld(item)) {
586
+ this.#removeWithTransition(item, "limit");
427
587
  }
428
588
  }
429
589
  }
430
- /** Wires stable-container delegation so newly appended items work immediately. */
590
+ #shownCount() {
591
+ return this.itemTargets.filter((item) => this.#isShown(item)).length;
592
+ }
593
+ #isShown(item) {
594
+ return this.#taken.has(item) && this.hasListTarget && item.parentNode === this.listTarget && item.dataset.state !== "leaving";
595
+ }
431
596
  #connectDelegatedEvents(list = this.hasListTarget ? this.listTarget : null) {
432
597
  this.#disconnectDelegatedEvents();
433
598
  if (!list) return;
@@ -436,7 +601,6 @@ var ToastController = class extends Controller {
436
601
  this.#delegatedList.addEventListener(type, this.#onListEvent);
437
602
  }
438
603
  }
439
- /** Releases every delegated listener from the exact list that owns it. */
440
604
  #disconnectDelegatedEvents() {
441
605
  if (!this.#delegatedList) return;
442
606
  for (const type of DELEGATED_EVENTS) {
@@ -444,7 +608,6 @@ var ToastController = class extends Controller {
444
608
  }
445
609
  this.#delegatedList = null;
446
610
  }
447
- /** Routes every delegated item interaction from the stable list. */
448
611
  #onListEvent = (event) => {
449
612
  if (event.type === "click") {
450
613
  const target = event.target instanceof Element ? event.target : null;
@@ -457,22 +620,31 @@ var ToastController = class extends Controller {
457
620
  else this.resume(event);
458
621
  }
459
622
  };
460
- /** Whether a bubbling focus/pointer event enters or leaves a toast boundary. */
461
623
  #crossesItemBoundary(event) {
462
- const item = this.#itemFromEvent(event);
624
+ const item = this.#itemFromSource(actionSource(event));
463
625
  if (!item) return false;
464
626
  const related = "relatedTarget" in event ? event.relatedTarget : null;
465
627
  return !(related instanceof Node && item.contains(related));
466
628
  }
467
- /** Cancels the pending entering-to-visible frame owned by one item. */
468
629
  #cancelAnimation(element) {
469
630
  const handle = this.#rafHandles.get(element);
470
631
  if (handle === void 0) return;
471
632
  window.cancelAnimationFrame(handle);
472
633
  this.#rafHandles.delete(element);
473
634
  }
635
+ get #safeDuration() {
636
+ return this.#numbers.read(
637
+ this,
638
+ "duration",
639
+ this.durationValue,
640
+ _ToastController.values.duration.default,
641
+ _ToastController.valueConstraints.duration
642
+ );
643
+ }
644
+ get #safeMax() {
645
+ return this.#numbers.read(this, "max", this.maxValue, 0, _ToastController.valueConstraints.max);
646
+ }
474
647
  };
475
648
 
476
649
  export { ToastController };
477
- //# sourceMappingURL=toast_controller.js.map
478
650
  //# sourceMappingURL=toast_controller.js.map