stimeo-ui 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -5
  3. data/dist/cable/index.js +60 -14
  4. data/dist/controllers/announcer_controller.js +88 -61
  5. data/dist/controllers/auto_submit_controller.js +40 -5
  6. data/dist/controllers/breadcrumb_controller.js +29 -3
  7. data/dist/controllers/calendar_controller.js +51 -10
  8. data/dist/controllers/carousel_controller.js +8 -6
  9. data/dist/controllers/checkbox_controller.js +27 -2
  10. data/dist/controllers/conditional_fields_controller.js +36 -7
  11. data/dist/controllers/currency_input_controller.js +77 -25
  12. data/dist/controllers/date_range_picker_controller.js +43 -10
  13. data/dist/controllers/direct_upload_controller.js +21 -10
  14. data/dist/controllers/dirty_form_controller.js +32 -12
  15. data/dist/controllers/flash_controller.js +145 -43
  16. data/dist/controllers/form_field_controller.js +7 -1
  17. data/dist/controllers/frame_loading_controller.js +29 -6
  18. data/dist/controllers/highlight_controller.js +63 -23
  19. data/dist/controllers/input_mask_controller.js +33 -12
  20. data/dist/controllers/intersection_controller.js +10 -5
  21. data/dist/controllers/lazy_frame_controller.js +10 -5
  22. data/dist/controllers/local_time_controller.js +43 -11
  23. data/dist/controllers/masonry_controller.js +32 -14
  24. data/dist/controllers/menu_controller.js +10 -2
  25. data/dist/controllers/menubar_controller.js +10 -2
  26. data/dist/controllers/navigation_menu_controller.js +8 -2
  27. data/dist/controllers/optimistic_controller.js +28 -4
  28. data/dist/controllers/otp_controller.js +23 -10
  29. data/dist/controllers/overflow_indicator_controller.js +31 -8
  30. data/dist/controllers/overflow_menu_controller.js +39 -4
  31. data/dist/controllers/pointer_drag_controller.js +10 -3
  32. data/dist/controllers/portal_controller.js +21 -10
  33. data/dist/controllers/radio_group_controller.js +11 -3
  34. data/dist/controllers/read_more_controller.js +31 -5
  35. data/dist/controllers/reading_progress_controller.js +65 -19
  36. data/dist/controllers/relative_time_controller.js +40 -9
  37. data/dist/controllers/reset_before_cache_controller.js +23 -10
  38. data/dist/controllers/resizable_controller.js +7 -2
  39. data/dist/controllers/roving_controller.js +7 -3
  40. data/dist/controllers/scroll_area_controller.js +70 -26
  41. data/dist/controllers/scroll_restore_controller.js +37 -16
  42. data/dist/controllers/scroll_visibility_controller.js +71 -42
  43. data/dist/controllers/scrollspy_controller.js +76 -32
  44. data/dist/controllers/smart_sticky_header_controller.js +72 -37
  45. data/dist/controllers/stick_to_bottom_controller.js +29 -3
  46. data/dist/controllers/sticky_observer_controller.js +10 -5
  47. data/dist/controllers/submit_once_controller.js +29 -6
  48. data/dist/controllers/textarea_autosize_controller.js +29 -3
  49. data/dist/controllers/theme_controller.js +23 -13
  50. data/dist/controllers/toast_controller.js +136 -73
  51. data/dist/controllers/toggle_group_controller.js +11 -3
  52. data/dist/controllers/toolbar_controller.js +7 -3
  53. data/dist/index.js +629 -597
  54. data/lib/stimeo/ui/version.rb +1 -1
  55. metadata +2 -2
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/flash_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+
5
16
  // src/utils/before_cache_reset.ts
6
17
  var BeforeCacheReset = class _BeforeCacheReset {
7
18
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -84,6 +95,121 @@ var SafeTimeout = class extends TimerRegistry {
84
95
  }
85
96
  };
86
97
 
