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,8 +1,27 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/flash_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/announce.ts
6
25
  function announce(message, options = {}) {
7
26
  const text = message.trim();
8
27
  if (text.length === 0) return;
@@ -13,72 +32,34 @@ function announce(message, options = {}) {
13
32
  );
14
33
  }
15
34
 
16
- // src/utils/before_cache_reset.ts
17
- var BeforeCacheReset = class _BeforeCacheReset {
18
- /** Every subscribed instance, iterated by the one shared document listener. */
19
- static #subscribers = /* @__PURE__ */ new Set();
20
- /** The shared listener; installed while at least one instance is subscribed. */
21
- static #onBeforeCache = () => {
22
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
23
- };
24
- #rewind;
25
- /** @param rewind - the pass that returns this controller's state to its initial form. */
26
- constructor(rewind) {
27
- this.#rewind = rewind;
28
- }
29
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
30
- activate() {
31
- const first = _BeforeCacheReset.#subscribers.size === 0;
32
- _BeforeCacheReset.#subscribers.add(this);
33
- if (first) {
34
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
35
- }
36
- }
37
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
38
- deactivate() {
39
- _BeforeCacheReset.#subscribers.delete(this);
40
- if (_BeforeCacheReset.#subscribers.size > 0) return;
41
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
42
- }
43
- };
35
+ function ownerIndex(candidates, node) {
36
+ if (!(node instanceof Node)) return -1;
37
+ return candidates.findIndex((candidate) => candidate.contains(node));
38
+ }
39
+ function ownerOf(candidates, node) {
40
+ return candidates[ownerIndex(candidates, node)] ?? null;
41
+ }
42
+
43
+ var MAX_TIMER_DELAY_MS = 2147483647;
44
44
 
45
- // src/utils/safe_timeout.ts
46
45
  var TimerRegistry = class {
47
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
48
46
  ids = /* @__PURE__ */ new Set();
49
- /**
50
- * Cancels a single tracked timer.
51
- *
52
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
53
- * registry), so callers can clear defensively without guarding.
54
- */
55
47
  clear(id) {
56
48
  if (this.ids.delete(id)) {
57
49
  this.cancel(id);
58
50
  }
59
51
  }
60
- /**
61
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
62
- * to guarantee no timer outlives the element.
63
- */
64
52
  clearAll() {
65
53
  for (const id of this.ids) {
66
54
  this.cancel(id);
67
55
  }
68
56
  this.ids.clear();
69
57
  }
70
- /** Number of timers currently tracked (pending). */
71
58
  get size() {
72
59
  return this.ids.size;
73
60
  }
74
61
  };
