stimeo-ui 0.16.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 +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  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 +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  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 +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,8 +1,27 @@
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
+ }
4
24
 
5
- // src/utils/event_owner.ts
6
25
  function ownerIndex(candidates, node) {
7
26
  if (!(node instanceof Node)) return -1;
8
27
  return candidates.findIndex((candidate) => candidate.contains(node));
@@ -11,51 +30,33 @@ function ownerOf(candidates, node) {
11
30
  return candidates[ownerIndex(candidates, node)] ?? null;
12
31
  }
13
32
 
14
- // src/utils/listener_set.ts
15
33
  var ListenerSet = class {
16
- /** The generation every `add` joins until the next `dispose()`. */
17
34
  #abort = new AbortController();
18
- /**
19
- * Attaches `handler` to the open generation, exactly as the caller spelled it.
20
- *
21
- * The set supplies the signal, so `options` carries everything else the DOM
22
- * accepts — `capture` included, which has to match at release time and no
23
- * longer has a second place to drift from.
24
- */
25
35
  add(target, type, handler, options) {
26
36
  target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
27
37
  }
28
- /**
29
- * Releases every listener of the open generation, synchronously, and opens the
30
- * next one. Idempotent, and safe before anything has been added.
31
- */
32
38
  dispose() {
33
39
  this.#abort.abort();
34
40
  this.#abort = new AbortController();
35
41
  }
36
42
  };
37
43
 
38
- // src/utils/microtask_coalescer.ts
39
44
  var MicrotaskCoalescer = class {
40
45
  #run;
41
46
  #queued = false;
42
47
  #active = false;
43
48
  #generation = 0;
44
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
45
49
  constructor(run) {
46
50
  this.#run = run;
47
51
  }
48
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
49
52
  activate() {
50
53
  this.#active = true;
51
54
  }
52
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
53
55
  cancel() {
54
56
  this.#active = false;
55
57
  this.#queued = false;
56
58
  this.#generation += 1;
57
59
  }
58
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
59
60
  schedule() {
60
61
  if (!this.#active || this.#queued) return;
61
62
  this.#queued = true;
@@ -68,43 +69,96 @@ var MicrotaskCoalescer = class {
68
69
  }
69
70
  };
70
71
 
71
- // src/utils/safe_timeout.ts
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
+ };
143
+
72
144
  var TimerRegistry = class {
73
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
74
145
  ids = /* @__PURE__ */ new Set();
75
- /**
76
- * Cancels a single tracked timer.
77
- *
78
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
79
- * registry), so callers can clear defensively without guarding.
80
- */
81
146
  clear(id) {
82
147
  if (this.ids.delete(id)) {
83
148
  this.cancel(id);
84
149
  }
85
150
  }
86
- /**
87
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
88
- * to guarantee no timer outlives the element.
89
- */
90
151
  clearAll() {
91
152
  for (const id of this.ids) {
92
153
  this.cancel(id);
93
154
  }
94
155
  this.ids.clear();
95
156
  }
96
- /** Number of timers currently tracked (pending). */
97
157
  get size() {
98
158
  return this.ids.size;
99
159
  }
100
160
  };
101
161
  var SafeTimeout = class extends TimerRegistry {
102
- /**
103
- * Schedules `callback` after `delay` ms and returns the timer id.
104
- *
105
- * The id is removed from the registry automatically when the timeout fires,
106
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
107
- */
108
162
  set(callback, delay) {
109
163
  const id = this.schedule(() => {
110
164
  this.ids.delete(id);
@@ -121,16 +175,9 @@ var SafeTimeout = class extends TimerRegistry {
121
175
  }
122
176
  };
123
177
 
124
- // src/utils/keyed_timers.ts
125
178
  var KeyedTimers = class {
126
179
  #timers = new SafeTimeout();
127
- /** The pending timer of each key; an entry lives exactly as long as its timer. */
128
180
  #pending = /* @__PURE__ */ new Map();
129
- /**
130
- * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
131
- * pending. The entry is dropped before the callback runs, so the callback sees
132
- * the key unarmed and may arm it again for the next round.
133
- */
134
181
  set(key, callback, delay) {
135
182
  this.clear(key);
136
183
  const id = this.#timers.set(() => {
@@ -139,52 +186,28 @@ var KeyedTimers = class {
139
186
  }, delay);
140
187
  this.#pending.set(key, id);
141
188
  }
142
- /**
143
- * Cancels `key`'s pending timer, if it has one.
144
- *
145
- * A timer id is a positive integer, so `-1` stands for "nothing pending" and
146
- * the registry ignores an id it does not own — the unarmed case needs no
147
- * branch of its own, and no other key's timer can be reached from here.
148
- */
149
189
  clear(key) {
150
190
  this.#timers.clear(this.#pending.get(key) ?? -1);
151
191
  this.#pending.delete(key);
152
192
  }
153
- /**
154
- * Cancels every pending timer and forgets every key. Call this from a
155
- * controller's `disconnect()` so no timer, and no entry, outlives the element.
156
- */
157
193
  clearAll() {
158
194
  this.#timers.clearAll();
159
195
  this.#pending.clear();
160
196
  }
161
- /** Whether `key` has a timer pending. */
162
197
  has(key) {
163
198
  return this.#pending.has(key);
164
199
  }
165
200
  };
166
201
 
167
- // src/utils/pausable_timers.ts
168
202
  var PausableTimers = class {
169
203
  #timers = new KeyedTimers();
170
- /** Every key armed or held; an entry goes as its timer fires, or with the key. */
171
204
  #entries = /* @__PURE__ */ new Map();
172
- /**
173
- * Arms `callback` after `delay` ms for `key`, replacing whatever `key` had.
174
- * A held key stays held and banks `delay` for its resume.
175
- */
176
205
  set(key, callback, delay) {
177
206
  const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
178
207
  const entry = { callback, startedAt: 0, remaining: delay, reasons };
179
208
  this.#entries.set(key, entry);
180
209
  if (reasons.size === 0) this.#arm(key, entry, callback);
181
210
  }
182
- /**
183
- * Holds `key` for `reason` and banks the time left, with a floor of one
184
- * millisecond. A key with no timer is held all the same. Reports whether this
185
- * call is the one that stopped a running timer, so the caller can write its
186
- * held-state hook exactly once.
187
- */
188
211
  pause(key, reason) {
189
212
  let entry = this.#entries.get(key);
190
213
  if (entry === void 0) {
@@ -197,11 +220,6 @@ var PausableTimers = class {
197
220
  entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
198
221
  return true;
199
222
  }
200
- /**
201
- * Releases `reason` on `key`, arming the banked time again once no reason is
202
- * left; a key released with no timer is forgotten. Reports whether this call
203
- * is the one that started the timer again.
204
- */
205
223
  resume(key, reason) {
206
224
  const entry = this.#entries.get(key);
207
225
  if (entry === void 0) return false;
@@ -215,38 +233,20 @@ var PausableTimers = class {
215
233
  this.#arm(key, entry, callback);
216
234
  return true;
217
235
  }
218
- /** Whether any reason holds `key`, with or without a timer. */
219
236
  isHeld(key) {
220
237
  return (this.#entries.get(key)?.reasons.size ?? 0) > 0;
221
238
  }
222
- /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
223
239
  tracks(key) {
224
240
  return this.#entries.has(key);
225
241
  }
226
- /**
227
- * Cancels `key`'s timer and keeps its hold, so a release arms nothing; a key
228
- * nothing holds is forgotten.
229
- */
230
- disarm(key) {
231
- this.#timers.clear(key);
232
- const entry = this.#entries.get(key);
233
- if (entry?.reasons.size) entry.callback = null;
234
- else this.#entries.delete(key);
235
- }
236
- /** Cancels `key`'s timer and drops its hold. */
237
242
  clear(key) {
238
243
  this.#timers.clear(key);
239
244
  this.#entries.delete(key);
240
245
  }
241
- /**
242
- * Cancels every timer and forgets every key. Call this from a controller's
243
- * `disconnect()` so neither a timer nor a hold outlives the element.
244
- */
245
246
  clearAll() {
246
247
  this.#timers.clearAll();
247
248
  this.#entries.clear();
248
249
  }
249
- /** Starts `entry`'s banked time running for `key`, and drops it as `callback` fires. */
250
250
  #arm(key, entry, callback) {
251
251
  entry.startedAt = Date.now();
252
252
  this.#timers.set(
@@ -260,18 +260,15 @@ var PausableTimers = class {
260
260
  }
261
261
  };
262
262
 
263
- // src/utils/target_selector.ts
264
263
  function targetSelector(identifier, name) {
265
264
  return `[data-${identifier}-target~="${name}"]`;
266
265
  }
267
266
 
268
- // src/utils/template_row.ts
269
267
  function cloneTemplateRoot(template) {
270
268
  const root = template.content.firstElementChild;
271
269
  return root instanceof HTMLElement ? root.cloneNode(true) : null;
272
270
  }
273
271
 
274
- // src/utils/transition_completion.ts
275
272
  function timeMs(value) {
276
273
  const trimmed = value.trim();
277
274
  const amount = Number.parseFloat(trimmed);
@@ -305,62 +302,49 @@ function maxTransitionTotalMs(style) {
305
302
  return maxTotalMs(transitionTimings(style));
306
303
  }
307
304
 
308
- // src/controllers/toast_controller.ts
309
305
  var DELEGATED_EVENTS = ["click", "focusin", "focusout", "keydown", "mouseover", "mouseout"];
310
- var ToastController = class extends Controller {
306
+ var ToastController = class _ToastController extends Controller {
307
+ #numbers = new NumberValueReader();
311
308
  static targets = ["list", "template", "item"];
312
309
  static values = {
313
310
  duration: { type: Number, default: 0 },
314
311
  max: { type: Number, default: 3 }
315
312
  };
313
+ static valueConstraints = {
314
+ duration: NUMBER_BOUNDS.timer,
315
+ max: NUMBER_BOUNDS.finite
316
+ };
316
317
  static actions = ["dismiss", "onKeydown", "pause", "resume", "show"];
317
318
  static events = ["dismiss", "show"];
318
- /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
319
319
  #timers = new SafeTimeout();
320
- /** Per-toast auto-dismiss, held open while the toast is hovered or focused. */
321
320
  #dismiss = new PausableTimers();
322
- /** Toasts taken on and not let go; a move within the list keeps a toast here. */
323
321
  #taken = /* @__PURE__ */ new Set();
324
- /** Applies the `max` limit again once the event that released a hold has run its course. */
325
- #reapply = new MicrotaskCoalescer(() => this.#reapplyLimit());
326
- /** Toasts the pointer or focus moved into since the limit was last applied again. */
322
+ #outside = /* @__PURE__ */ new WeakSet();
323
+ #phases = /* @__PURE__ */ new WeakMap();
324
+ #reapply = new MorphRenderWatcher(() => this.#reapplyLimit());
327
325
  #spared = [];
328
- /** The next pointer movement, listened for while a hover hold waits on it. */
329
326
  #pointer = new ListenerSet();
330
- /** Toasts whose hover hold waits for the next pointer movement to be confirmed. */
331
327
  #awaitingPointer = /* @__PURE__ */ new Set();
332
- /**
333
- * Pending one-shot `requestAnimationFrame` handles (the entering→visible flip).
334
- * Tracked so {@link disconnect} can cancel any that have not fired, preventing a
335
- * detached element from being mutated after it leaves the DOM (Turbo).
336
- */
337
328
  #rafHandles = /* @__PURE__ */ new Map();
338
- /** The stable list that owns delegated listeners for dynamically added items. */
339
329
  #delegatedList = null;
340
- /** Whether the controller is between `connect()` and `disconnect()`. */
341
330
  #connected = false;
342
- /**
343
- * The `max` the list was last held to, or `null` before the first time. `connect()`
344
- * applies the limit only when `max` is not that one, so a reconnect of the same instance
345
- * leaves the list as it was unless `max` changed while it was away.
346
- */
331
+ #arrivals = /* @__PURE__ */ new Set();
347
332
  #appliedMax = null;
348
- /**
349
- * Stimulus reports every toast the list already holds before this runs, and each is
350
- * taken on then with its holds, meeting no limit. The limit is applied here once, over
351
- * all of them, unless the list was already held to the current `max`.
352
- */
353
333
  connect() {
354
334
  this.#connected = true;
355
- this.#reapply.activate();
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);
356
340
  this.#connectDelegatedEvents();
357
341
  for (const item of this.itemTargets) {
358
342
  if (!this.#dismiss.tracks(item) && item.dataset.state !== "leaving") {
359
343
  this.#startTimer(item);
360
344
  }
361
345
  }
362
- if (!Object.is(this.#appliedMax, this.maxValue)) {
363
- this.#appliedMax = this.maxValue;
346
+ if (!Object.is(this.#appliedMax, this.#safeMax)) {
347
+ this.#appliedMax = this.#safeMax;
364
348
  this.#enforceMax();
365
349
  }
366
350
  }
@@ -370,7 +354,7 @@ var ToastController = class extends Controller {
370
354
  this.#timers.clearAll();
371
355
  this.#dismiss.clearAll();
372
356
  this.#taken.clear();
373
- this.#reapply.cancel();
357
+ this.#reapply.disconnect();
374
358
  this.#spared = [];
375
359
  this.#pointer.dispose();
376
360
  this.#awaitingPointer.clear();
@@ -379,66 +363,44 @@ var ToastController = class extends Controller {
379
363
  }
380
364
  this.#rafHandles.clear();
381
365
  }
382
- /** Rebinds delegated interaction when Turbo replaces the list target in place. */
383
- listTargetConnected(element) {
384
- if (this.#delegatedList !== element) this.#connectDelegatedEvents(element);
366
+ listTargetConnected() {
367
+ if (this.#delegatedList !== this.listTarget) this.#connectDelegatedEvents();
385
368
  }
386
- /** Releases delegation only when the removed target is its current owner. */
387
369
  listTargetDisconnected(element) {
388
370
  if (this.#delegatedList === element) this.#disconnectDelegatedEvents();
389
- }
390
- durationValueChanged() {
371
+ if (!this.#connected || !this.hasListTarget || this.listTarget === element) return;
372
+ if (this.#delegatedList !== this.listTarget) this.#connectDelegatedEvents();
391
373
  for (const item of this.itemTargets) {
392
- if (item.dataset.state === "leaving") continue;
393
- if (this.durationValue <= 0) {
394
- this.#dismiss.disarm(item);
395
- item.removeAttribute("data-paused");
396
- } else {
397
- this.#startTimer(item);
398
- }
374
+ if (this.#outside.has(item)) this.#takeIn(item);
399
375
  }
400
376
  }
401
- /**
402
- * Holds the list to a changed `max`. Stimulus can also call this ahead of `connect()` —
403
- * for an attribute that changed while the controller was away, and on every connect
404
- * for an undeclared `max`, with its default; `connect()` decides then.
405
- */
406
377
  maxValueChanged() {
407
378
  if (!this.#connected) return;
408
- this.#appliedMax = this.maxValue;
379
+ this.#appliedMax = this.#safeMax;
409
380
  this.enforceMaxLimit();
410
381
  }
411
- /**
412
- * Stimulus lifecycle callback triggered automatically when a new item target
413
- * enters the DOM, from a client-side injection or a Turbo Stream append alike, and for
414
- * every toast the list already holds ahead of `connect()`. An item already leaving,
415
- * parented outside `list`, or already taken on (a move within the list) is skipped; any
416
- * other is taken on with the holds it already has. On a connected controller the `max`
417
- * limit is then applied, never to the arrival itself; the toasts reported ahead of
418
- * `connect()` are left to it.
419
- */
420
382
  itemTargetConnected(element) {
421
- if (this.#taken.has(element)) return;
422
- 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
+ }
423
391
  this.#taken.add(element);
392
+ this.#arrivals.add(element);
424
393
  this.#readHolds(element);
425
394
  this.#startTimer(element);
426
- element.setAttribute("data-state", "entering");
427
- this.#cancelAnimation(element);
395
+ this.#setPhase(element, "entering");
428
396
  const handle = window.requestAnimationFrame(() => {
429
397
  this.#rafHandles.delete(element);
430
398
  if (element.parentNode !== this.listTarget || element.dataset.state === "leaving") return;
431
- element.setAttribute("data-state", "visible");
399
+ this.#setPhase(element, "visible");
432
400
  });
433
401
  this.#rafHandles.set(element, handle);
434
- if (this.#connected) this.#enforceMax([element]);
435
- }
436
- /**
437
- * Lets go of a toast that left the list or stopped being an item: its timer, holds,
438
- * `data-paused` and pending frame. After a move within the list the node is already
439
- * back, still an item, when Stimulus reports it, so it keeps all of those, and only the
440
- * holds the move may have ended are read again.
441
- */
402
+ return true;
403
+ }
442
404
  itemTargetDisconnected(element) {
443
405
  const moved = this.#isShown(element) && element.matches(targetSelector(this.identifier, "item"));
444
406
  if (moved) {
@@ -446,26 +408,12 @@ var ToastController = class extends Controller {
446
408
  return;
447
409
  }
448
410
  this.#taken.delete(element);
411
+ this.#phases.delete(element);
412
+ if (this.#connected) this.#arrivals.delete(element);
449
413
  element.removeAttribute("data-paused");
450
414
  this.#letGo(element);
451
415
  this.#cancelAnimation(element);
452
416
  }
453
- /**
454
- * Shows a new toast. Accepts its content from either a Stimulus action param
455
- * (attribute-only trigger) or a programmatic `show` CustomEvent `detail`
456
- * (remote / Turbo trigger); the action param wins when both are present.
457
- *
458
- * <button data-action="click->stimeo--toast#show"
459
- * data-stimeo--toast-message-param="Saved"
460
- * data-stimeo--toast-type-param="status">Show</button>
461
- *
462
- * element.dispatchEvent(new CustomEvent("show", { detail: { message, type } }))
463
- *
464
- * Clones the template slot, interpolates the message, and appends to the list.
465
- * `message` is the wording key the rest of the library dispatches under, so a
466
- * part that already carries its own wording — `stimeo--clipboard:copy`,
467
- * `stimeo--auto-submit:done` — wires straight into this action.
468
- */
469
417
  show(event) {
470
418
  if (!this.hasTemplateTarget || !this.hasListTarget) return;
471
419
  const message = this.#readField(event, "message");
@@ -484,11 +432,6 @@ var ToastController = class extends Controller {
484
432
  this.listTarget.appendChild(item);
485
433
  this.dispatch("show", { detail: { item } });
486
434
  }
487
- /**
488
- * Reads a string field from a Stimulus action param or a CustomEvent `detail`,
489
- * preferring the action param. Returns null unless a non-empty string is found,
490
- * so untrusted runtime payloads cannot inject non-string values.
491
- */
492
435
  #readField(event, key) {
493
436
  const params = event.params;
494
437
  const fromParams = params?.[key];
@@ -500,97 +443,77 @@ var ToastController = class extends Controller {
500
443
  }
501
444
  return null;
502
445
  }
503
- /** Dismisses the toast that contained the trigger. */
504
- dismiss(event) {
505
- const item = this.#itemFromEvent(event);
446
+ dismiss(input) {
447
+ const source = actionSource(input);
448
+ const item = this.#itemFromSource(source);
506
449
  if (!item) return;
507
- this.#removeWithTransition(item, "user");
450
+ this.#removeWithTransition(item, source.reason);
508
451
  }
509
- /** Dismisses the focused toast when Escape is pressed. */
510
452
  onKeydown(event) {
511
453
  if (event.key === "Escape") {
512
454
  if (event.defaultPrevented || event.isComposing) return;
513
- const item = this.#itemFromEvent(event);
455
+ const item = this.#itemFromSource(actionSource(event));
514
456
  if (!item) return;
515
457
  event.preventDefault();
516
458
  this.#removeWithTransition(item, "user");
517
459
  }
518
460
  }
519
- /**
520
- * Pauses the auto-dismiss timer on mouse entry or keyboard focus.
521
- *
522
- * Hover and focus are tracked as independent reasons: the timer is only
523
- * snapshotted on the first active reason, and only resumed once *every*
524
- * reason has been released (see {@link resume}). This keeps a toast paused
525
- * while it is still hovered *or* focused, per WCAG 2.2.1. The hold is recorded
526
- * whether or not a timer runs, so the `max` limit passes over the toast either
527
- * way; `data-paused` marks a timer that stopped, and only that.
528
- */
529
- pause(event) {
530
- const item = this.#itemFromEvent(event);
531
- 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))) {
532
465
  item.setAttribute("data-paused", "true");
533
466
  }
534
467
  }
535
- /**
536
- * Resumes the auto-dismiss timer once both hover and focus have been released,
537
- * and then applies the `max` limit again, sparing the toast the pointer or focus is
538
- * moving into: its own hold only arrives after this event.
539
- */
540
- resume(event) {
541
- const item = this.#itemFromEvent(event);
468
+ resume(input) {
469
+ const source = actionSource(input);
470
+ const item = this.#itemFromSource(source);
542
471
  if (item) {
543
472
  this.#release(
544
473
  item,
545
- this.#pauseReason(event),
546
- ownerOf(this.itemTargets, event.relatedTarget)
474
+ this.#pauseReason(source.event),
475
+ ownerOf(this.itemTargets, source.event?.relatedTarget)
547
476
  );
548
477
  }
549
478
  }
550
- /** Releases one reason on `item`; releasing the last one applies the limit again. */
551
479
  #release(item, reason, spare) {
552
480
  const held = this.#dismiss.isHeld(item);
553
481
  if (this.#dismiss.resume(item, reason)) item.removeAttribute("data-paused");
554
482
  if (held && !this.#dismiss.isHeld(item)) this.#limitLater(spare);
555
483
  }
556
- /**
557
- * Applies the limit again once the current event is over, sparing `spare`. A hold can
558
- * end in a `focusout` the engine fires while it is itself taking the node out for a
559
- * script's move or removal, and taking the toast away inside that event would pull
560
- * the node from under the caller's own call.
561
- */
562
484
  #limitLater(spare) {
563
485
  if (spare) this.#spared.push(spare);
564
486
  this.#reapply.schedule();
565
487
  }
566
- /** Applies the limit again, sparing every toast gathered since the last pass. */
488
+ #setPhase(item, phase) {
489
+ this.#phases.set(item, phase);
490
+ item.setAttribute("data-state", phase);
491
+ }
567
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
+ }
568
503
  const spared = this.#spared;
569
504
  this.#spared = [];
570
505
  this.#enforceMax(spared);
571
506
  }
572
- /**
573
- * Reads the holds of a toast that moved within the list. Focus is read at once: an
574
- * ordinary move ends it with a `focusout` and `moveBefore` keeps it. Where the pointer
575
- * is, nothing says until it moves again: no `mouseout` reaches a moved node, and
576
- * `:hover` can answer from before the move. A toast still held waits for that movement.
577
- */
578
507
  #afterMove(item) {
579
508
  if (!item.contains(document.activeElement)) this.#release(item, "focus", null);
580
509
  if (this.#dismiss.isHeld(item)) this.#awaitPointer(item);
581
510
  }
582
- /**
583
- * Takes the holds a toast already has as it is taken on: focus inside it, and the pointer
584
- * over it by `:hover`. That answer can date from before the toast came here, so a hover
585
- * hold taken on it waits for the next pointer movement to be confirmed.
586
- */
587
511
  #readHolds(item) {
588
512
  if (item.contains(document.activeElement)) this.#dismiss.pause(item, "focus");
589
513
  if (!item.matches(":hover")) return;
590
514
  this.#dismiss.pause(item, "hover");
591
515
  this.#awaitPointer(item);
592
516
  }
593
- /** Leaves `item`'s hover hold to be confirmed or let go on the next pointer movement. */
594
517
  #awaitPointer(item) {
595
518
  this.#awaitingPointer.add(item);
596
519
  this.#pointer.add(document, "pointermove", this.#onPointerMove, {
@@ -598,10 +521,6 @@ var ToastController = class extends Controller {
598
521
  passive: true
599
522
  });
600
523
  }
601
- /**
602
- * Reads `:hover` once the pointer has moved, when the answer is current, and lets go of
603
- * the hover hold of every waiting toast the pointer is not over. Listens once.
604
- */
605
524
  #onPointerMove = () => {
606
525
  this.#pointer.dispose();
607
526
  const waiting = [...this.#awaitingPointer];
@@ -610,43 +529,30 @@ var ToastController = class extends Controller {
610
529
  if (!item.matches(":hover")) this.#release(item, "hover", null);
611
530
  }
612
531
  };
613
- /**
614
- * Drops a toast's timer and holds as it leaves, and stops waiting on the pointer for it.
615
- * A toast something still held counts as released: the limit is applied again.
616
- */
617
532
  #letGo(item) {
618
533
  if (this.#dismiss.isHeld(item)) this.#limitLater(null);
619
534
  this.#dismiss.clear(item);
620
535
  this.#awaitingPointer.delete(item);
621
536
  if (this.#awaitingPointer.size === 0) this.#pointer.dispose();
622
537
  }
623
- /** Resolves the toast item element a pause/resume event targets. */
624
- #itemFromEvent(event) {
625
- const target = event.target instanceof Element ? event.target : event.currentTarget;
626
- if (!(target instanceof Element) || !this.hasListTarget) return null;
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;
627
542
  const item = target.closest(targetSelector(this.identifier, "item"));
628
- return item && this.listTarget.contains(item) ? item : null;
543
+ return item && this.itemTargets.includes(item) && this.listTarget.contains(item) ? item : null;
629
544
  }
630
- /** Classifies a pause/resume event as a hover or focus reason. */
631
545
  #pauseReason(event) {
632
- return event.type === "focusin" || event.type === "focusout" ? "focus" : "hover";
633
- }
634
- /**
635
- * Arms a toast's auto-dismiss; a non-positive `duration` means it never expires.
636
- * On a toast hover or focus already holds, the timer waits for the release.
637
- * `data-paused` is written from that state, so a mark the toast carried in with it,
638
- * from a restored page, does not outlive it.
639
- *
640
- * @stimeoRuntimeOnly `duration` is the delay of the one dismissal timer this call arms.
641
- */
546
+ return event?.type === "focusin" || event?.type === "focusout" ? "focus" : "hover";
547
+ }
642
548
  #startTimer(element) {
643
549
  if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
644
- const armed = this.durationValue > 0;
550
+ const armed = this.#safeDuration > 0;
645
551
  if (armed) {
646
552
  this.#dismiss.set(
647
553
  element,
648
554
  () => this.#removeWithTransition(element, "timeout"),
649
- this.durationValue
555
+ this.#safeDuration
650
556
  );
651
557
  }
652
558
  if (armed && this.#dismiss.isHeld(element)) element.setAttribute("data-paused", "true");
@@ -656,7 +562,7 @@ var ToastController = class extends Controller {
656
562
  if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
657
563
  this.#letGo(element);
658
564
  this.#cancelAnimation(element);
659
- element.setAttribute("data-state", "leaving");
565
+ this.#setPhase(element, "leaving");
660
566
  const finalize = () => {
661
567
  if (element.parentNode !== this.listTarget) return;
662
568
  this.listTarget.removeChild(element);
@@ -669,48 +575,24 @@ var ToastController = class extends Controller {
669
575
  finalize();
670
576
  }
671
577
  }
672
- /**
673
- * Removes the oldest toasts while the list shows more than `maxValue`, passing
674
- * over every toast hover or focus holds.
675
- *
676
- * Public (not `#private`) as a deterministic seam: enforcement normally runs
677
- * as each toast is taken on in `itemTargetConnected`, a Stimulus callback delivered
678
- * through a MutationObserver, which a DOM-only environment does not reliably fire —
679
- * so it can also be invoked directly. It counts only the toasts taken on. It is not a
680
- * user-wired action.
681
- */
682
578
  enforceMaxLimit() {
683
579
  this.#enforceMax();
684
580
  }
685
- /**
686
- * Removes the oldest toasts with reason `limit` while the list shows more than `max`
687
- * (`0` or less, or not a finite number, means no limit), passing over every toast hover
688
- * or focus holds and those in `spared` — the toast just taken on, or the ones the
689
- * pointer or focus moved into. Only toasts taken on count or go, so one Stimulus has
690
- * yet to report is neither; a toast already leaving does not count. With nothing else
691
- * left to go the list stays over the limit. The count is read again on every step,
692
- * because a `dismiss` listener may already have changed it.
693
- *
694
- * @stimeoRenderRoot
695
- */
696
581
  #enforceMax(spared = []) {
697
- if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return;
698
- for (const item of this.itemTargets) {
699
- if (this.#shownCount() <= this.maxValue) return;
582
+ if (this.#safeMax <= 0) return;
583
+ for (const item of [...this.#arrivals]) {
584
+ if (this.#shownCount() <= this.#safeMax) return;
700
585
  if (this.#isShown(item) && !spared.includes(item) && !this.#dismiss.isHeld(item)) {
701
586
  this.#removeWithTransition(item, "limit");
702
587
  }
703
588
  }
704
589
  }
705
- /** How many toasts the list shows: the ones taken on that are still shown. */
706
590
  #shownCount() {
707
591
  return this.itemTargets.filter((item) => this.#isShown(item)).length;
708
592
  }
709
- /** Whether the list shows `item`: a toast taken on, a child of `list`, not leaving. */
710
593
  #isShown(item) {
711
594
  return this.#taken.has(item) && this.hasListTarget && item.parentNode === this.listTarget && item.dataset.state !== "leaving";
712
595
  }
713
- /** Wires stable-container delegation so newly appended items work immediately. */
714
596
  #connectDelegatedEvents(list = this.hasListTarget ? this.listTarget : null) {
715
597
  this.#disconnectDelegatedEvents();
716
598
  if (!list) return;
@@ -719,7 +601,6 @@ var ToastController = class extends Controller {
719
601
  this.#delegatedList.addEventListener(type, this.#onListEvent);
720
602
  }
721
603
  }
722
- /** Releases every delegated listener from the exact list that owns it. */
723
604
  #disconnectDelegatedEvents() {
724
605
  if (!this.#delegatedList) return;
725
606
  for (const type of DELEGATED_EVENTS) {
@@ -727,7 +608,6 @@ var ToastController = class extends Controller {
727
608
  }
728
609
  this.#delegatedList = null;
729
610
  }
730
- /** Routes every delegated item interaction from the stable list. */
731
611
  #onListEvent = (event) => {
732
612
  if (event.type === "click") {
733
613
  const target = event.target instanceof Element ? event.target : null;
@@ -740,22 +620,31 @@ var ToastController = class extends Controller {
740
620
  else this.resume(event);
741
621
  }
742
622
  };
743
- /** Whether a bubbling focus/pointer event enters or leaves a toast boundary. */
744
623
  #crossesItemBoundary(event) {
745
- const item = this.#itemFromEvent(event);
624
+ const item = this.#itemFromSource(actionSource(event));
746
625
  if (!item) return false;
747
626
  const related = "relatedTarget" in event ? event.relatedTarget : null;
748
627
  return !(related instanceof Node && item.contains(related));
749
628
  }
750
- /** Cancels the pending entering-to-visible frame owned by one item. */
751
629
  #cancelAnimation(element) {
752
630
  const handle = this.#rafHandles.get(element);
753
631
  if (handle === void 0) return;
754
632
  window.cancelAnimationFrame(handle);
755
633
  this.#rafHandles.delete(element);
756
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
+ }
757
647
  };
758
648
 
759
649
  export { ToastController };
760
- //# sourceMappingURL=toast_controller.js.map
761
650
  //# sourceMappingURL=toast_controller.js.map