98
+ // src/utils/keyed_timers.ts
99
+ var KeyedTimers = class {
100
+ #timers = new SafeTimeout();
101
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
102
+ #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
+ set(key, callback, delay) {
109
+ this.clear(key);
110
+ const id = this.#timers.set(() => {
111
+ this.#pending.delete(key);
112
+ callback();
113
+ }, delay);
114
+ this.#pending.set(key, id);
115
+ }
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
+ clear(key) {
124
+ this.#timers.clear(this.#pending.get(key) ?? -1);
125
+ this.#pending.delete(key);
126
+ }
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
+ clearAll() {
132
+ this.#timers.clearAll();
133
+ this.#pending.clear();
134
+ }
135
+ /** Whether `key` has a timer pending. */
136
+ has(key) {
137
+ return this.#pending.has(key);
138
+ }
139
+ };
140
+
141
+ // src/utils/pausable_timers.ts
142
+ var PausableTimers = class {
143
+ #timers = new KeyedTimers();
144
+ /** Every key armed or held; an entry goes as its timer fires, or with the key. */
145
+ #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
+ set(key, callback, delay) {
151
+ const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
152
+ const entry = { callback, startedAt: 0, remaining: delay, reasons };
153
+ this.#entries.set(key, entry);
154
+ if (reasons.size === 0) this.#arm(key, entry);
155
+ }
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
+ pause(key, reason) {
162
+ const entry = this.#entries.get(key);
163
+ if (entry === void 0) return false;
164
+ entry.reasons.add(reason);
165
+ if (!this.#timers.has(key)) return false;
166
+ this.#timers.clear(key);
167
+ entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
168
+ return true;
169
+ }
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
+ resume(key, reason) {
175
+ const entry = this.#entries.get(key);
176
+ if (entry === void 0) return false;
177
+ entry.reasons.delete(reason);
178
+ if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
179
+ this.#arm(key, entry);
180
+ return true;
181
+ }
182
+ /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
183
+ tracks(key) {
184
+ return this.#entries.has(key);
185
+ }
186
+ /** Cancels `key`'s timer and drops its hold. */
187
+ clear(key) {
188
+ this.#timers.clear(key);
189
+ this.#entries.delete(key);
190
+ }
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
+ clearAll() {
196
+ this.#timers.clearAll();
197
+ this.#entries.clear();
198
+ }
199
+ /** Starts `entry`'s banked time running for `key`, and drops it as it fires. */
200
+ #arm(key, entry) {
201
+ entry.startedAt = Date.now();
202
+ this.#timers.set(
203
+ key,
204
+ () => {
205
+ this.#entries.delete(key);
206
+ entry.callback();
207
+ },
208
+ entry.remaining
209
+ );
210
+ }
211
+ };
212
+
87
213
  // src/utils/transition_completion.ts