75
62
  var SafeTimeout = class extends TimerRegistry {
76
- /**
77
- * Schedules `callback` after `delay` ms and returns the timer id.
78
- *
79
- * The id is removed from the registry automatically when the timeout fires,
80
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
81
- */
82
63
  set(callback, delay) {
83
64
  const id = this.schedule(() => {
84
65
  this.ids.delete(id);
@@ -95,16 +76,9 @@ var SafeTimeout = class extends TimerRegistry {
95
76
  }
96
77
  };
97
78
 
98
- // src/utils/keyed_timers.ts
99
79
  var KeyedTimers = class {
100
80
  #timers = new SafeTimeout();
101
- /** The pending timer of each key; an entry lives exactly as long as its timer. */
102
81
  #pending = /* @__PURE__ */ new Map();
103
- /**
104
- * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
105
- * pending. The entry is dropped before the callback runs, so the callback sees
106
- * the key unarmed and may arm it again for the next round.
107
- */
108
82
  set(key, callback, delay) {
109
83
  this.clear(key);
110
84
  const id = this.#timers.set(() => {
@@ -113,104 +87,193 @@ var KeyedTimers = class {
113
87
  }, delay);
114
88
  this.#pending.set(key, id);
115
89
  }
116
- /**
117
- * Cancels `key`'s pending timer, if it has one.
118
- *
119
- * A timer id is a positive integer, so `-1` stands for "nothing pending" and
120
- * the registry ignores an id it does not own — the unarmed case needs no
121
- * branch of its own, and no other key's timer can be reached from here.
122
- */
123
90
  clear(key) {
124
91
  this.#timers.clear(this.#pending.get(key) ?? -1);
125
92
  this.#pending.delete(key);
126
93
  }
127
- /**
128
- * Cancels every pending timer and forgets every key. Call this from a
129
- * controller's `disconnect()` so no timer, and no entry, outlives the element.
130
- */
131
94
  clearAll() {
132
95
  this.#timers.clearAll();
133
96
  this.#pending.clear();
134
97
  }
135
- /** Whether `key` has a timer pending. */
136
98
  has(key) {
137
99
  return this.#pending.has(key);
138
100
  }
139
101
  };
140
102
 
141
- // src/utils/pausable_timers.ts
103
+ var ListenerSet = class {
104
+ #abort = new AbortController();
105
+ add(target, type, handler, options) {
106
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
107
+ }
108
+ dispose() {
109
+ this.#abort.abort();
110
+ this.#abort = new AbortController();
111
+ }
112
+ };
113
+
114
+ var MicrotaskCoalescer = class {
115
+ #run;
116
+ #queued = false;
117
+ #active = false;
118
+ #generation = 0;
119
+ constructor(run) {
120
+ this.#run = run;
121
+ }
122
+ activate() {
123
+ this.#active = true;
124
+ }
125
+ cancel() {
126
+ this.#active = false;
127
+ this.#queued = false;
128
+ this.#generation += 1;
129
+ }
130
+ schedule() {
131
+ if (!this.#active || this.#queued) return;
132
+ this.#queued = true;
133
+ const generation = this.#generation;
134
+ queueMicrotask(() => {
135
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
136
+ this.#queued = false;
137
+ this.#run();
138
+ });
139
+ }
140
+ };
141
+
142
+ var MorphRenderWatcher = class {
143
+ #listeners = new ListenerSet();
144
+ #pass;
145
+ #element = null;
146
+ #onMorph = () => this.#pass.schedule();
147
+ constructor(run) {
148
+ this.#pass = new MicrotaskCoalescer(run);
149
+ }
150
+ observe(element) {
151
+ if (this.#element === element) return;
152
+ this.disconnect();
153
+ this.#element = element;
154
+ this.#pass.activate();
155
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
156
+ }
157
+ schedule() {
158
+ this.#pass.schedule();
159
+ }
160
+ disconnect() {
161
+ this.#listeners.dispose();
162
+ this.#pass.cancel();
163
+ this.#element = null;
164
+ }
165
+ };
166
+
167
+ var NUMBER_BOUNDS = {
168
+ finite: { finite: true },
169
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
170
+ function matchesNumberBounds(value, bounds) {
171
+ if (!Number.isFinite(value)) {
172
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
173
+ if (direction === null) return false;
174
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
175
+ }
176
+ if (bounds.min !== void 0 && value < bounds.min) return false;
177
+ if (bounds.max !== void 0 && value > bounds.max) return false;
178
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
179
+ if (bounds.integer && !Number.isInteger(value)) return false;
180
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
181
+ return true;
182
+ }
183
+
184
+ function readNumber(raw, fallback, bounds) {
185
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
186
+ }
187
+
188
+ var NumberValueReader = class {
189
+ #lastRejected = /* @__PURE__ */ new Map();
190
+ read(owner, name, raw, fallback, bounds) {
191
+ const resolved = readNumber(raw, fallback, bounds);
192
+ if (matchesNumberBounds(raw, bounds)) {
193
+ this.#lastRejected.delete(name);
194
+ return resolved;
195
+ }
196
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
197
+ const literal = owner.element.getAttribute(attribute);
198
+ if (literal === null) {
199
+ this.#lastRejected.delete(name);
200
+ return resolved;
201
+ }
202
+ if (this.#lastRejected.get(name) !== literal) {
203
+ this.#lastRejected.set(name, literal);
204
+ console.warn(
205
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
206
+ );
207
+ }
208
+ return resolved;
209
+ }
210
+ };
211
+
142
212
  var PausableTimers = class {
143
213
  #timers = new KeyedTimers();
144
- /** Every key armed or held; an entry goes as its timer fires, or with the key. */
145
214
  #entries = /* @__PURE__ */ new Map();
146
- /**
147
- * Arms `callback` after `delay` ms for `key`, replacing whatever `key` had.
148
- * A held key stays held and banks `delay` for its resume.
149
- */
150
215
  set(key, callback, delay) {
151
216
  const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
152
217
  const entry = { callback, startedAt: 0, remaining: delay, reasons };
153
218
  this.#entries.set(key, entry);
154
- if (reasons.size === 0) this.#arm(key, entry);
219
+ if (reasons.size === 0) this.#arm(key, entry, callback);
155
220
  }
156
- /**
157
- * Holds `key` for `reason` and banks the time left, with a floor of one
158
- * millisecond. Reports whether this call is the one that stopped a running
159
- * timer, so the caller can write its held-state hook exactly once.
160
- */
161
221
  pause(key, reason) {
162
- const entry = this.#entries.get(key);
163
- if (entry === void 0) return false;
222
+ let entry = this.#entries.get(key);
223
+ if (entry === void 0) {
224
+ entry = { callback: null, startedAt: 0, remaining: 0, reasons: /* @__PURE__ */ new Set() };
225
+ this.#entries.set(key, entry);
226
+ }
164
227
  entry.reasons.add(reason);
165
228
  if (!this.#timers.has(key)) return false;
166
229
  this.#timers.clear(key);
167
230
  entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
168
231
  return true;
169
232
  }
170
- /**
171
- * Releases `reason` on `key`, arming the banked time again once no reason is
172
- * left. Reports whether this call is the one that started the timer again.
173
- */
174
233
  resume(key, reason) {
175
234
  const entry = this.#entries.get(key);
176
235
  if (entry === void 0) return false;
177
236
  entry.reasons.delete(reason);
178
237
  if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
179
- this.#arm(key, entry);
238
+ const { callback } = entry;
239
+ if (callback === null) {
240
+ this.#entries.delete(key);
241
+ return false;
242
+ }
243
+ this.#arm(key, entry, callback);
180
244
  return true;
181
245
  }
182
- /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
246
+ isHeld(key) {
247
+ return (this.#entries.get(key)?.reasons.size ?? 0) > 0;
248
+ }
183
249
  tracks(key) {
184
250
  return this.#entries.has(key);
185
251
  }
186
- /** Cancels `key`'s timer and drops its hold. */
187
252
  clear(key) {
188
253
  this.#timers.clear(key);
189
254
  this.#entries.delete(key);
190
255
  }
191
- /**
192
- * Cancels every timer and forgets every key. Call this from a controller's
193
- * `disconnect()` so neither a timer nor a hold outlives the element.
194
- */
195
256
  clearAll() {
196
257
  this.#timers.clearAll();
197
258
  this.#entries.clear();
198
259
  }
199
- /** Starts `entry`'s banked time running for `key`, and drops it as it fires. */
200
- #arm(key, entry) {
260
+ #arm(key, entry, callback) {
201
261
  entry.startedAt = Date.now();
202
262
  this.#timers.set(
203
263
  key,
204
264
  () => {
205
265
  this.#entries.delete(key);
206
- entry.callback();
266
+ callback();
207
267
  },
208
268
  entry.remaining
209
269
  );
210
270
  }
211
271
  };
212
272
 
213
- // src/utils/transition_completion.ts
273
+ function targetSelector(identifier, name) {
274
+ return `[data-${identifier}-target~="${name}"]`;
275
+ }
276
+
214
277
  function timeMs(value) {
215
278
  const trimmed = value.trim();
216
279
  const amount = Number.parseFloat(trimmed);
@@ -244,115 +307,115 @@ function maxTransitionTotalMs(style) {
244
307
  return maxTotalMs(transitionTimings(style));
245
308
  }
246
309
 
247
- // src/controllers/flash_controller.ts
248
310
  var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
249
- var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
250
- var FlashController = class extends Controller {
311
+ var MESSAGE_PART = "message";
312
+ var SHOWN_ATTRIBUTE = "shown";
313
+ var FlashController = class _FlashController extends Controller {
314
+ #numbers = new NumberValueReader();
315
+ get #messageSelector() {
316
+ return targetSelector(this.identifier, MESSAGE_PART);
317
+ }
251
318
  static targets = ["region", "message"];
252
319
  static values = {
253
320
  duration: { type: Number, default: 5e3 },
254
321
  pauseOnHover: { type: Boolean, default: true },
255
322
  max: { type: Number, default: 0 }
256
323
  };
324
+ static valueConstraints = {
325
+ duration: NUMBER_BOUNDS.timer,
326
+ max: NUMBER_BOUNDS.finite
327
+ };
257
328
  static actions = ["dismiss"];
258
329
  static events = ["show", "dismiss", "reconcile"];
259
- /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
260
330
  #timers = new SafeTimeout();
261
- /** Per-message auto-dismiss, held open while the message is hovered or focused. */
262
331
  #dismiss = new PausableTimers();
332
+ #fixedDismiss = new KeyedTimers();
333
+ #reapply = new MorphRenderWatcher(() => this.#reapplyCap());
334
+ #spared = [];
335
+ #pointer = new ListenerSet();
336
+ #awaitingPointer = /* @__PURE__ */ new Set();
263
337
  #observer = null;
264
- /** Whether the controller is between `connect()` and `disconnect()`. */
265
338
  #connected = false;
266
- /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
339
+ #arrivals = /* @__PURE__ */ new Set();
340
+ #appliedMax = null;
267
341
  #order = [];
268
- /**
269
- * Messages between `leaving` and their removal. {@link FlashController.#beginDismiss}
270
- * releases the bookkeeping above *before* the transition wait, so for that window the
271
- * element is in the DOM but in neither collection — without this set a re-scan would
272
- * read it as a brand-new flash and show it a second time.
273
- */
274
342
  #leaving = /* @__PURE__ */ new Set();
275
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
343
+ get #shownAttribute() {
344
+ return `data-${this.identifier}-${SHOWN_ATTRIBUTE}`;
345
+ }
276
346
  #onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
277
- #onLeave = (event) => this.#resume(event.currentTarget, event.type === "focusout" ? "focus" : "hover");
347
+ #onLeave = (event) => {
348
+ const message = event.currentTarget;
349
+ const next = ownerOf(this.#order, event.relatedTarget);
350
+ if (next === message) return;
351
+ this.#resume(message, event.type === "focusout" ? "focus" : "hover", next);
352
+ };
278
353
  connect() {
279
354
  this.#connected = true;
355
+ const current = this.messageTargets;
356
+ for (const item of this.#arrivals) {
357
+ if (!current.includes(item)) this.#arrivals.delete(item);
358
+ }
359
+ this.#discardInherited();
360
+ this.#reapply.observe(this.element);
280
361
  for (const message of this.messageTargets) {
281
362
  if (this.#owns(message)) this.#process(message, true);
282
363
  }
364
+ if (!Object.is(this.#appliedMax, this.#safeMax)) {
365
+ this.#appliedMax = this.#safeMax;
366
+ this.#enforceMax();
367
+ }
283
368
  this.#syncObservation();
284
- this.#beforeCache.activate();
285
369
  }
286
370
  disconnect() {
287
371
  this.#connected = false;
288
- this.#beforeCache.deactivate();
289
372
  this.#stopObserving();
290
373
  this.#timers.clearAll();
291
374
  this.#dismiss.clearAll();
375
+ this.#fixedDismiss.clearAll();
376
+ this.#reapply.disconnect();
377
+ this.#spared = [];
378
+ this.#pointer.dispose();
379
+ this.#awaitingPointer.clear();
292
380
  for (const message of this.#order) this.#unbindPause(message);
293
381
  this.#order.length = 0;
294
382
  this.#leaving.clear();
295
383
  }
296
- /**
297
- * Takes the managed flashes out of the page just before Turbo freezes it, so a
298
- * restored snapshot carries no notification the visitor has already received: the
299
- * fresh `connect()` there reads a leftover flash as a brand-new one and announces it
300
- * a second time. A message that never auto-dismisses (`duration: 0`) is one of these
301
- * too — that value governs the timer, not what belongs in a cached page. Removal
302
- * only: `dismiss` reports a dismissal, and freezing the page is not one.
303
- */
304
- #rewindForCache() {
305
- const removed = this.#order.length;
306
- for (const message of [...this.#order]) {
384
+ #discardInherited() {
385
+ let removed = 0;
386
+ for (const message of this.messageTargets) {
387
+ if (!message.hasAttribute(this.#shownAttribute) || this.#arrivals.has(message)) continue;
388
+ if (!this.#owns(message)) continue;
307
389
  message.remove();
308
- this.#forget(message);
390
+ removed += 1;
309
391
  }
310
- for (const message of this.#leaving) message.remove();
311
- this.#leaving.clear();
312
392
  if (removed > 0) this.dispatch("reconcile", { detail: { removed } });
313
393
  }
314
- /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
315
394
  regionTargetConnected() {
316
395
  this.#resync();
317
396
  }
318
- /** Releases the observation when the `region` element leaves the target set. */
319
397
  regionTargetDisconnected() {
320
398
  this.#resync();
321
399
  }
322
- /**
323
- * Whether this controller owns `message`. Ownership is the current `region`'s
324
- * subtree: a message target anywhere else in the controller's scope is the
325
- * consumer's, and so is one in a region that has gone away. The initial scan, a
326
- * re-scan after a `region` swap, and a departure from the target set all resolve
327
- * ownership through this one test; the observation gets it structurally, by watching
328
- * that subtree and nothing else.
329
- */
400
+ maxValueChanged() {
401
+ if (!this.#connected) return;
402
+ this.#appliedMax = this.#safeMax;
403
+ this.#enforceMax();
404
+ }
330
405
  #owns(message) {
331
406
  return this.hasRegionTarget && this.regionTarget.contains(message);
332
407
  }
333
- /**
334
- * Releases a message that left the target set (a Turbo Stream `remove`, the consumer
335
- * detaching the node, or a morph that rewrote the target attribute in place): it
336
- * stops occupying a `max` slot, and both its pending auto-dismiss and an already
337
- * scheduled removal are cancelled. A move *within* the region keeps all of them —
338
- * which is why the element must still be a message to be treated as one: ownership
339
- * alone reads an in-place attribute rewrite as a move, and a node outside the target
340
- * set belongs to the consumer, so nothing here may dismiss it.
341
- */
342
408
  messageTargetDisconnected(message) {
343
409
  if (!this.#connected) return;
344
- const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
345
- if (moved) return;
410
+ const moved = this.#owns(message) && message.matches(this.#messageSelector);
411
+ if (moved) {
412
+ this.#afterMove(message);
413
+ return;
414
+ }
346
415
  this.#forget(message);
416
+ this.#arrivals.delete(message);
347
417
  this.#leaving.delete(message);
348
418
  }
349
- /**
350
- * Re-points the observation after a `region` swap and picks up the messages the
351
- * new element brought with it (dynamic inserts, so their own `role` announces
352
- * them). The `#connected` guard is load-bearing: Stimulus runs target callbacks
353
- * for the initial markup *before* `connect()` and again during teardown *after*
354
- * `disconnect()`, and re-observing there would outlive the controller.
355
- */
356
419
  #resync() {
357
420
  if (!this.#connected) return;
358
421
  this.#syncObservation();
@@ -360,11 +423,6 @@ var FlashController = class extends Controller {
360
423
  if (this.#owns(message)) this.#process(message, false);
361
424
  }
362
425
  }
363
- /**
364
- * Points the mutation observation at the current `region` target, re-resolved on
365
- * every sync rather than captured at connect, so an element swapped in at runtime
366
- * is observed instead of the detached original.
367
- */
368
426
  #syncObservation() {
369
427
  this.#stopObserving();
370
428
  if (!this.hasRegionTarget || typeof MutationObserver === "undefined") return;
@@ -375,14 +433,7 @@ var FlashController = class extends Controller {
375
433
  this.#observer?.disconnect();
376
434
  this.#observer = null;
377
435
  }
378
- /**
379
- * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
380
- * stay in sync. Binding is gated by `pauseOnHover`; unbinding is unconditional
381
- * and idempotent (a no-op when nothing was bound), which keeps teardown correct
382
- * even if `pauseOnHover` were ever toggled during a message's life.
383
- */
384
436
  #bindPause(message) {
385
- if (!this.pauseOnHoverValue) return;
386
437
  message.addEventListener("mouseenter", this.#onEnter);
387
438
  message.addEventListener("mouseleave", this.#onLeave);
388
439
  message.addEventListener("focusin", this.#onEnter);
@@ -394,79 +445,128 @@ var FlashController = class extends Controller {
394
445
  message.removeEventListener("focusin", this.#onEnter);
395
446
  message.removeEventListener("focusout", this.#onLeave);
396
447
  }
397
- /** Dismisses the flash whose close control fired the event. */
398
- dismiss(event) {
399
- const target = event.currentTarget || event.target;
400
- const message = target?.closest(MESSAGE_SELECTOR);
401
- if (message) this.#beginDismiss(message, "user");
448
+ dismiss(input) {
449
+ const source = actionSource(input);
450
+ if (source.origin && source.origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
451
+ return;
452
+ const message = (source.origin ?? source.host)?.closest(this.#messageSelector);
453
+ if (message && this.messageTargets.includes(message))
454
+ this.#beginDismiss(message, source.reason);
402
455
  }
403
- /** Processes messages added after connect (Turbo Stream); their own role announces them. */
404
456
  #onMutations(mutations) {
405
457
  for (const mutation of mutations) {
406
458
  for (const node of mutation.addedNodes) {
407
459
  if (!(node instanceof HTMLElement)) continue;
408
- if (node.matches(MESSAGE_SELECTOR)) this.#process(node, false);
409
- for (const message of node.querySelectorAll(MESSAGE_SELECTOR)) {
460
+ if (node.matches(this.#messageSelector)) this.#process(node, false);
461
+ for (const message of node.querySelectorAll(this.#messageSelector)) {
410
462
  this.#process(message, false);
411
463
  }
412
464
  }
413
465
  }
414
466
  }
415
- /**
416
- * Applies role/state, wires pause listeners, schedules auto-dismiss, and either
417
- * bridges to the Announcer (`bridge`, for initial flashes) or leaves the message's
418
- * own role to do the announcing (dynamic inserts). Idempotent per message.
419
- */
420
- #process(message, bridge) {
421
- if (this.#order.includes(message) || this.#leaving.has(message)) return;
467
+ #process(message, atConnect) {
468
+ if (this.#order.includes(message) || this.#leaving.has(message) || !this.#owns(message)) {
469
+ return;
470
+ }
422
471
  const type = message.getAttribute("data-flash-type") ?? "";
423
472
  const assertive = ASSERTIVE_TYPES.has(type);
424
473
  if (!message.hasAttribute("role")) {
425
474
  message.setAttribute("role", assertive ? "alert" : "status");
426
475
  }
427
476
  message.setAttribute("data-flash-state", "visible");
477
+ message.setAttribute(this.#shownAttribute, "");
478
+ const known = this.#arrivals.has(message);
428
479
  this.#order.push(message);
480
+ this.#arrivals.add(message);
429
481
  this.#bindPause(message);
430
- const text = message.textContent?.trim() ?? "";
431
- this.dispatch("show", { target: message, detail: { type, message: text } });
432
- if (bridge) announce(text, { assertive });
482
+ this.#readHolds(message);
483
+ if (!known) {
484
+ const text = message.textContent?.trim() ?? "";
485
+ this.dispatch("show", { target: message, detail: { type, message: text } });
486
+ if (atConnect) announce(text, { assertive });
487
+ }
433
488
  this.#startTimer(message);
434
- this.#enforceMax();
435
- }
436
- /** Removes the oldest visible flashes once the count exceeds `max` (0 = unlimited). */
437
- #enforceMax() {
438
- if (this.maxValue <= 0) return;
439
- while (this.#order.length > this.maxValue) {
440
- const oldest = this.#order[0];
441
- if (!oldest) break;
442
- this.#beginDismiss(oldest, "limit");
489
+ if (!atConnect) this.#enforceMax([message]);
490
+ }
491
+ #enforceMax(spared = []) {
492
+ if (this.#safeMax <= 0) return;
493
+ for (const message of [...this.#arrivals]) {
494
+ if (this.#shownCount() <= this.#safeMax) return;
495
+ if (this.#owns(message) && !spared.includes(message) && !this.#dismiss.isHeld(message)) {
496
+ this.#beginDismiss(message, "limit");
497
+ }
443
498
  }
444
499
  }
445
- /** Arms a message's auto-dismiss; a non-positive `duration` means it never expires. */
500
+ #shownCount() {
501
+ return this.#order.filter((message) => this.#owns(message)).length;
502
+ }
446
503
  #startTimer(message) {
447
- if (this.durationValue <= 0) return;
448
- this.#dismiss.set(message, () => this.#beginDismiss(message, "timeout"), this.durationValue);
449
- }
450
- /**
451
- * Holds a message's auto-dismiss open while it is hovered or focused
452
- * (WCAG 2.2 2.2.1). Hover and focus are independent reasons, and the registry
453
- * banks the time left on the first of them and waits for the last.
454
- */
504
+ if (this.#safeDuration <= 0) return;
505
+ const dismiss = () => this.#beginDismiss(message, "timeout");
506
+ if (this.pauseOnHoverValue) this.#dismiss.set(message, dismiss, this.#safeDuration);
507
+ else this.#fixedDismiss.set(message, dismiss, this.#safeDuration);
508
+ }
455
509
  #pause(message, reason) {
456
510
  this.#dismiss.pause(message, reason);
457
511
  }
458
- /** Releases one reason, resuming the banked time once no reason is left. */
459
- #resume(message, reason) {
512
+ #resume(message, reason, spare) {
513
+ const held = this.#dismiss.isHeld(message);
460
514
  this.#dismiss.resume(message, reason);
461
- }
462
- /** Releases every per-message resource: timer, stacking slot, pause listeners. */
515
+ if (held && !this.#dismiss.isHeld(message)) this.#capLater(spare);
516
+ }
517
+ #capLater(spare) {
518
+ if (spare) this.#spared.push(spare);
519
+ this.#reapply.schedule();
520
+ }
521
+ #reapplyCap() {
522
+ for (const message of [...this.#order, ...this.#leaving]) {
523
+ if (!this.#owns(message)) continue;
524
+ message.setAttribute("data-flash-state", this.#leaving.has(message) ? "leaving" : "visible");
525
+ message.setAttribute(this.#shownAttribute, "");
526
+ if (!message.hasAttribute("role")) {
527
+ const assertive = ASSERTIVE_TYPES.has(message.getAttribute("data-flash-type") ?? "");
528
+ message.setAttribute("role", assertive ? "alert" : "status");
529
+ }
530
+ }
531
+ const spared = this.#spared;
532
+ this.#spared = [];
533
+ this.#enforceMax(spared);
534
+ }
535
+ #afterMove(message) {
536
+ if (!message.contains(document.activeElement)) this.#resume(message, "focus", null);
537
+ if (this.#dismiss.isHeld(message)) this.#awaitPointer(message);
538
+ }
539
+ #readHolds(message) {
540
+ if (message.contains(document.activeElement)) this.#pause(message, "focus");
541
+ if (!message.matches(":hover")) return;
542
+ this.#pause(message, "hover");
543
+ this.#awaitPointer(message);
544
+ }
545
+ #awaitPointer(message) {
546
+ this.#awaitingPointer.add(message);
547
+ this.#pointer.add(document, "pointermove", this.#onPointerMove, {
548
+ capture: true,
549
+ passive: true
550
+ });
551
+ }
552
+ #onPointerMove = () => {
553
+ this.#pointer.dispose();
554
+ const waiting = [...this.#awaitingPointer];
555
+ this.#awaitingPointer.clear();
556
+ for (const message of waiting) {
557
+ if (!message.matches(":hover")) this.#resume(message, "hover", null);
558
+ }
559
+ };
463
560
  #forget(message) {
561
+ if (this.#dismiss.isHeld(message)) this.#capLater(null);
464
562
  this.#dismiss.clear(message);
563
+ this.#fixedDismiss.clear(message);
465
564
  const index = this.#order.indexOf(message);
466
565
  if (index !== -1) this.#order.splice(index, 1);
467
566
  this.#unbindPause(message);
567
+ this.#awaitingPointer.delete(message);
568
+ if (this.#awaitingPointer.size === 0) this.#pointer.dispose();
468
569
  }
469
- /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
470
570
  #beginDismiss(message, reason) {
471
571
  if (!this.#order.includes(message)) return;
472
572
  this.#forget(message);
@@ -475,6 +575,7 @@ var FlashController = class extends Controller {
475
575
  const finalize = () => {
476
576
  if (!this.#leaving.delete(message)) return;
477
577
  message.remove();
578
+ this.#arrivals.delete(message);
478
579
  this.dispatch("dismiss", { detail: { element: message, reason } });
479
580
  };
480
581
  const transition = this.#transitionMs(message);
@@ -484,13 +585,29 @@ var FlashController = class extends Controller {
484
585
  finalize();
485
586
  }
486
587
  }
487
- /** Maximum transition total (duration + delay) of `el` in ms (0 when none / unsupported). */
488
588
  #transitionMs(el) {
489
589
  if (typeof window.getComputedStyle !== "function") return 0;
490
590
  return maxTransitionTotalMs(window.getComputedStyle(el));
491
591
  }
592
+ get #safeDuration() {
593
+ return this.#numbers.read(
594
+ this,
595
+ "duration",
596
+ this.durationValue,
597
+ _FlashController.values.duration.default,
598
+ _FlashController.valueConstraints.duration
599
+ );
600
+ }
601
+ get #safeMax() {
602
+ return this.#numbers.read(
603
+ this,
604
+ "max",
605
+ this.maxValue,
606
+ _FlashController.values.max.default,
607
+ _FlashController.valueConstraints.max
608
+ );
609
+ }
492
610
  };
493
611
 
494
612
  export { FlashController };
495
- //# sourceMappingURL=flash_controller.js.map
496
613
  //# sourceMappingURL=flash_controller.js.map