stimeo-ui 0.12.0 → 0.14.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/dist/cable/index.js +438 -152
  4. data/dist/controllers/calendar_controller.js +34 -2
  5. data/dist/controllers/carousel_controller.js +9 -7
  6. data/dist/controllers/command_palette_controller.js +2 -2
  7. data/dist/controllers/conditional_fields_controller.js +7 -1
  8. data/dist/controllers/currency_input_controller.js +68 -23
  9. data/dist/controllers/date_range_picker_controller.js +34 -10
  10. data/dist/controllers/direct_upload_controller.js +21 -10
  11. data/dist/controllers/drawer_controller.js +2 -2
  12. data/dist/controllers/form_field_controller.js +7 -1
  13. data/dist/controllers/input_mask_controller.js +33 -12
  14. data/dist/controllers/intersection_controller.js +10 -5
  15. data/dist/controllers/lazy_frame_controller.js +10 -5
  16. data/dist/controllers/local_time_controller.js +34 -7
  17. data/dist/controllers/masonry_controller.js +1 -1
  18. data/dist/controllers/menu_controller.js +10 -2
  19. data/dist/controllers/menubar_controller.js +10 -2
  20. data/dist/controllers/navigation_menu_controller.js +8 -2
  21. data/dist/controllers/optimistic_controller.js +141 -20
  22. data/dist/controllers/otp_controller.js +23 -10
  23. data/dist/controllers/overflow_menu_controller.js +10 -1
  24. data/dist/controllers/pointer_drag_controller.js +10 -3
  25. data/dist/controllers/portal_controller.js +21 -10
  26. data/dist/controllers/radio_group_controller.js +11 -3
  27. data/dist/controllers/relative_time_controller.js +31 -5
  28. data/dist/controllers/reset_before_cache_controller.js +23 -10
  29. data/dist/controllers/resizable_controller.js +7 -2
  30. data/dist/controllers/roving_controller.js +7 -3
  31. data/dist/controllers/scroll_visibility_controller.js +22 -13
  32. data/dist/controllers/scrollspy_controller.js +11 -6
  33. data/dist/controllers/smart_sticky_header_controller.js +23 -12
  34. data/dist/controllers/sticky_observer_controller.js +10 -5
  35. data/dist/controllers/theme_controller.js +23 -13
  36. data/dist/controllers/toast_controller.js +1 -1
  37. data/dist/controllers/toggle_group_controller.js +11 -3
  38. data/dist/controllers/toolbar_controller.js +7 -3
  39. data/dist/index.js +259 -202
  40. data/lib/stimeo/ui/version.rb +1 -1
  41. metadata +2 -2
data/dist/index.js CHANGED
@@ -1853,6 +1853,36 @@ var BulkSelectController = class extends Controller {
1853
1853
  }
1854
1854
  };
1855
1855
 
1856
+ // src/utils/intl_format.ts
1857
+ var formatters = /* @__PURE__ */ new WeakMap();
1858
+ function intlFormatter(factory, locale, options, fallbackLocale) {
1859
+ const cache = cacheFor(factory);
1860
+ const key = cacheKey(locale, options, fallbackLocale);
1861
+ if (cache.has(key)) return cache.get(key);
1862
+ const formatter = construct(factory, locale, options, fallbackLocale);
1863
+ cache.set(key, formatter);
1864
+ return formatter;
1865
+ }
1866
+ function cacheFor(factory) {
1867
+ const existing = formatters.get(factory);
1868
+ if (existing) return existing;
1869
+ const created = /* @__PURE__ */ new Map();
1870
+ formatters.set(factory, created);
1871
+ return created;
1872
+ }
1873
+ function cacheKey(locale, options, fallbackLocale) {
1874
+ return JSON.stringify([locale, options, fallbackLocale]);
1875
+ }
1876
+ function construct(factory, locale, options, fallbackLocale) {
1877
+ try {
1878
+ return new factory(locale, options);
1879
+ } catch (error) {
1880
+ if (!(error instanceof RangeError)) throw error;
1881
+ if (fallbackLocale === void 0) return null;
1882
+ return new factory(fallbackLocale, options);
1883
+ }
1884
+ }
1885
+
1856
1886
  // src/utils/dates.ts
