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
@@ -18,6 +18,35 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
20
 
21
+ // src/utils/intl_format.ts
22
+ var formatters = /* @__PURE__ */ new WeakMap();
23
+ function intlFormatter(factory, locale, options, fallbackLocale) {
24
+ const cache = cacheFor(factory);
25
+ const key = cacheKey(locale, options, fallbackLocale);
26
+ if (cache.has(key)) return cache.get(key);
27
+ const formatter = construct(factory, locale, options, fallbackLocale);
28
+ cache.set(key, formatter);
29
+ return formatter;
30
+ }
31
+ function cacheFor(factory) {
32
+ const existing = formatters.get(factory);
33
+ if (existing) return existing;
34
+ const created = /* @__PURE__ */ new Map();
35
+ formatters.set(factory, created);
36
+ return created;
37
+ }
38
+ function cacheKey(locale, options, fallbackLocale) {
39
+ return JSON.stringify([locale, options, fallbackLocale]);
40
+ }
41
+ function construct(factory, locale, options, fallbackLocale) {
42
+ try {
43
+ return new factory(locale, options);
44
+ } catch (error) {
45
+ if (!(error instanceof RangeError)) throw error;
46
+ return new factory(fallbackLocale, options);
47
+ }
48
+ }
49
+
21
50
  // src/utils/dates.ts