88
214
  function timeMs(value) {
89
215
  const trimmed = value.trim();
@@ -130,12 +256,13 @@ var FlashController = class extends Controller {
130
256
  };
131
257
  static actions = ["dismiss"];
132
258
  static events = ["show", "dismiss", "reconcile"];
259
+ /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
133
260
  #timers = new SafeTimeout();
261
+ /** Per-message auto-dismiss, held open while the message is hovered or focused. */
262
+ #dismiss = new PausableTimers();
134
263
  #observer = null;
135
264
  /** Whether the controller is between `connect()` and `disconnect()`. */
136
265
  #connected = false;
137
- /** Auto-dismiss timer state keyed by message element. */
138
- #state = /* @__PURE__ */ new Map();
139
266
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
140
267
  #order = [];
141
268
  /**
@@ -161,8 +288,8 @@ var FlashController = class extends Controller {
161
288
  this.#beforeCache.deactivate();
162
289
  this.#stopObserving();
163
290
  this.#timers.clearAll();
291
+ this.#dismiss.clearAll();
164
292
  for (const message of this.#order) this.#unbindPause(message);
165
- this.#state.clear();
166
293
  this.#order.length = 0;
167
294
  this.#leaving.clear();
168
295
  }
@@ -291,8 +418,7 @@ var FlashController = class extends Controller {
291
418
  * own role to do the announcing (dynamic inserts). Idempotent per message.
292
419
  */
293
420
  #process(message, bridge) {
294
- if (this.#state.has(message) || this.#order.includes(message)) return;
295
- if (this.#leaving.has(message)) return;
421
+ if (this.#order.includes(message) || this.#leaving.has(message)) return;
296
422
  const type = message.getAttribute("data-flash-type") ?? "";
297
423
  const assertive = ASSERTIVE_TYPES.has(type);
298
424
  if (!message.hasAttribute("role")) {
@@ -303,11 +429,7 @@ var FlashController = class extends Controller {
303
429
  this.#bindPause(message);
304
430
  const text = message.textContent?.trim() ?? "";
305
431
  this.dispatch("show", { target: message, detail: { type, message: text } });
306
- if (bridge && text) {
307
- window.dispatchEvent(
308
- new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
309
- );
310
- }
432
+ if (bridge) announce(text, { assertive });
311
433
  this.#startTimer(message);
312
434
  this.#enforceMax();
313
435
  }
@@ -320,53 +442,33 @@ var FlashController = class extends Controller {
320
442
  this.#beginDismiss(oldest, "limit");
321
443
  }
322
444
  }
323
- #startTimer(message, duration = this.durationValue) {
324
- if (duration <= 0) return;
325
- const existing = this.#state.get(message);
326
- if (existing?.id) this.#timers.clear(existing.id);
327
- const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
328
- this.#state.set(message, {
329
- id,
330
- startedAt: Date.now(),
331
- remaining: duration,
332
- paused: existing?.paused ?? /* @__PURE__ */ new Set()
333
- });
445
+ /** Arms a message's auto-dismiss; a non-positive `duration` means it never expires. */
446
+ #startTimer(message) {
447
+ if (this.durationValue <= 0) return;
448
+ this.#dismiss.set(message, () => this.#beginDismiss(message, "timeout"), this.durationValue);
334
449
  }
335
450
  /**
336
- * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
337
- * Hover and focus are independent reasons: the remaining time is banked on the
338
- * first of them, and {@link FlashController.#resume} waits for the last one.
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.
339
454
  */
340
455
  #pause(message, reason) {
341
- const timer = this.#state.get(message);
342
- if (!timer) return;
343
- timer.paused.add(reason);
344
- if (timer.id === 0) return;
345
- this.#timers.clear(timer.id);
346
- const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
347
- this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
348
- }
349
- /** Resumes a paused message's auto-dismiss with the banked time. */
456
+ this.#dismiss.pause(message, reason);
457
+ }
458
+ /** Releases one reason, resuming the banked time once no reason is left. */
350
459
  #resume(message, reason) {
351
- const timer = this.#state.get(message);
352
- if (!timer) return;
353
- timer.paused.delete(reason);
354
- if (timer.paused.size > 0) return;
355
- if (timer.id !== 0) return;
356
- this.#startTimer(message, timer.remaining);
460
+ this.#dismiss.resume(message, reason);
357
461
  }
358
462
  /** Releases every per-message resource: timer, stacking slot, pause listeners. */
359
463
  #forget(message) {
360
- const timer = this.#state.get(message);
361
- if (timer?.id) this.#timers.clear(timer.id);
362
- this.#state.delete(message);
464
+ this.#dismiss.clear(message);
363
465
  const index = this.#order.indexOf(message);
364
466
  if (index !== -1) this.#order.splice(index, 1);
365
467
  this.#unbindPause(message);
366
468
  }
367
469
  /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
368
470
  #beginDismiss(message, reason) {