1857
1887
  function toISODateString(date) {
1858
1888
  const year = date.getFullYear();
@@ -1886,6 +1916,10 @@ function parseISOMonthString(monthStr) {
1886
1916
  function toISOMonthString(date) {
1887
1917
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
1888
1918
  }
1919
+ var MONTH_LABEL_OPTIONS = { month: "long", year: "numeric" };
1920
+ function monthLabelFormatter(locale) {
1921
+ return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
1922
+ }
1889
1923
 
1890
1924
  // src/controllers/calendar_controller.ts
1891
1925
  var OWNED_DISABLED = "data-stimeo--calendar-owns-disabled";
@@ -2070,8 +2104,7 @@ var CalendarController = class extends Controller {
2070
2104
  const month = monthStart.getMonth() + 1;
2071
2105
  if (this.hasLabelTarget) {
2072
2106
  const lang = document.documentElement.lang || "en";
2073
- const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
2074
- this.labelTarget.textContent = formatter.format(monthStart);
2107
+ this.labelTarget.textContent = monthLabelFormatter(lang).format(monthStart);
2075
2108
  }
2076
2109
  const days = this.#calculateGridDays(year, month);
2077
2110
  const dayElements = this.dayTargets;
@@ -2238,6 +2271,15 @@ var CalendarController = class extends Controller {
2238
2271
  }
2239
2272
  };
2240
2273
 
2274
+ // src/utils/event_owner.ts
2275
+ function ownerIndex(candidates, node) {
2276
+ if (!(node instanceof Node)) return -1;
2277
+ return candidates.findIndex((candidate) => candidate.contains(node));
2278
+ }
2279
+ function ownerOf(candidates, node) {
2280
+ return candidates[ownerIndex(candidates, node)] ?? null;
2281
+ }
2282
+
2241
2283
  // src/utils/reduced_motion.ts
2242
2284
  function prefersReducedMotion() {
2243
2285
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -2323,7 +2365,7 @@ var CarouselController = class extends Controller {
2323
2365
  #handledEvents = /* @__PURE__ */ new WeakSet();
2324
2366
  /**
2325
2367
  * Whether `connect()` has run. Scheduling is already inert outside that window
2326
- * ({@link MicrotaskCoalescer}), so this gates the Value callbacks Stimulus
2368
+ * (`MicrotaskCoalescer`), so this gates the Value callbacks Stimulus
2327
2369
  * delivers ahead of `connect()`.
2328
2370
  */
2329
2371
  #connected = false;
@@ -2581,9 +2623,7 @@ var CarouselController = class extends Controller {
2581
2623
  }
2582
2624
  /** Position of the picker that is or contains `node`, or `-1` when none does. */
2583
2625
  #pickerIndexFor(node) {
2584
- return this.pickerTargets.findIndex(
2585
- (picker) => node instanceof Node && (picker === node || picker.contains(node))
2586
- );
2626
+ return ownerIndex(this.pickerTargets, node);
2587
2627
  }
2588
2628
  /** Whether a focus transition lands on another control of this same carousel. */
2589
2629
  #focusStaysInside(event) {
@@ -2802,9 +2842,7 @@ function hasModifier(event) {
2802
2842
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
2803
2843
  }
2804
2844
  function hits(elements, node) {
2805
- return elements.some(
2806
- (element) => node instanceof Node && (element === node || element.contains(node))
2807
- );
2845
+ return ownerIndex(elements, node) !== -1;
2808
2846
  }
2809
2847
  function setAttributeIfChanged(element, name, value) {
2810
2848
  if (element.getAttribute(name) !== value) element.setAttribute(name, value);
@@ -4383,7 +4421,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
4383
4421
  * re-deriving from the declarative `open` Value (which would slam a user-opened
4384
4422
  * palette shut). The `open` Value only seeds the initial state of a genuinely
4385
4423
  * fresh render. We normalize to a clean closed baseline first so {@link open}
4386
- * runs its full setup — the {@link FocusTrap} is a fresh instance after a
4424
+ * runs its full setup — the `FocusTrap` is a fresh instance after a
4387
4425
  * reconnect and must be re-activated.
4388
4426
  */
4389
4427
  connect() {
@@ -4488,7 +4526,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
4488
4526
  }
4489
4527
  /**
4490
4528
  * Combobox navigation keys (arrows / Home / End / Enter), bound to the input.
4491
- * `Tab` (focus trap) and `Escape` (close) are owned by the {@link FocusTrap} at
4529
+ * `Tab` (focus trap) and `Escape` (close) are owned by the shared `FocusTrap` at
4492
4530
  * the document level, so they work no matter which element inside the dialog has
4493
4531
  * focus — not only the input.
4494
4532
  */
@@ -5060,7 +5098,7 @@ var ConditionalFieldsController = class extends Controller {
5060
5098
  }
5061
5099
  /** Whether an authored mutation target belongs to a trigger, region, or managed control. */
5062
5100
  #elementAffectsState(target, triggers, regions) {
5063
- return triggers.some((trigger) => trigger === target || trigger.contains(target)) || regions.some((region) => region === target || region.contains(target));
5101
+ return ownerIndex(triggers, target) !== -1 || ownerIndex(regions, target) !== -1;
5064
5102
  }
5065
5103
  /** Whether a form owns at least one current trigger, including `form=` associations. */
5066
5104
  #hasTriggerOwnedBy(form) {
@@ -5920,37 +5958,52 @@ var CurrencyInputController = class extends Controller {
5920
5958
  * formatters from the validated set.
5921
5959
  */
5922
5960
  #revalidate() {
5923
- this.#locale = "en-US";
5924
- try {
5925
- new Intl.NumberFormat(this.localeValue);
5926
- this.#locale = this.localeValue;
5927
- } catch {
5928
- }
5961
+ const declaredLocale = intlFormatter(Intl.NumberFormat, this.localeValue, {});
5962
+ this.#locale = declaredLocale === null ? "en-US" : this.localeValue;
5929
5963
  const precision = this.precisionValue;
5930
5964
  this.#precision = Number.isInteger(precision) && precision >= 0 && precision <= 100 ? precision : 2;
5931
5965
  this.#currency = "";
5932
5966
  if (this.currencyValue !== "") {
5933
- try {
5934
- new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.currencyValue });
5935
- this.#currency = this.currencyValue;
5936
- } catch {
5937
- }
5967
+ const declaredCurrency = intlFormatter(Intl.NumberFormat, this.#locale, {
5968
+ style: "currency",
5969
+ currency: this.currencyValue
5970
+ });
5971
+ if (declaredCurrency !== null) this.#currency = this.currencyValue;
5938
5972
  }
5939
- this.#grouping = new Intl.NumberFormat(this.#locale, {
5940
- useGrouping: true,
5941
- maximumFractionDigits: 0
5942
- });
5943
- this.#fixed = new Intl.NumberFormat(this.#locale, {
5944
- useGrouping: true,
5945
- minimumFractionDigits: this.#precision,
5946
- maximumFractionDigits: this.#precision
5947
- });
5948
- this.#accessible = this.#currency ? new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.#currency }) : this.#fixed;
5949
- const parts = new Intl.NumberFormat(this.#locale).formatToParts(11111.1);
5973
+ this.#grouping = intlFormatter(
5974
+ Intl.NumberFormat,
5975
+ this.#locale,
5976
+ { useGrouping: true, maximumFractionDigits: 0 },
5977
+ this.#locale
5978
+ );
5979
+ this.#fixed = intlFormatter(
5980
+ Intl.NumberFormat,
5981
+ this.#locale,
5982
+ {
5983
+ useGrouping: true,
5984
+ minimumFractionDigits: this.#precision,
5985
+ maximumFractionDigits: this.#precision
5986
+ },
5987
+ this.#locale
5988
+ );
5989
+ this.#accessible = this.#currency ? intlFormatter(
5990
+ Intl.NumberFormat,
5991
+ this.#locale,
5992
+ { style: "currency", currency: this.#currency },
5993
+ this.#locale
5994
+ ) : this.#fixed;
5995
+ const parts = intlFormatter(Intl.NumberFormat, this.#locale, {}, this.#locale).formatToParts(
5996
+ 11111.1
5997
+ );
5950
5998
  this.#group = parts.find((p) => p.type === "group")?.value ?? ",";