22
51
  function toISODateString(date) {
23
52
  const year = date.getFullYear();
@@ -51,14 +80,14 @@ function parseISOMonthString(monthStr) {
51
80
  function toISOMonthString(date) {
52
81
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
53
82
  }
83
+ var MONTH_LABEL_OPTIONS = { month: "long", year: "numeric" };
54
84
  function monthLabelFormatter(locale) {
55
- const options = { month: "long", year: "numeric" };
56
- try {
57
- return new Intl.DateTimeFormat(locale, options);
58
- } catch (error) {
59
- if (!(error instanceof RangeError)) throw error;
60
- return new Intl.DateTimeFormat("en", options);
61
- }
85
+ return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
86
+ }
87
+
88
+ // src/utils/locale.ts
89
+ function resolveLocale(element, declared = "") {
90
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
62
91
  }
63
92
 
64
93
  // src/utils/safe_timeout.ts
@@ -123,8 +152,9 @@ var CalendarController = class extends Controller {
123
152
  selected: { type: String, default: "" },
124
153
  min: { type: String, default: "" },
125
154
  max: { type: String, default: "" },
126
- weekStart: { type: Number, default: 0 }
155
+ weekStart: { type: Number, default: 0 },
127
156
  // 0 = Sunday, 1 = Monday, etc.
157
+ locale: { type: String, default: "" }
128
158
  };
129
159
  static actions = ["next", "onKeydown", "prev", "selectByClick"];
130
160
  static events = ["monthchange", "select"];
@@ -166,6 +196,17 @@ var CalendarController = class extends Controller {
166
196
  this.#syncFocusedDateWithMonth();
167
197
  this.render();
168
198
  }
199
+ /**
200
+ * Repaints the label when application code changes `locale` at runtime.
201
+ *
202
+ * Stimulus runs this for the initial Value too, before `connect()`. The paint
203
+ * it triggers is idempotent — the month comes from `month` (or, while that is
204
+ * still empty, from `focusedDate`), and `monthchange` only reports a move away
205
+ * from a month already announced, so the first paint never reports one.
206
+ */
207
+ localeValueChanged() {
208
+ this.render();
209
+ }
169
210
  /**
170
211
  * Stimulus lifecycle callback triggered automatically when the selectedValue changes.
171
212
  * Re-renders grid cells to update `aria-selected` indicators.
@@ -296,8 +337,8 @@ var CalendarController = class extends Controller {
296
337
  const year = monthStart.getFullYear();
297
338
  const month = monthStart.getMonth() + 1;
298
339
  if (this.hasLabelTarget) {
299
- const lang = document.documentElement.lang || "en";
300
- this.labelTarget.textContent = monthLabelFormatter(lang).format(monthStart);
340
+ const locale = resolveLocale(this.element, this.localeValue);
341
+ this.labelTarget.textContent = monthLabelFormatter(locale).format(monthStart);
301
342
  }
302
343
  const days = this.#calculateGridDays(year, month);
303
344
  const dayElements = this.dayTargets;
@@ -90,6 +90,12 @@ var BeforeCacheReset = class _BeforeCacheReset {
90
90
  }
91
91
  };
92
92
 
93
+ // src/utils/event_owner.ts
94
+ function ownerIndex(candidates, node) {
95
+ if (!(node instanceof Node)) return -1;
96
+ return candidates.findIndex((candidate) => candidate.contains(node));
97
+ }
98
+
93
99
  // src/utils/microtask_coalescer.ts
94
100
  var MicrotaskCoalescer = class {
95
101
  #run;
@@ -510,9 +516,7 @@ var CarouselController = class extends Controller {
510
516
  }
511
517
  /** Position of the picker that is or contains `node`, or `-1` when none does. */
512
518
  #pickerIndexFor(node) {
513
- return this.pickerTargets.findIndex(
514
- (picker) => node instanceof Node && (picker === node || picker.contains(node))
515
- );
519
+ return ownerIndex(this.pickerTargets, node);
516
520
  }
517
521
  /** Whether a focus transition lands on another control of this same carousel. */
518
522
  #focusStaysInside(event) {
@@ -731,9 +735,7 @@ function hasModifier(event) {
731
735
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
732
736
  }
733
737
  function hits(elements, node) {
734
- return elements.some(
735
- (element) => node instanceof Node && (element === node || element.contains(node))
736
- );
738
+ return ownerIndex(elements, node) !== -1;
737
739
  }
738
740
  function setAttributeIfChanged(element, name, value) {
739
741
  if (element.getAttribute(name) !== value) element.setAttribute(name, value);
@@ -2,6 +2,30 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/checkbox_controller.ts
4
4
 
5
+ // src/utils/listener_set.ts
6
+ var ListenerSet = class {
7
+ /** The generation every `add` joins until the next `dispose()`. */
8
+ #abort = new AbortController();
9
+ /**
10
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
11
+ *
12
+ * The set supplies the signal, so `options` carries everything else the DOM
13
+ * accepts — `capture` included, which has to match at release time and no
14
+ * longer has a second place to drift from.
15
+ */
16
+ add(target, type, handler, options) {
17
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
18
+ }
19
+ /**
20
+ * Releases every listener of the open generation, synchronously, and opens the
21
+ * next one. Idempotent, and safe before anything has been added.
22
+ */
23
+ dispose() {
24
+ this.#abort.abort();
25
+ this.#abort = new AbortController();
26
+ }
27
+ };
28
+
5
29
  // src/utils/microtask_coalescer.ts
6
30
  var MicrotaskCoalescer = class {
7
31
  #run;
@@ -42,6 +66,7 @@ var CheckboxController = class extends Controller {
42
66
  static events = ["change", "reconcile"];
43
67
  /** Collapses every lifecycle signal from one DOM update into one derived pass. */
44
68
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
69
+ #listeners = new ListenerSet();
45
70
  /** Aggregate this root last settled on, so a derived repair is reported once. */
46
71
  #committedState = null;
47
72
  /** Watches authored checked-attribute changes on retained target elements. */
@@ -59,14 +84,14 @@ var CheckboxController = class extends Controller {
59
84
  attributeFilter: ["checked"],
60
85
  subtree: true
61
86
  });
62
- this.element.addEventListener("turbo:morph-element", this.#onMorph);
87
+ this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
63
88
  document.addEventListener("reset", this.#onReset, true);
64
89
  }
65
90
  /** Releases the observer, global reset listener, and every pending reconciliation. */
66
91
  disconnect() {
67
92
  this.#reconcile.cancel();
68
93
  this.#checkedObserver.disconnect();
69
- this.element.removeEventListener("turbo:morph-element", this.#onMorph);
94
+ this.#listeners.dispose();
70
95
  document.removeEventListener("reset", this.#onReset, true);
71
96
  }
72
97
  /** Reconciles the aggregate for a parent added or replaced at runtime. */
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/conditional_fields_controller.ts
4
4
 
5
+ // src/utils/event_owner.ts
6
+ function ownerIndex(candidates, node) {
7
+ if (!(node instanceof Node)) return -1;
8
+ return candidates.findIndex((candidate) => candidate.contains(node));
9
+ }
10
+
5
11
  // src/utils/focus_candidate.ts
6
12
  function inheritsFieldsetDisabled(control) {
7
13
  let fieldset = control.closest("fieldset[disabled]");
@@ -20,6 +26,30 @@ function canTakeFocus(element) {
20
26
  return !inheritsFieldsetDisabled(element);
21
27
  }
22
28
 
29
+ // src/utils/listener_set.ts
30
+ var ListenerSet = class {
31
+ /** The generation every `add` joins until the next `dispose()`. */
32
+ #abort = new AbortController();
33
+ /**
34
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
35
+ *
36
+ * The set supplies the signal, so `options` carries everything else the DOM
37
+ * accepts — `capture` included, which has to match at release time and no
38
+ * longer has a second place to drift from.
39
+ */
40
+ add(target, type, handler, options) {
41
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
42
+ }
43
+ /**
44
+ * Releases every listener of the open generation, synchronously, and opens the
45
+ * next one. Idempotent, and safe before anything has been added.
46
+ */
47
+ dispose() {
48
+ this.#abort.abort();
49
+ this.#abort = new AbortController();
50
+ }
51
+ };
52
+
23
53
  // src/utils/microtask_coalescer.ts
24
54
  var MicrotaskCoalescer = class {
25
55
  #run;
@@ -144,6 +174,7 @@ var ConditionalFieldsController = class extends Controller {
144
174
  #observing = false;
145
175
  /** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
146
176
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
177
+ #listeners = new ListenerSet();
147
178
  /** Provides the last-resort focus landmark without claiming an authored tabindex. */
148
179
  #rootTabindex = new TabindexLoan();
149
180
  /** Watches retained targets and controls whose declarative or reflected state changed. */
@@ -182,9 +213,9 @@ var ConditionalFieldsController = class extends Controller {
182
213
  subtree: true
183
214
  });
184
215
  this.#observing = true;
185
- this.element.addEventListener("change", this.#onTriggerInput);
186
- this.element.addEventListener("input", this.#onTriggerInput);
187
- this.element.addEventListener("turbo:morph-element", this.#onMorph);
216
+ this.#listeners.add(this.element, "change", this.#onTriggerInput);
217
+ this.#listeners.add(this.element, "input", this.#onTriggerInput);
218
+ this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
188
219
  document.addEventListener("reset", this.#onReset, true);
189
220
  }
190
221
  /** Releases all external resources and returns the temporary focus landmark loan. */
@@ -192,9 +223,7 @@ var ConditionalFieldsController = class extends Controller {
192
223
  this.#reconcile.cancel();
193
224
  this.#observing = false;
194
225
  this.#observer.disconnect();
195
- this.element.removeEventListener("change", this.#onTriggerInput);
196
- this.element.removeEventListener("input", this.#onTriggerInput);
197
- this.element.removeEventListener("turbo:morph-element", this.#onMorph);
226
+ this.#listeners.dispose();
198
227
  document.removeEventListener("reset", this.#onReset, true);
199
228
  this.#rootTabindex.returnAll();
200
229
  }
@@ -393,7 +422,7 @@ var ConditionalFieldsController = class extends Controller {
393
422
  }
394
423
  /** Whether an authored mutation target belongs to a trigger, region, or managed control. */
395
424
  #elementAffectsState(target, triggers, regions) {
396
- return triggers.some((trigger) => trigger === target || trigger.contains(target)) || regions.some((region) => region === target || region.contains(target));
425
+ return ownerIndex(triggers, target) !== -1 || ownerIndex(regions, target) !== -1;
397
426
  }
398
427
  /** Whether a form owns at least one current trigger, including `form=` associations. */
399
428
  #hasTriggerOwnedBy(form) {
@@ -58,11 +58,47 @@ function halfWidthChar(char) {
58
58
  return char === "\u3000" ? " " : char;
59
59
  }
60
60
 
61
+ // src/utils/intl_format.ts
62
+ var formatters = /* @__PURE__ */ new WeakMap();
63
+ function intlFormatter(factory, locale, options, fallbackLocale) {
64
+ const cache = cacheFor(factory);
65
+ const key = cacheKey(locale, options, fallbackLocale);
66
+ if (cache.has(key)) return cache.get(key);
67
+ const formatter = construct(factory, locale, options, fallbackLocale);
68
+ cache.set(key, formatter);
69
+ return formatter;
70
+ }
71
+ function cacheFor(factory) {
72
+ const existing = formatters.get(factory);
73
+ if (existing) return existing;
74
+ const created = /* @__PURE__ */ new Map();
75
+ formatters.set(factory, created);
76
+ return created;
77
+ }
78
+ function cacheKey(locale, options, fallbackLocale) {
79
+ return JSON.stringify([locale, options, fallbackLocale]);
80
+ }
81
+ function construct(factory, locale, options, fallbackLocale) {
82
+ try {
83
+ return new factory(locale, options);
84
+ } catch (error) {
85
+ if (!(error instanceof RangeError)) throw error;
86
+ if (fallbackLocale === void 0) return null;
87
+ return new factory(fallbackLocale, options);
88
+ }
89
+ }
90
+
91
+ // src/utils/locale.ts
92
+ function resolveLocale(element, declared = "") {
93
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
94
+ }
95
+
61
96
  // src/controllers/currency_input_controller.ts
97
+ var FALLBACK_LOCALE = "en-US";
62
98
  var CurrencyInputController = class extends Controller {
63
99
  static targets = ["display", "field", "srValue"];
64
100
  static values = {
65
- locale: { type: String, default: "en-US" },
101
+ locale: { type: String, default: "" },
66
102
  currency: { type: String, default: "" },
67
103
  precision: { type: Number, default: 2 }
68
104
  };
@@ -72,7 +108,7 @@ var CurrencyInputController = class extends Controller {
72
108
  #lastValue = null;
73
109
  #started = false;
74
110
  /** Validated mirrors of the Values; the hot path never reads a raw Value. */
75
- #locale = "en-US";
111
+ #locale;
76
112
  #currency = "";
77
113
  #precision = 2;
78
114
  /** Formatters rebuilt only when a Value changes, never per keystroke. */
@@ -309,37 +345,53 @@ var CurrencyInputController = class extends Controller {
309
345
  * formatters from the validated set.
310
346
  */
311
347
  #revalidate() {
312
- this.#locale = "en-US";
313
- try {
314
- new Intl.NumberFormat(this.localeValue);
315
- this.#locale = this.localeValue;
316
- } catch {
317
- }
348
+ const resolved = resolveLocale(this.element, this.localeValue);
349
+ const usable = intlFormatter(Intl.NumberFormat, resolved, {});
350
+ this.#locale = usable === null ? FALLBACK_LOCALE : resolved;
318
351
  const precision = this.precisionValue;
319
352
  this.#precision = Number.isInteger(precision) && precision >= 0 && precision <= 100 ? precision : 2;
320
353
  this.#currency = "";
321
354
  if (this.currencyValue !== "") {
322
- try {
323
- new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.currencyValue });
324
- this.#currency = this.currencyValue;
325
- } catch {
326
- }
355
+ const declaredCurrency = intlFormatter(Intl.NumberFormat, this.#locale, {
356
+ style: "currency",
357
+ currency: this.currencyValue
358
+ });
359
+ if (declaredCurrency !== null) this.#currency = this.currencyValue;
327
360
  }
328
- this.#grouping = new Intl.NumberFormat(this.#locale, {
329
- useGrouping: true,
330
- maximumFractionDigits: 0
331
- });
332
- this.#fixed = new Intl.NumberFormat(this.#locale, {
333
- useGrouping: true,
334
- minimumFractionDigits: this.#precision,
335
- maximumFractionDigits: this.#precision
336
- });
337
- this.#accessible = this.#currency ? new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.#currency }) : this.#fixed;
338
- const parts = new Intl.NumberFormat(this.#locale).formatToParts(11111.1);
361
+ this.#grouping = intlFormatter(
362
+ Intl.NumberFormat,
363
+ this.#locale,
364
+ { useGrouping: true, maximumFractionDigits: 0 },
365
+ FALLBACK_LOCALE
366
+ );
367
+ this.#fixed = intlFormatter(
368
+ Intl.NumberFormat,
369
+ this.#locale,
370
+ {
371
+ useGrouping: true,
372
+ minimumFractionDigits: this.#precision,
373
+ maximumFractionDigits: this.#precision
374
+ },
375
+ FALLBACK_LOCALE
376
+ );
377
+ this.#accessible = this.#currency ? intlFormatter(
378
+ Intl.NumberFormat,
379
+ this.#locale,
380
+ { style: "currency", currency: this.#currency },
381
+ FALLBACK_LOCALE
382
+ ) : this.#fixed;
383
+ const parts = intlFormatter(Intl.NumberFormat, this.#locale, {}, FALLBACK_LOCALE).formatToParts(
384
+ 11111.1
385
+ );
339
386
  this.#group = parts.find((p) => p.type === "group")?.value ?? ",";
340
387
  this.#decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
341
388
  this.#digits.clear();
342
- const digitFormatter = new Intl.NumberFormat(this.#locale, { useGrouping: false });
389
+ const digitFormatter = intlFormatter(
390
+ Intl.NumberFormat,
391
+ this.#locale,
392
+ { useGrouping: false },
393
+ FALLBACK_LOCALE
394
+ );
343
395
  for (let i = 0; i <= 9; i++) {
344
396
  const digit = digitFormatter.format(i);
345
397
  if (digit !== String(i)) this.#digits.set(digit, String(i));
@@ -47,6 +47,35 @@ var BeforeCacheReset = class _BeforeCacheReset {
47
47
  }
48
48
  };
49
49
 
50
+ // src/utils/intl_format.ts
51
+ var formatters = /* @__PURE__ */ new WeakMap();
52
+ function intlFormatter(factory, locale, options, fallbackLocale) {
53
+ const cache = cacheFor(factory);
54
+ const key = cacheKey(locale, options, fallbackLocale);
55
+ if (cache.has(key)) return cache.get(key);
56
+ const formatter = construct(factory, locale, options, fallbackLocale);
57
+ cache.set(key, formatter);
58
+ return formatter;
59
+ }
60
+ function cacheFor(factory) {
61
+ const existing = formatters.get(factory);
62
+ if (existing) return existing;
63
+ const created = /* @__PURE__ */ new Map();
64
+ formatters.set(factory, created);
65
+ return created;
66
+ }
67
+ function cacheKey(locale, options, fallbackLocale) {
68
+ return JSON.stringify([locale, options, fallbackLocale]);
69
+ }
70
+ function construct(factory, locale, options, fallbackLocale) {
71
+ try {
72
+ return new factory(locale, options);
73
+ } catch (error) {
74
+ if (!(error instanceof RangeError)) throw error;
75
+ return new factory(fallbackLocale, options);
76
+ }
77
+ }
78
+
50
79
  // src/utils/dates.ts
51
80
  function toISODateString(date) {
52
81
  const year = date.getFullYear();
@@ -80,14 +109,14 @@ function parseISOMonthString(monthStr) {
80
109
  function toISOMonthString(date) {
81
110
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
82
111
  }
112
+ var MONTH_LABEL_OPTIONS = { month: "long", year: "numeric" };
83
113
  function monthLabelFormatter(locale) {
84
- const options = { month: "long", year: "numeric" };
85
- try {
86
- return new Intl.DateTimeFormat(locale, options);
87
- } catch (error) {
88
- if (!(error instanceof RangeError)) throw error;
89
- return new Intl.DateTimeFormat("en", options);
90
- }
114
+ return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
115
+ }
116
+
117
+ // src/utils/locale.ts
118
+ function resolveLocale(element, declared = "") {
119
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
91
120
  }
92
121
 
93
122
  // src/utils/microtask_coalescer.ts
@@ -200,7 +229,8 @@ var DateRangePickerController = class extends Controller {
200
229
  // A JSON list read through `parseStringList` rather than Stimulus's `Array`
201
230
  // type: that reader throws out of the value observer before any callback
202
231
  // runs, so one malformed attribute would stop the picker from connecting.
203
- disabledDates: { type: String, default: "" }
232
+ disabledDates: { type: String, default: "" },
233
+ locale: { type: String, default: "" }
204
234
  };
205
235
  static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
206
236
  static events = ["change", "monthchange"];
@@ -253,6 +283,10 @@ var DateRangePickerController = class extends Controller {
253
283
  this.#beforeCache.deactivate();
254
284
  this.#focusTimer.clearAll();
255
285
  }
286
+ /** Repaints the label when application code changes `locale` at runtime. */
287
+ localeValueChanged() {
288
+ this.#repaint.schedule();
289
+ }
256
290
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
257
291
  minValueChanged() {
258
292
  this.#repaint.schedule();
@@ -441,8 +475,7 @@ var DateRangePickerController = class extends Controller {
441
475
  const { year, month } = info;
442
476
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
443
477
  if (this.hasMonthLabelTarget) {
444
- const lang = document.documentElement.lang || "en";
445
- const formatter = monthLabelFormatter(lang);
478
+ const formatter = monthLabelFormatter(resolveLocale(this.element, this.localeValue));
446
479
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
447
480
  }
448
481
  const [rangeStart, rangeEnd] = this.#visualRange();
@@ -48,6 +48,26 @@ var BeforeCacheReset = class _BeforeCacheReset {
48
48
  }
49
49
  };
50
50
 
51
+ // src/utils/declared_value.ts
52
+ function parseDeclared(raw, parse, fallback) {
53
+ try {
54
+ return parse(raw);
55
+ } catch {
56
+ return fallback;
57
+ }
58
+ }
59
+ function validSelector(element, raw, fallback) {
60
+ if (raw.length === 0) return fallback;
61
+ return parseDeclared(
62
+ raw,
63
+ (selector) => {
64
+ element.matches(selector);
65
+ return selector;
66
+ },
67
+ fallback
68
+ );
69
+ }
70
+
51
71
  // src/utils/safe_timeout.ts
52
72
  var TimerRegistry = class {
53
73
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -156,16 +176,7 @@ var DirectUploadController = class extends Controller {
156
176
  }
157
177
  /** Validates `scope` once so the per-event path never parses or throws. */
158
178
  scopeValueChanged() {
159
- const selector = this.scopeValue;
160
- if (selector.length > 0) {
161
- try {
162
- this.element.matches(selector);
163
- this.#scopeSelector = selector;
164
- return;
165
- } catch {
166
- }
167
- }
168
- this.#scopeSelector = "";
179
+ this.#scopeSelector = validSelector(this.element, this.scopeValue, "");
169
180
  }
170
181
  /** Updates a row's progress and the aggregate, emitting `progress`. */
171
182
  #updateProgress(id, percent, name) {
@@ -2,6 +2,30 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/dirty_form_controller.ts
4
4
 
5
+ // src/utils/listener_set.ts
6
+ var ListenerSet = class {
7
+ /** The generation every `add` joins until the next `dispose()`. */
8
+ #abort = new AbortController();
9
+ /**
10
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
11
+ *
12
+ * The set supplies the signal, so `options` carries everything else the DOM
13
+ * accepts — `capture` included, which has to match at release time and no
14
+ * longer has a second place to drift from.
15
+ */
16
+ add(target, type, handler, options) {
17
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
18
+ }
19
+ /**
20
+ * Releases every listener of the open generation, synchronously, and opens the
21
+ * next one. Idempotent, and safe before anything has been added.
22
+ */
23
+ dispose() {
24
+ this.#abort.abort();
25
+ this.#abort = new AbortController();
26
+ }
27
+ };
28
+
5
29
  // src/utils/safe_timeout.ts
6
30
  var TimerRegistry = class {
7
31
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -125,6 +149,7 @@ var DirtyFormController = class extends Controller {
125
149
  #dirty = false;
126
150
  #beforeunloadBound = false;
127
151
  #timeouts = new SafeTimeout();
152
+ #listeners = new ListenerSet();
128
153
  /** Whether the active submission still represents the form's current values. */
129
154
  #submitting = false;
130
155
  #submitAttempt = null;
@@ -182,24 +207,19 @@ var DirtyFormController = class extends Controller {
182
207
  this.#baseline = this.#serialize();
183
208
  this.#setDirty(false);
184
209
  this.element.removeAttribute("data-dirty");
185
- this.element.addEventListener("input", this.#onFieldChange);
186
- this.element.addEventListener("change", this.#onFieldChange);
187
- this.element.addEventListener("submit", this.#onSubmit);
188
- this.element.addEventListener("formdata", this.#onFormData);
189
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
190
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
210
+ this.#listeners.add(this.element, "input", this.#onFieldChange);
211
+ this.#listeners.add(this.element, "change", this.#onFieldChange);
212
+ this.#listeners.add(this.element, "submit", this.#onSubmit);
213
+ this.#listeners.add(this.element, "formdata", this.#onFormData);
214
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
215
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
191
216
  this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
192
217
  form: this.element,
193
218
  evaluate: (event) => this.#evaluateVisit(event)
194
219
  });
195
220
  }
196
221
  disconnect() {
197
- this.element.removeEventListener("input", this.#onFieldChange);
198
- this.element.removeEventListener("change", this.#onFieldChange);
199
- this.element.removeEventListener("submit", this.#onSubmit);
200
- this.element.removeEventListener("formdata", this.#onFormData);
201
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
202
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
222
+ this.#listeners.dispose();
203
223
  this.#unregisterVisit?.();
204
224
  this.#unregisterVisit = null;
205
225
  this.#unbindBeforeUnload();