369
- if (!this.#state.has(message) && !this.#order.includes(message)) return;
471
+ if (!this.#order.includes(message)) return;
370
472
  this.#forget(message);
371
473
  this.#leaving.add(message);
372
474
  message.setAttribute("data-flash-state", "leaving");
@@ -100,6 +100,12 @@ var BeforeCacheReset = class _BeforeCacheReset {
100
100
  }
101
101
  };
102
102
 
103
+ // src/utils/event_owner.ts
104
+ function ownerIndex(candidates, node) {
105
+ if (!(node instanceof Node)) return -1;
106
+ return candidates.findIndex((candidate) => candidate.contains(node));
107
+ }
108
+
103
109
  // src/utils/microtask_coalescer.ts
104
110
  var MicrotaskCoalescer = class {
105
111
  #run;
@@ -357,7 +363,7 @@ var FormFieldController = class _FormFieldController extends Controller {
357
363
  return this.errorTargets.includes(target);
358
364
  return record.attributeName === "id" && [...this.descriptionTargets, ...this.errorTargets].includes(target);
359
365
  }
360
- return this.errorTargets.some((error) => error === target || error.contains(target));
366
+ return ownerIndex(this.errorTargets, target) !== -1;
361
367
  }
362
368
  /** Returns borrowed control ARIA before Turbo snapshots the page. */
363
369
  #rewindForCache() {
@@ -103,6 +103,30 @@ var DetachGate = class _DetachGate {
103
103
  }
104
104
  };
105
105
 
106
+ // src/utils/listener_set.ts
107
+ var ListenerSet = class {
108
+ /** The generation every `add` joins until the next `dispose()`. */
109
+ #abort = new AbortController();
110
+ /**
111
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
112
+ *
113
+ * The set supplies the signal, so `options` carries everything else the DOM
114
+ * accepts — `capture` included, which has to match at release time and no
115
+ * longer has a second place to drift from.
116
+ */
117
+ add(target, type, handler, options) {
118
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
119
+ }
120
+ /**
121
+ * Releases every listener of the open generation, synchronously, and opens the
122
+ * next one. Idempotent, and safe before anything has been added.
123
+ */
124
+ dispose() {
125
+ this.#abort.abort();
126
+ this.#abort = new AbortController();
127
+ }
128
+ };
129
+
106
130
  // src/utils/min_duration_floor.ts