5951
5999
  this.#decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
5952
6000
  this.#digits.clear();
5953
- const digitFormatter = new Intl.NumberFormat(this.#locale, { useGrouping: false });
6001
+ const digitFormatter = intlFormatter(
6002
+ Intl.NumberFormat,
6003
+ this.#locale,
6004
+ { useGrouping: false },
6005
+ this.#locale
6006
+ );
5954
6007
  for (let i = 0; i <= 9; i++) {
5955
6008
  const digit = digitFormatter.format(i);
5956
6009
  if (digit !== String(i)) this.#digits.set(digit, String(i));
@@ -6566,7 +6619,7 @@ var DateRangePickerController = class extends Controller {
6566
6619
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
6567
6620
  if (this.hasMonthLabelTarget) {
6568
6621
  const lang = document.documentElement.lang || "en";
6569
- const formatter = monthFormatter(lang);
6622
+ const formatter = monthLabelFormatter(lang);
6570
6623
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
6571
6624
  }
6572
6625
  const [rangeStart, rangeEnd] = this.#visualRange();
@@ -6721,15 +6774,6 @@ function normalizeISO(value) {
6721
6774
  function orderRange(start, end) {
6722
6775
  return start && end && end < start ? [end, start] : [start, end];
6723
6776
  }
6724
- function monthFormatter(locale) {
6725
- const options = { month: "long", year: "numeric" };
6726
- try {
6727
- return new Intl.DateTimeFormat(locale, options);
6728
- } catch (error) {
6729
- if (!(error instanceof RangeError)) throw error;
6730
- return new Intl.DateTimeFormat("en", options);
6731
- }
6732
- }
6733
6777
  function computePreset(name) {
6734
6778
  const today = /* @__PURE__ */ new Date();
6735
6779
  const todayStr = toISODateString(today);
@@ -6791,6 +6835,43 @@ var DialogController = class extends Controller {
6791
6835
  return this.hasDialogTarget && !this.dialogTarget.hidden;
6792
6836
  }
6793
6837
  };
6838
+
6839
+ // src/utils/declared_value.ts
6840
+ function parseDeclared(raw, parse, fallback) {
6841
+ try {
6842
+ return parse(raw);
6843
+ } catch {
6844
+ return fallback;
6845
+ }
6846
+ }
6847
+ function validSelector(element, raw, fallback) {
6848
+ if (raw.length === 0) return fallback;
6849
+ return parseDeclared(
6850
+ raw,
6851
+ (selector) => {
6852
+ element.matches(selector);
6853
+ return selector;
6854
+ },
6855
+ fallback
6856
+ );
6857
+ }
6858
+ function compileRegExp(source, anchor = "none") {
6859
+ return parseDeclared(
6860
+ source,
6861
+ (text) => {
6862
+ const bare = new RegExp(text);
6863
+ return anchor === "exact" ? new RegExp(`^(?:${text})$`) : bare;
6864
+ },
6865
+ null
6866
+ );
6867
+ }
6868
+ function parseJsonObject(raw) {
6869
+ const parsed = parseDeclared(raw, (text) => JSON.parse(text), null);
6870
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
6871
+ return parsed;
6872
+ }
6873
+
6874
+ // src/controllers/direct_upload_controller.ts
6794
6875
  var REMOVE_DELAY = 4e3;
6795
6876
  var DirectUploadController = class extends Controller {
6796
6877
  static targets = ["list", "row"];
@@ -6845,16 +6926,7 @@ var DirectUploadController = class extends Controller {
6845
6926
  }
6846
6927
  /** Validates `scope` once so the per-event path never parses or throws. */
6847
6928
  scopeValueChanged() {
6848
- const selector = this.scopeValue;
6849
- if (selector.length > 0) {
6850
- try {
6851
- this.element.matches(selector);
6852
- this.#scopeSelector = selector;
6853
- return;
6854
- } catch {
6855
- }
6856
- }
6857
- this.#scopeSelector = "";
6929
+ this.#scopeSelector = validSelector(this.element, this.scopeValue, "");
6858
6930
  }
6859
6931
  /** Updates a row's progress and the aggregate, emitting `progress`. */
6860
6932
  #updateProgress(id, percent, name) {
@@ -7411,7 +7483,7 @@ var DrawerController = class extends Controller {
7411
7483
  * rather than being re-derived from the declarative `open` Value (which would
7412
7484
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
7413
7485
  * render. We normalize to a clean closed baseline first so {@link open} runs its
7414
- * full reveal + trap activation — the {@link FocusTrap} is inactive after a
7486
+ * full reveal + trap activation — the `FocusTrap` is inactive after a
7415
7487
  * disconnect and must be re-activated.
7416
7488
  */
7417
7489
  connect() {
@@ -7544,7 +7616,7 @@ var DrawerController = class extends Controller {
7544
7616
  * Runs once the close transition has finished: applies `hidden` to the panel
7545
7617
  * and overlay, then reverts the modal side effects (scroll lock, background
7546
7618
  * `inert`, keydown listener) and restores focus to the opener. Deferring the
7547
- * {@link FocusTrap} teardown to here — rather than at {@link close} time — keeps
7619
+ * `FocusTrap` teardown to here — rather than at {@link close} time — keeps
7548
7620
  * the background unreachable and focus trapped for the whole exit animation.
7549
7621
  */
7550
7622
  #applyHidden(panel) {
@@ -9089,7 +9161,7 @@ var FormFieldController = class _FormFieldController extends Controller {
9089
9161
  return this.errorTargets.includes(target);
9090
9162
  return record.attributeName === "id" && [...this.descriptionTargets, ...this.errorTargets].includes(target);
9091
9163
  }
9092
- return this.errorTargets.some((error) => error === target || error.contains(target));
9164
+ return ownerIndex(this.errorTargets, target) !== -1;
9093
9165
  }
9094
9166
  /** Returns borrowed control ARIA before Turbo snapshots the page. */
9095
9167
  #rewindForCache() {
@@ -10009,22 +10081,19 @@ function applyMask(value, pattern, tokens) {
10009
10081
  }
10010
10082
  function compileTokens(declaration) {
10011
10083
  const map = /* @__PURE__ */ new Map();
10012
- for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {
10013
- try {
10014
- map.set(key, new RegExp(`^(?:${source})$`));
10015
- } catch {
10016
- }
10084
+ for (const [key, source] of Object.entries(DEFAULT_TOKENS)) {
10085
+ const compiled = compileRegExp(source, "exact");
10086
+ if (compiled) map.set(key, compiled);
10087
+ }
10088
+ for (const [key, source] of Object.entries(parseTokens(declaration))) {
10089
+ const compiled = compileRegExp(source, "exact");
10090
+ if (compiled) map.set(key, compiled);
10017
10091
  }
10018
10092
  return map;
10019
10093
  }
10020
10094
  function parseTokens(declaration) {
10021
- let parsed;
10022
- try {
10023
- parsed = JSON.parse(declaration);
10024
- } catch {
10025
- return {};
10026
- }
10027
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
10095
+ const parsed = parseJsonObject(declaration);
10096
+ if (parsed === null) return {};
10028
10097
  const tokens = {};
10029
10098
  for (const [key, source] of Object.entries(parsed)) {
10030
10099
  if (typeof source === "string") tokens[key] = source;
@@ -10236,11 +10305,7 @@ function isBeforeRootStart(entry) {
10236
10305
  }
10237
10306
  function queryRoot(selector) {
10238
10307
  if (!selector) return null;
10239
- try {
10240
- return document.querySelector(selector);
10241
- } catch {
10242
- }
10243
- return null;
10308
+ return parseDeclared(selector, (raw) => document.querySelector(raw), null);
10244
10309
  }
10245
10310
  var IntersectionWatcher = class {
10246
10311
  #onEntries;
@@ -11071,8 +11136,9 @@ var LocalTimeController = class extends Controller {
11071
11136
  /**
11072
11137
  * Formats `date` with `Intl.DateTimeFormat`, including each style only when it
11073
11138
  * is a valid keyword (so a consumer can show date-only or time-only by clearing
11074
- * the other). Returns `null` when neither style is usable or `Intl` throws, so
11075
- * the caller can leave the authored text untouched.
11139
+ * the other). Returns `null` when neither style is usable or the declaration
11140
+ * names a locale or time zone `Intl` rejects, so the caller can leave the
11141
+ * authored text untouched.
11076
11142
  */
11077
11143
  #applyFormat(date, dateStyle, timeStyle) {
11078
11144
  const options = {
@@ -11081,11 +11147,8 @@ var LocalTimeController = class extends Controller {
11081
11147
  };
11082
11148
  if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
11083
11149
  if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
11084
- try {
11085
- return new Intl.DateTimeFormat(this.#locale, options).format(date);
11086
- } catch {
11087
- return null;
11088
- }
11150
+ const formatter = intlFormatter(Intl.DateTimeFormat, this.#locale, options);
11151
+ return formatter === null ? null : formatter.format(date);
11089
11152
  }
11090
11153
  /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
11091
11154
  get #locale() {
@@ -11153,7 +11216,7 @@ var MasonryController = class extends Controller {
11153
11216
  *
11154
11217
  * The removal is queued rather than immediate because teardown reports every
11155
11218
  * target as disconnected: doing it here would strip the whole grid just before
11156
- * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
11219
+ * a Turbo snapshot is taken. The coalescer's `cancel` drops the queue
11157
11220
  * with the pass, so only a genuine target change reaches it.
11158
11221
  */
11159
11222
  itemTargetDisconnected(item) {
@@ -11434,8 +11497,7 @@ var MenuController = class extends Controller {
11434
11497
  };
11435
11498
  /** The item target that is, or contains, `node`; `null` when it is neither. */
11436
11499
  #itemFrom(node) {
11437
- if (!(node instanceof Node)) return null;
11438
- return this.itemTargets.find((item) => item === node || item.contains(node)) ?? null;
11500
+ return ownerOf(this.itemTargets, node);
11439
11501
  }
11440
11502
  /**
11441
11503
  * First look at every click inside the controller, and the only point at which
@@ -11811,8 +11873,7 @@ var MenubarController = class extends Controller {
11811
11873
  * menu without moving focus.
11812
11874
  */
11813
11875
  #dismissOpenMenu() {
11814
- const active = document.activeElement;
11815
- const menu = this.menuTargets.find((candidate) => candidate.contains(active));
11876
+ const menu = ownerOf(this.menuTargets, document.activeElement);
11816
11877
  if (menu) {
11817
11878
  const top = this.#topFor(menu);
11818
11879
  this.#closeMenu(top);
@@ -13226,7 +13287,7 @@ var NavigationMenuController = class extends Controller {
13226
13287
  */
13227
13288
  #onPointerLeave = (event) => {
13228
13289
  const next = event instanceof MouseEvent ? event.relatedTarget : null;
13229
- if (next instanceof Node && this.#hoverElements.some((el) => el.contains(next))) return;
13290
+ if (ownerIndex(this.#hoverElements, next) !== -1) return;
13230
13291
  this.#hoveredTrigger = null;
13231
13292
  this.#hoverTimers.clearAll();
13232
13293
  this.#hoverTimers.set(() => this.#closeFromHover(), this.hoverDelayValue);
@@ -13296,7 +13357,7 @@ var NavigationMenuController = class extends Controller {
13296
13357
  */
13297
13358
  get #hoverElements() {
13298
13359
  const areas = this.hoverAreaTargets;
13299
- const covered = (element) => areas.some((area) => area.contains(element));
13360
+ const covered = (element) => ownerIndex(areas, element) !== -1;
13300
13361
  return [
13301
13362
  ...areas,
13302
13363
  ...this.triggerTargets.filter((trigger) => !covered(trigger)),
@@ -14221,32 +14282,58 @@ var NumberInputController = class _NumberInputController extends Controller {
14221
14282
  this.#ariaValueMax.returnAll();
14222
14283
  }
14223
14284
  };
14224
- var TOGGLED_MARKER = "data-optimistic-toggled";
14285
+ var HIDDEN_MARKER = "data-optimistic-toggled";
14286
+ var BUSY_MARKER = "data-optimistic-busy";
14287
+ var AUTHORED_ABSENT = "absent";
14288
+ var AUTHORED_VALUE = "value:";
14289
+ var SYNTHETIC_SUBMISSION = {};
14290
+ function submissionOf(event) {
14291
+ const detail = event.detail;
14292
+ return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;
14293
+ }
14225
14294
  var OptimisticController = class extends Controller {
14226
14295
  static targets = ["show", "hide"];
14227
14296
  static events = ["commit", "rollback"];
14297
+ /** The submission that owns the applied optimistic state, if any. */
14298
+ #pending = null;
14299
+ #gate = new DetachGate();
14300
+ /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
14301
+ #connected = false;
14228
14302
  connect() {
14229
- if (this.element.hasAttribute("data-optimistic")) this.#revert();
14303
+ const restored = !this.#connected && !this.#gate.pending;
14304
+ this.#gate.cancel();
14305
+ this.#connected = true;
14306
+ if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
14230
14307
  this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
14231
14308
  this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
14232
14309
  }
14233
14310
  disconnect() {
14311
+ this.#connected = false;
14234
14312
  this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
14235
14313
  this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
14314
+ this.#gate.disconnected(this, () => {
14315
+ this.#pending = null;
14316
+ });
14236
14317
  }
14237
- #onSubmitStart = () => {
14318
+ #onSubmitStart = (event) => {
14319
+ this.#pending = submissionOf(event);
14320
+ if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {
14321
+ this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);
14322
+ }
14323
+ this.#write(this.element, "aria-busy", "true", BUSY_MARKER);
14238
14324
  this.element.setAttribute("data-optimistic", "true");
14239
- this.element.setAttribute("aria-busy", "true");
14240
- for (const target of this.showTargets) this.#toggleHidden(target, false);
14241
- for (const target of this.hideTargets) this.#toggleHidden(target, true);
14325
+ for (const target of this.showTargets) this.#setHidden(target, false);
14326
+ for (const target of this.hideTargets) this.#setHidden(target, true);
14242
14327
  };
14243
14328
  #onSubmitEnd = (event) => {
14329
+ if (submissionOf(event) !== this.#pending) return;
14330
+ this.#pending = null;
14244
14331
  const success = event.detail?.success === true;
14245
- this.element.removeAttribute("aria-busy");
14246
14332
  if (success) {
14247
14333
  this.element.removeAttribute("data-optimistic");
14334
+ this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
14248
14335
  for (const target of [...this.showTargets, ...this.hideTargets]) {
14249
- target.removeAttribute(TOGGLED_MARKER);
14336
+ target.removeAttribute(HIDDEN_MARKER);
14250
14337
  }
14251
14338
  this.dispatch("commit");
14252
14339
  } else {
@@ -14254,32 +14341,64 @@ var OptimisticController = class extends Controller {
14254
14341
  this.dispatch("rollback");
14255
14342
  }
14256
14343
  };
14257
- /** Sets `hidden` only when it actually changes, marker-tracking the mutation. */
14258
- #toggleHidden(target, hidden) {
14259
- if (target.hidden === hidden) return;
14260
- target.hidden = hidden;
14261
- target.setAttribute(TOGGLED_MARKER, "true");
14344
+ /** Hides or reveals a target, recording the authored `hidden` on first write. */
14345
+ #setHidden(target, hidden) {
14346
+ if (target.hasAttribute("hidden") === hidden) return;
14347
+ this.#write(target, "hidden", hidden ? "" : null, HIDDEN_MARKER);
14348
+ }
14349
+ /** Writes an attribute, recording what it displaced under `marker` once. */
14350
+ #write(element, attribute, value, marker) {
14351
+ if (!element.hasAttribute(marker)) {
14352
+ const authored = element.getAttribute(attribute);
14353
+ element.setAttribute(
14354
+ marker,
14355
+ authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`
14356
+ );
14357
+ }
14358
+ if (value === null) element.removeAttribute(attribute);
14359
+ else element.setAttribute(attribute, value);
14360
+ }
14361
+ /**
14362
+ * Puts back the value `marker` recorded and drops the record.
14363
+ *
14364
+ * `written` is what this controller last put in the attribute. Anything else
14365
+ * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and
14366
+ * clears it before the terminal arrives, and a consumer may have moved a face
14367
+ * mid-flight — so the record is dropped without touching the attribute. A
14368
+ * marker this build did not write names no authored value, so the attribute
14369
+ * goes back to being absent.
14370
+ */
14371
+ #restore(element, attribute, marker, written) {
14372
+ const recorded = element.getAttribute(marker);
14373
+ if (recorded === null) return;
14374
+ element.removeAttribute(marker);
14375
+ if (element.getAttribute(attribute) !== written) return;
14376
+ if (recorded.startsWith(AUTHORED_VALUE)) {
14377
+ element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));
14378
+ return;
14379
+ }
14380
+ element.removeAttribute(attribute);
14262
14381
  }
14263
- /** Reverts exactly the mutations this controller made (marker-owned only). */
14382
+ /**
14383
+ * Puts back exactly the markup this controller displaced (record-owned only).
14384
+ *
14385
+ * A face can be registered on both sides, and the hide pass runs last, so what
14386
+ * this controller wrote there is `hidden` — matching it against the reveal's
14387
+ * absent value instead would read as someone else's write and leave the face
14388
+ * hidden. Each target is therefore restored once, against the write it last
14389
+ * received.
14390
+ */
14264
14391
  #revert() {
14265
14392
  this.element.removeAttribute("data-optimistic");
14266
- this.element.removeAttribute("aria-busy");
14267
- for (const target of [...this.showTargets, ...this.hideTargets]) {
14268
- if (target.hasAttribute(TOGGLED_MARKER)) {
14269
- target.hidden = !target.hidden;
14270
- target.removeAttribute(TOGGLED_MARKER);
14271
- }
14393
+ this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
14394
+ const hidden = new Set(this.hideTargets);
14395
+ for (const target of /* @__PURE__ */ new Set([...this.showTargets, ...this.hideTargets])) {
14396
+ this.#restore(target, "hidden", HIDDEN_MARKER, hidden.has(target) ? "" : null);
14272
14397
  }
14273
14398
  }
14274
14399
  };
14275
14400
  var DEFAULT_PATTERN = "[0-9]";
14276
- function compilePattern(source) {
14277
- try {
14278
- return new RegExp(`^${source}$`);
14279
- } catch {
14280
- return null;
14281
- }
14282
- }
14401
+ var DEFAULT_MATCHER = new RegExp(`^(?:${DEFAULT_PATTERN})$`);
14283
14402
  function hasModifier2(event) {
14284
14403
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
14285
14404
  }
@@ -14294,7 +14413,7 @@ var OtpController = class extends Controller {
14294
14413
  static actions = ["onInput", "onKeydown", "onPaste", "onPointerDown", "clear"];
14295
14414
  static events = ["change", "complete", "invalid", "reconcile"];
14296
14415
  /** Validated matcher; the hot path never compiles a raw declaration. */
14297
- #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
14416
+ #pattern = DEFAULT_MATCHER;
14298
14417
  /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
14299
14418
  #patternSource = DEFAULT_PATTERN;
14300
14419
  /** Public state carried by the last dispatch; keeps a no-op sync silent. */
@@ -14367,9 +14486,9 @@ var OtpController = class extends Controller {
14367
14486
  * the new pattern no longer accepts, so the combined value stays interpretable.
14368
14487
  */
14369
14488
  patternValueChanged() {
14370
- const compiled = compilePattern(this.patternValue);
14489
+ const compiled = compileRegExp(this.patternValue, "exact");
14371
14490
  this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
14372
- this.#pattern = compiled ?? new RegExp(`^${DEFAULT_PATTERN}$`);
14491
+ this.#pattern = compiled ?? DEFAULT_MATCHER;
14373
14492
  if (!this.#connected) return;
14374
14493
  let dropped = false;
14375
14494
  for (const field of this.fieldTargets) {
@@ -15253,7 +15372,7 @@ var OverflowMenuController = class extends Controller {
15253
15372
  }
15254
15373
  /** The managed item that is, or contains, `el` — focus rescue works on either. */
15255
15374
  #ownerOf(el) {
15256
- return this.#items.find((item) => item === el || item.contains(el)) ?? null;
15375
+ return ownerOf(this.#items, el);
15257
15376
  }
15258
15377
  /** Whether the item takes up room in the bar; an authored `hidden` one does not. */
15259
15378
  #rendered(item) {
@@ -16770,9 +16889,7 @@ var PointerDragController = class _PointerDragController extends Controller {
16770
16889
  }
16771
16890
  /** Resolves the handle owning an event target (the handle or a descendant). */
16772
16891
  #handleFor(target) {
16773
- const node = target;
16774
- if (!node) return null;
16775
- return this.#handles().find((handle) => handle === node || handle.contains(node)) ?? null;
16892
+ return ownerOf(this.#handles(), target);
16776
16893
  }
16777
16894
  #pointerTypeOf(event) {
16778
16895
  const type = event.pointerType;
@@ -16953,16 +17070,7 @@ var PortalController = class extends Controller {
16953
17070
  * instead, which reads as a mistake and can be traced back to the declaration.
16954
17071
  */
16955
17072
  toValueChanged() {
16956
- const selector = this.toValue;
16957
- if (selector.length > 0) {
16958
- try {
16959
- this.element.matches(selector);
16960
- this.#toSelector = selector;
16961
- return;
16962
- } catch {
16963
- }
16964
- }
16965
- this.#toSelector = "body";
17073
+ this.#toSelector = validSelector(this.element, this.toValue, "body");
16966
17074
  }
16967
17075
  connect() {
16968
17076
  this.#gate.cancel();
@@ -17688,9 +17796,8 @@ var RadioGroupController = class extends Controller {
17688
17796
  }
17689
17797
  /** Finds this group's radio containing an event target, excluding nested groups. */
17690
17798
  #radioForEventTarget(target) {
17691
- if (!this.#ownsEventTarget(target)) return void 0;
17692
- const node = target;
17693
- return this.radioTargets.find((radio) => radio === node || radio.contains(node));
17799
+ if (!this.#ownsEventTarget(target)) return null;
17800
+ return ownerOf(this.radioTargets, target);
17694
17801
  }
17695
17802
  /** Whether the closest Radio Group scope around a target is this instance. */
17696
17803
  #ownsEventTarget(target) {
@@ -18597,6 +18704,7 @@ var UNITS = [
18597
18704
  { limit: 31557600, unit: "month", ms: 26298e5 },
18598
18705
  YEAR_SCALE
18599
18706
  ];
18707
+ var RELATIVE_OPTIONS = { numeric: "auto" };
18600
18708
  var RelativeTimeController = class extends Controller {
18601
18709
  static values = {
18602
18710
  locale: { type: String, default: "" },
@@ -18704,11 +18812,7 @@ var RelativeTimeController = class extends Controller {
18704
18812
  * when the runtime rejects that locale.
18705
18813
  */
18706
18814
  get #formatter() {
18707
- try {
18708
- return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
18709
- } catch {
18710
- return null;
18711
- }
18815
+ return intlFormatter(Intl.RelativeTimeFormat, this.#locale, RELATIVE_OPTIONS);
18712
18816
  }
18713
18817
  /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
18714
18818
  get #locale() {
@@ -18757,16 +18861,7 @@ var ResetBeforeCacheController = class extends Controller {
18757
18861
  * silently taking the sweep down.
18758
18862
  */
18759
18863
  scopeValueChanged() {
18760
- const selector = this.scopeValue;
18761
- if (selector.length > 0) {
18762
- try {
18763
- this.element.matches(selector);
18764
- this.#scopeSelector = selector;
18765
- return;
18766
- } catch {
18767
- }
18768
- }
18769
- this.#scopeSelector = "";
18864
+ this.#scopeSelector = validSelector(this.element, this.scopeValue, "");
18770
18865
  }
18771
18866
  connect() {
18772
18867
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
@@ -18868,8 +18963,7 @@ var ResizableController = class extends Controller {
18868
18963
  const panes = [];
18869
18964
  if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
18870
18965
  if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
18871
- const target = event.target;
18872
- const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
18966
+ const current = ownerIndex(panes, event.target);
18873
18967
  const next = panes[(current + 1) % panes.length];
18874
18968
  if (!next) return;
18875
18969
  event.preventDefault();
@@ -19121,9 +19215,7 @@ var RovingController = class extends Controller {
19121
19215
  };
19122
19216
  /** Resolves the item index owning an event target (the item or a descendant). */
19123
19217
  #indexOf(target) {
19124
- const node = target;
19125
- if (!node) return -1;
19126
- return this.itemTargets.findIndex((item) => item === node || item.contains(node));
19218
+ return ownerIndex(this.itemTargets, target);
19127
19219
  }
19128
19220
  /** Makes `index` the tab stop (optionally focusing it), emitting `change` once. */
19129
19221
  #activate(index, focus) {
@@ -19878,11 +19970,11 @@ var ScrollVisibilityController = class extends Controller {
19878
19970
  }
19879
19971
  /** Validates `root` once so connect never parses a selector that throws. */
19880
19972
  rootValueChanged() {
19881
- this.#rootSelector = this.#validSelector(this.rootValue);
19973
+ this.#rootSelector = validSelector(this.element, this.rootValue, "");
19882
19974
  }
19883
19975
  /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
19884
19976
  focusSelectorValueChanged() {
19885
- this.#focusSelector = this.#validSelector(this.focusSelectorValue);
19977
+ this.#focusSelector = validSelector(this.element, this.focusSelectorValue, "");
19886
19978
  }
19887
19979
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
19888
19980
  toTop() {
@@ -19954,17 +20046,6 @@ var ScrollVisibilityController = class extends Controller {
19954
20046
  if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
19955
20047
  return null;
19956
20048
  }
19957
- /** Returns `declared` when it parses as a selector, and `""` when it does not. */
19958
- #validSelector(declared) {
19959
- if (declared.length > 0) {
19960
- try {
19961
- this.element.matches(declared);
19962
- return declared;
19963
- } catch {
19964
- }
19965
- }
19966
- return "";
19967
- }
19968
20049
  #scrollY() {
19969
20050
  if (this.#scrollSource === window) {
19970
20051
  return window.scrollY ?? window.pageYOffset ?? 0;
@@ -20098,7 +20179,7 @@ var ScrollspyController = class extends Controller {
20098
20179
  * only rewrites attributes, so Stimulus fires no target callback: without
20099
20180
  * this, a link re-pointed from `#intro` to `#faq` would keep the controller
20100
20181
  * observing `#intro` for the rest of the page's life. The filter is exactly
20101
- * {@link ANCHOR_ATTRIBUTES}; guarding on `MutationObserver` keeps the
20182
+ * `href` and `data-href`; guarding on `MutationObserver` keeps the
20102
20183
  * controller usable where the API is absent, matching `IntersectionWatcher`'s
20103
20184
  * own support guard.
20104
20185
  */
@@ -21148,7 +21229,7 @@ var SmartStickyHeaderController = class extends Controller {
21148
21229
  #onFocusin = () => this.#apply(false);
21149
21230
  /** Validates `containerSelector` once so connect never parses a selector that throws. */
21150
21231
  containerSelectorValueChanged() {
21151
- this.#containerSelector = this.#validSelector(this.containerSelectorValue);
21232
+ this.#containerSelector = validSelector(this.element, this.containerSelectorValue, "");
21152
21233
  }
21153
21234
  /** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
21154
21235
  offsetValueChanged() {
@@ -21178,17 +21259,6 @@ var SmartStickyHeaderController = class extends Controller {
21178
21259
  }
21179
21260
  return window;
21180
21261
  }
21181
- /** Returns `declared` when it parses as a selector, and `""` when it does not. */
21182
- #validSelector(declared) {
21183
- if (declared.length > 0) {
21184
- try {
21185
- this.element.matches(declared);
21186
- return declared;
21187
- } catch {
21188
- }
21189
- }
21190
- return "";
21191
- }
21192
21262
  get #scrollY() {
21193
21263
  const scroller = this.#scrollerEl;
21194
21264
  return scroller === window ? window.scrollY : scroller.scrollTop;
@@ -23136,16 +23206,7 @@ var ThemeController = class extends Controller {
23136
23206
  }
23137
23207
  /** Validates the `target` declaration once, so the render path never parses. */
23138
23208
  targetValueChanged() {
23139
- const selector = this.targetValue;
23140
- if (selector.length > 0) {
23141
- try {
23142
- this.element.matches(selector);
23143
- this.#targetSelector = selector;
23144
- return;
23145
- } catch {
23146
- }
23147
- }
23148
- this.#targetSelector = DEFAULT_TARGET;
23209
+ this.#targetSelector = validSelector(this.element, this.targetValue, DEFAULT_TARGET);
23149
23210
  }
23150
23211
  /** Re-derives the single Tab stop and ARIA for an option set that changed. */
23151
23212
  optionTargetConnected() {
@@ -23158,9 +23219,8 @@ var ThemeController = class extends Controller {
23158
23219
  /**
23159
23220
  * Selects the mode the activated option declares.
23160
23221
  *
23161
- * Read through {@link ThemeController.#optionMode}, the same lane that decides
23162
- * which option is checked, so the two can never disagree about what an option
23163
- * declares.
23222
+ * Read through the same lane that decides which option is checked, so the two
23223
+ * can never disagree about what an option declares.
23164
23224
  */
23165
23225
  set(event) {
23166
23226
  const option = event.currentTarget;
@@ -23635,7 +23695,7 @@ var ToastController = class extends Controller {
23635
23695
  static events = ["dismiss", "show"];
23636
23696
  /**
23637
23697
  * Registry for every auto-dismiss and transition-finalize timer the controller
23638
- * schedules. {@link SafeTimeout} owns *registration and teardown only*; the
23698
+ * schedules. `SafeTimeout` owns *registration and teardown only*; the
23639
23699
  * pause/resume remaining-time accounting stays in `#activeTimeouts` so the
23640
23700
  * per-widget WCAG 2.2.1 semantics are not flattened into the helper.
23641
23701
  */
@@ -24217,9 +24277,8 @@ var ToggleGroupController = class extends Controller {
24217
24277
  }
24218
24278
  /** Finds this group's item containing an event target, excluding nested groups. */
24219
24279
  #itemForEventTarget(target) {
24220
- if (!this.#ownsEventTarget(target)) return void 0;
24221
- const node = target;
24222
- return this.itemTargets.find((item) => item === node || item.contains(node));
24280
+ if (!this.#ownsEventTarget(target)) return null;
24281
+ return ownerOf(this.itemTargets, target);
24223
24282
  }
24224
24283
  /** Whether the closest Toggle Group scope around a target is this instance. */
24225
24284
  #ownsEventTarget(target) {
@@ -24477,9 +24536,7 @@ var ToolbarController = class extends Controller {
24477
24536
  }
24478
24537
  /** Index in `controlTargets` of the control owning `target` (it or a descendant). */
24479
24538
  #indexOf(target) {
24480
- const node = target;
24481
- if (!node) return -1;
24482
- return this.controlTargets.findIndex((control) => control === node || control.contains(node));
24539
+ return ownerIndex(this.controlTargets, target);
24483
24540
  }
24484
24541
  /** Controls eligible for the roving tab stop (excludes native disabled / hidden). */
24485
24542
  get #navigableControls() {