107
131
  var MinDurationFloor = class {
108
132
  #timers;
@@ -218,6 +242,7 @@ var FrameLoadingController = class extends Controller {
218
242
  #floor = new MinDurationFloor(this.#timeouts);
219
243
  #gate = new DetachGate();
220
244
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
245
+ #listeners = new ListenerSet();
221
246
  #loading = false;
222
247
  /**
223
248
  * The optional targets this controller revealed, and the content it marked inert.
@@ -244,14 +269,12 @@ var FrameLoadingController = class extends Controller {
244
269
  connect() {
245
270
  this.#gate.cancel();
246
271
  this.#beforeCache.activate();
247
- this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
248
- this.element.addEventListener("turbo:frame-load", this.#onEnd);
249
- this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
272
+ this.#listeners.add(this.element, "turbo:before-fetch-request", this.#onStart);
273
+ this.#listeners.add(this.element, "turbo:frame-load", this.#onEnd);
274
+ this.#listeners.add(this.element, "turbo:fetch-request-error", this.#onEnd);
250
275
  }
251
276
  disconnect() {
252
- this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
253
- this.element.removeEventListener("turbo:frame-load", this.#onEnd);
254
- this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
277
+ this.#listeners.dispose();
255
278
  this.#beforeCache.deactivate();
256
279
  this.#gate.disconnected(this, () => this.#teardown());
257
280
  }
@@ -2,11 +2,6 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/highlight_controller.ts
4
4
 
5
- // src/utils/reduced_motion.ts
6
- function prefersReducedMotion() {
7
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
- }
9
-
10
5
  // src/utils/safe_timeout.ts
11
6
  var TimerRegistry = class {
12
7
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -60,6 +55,54 @@ var SafeTimeout = class extends TimerRegistry {
60
55
  }
61
56
  };
62
57
 
58
+ // src/utils/keyed_timers.ts
59
+ var KeyedTimers = class {
60
+ #timers = new SafeTimeout();
61
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
62
+ #pending = /* @__PURE__ */ new Map();
63
+ /**
64
+ * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
65
+ * pending. The entry is dropped before the callback runs, so the callback sees
66
+ * the key unarmed and may arm it again for the next round.
67
+ */
68
+ set(key, callback, delay) {
69
+ this.clear(key);
70
+ const id = this.#timers.set(() => {
71
+ this.#pending.delete(key);
72
+ callback();
73
+ }, delay);
74
+ this.#pending.set(key, id);
75
+ }
76
+ /**
77
+ * Cancels `key`'s pending timer, if it has one.
78
+ *
79
+ * A timer id is a positive integer, so `-1` stands for "nothing pending" and
80
+ * the registry ignores an id it does not own — the unarmed case needs no
81
+ * branch of its own, and no other key's timer can be reached from here.
82
+ */
83
+ clear(key) {
84
+ this.#timers.clear(this.#pending.get(key) ?? -1);
85
+ this.#pending.delete(key);
86
+ }
87
+ /**
88
+ * Cancels every pending timer and forgets every key. Call this from a
89
+ * controller's `disconnect()` so no timer, and no entry, outlives the element.
90
+ */
91
+ clearAll() {
92
+ this.#timers.clearAll();
93
+ this.#pending.clear();
94
+ }
95
+ /** Whether `key` has a timer pending. */
96
+ has(key) {
97
+ return this.#pending.has(key);
98
+ }
99
+ };
100
+
101
+ // src/utils/reduced_motion.ts
102
+ function prefersReducedMotion() {
103
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
104
+ }
105
+
63
106
  // src/controllers/highlight_controller.ts
64
107
  var hookOwners = /* @__PURE__ */ new WeakMap();
65
108
  var HighlightController = class extends Controller {
@@ -68,14 +111,11 @@ var HighlightController = class extends Controller {
68
111
  observe: { type: Boolean, default: false }
69
112
  };
70
113
  static events = ["start", "end"];
71
- #timeouts = new SafeTimeout();
72
114
  /**
73
- * The removal timer this connection has outstanding for an element. Held weakly so
74
- * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
75
- * so a cleared id can never be matched against a recycled one. Which connection owns
76
- * an element's hook is answered by the shared owner registry above.
115
+ * The removal timer this connection has outstanding for each element. Which
116
+ * connection owns an element's hook is answered by the shared owner registry above.
77
117
  */
78
- #pending = /* @__PURE__ */ new WeakMap();
118
+ #removal = new KeyedTimers();
79
119
  #observer = null;
80
120
  connect() {
81
121
  this.#clearArrivedHook(this.element);
@@ -92,8 +132,7 @@ var HighlightController = class extends Controller {
92
132
  disconnect() {
93
133
  this.#observer?.disconnect();
94
134
  this.#observer = null;
95
- this.#timeouts.clearAll();
96
- this.#pending = /* @__PURE__ */ new WeakMap();
135
+ this.#removal.clearAll();
97
136
  }
98
137
  /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
99
138
  #clearArrivedHook(el) {
@@ -114,13 +153,15 @@ var HighlightController = class extends Controller {
114
153
  this.#releasePending(el);
115
154
  el.setAttribute("data-highlight", "true");
116
155
  this.dispatch("start", { target: el, detail: { element: el } });
117
- const id = this.#timeouts.set(() => {
118
- this.#pending.delete(el);
119
- hookOwners.delete(el);
120
- el.removeAttribute("data-highlight");
121
- this.dispatch("end", { target: el, detail: { element: el } });
122
- }, this.durationValue);
123
- this.#pending.set(el, id);
156
+ this.#removal.set(
157
+ el,
158
+ () => {
159
+ hookOwners.delete(el);
160
+ el.removeAttribute("data-highlight");
161
+ this.dispatch("end", { target: el, detail: { element: el } });
162
+ },
163
+ this.durationValue
164
+ );
124
165
  hookOwners.set(el, this);
125
166
  }
126
167
  /**
@@ -133,10 +174,9 @@ var HighlightController = class extends Controller {
133
174
  if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
134
175
  this.#cancelPending(el);
135
176
  }
136
- /** Releases `el`'s pending removal timer, if it has one. */
177
+ /** Releases `el`'s pending removal timer, if it has one, and this connection's claim. */
137
178
  #cancelPending(el) {
138
- this.#timeouts.clear(this.#pending.get(el) ?? -1);
139
- this.#pending.delete(el);
179
+ this.#removal.clear(el);
140
180
  hookOwners.delete(el);
141
181
  }
142
182
  };
@@ -49,6 +49,30 @@ var CompositionTracker = class {
49
49
  };
50
50
  };
51
51
 
52
+ // src/utils/declared_value.ts
53
+ function parseDeclared(raw, parse, fallback) {
54
+ try {
55
+ return parse(raw);
56
+ } catch {
57
+ return fallback;
58
+ }
59
+ }
60
+ function compileRegExp(source, anchor = "none") {
61
+ return parseDeclared(
62
+ source,
63
+ (text) => {
64
+ const bare = new RegExp(text);
65
+ return anchor === "exact" ? new RegExp(`^(?:${text})$`) : bare;
66
+ },
67
+ null
68
+ );
69
+ }
70
+ function parseJsonObject(raw) {
71
+ const parsed = parseDeclared(raw, (text) => JSON.parse(text), null);
72
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
73
+ return parsed;
74
+ }
75
+
52
76
  // src/utils/focus_candidate.ts
53
77
  function inheritsFieldsetDisabled(control) {
54
78
  let fieldset = control.closest("fieldset[disabled]");
@@ -118,22 +142,19 @@ function applyMask(value, pattern, tokens) {
118
142
  }
119
143
  function compileTokens(declaration) {
120
144
  const map = /* @__PURE__ */ new Map();
121
- for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {
122
- try {
123
- map.set(key, new RegExp(`^(?:${source})$`));
124
- } catch {
125
- }
145
+ for (const [key, source] of Object.entries(DEFAULT_TOKENS)) {
146
+ const compiled = compileRegExp(source, "exact");
147
+ if (compiled) map.set(key, compiled);
148
+ }
149
+ for (const [key, source] of Object.entries(parseTokens(declaration))) {
150
+ const compiled = compileRegExp(source, "exact");
151
+ if (compiled) map.set(key, compiled);
126
152
  }
127
153
  return map;
128
154
  }
129
155
  function parseTokens(declaration) {
130
- let parsed;
131
- try {
132
- parsed = JSON.parse(declaration);
133
- } catch {
134
- return {};
135
- }
136
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
156
+ const parsed = parseJsonObject(declaration);
157
+ if (parsed === null) return {};
137
158
  const tokens = {};
138
159
  for (const [key, source] of Object.entries(parsed)) {
139
160
  if (typeof source === "string") tokens[key] = source;
@@ -2,6 +2,15 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/intersection_controller.ts
4
4
 
5
+ // src/utils/declared_value.ts
6
+ function parseDeclared(raw, parse, fallback) {
7
+ try {
8
+ return parse(raw);
9
+ } catch {
10
+ return fallback;
11
+ }
12
+ }
13
+
5
14
  // src/utils/intersection_watcher.ts
6
15
  function isBeforeRootStart(entry) {
7
16
  const rect = entry.boundingClientRect;
@@ -11,11 +20,7 @@ function isBeforeRootStart(entry) {
11
20
  }
12
21
  function queryRoot(selector) {
13
22
  if (!selector) return null;
14
- try {
15
- return document.querySelector(selector);
16
- } catch {
17
- }
18
- return null;
23
+ return parseDeclared(selector, (raw) => document.querySelector(raw), null);
19
24
  }
20
25
  var IntersectionWatcher = class {
21
26
  #onEntries;
@@ -2,14 +2,19 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/lazy_frame_controller.ts
4
4
 
5
- // src/utils/intersection_watcher.ts
6
- function queryRoot(selector) {
7
- if (!selector) return null;
5
+ // src/utils/declared_value.ts
6
+ function parseDeclared(raw, parse, fallback) {
8
7
  try {
9
- return document.querySelector(selector);
8
+ return parse(raw);
10
9
  } catch {
10
+ return fallback;
11
11
  }
12
- return null;
12
+ }
13
+
14
+ // src/utils/intersection_watcher.ts
15
+ function queryRoot(selector) {
16
+ if (!selector) return null;
17
+ return parseDeclared(selector, (raw) => document.querySelector(raw), null);
13
18
  }
14
19
  var IntersectionWatcher = class {
15
20
  #onEntries;
@@ -2,6 +2,40 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/local_time_controller.ts
4
4
 
5
+ // src/utils/intl_format.ts
6
+ var formatters = /* @__PURE__ */ new WeakMap();
7
+ function intlFormatter(factory, locale, options, fallbackLocale) {
8
+ const cache = cacheFor(factory);
9
+ const key = cacheKey(locale, options, fallbackLocale);
10
+ if (cache.has(key)) return cache.get(key);
11
+ const formatter = construct(factory, locale, options);
12
+ cache.set(key, formatter);
13
+ return formatter;
14
+ }
15
+ function cacheFor(factory) {
16
+ const existing = formatters.get(factory);
17
+ if (existing) return existing;
18
+ const created = /* @__PURE__ */ new Map();
19
+ formatters.set(factory, created);
20
+ return created;
21
+ }
22
+ function cacheKey(locale, options, fallbackLocale) {
23
+ return JSON.stringify([locale, options, fallbackLocale]);
24
+ }
25
+ function construct(factory, locale, options, fallbackLocale) {
26
+ try {
27
+ return new factory(locale, options);
28
+ } catch (error) {
29
+ if (!(error instanceof RangeError)) throw error;
30
+ return null;
31
+ }
32
+ }
33
+
34
+ // src/utils/locale.ts
35
+ function resolveLocale(element, declared = "") {
36
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
37
+ }
38
+
5
39
  // src/utils/microtask_coalescer.ts
6
40
  var MicrotaskCoalescer = class {
7
41
  #run;
@@ -150,8 +184,9 @@ var LocalTimeController = class extends Controller {
150
184
  /**
151
185
  * Formats `date` with `Intl.DateTimeFormat`, including each style only when it
152
186
  * is a valid keyword (so a consumer can show date-only or time-only by clearing
153
- * the other). Returns `null` when neither style is usable or `Intl` throws, so
154
- * the caller can leave the authored text untouched.
187
+ * the other). Returns `null` when neither style is usable or the declaration
188
+ * names a locale or time zone `Intl` rejects, so the caller can leave the
189
+ * authored text untouched.
155
190
  */
156
191
  #applyFormat(date, dateStyle, timeStyle) {
157
192
  const options = {
@@ -160,15 +195,12 @@ var LocalTimeController = class extends Controller {
160
195
  };
161
196
  if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
162
197
  if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
163
- try {
164
- return new Intl.DateTimeFormat(this.#locale, options).format(date);
165
- } catch {
166
- return null;
167
- }
168
- }
169
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
170
- get #locale() {
171
- return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
198
+ const formatter = intlFormatter(
199
+ Intl.DateTimeFormat,
200
+ resolveLocale(this.element, this.localeValue),
201
+ options
202
+ );
203
+ return formatter === null ? null : formatter.format(date);
172
204
  }
173
205
  };
174
206