stimeo-ui 0.2.0 → 0.2.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/dist/controllers/alert_dialog_controller.js +318 -0
  4. data/dist/controllers/carousel_controller.js +272 -0
  5. data/dist/controllers/clipboard_controller.js +144 -0
  6. data/dist/controllers/collapsible_controller.js +327 -0
  7. data/dist/controllers/color_picker_controller.js +213 -0
  8. data/dist/controllers/count_up_controller.js +8 -1
  9. data/dist/controllers/currency_input_controller.js +147 -0
  10. data/dist/controllers/data_grid_controller.js +168 -0
  11. data/dist/controllers/date_range_picker_controller.js +417 -0
  12. data/dist/controllers/dismissible_controller.js +117 -0
  13. data/dist/controllers/drawer_controller.js +630 -0
  14. data/dist/controllers/editable_controller.js +168 -0
  15. data/dist/controllers/file_dropzone_controller.js +165 -0
  16. data/dist/controllers/filter_controller.js +86 -0
  17. data/dist/controllers/flash_controller.js +36 -5
  18. data/dist/controllers/highlight_controller.js +6 -4
  19. data/dist/controllers/intersection_controller.js +41 -18
  20. data/dist/controllers/lazy_frame_controller.js +33 -11
  21. data/dist/controllers/masonry_controller.js +142 -0
  22. data/dist/controllers/menubar_controller.js +433 -0
  23. data/dist/controllers/multi_select_controller.js +472 -0
  24. data/dist/controllers/navigation_menu_controller.js +384 -0
  25. data/dist/controllers/overflow_indicator_controller.js +178 -27
  26. data/dist/controllers/password_reveal_controller.js +117 -0
  27. data/dist/controllers/range_slider_controller.js +166 -0
  28. data/dist/controllers/read_more_controller.js +194 -0
  29. data/dist/controllers/scroll_area_controller.js +15 -2
  30. data/dist/controllers/scroll_restore_controller.js +93 -0
  31. data/dist/controllers/scroll_visibility_controller.js +8 -4
  32. data/dist/controllers/scrollspy_controller.js +33 -11
  33. data/dist/controllers/separator_controller.js +87 -0
  34. data/dist/controllers/sidebar_controller.js +761 -0
  35. data/dist/controllers/stepper_controller.js +28 -12
  36. data/dist/controllers/stick_to_bottom_controller.js +8 -4
  37. data/dist/controllers/sticky_observer_controller.js +88 -20
  38. data/dist/controllers/tags_input_controller.js +275 -0
  39. data/dist/controllers/theme_controller.js +20 -10
  40. data/dist/controllers/time_picker_controller.js +212 -0
  41. data/dist/controllers/toast_controller.js +36 -9
  42. data/dist/controllers/transition_controller.js +153 -38
  43. data/dist/controllers/tree_view_controller.js +275 -0
  44. data/dist/index.js +811 -295
  45. data/lib/stimeo/ui/version.rb +1 -1
  46. metadata +28 -2
data/dist/index.js CHANGED
@@ -1814,28 +1814,176 @@ var ClipboardController = class extends Controller {
1814
1814
  }
1815
1815
  }
1816
1816
  };
1817
+
1818
+ // src/utils/transition_completion.ts
1819
+ function timeMs(value) {
1820
+ const trimmed = value.trim();
1821
+ const amount = Number.parseFloat(trimmed);
1822
+ if (!Number.isFinite(amount)) return 0;
1823
+ if (trimmed.endsWith("ms")) return amount;
1824
+ if (trimmed.endsWith("s")) return amount * 1e3;
1825
+ return 0;
1826
+ }
1827
+ function cssList(value) {
1828
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
1829
+ }
1830
+ function transitionTimings(style) {
1831
+ const properties = cssList(style.transitionProperty);
1832
+ const durations = cssList(style.transitionDuration).map(timeMs);
1833
+ const delays = cssList(style.transitionDelay).map(timeMs);
1834
+ const effectiveProperties = properties.length > 0 ? properties : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => "all");
1835
+ const effectiveDurations = durations.length > 0 ? durations : [0];
1836
+ const effectiveDelays = delays.length > 0 ? delays : [0];
1837
+ return effectiveProperties.filter((property) => property !== "none").map((property, index) => ({
1838
+ property,
1839
+ totalMs: Math.max(
1840
+ 0,
1841
+ (effectiveDurations[index % effectiveDurations.length] ?? 0) + (effectiveDelays[index % effectiveDelays.length] ?? 0)
1842
+ )
1843
+ }));
1844
+ }
1845
+ function maxTotalMs(timings) {
1846
+ return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);
1847
+ }
1848
+ function maxTransitionTotalMs(style) {
1849
+ return maxTotalMs(transitionTimings(style));
1850
+ }
1851
+ var TransitionCompletion = class {
1852
+ #timers = new SafeTimeout();
1853
+ #element = null;
1854
+ #complete = null;
1855
+ #pendingProperties = null;
1856
+ #deadline = 0;
1857
+ /**
1858
+ * Replaces any prior wait and invokes `complete` synchronously for a 0ms
1859
+ * transition (including when `getComputedStyle` is unavailable).
1860
+ *
1861
+ * With a positive `options.timeoutMs` the synchronous fast-path is skipped and
1862
+ * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
1863
+ */
1864
+ wait(element, complete, options = {}) {
1865
+ this.cancel();
1866
+ const requested = options.timeoutMs ?? 0;
1867
+ const override = Number.isFinite(requested) && requested > 0 ? requested : 0;
1868
+ const timings = typeof window.getComputedStyle === "function" ? transitionTimings(window.getComputedStyle(element)) : [];
1869
+ const maximum = maxTotalMs(timings);
1870
+ if (maximum <= 0 && override <= 0) {
1871
+ complete();
1872
+ return;
1873
+ }
1874
+ this.#element = element;
1875
+ this.#complete = complete;
1876
+ this.#deadline = Date.now() + maximum;
1877
+ const activeProperties = this.#activeTransitionProperties(element);
1878
+ this.#pendingProperties = activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);
1879
+ element.addEventListener("transitionend", this.#onTerminal);
1880
+ element.addEventListener("transitioncancel", this.#onTerminal);
1881
+ this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
1882
+ }
1883
+ /** Cancels the pending wait without invoking its completion callback. */
1884
+ cancel() {
1885
+ this.#complete = null;
1886
+ this.#teardown();
1887
+ }
1888
+ /**
1889
+ * Handles terminal events from the observed element only.
1890
+ *
1891
+ * For explicit property lists, every declared positive-time property must
1892
+ * settle. For `all`, no reliable property set exists, so an event can finish
1893
+ * only after the computed maximum time; the safety timeout owns the usual path.
1894
+ */
1895
+ #onTerminal = (event) => {
1896
+ if (event.target !== this.#element) return;
1897
+ const transitionEvent = event;
1898
+ if (transitionEvent.pseudoElement) return;
1899
+ if (this.#pendingProperties) {
1900
+ const propertyName = transitionEvent.propertyName;
1901
+ const active = this.#activeTransitionProperties(this.#element);
1902
+ if (active.has(propertyName)) return;
1903
+ if (!this.#pendingProperties.delete(propertyName)) return;
1904
+ if (this.#pendingProperties.size > 0) return;
1905
+ if (active.size > 0) {
1906
+ this.#pendingProperties = active;
1907
+ return;
1908
+ }
1909
+ this.#finish();
1910
+ return;
1911
+ }
1912
+ if (Date.now() >= this.#deadline) this.#finish();
1913
+ };
1914
+ /**
1915
+ * Returns active CSS transition properties expanded to the names reported by
1916
+ * terminal events. CSS animations and pseudo-element effects are excluded.
1917
+ */
1918
+ #activeTransitionProperties(element) {
1919
+ if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
1920
+ try {
1921
+ const properties = element.getAnimations().flatMap((animation) => {
1922
+ if (animation.playState === "idle" || animation.playState === "finished") return [];
1923
+ const effect = animation.effect;
1924
+ if (effect?.pseudoElement) return [];
1925
+ const target = effect?.target;
1926
+ if (target && target !== element) return [];
1927
+ const property = animation.transitionProperty;
1928
+ return typeof property === "string" && property.length > 0 ? [property] : [];
1929
+ });
1930
+ return new Set(properties);
1931
+ } catch {
1932
+ return /* @__PURE__ */ new Set();
1933
+ }
1934
+ }
1935
+ /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
1936
+ #explicitPendingProperties(timings) {
1937
+ if (timings.some(({ property }) => property === "all")) return null;
1938
+ const pending = new Set(
1939
+ timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property)
1940
+ );
1941
+ return pending.size > 0 ? pending : null;
1942
+ }
1943
+ /** Completes exactly once, releasing listeners and the fallback before the callback. */
1944
+ #finish() {
1945
+ const complete = this.#complete;
1946
+ if (!complete) return;
1947
+ this.#complete = null;
1948
+ this.#teardown();
1949
+ complete();
1950
+ }
1951
+ /** Releases the exact element listeners and timer owned by the current wait. */
1952
+ #teardown() {
1953
+ this.#timers.clearAll();
1954
+ this.#element?.removeEventListener("transitionend", this.#onTerminal);
1955
+ this.#element?.removeEventListener("transitioncancel", this.#onTerminal);
1956
+ this.#element = null;
1957
+ this.#pendingProperties = null;
1958
+ this.#deadline = 0;
1959
+ }
1960
+ };
1961
+
1962
+ // src/controllers/collapsible_controller.ts
1817
1963
  var CollapsibleController = class extends Controller {
1818
1964
  static targets = ["trigger", "content"];
1819
1965
  static values = {
1820
1966
  open: { type: Boolean, default: false }
1821
1967
  };
1822
1968
  static actions = ["toggle"];
1969
+ /** Owns the cancellable close-transition wait and its bounded fallback. */
1970
+ #transition = new TransitionCompletion();
1971
+ /** Distinguishes dynamic target churn from the callbacks that precede `connect()`. */
1972
+ #connected = false;
1823
1973
  /**
1824
- * The pending `transitionend` handler that re-applies `hidden` after a close.
1825
- * Tracked so {@link disconnect} can detach it and a reopen can supersede it.
1826
- */
1827
- #pendingTransitionEnd = null;
1828
- /**
1829
- * Establishes the initial open/closed state without animating.
1974
+ * Establishes the initial open/closed state without waiting for a close transition.
1830
1975
  *
1831
1976
  * The DOM is the source of truth on reconnect (Turbo cache restore / morph): an
1832
1977
  * **explicit** state attribute — `aria-expanded="true"`/`"false"` (or, with no
1833
1978
  * trigger, `data-state="open"`/`"closed"`) — is honored verbatim so a region the
1834
1979
  * user opened *or* closed survives a back-navigation, even when the declarative
1835
- * `open` Value disagrees. The Value only seeds a genuinely fresh render where no
1836
- * state attribute is present yet. Mirrors `sidebar`'s `#restoreCollapsed`.
1980
+ * `open` Value disagrees. An already-open region remains open without a
1981
+ * close/reopen cycle. The Value only seeds a genuinely fresh render where no
1982
+ * state attribute is present yet; any opening animation in that case belongs to
1983
+ * the consumer's CSS. Mirrors `sidebar`'s `#restoreCollapsed`.
1837
1984
  */
1838
1985
  connect() {
1986
+ this.#connected = true;
1839
1987
  this.#apply(this.#initialOpen(), false);
1840
1988
  }
1841
1989
  /** Resolves the connect-time state: explicit DOM state wins, else the `open` Value. */
@@ -1852,7 +2000,27 @@ var CollapsibleController = class extends Controller {
1852
2000
  return this.openValue;
1853
2001
  }
1854
2002
  disconnect() {
1855
- this.#detachTransitionEnd();
2003
+ this.#connected = false;
2004
+ this.#transition.cancel();
2005
+ }
2006
+ /** Reconciles a replacement trigger target with the content's live state. */
2007
+ triggerTargetConnected(trigger) {
2008
+ if (!this.#connected || !this.hasContentTarget) return;
2009
+ trigger.setAttribute(
2010
+ "aria-expanded",
2011
+ this.contentTarget.getAttribute("data-state") === "open" ? "true" : "false"
2012
+ );
2013
+ }
2014
+ /** Reconciles a replacement content target with the disclosure's live state. */
2015
+ contentTargetConnected(content) {
2016
+ if (!this.#connected) return;
2017
+ this.#transition.cancel();
2018
+ const open = this.hasTriggerTarget ? this.triggerTarget.getAttribute("aria-expanded") === "true" : content.getAttribute("data-state") === "open";
2019
+ this.#applyContent(content, open, false);
2020
+ }
2021
+ /** Cancels a wait tied to a content target that was removed or replaced. */
2022
+ contentTargetDisconnected() {
2023
+ this.#transition.cancel();
1856
2024
  }
1857
2025
  /** Toggles the region open/closed. Bound via `data-action` (click). */
1858
2026
  toggle() {
@@ -1875,16 +2043,21 @@ var CollapsibleController = class extends Controller {
1875
2043
  * spec requires.
1876
2044
  *
1877
2045
  * @param open - Target state.
1878
- * @param animate - When `false` (initial `connect`) the close path applies
1879
- * `hidden` immediately instead of waiting for a transition.
2046
+ * @param waitForCloseTransition - When `false` (initial `connect`) the close
2047
+ * path applies `hidden` immediately. This flag does not suppress consumer CSS
2048
+ * on the open path.
1880
2049
  */
1881
- #apply(open, animate) {
2050
+ #apply(open, waitForCloseTransition) {
1882
2051
  if (this.hasTriggerTarget) {
1883
2052
  this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false");
1884
2053
  }
1885
2054
  if (!this.hasContentTarget) return;
1886
2055
  const content = this.contentTarget;
1887
- this.#detachTransitionEnd();
2056
+ this.#transition.cancel();
2057
+ this.#applyContent(content, open, waitForCloseTransition);
2058
+ }
2059
+ /** Reflects one content target without relying on a later target lookup. */
2060
+ #applyContent(content, open, waitForCloseTransition) {
1888
2061
  if (open) {
1889
2062
  content.hidden = false;
1890
2063
  content.style.setProperty("--stimeo-collapsible-content-height", `${content.scrollHeight}px`);
@@ -1892,44 +2065,23 @@ var CollapsibleController = class extends Controller {
1892
2065
  return;
1893
2066
  }
1894
2067
  content.setAttribute("data-state", "closed");
1895
- if (animate && this.#transitionMs(content) > 0) {
2068
+ if (waitForCloseTransition) {
1896
2069
  this.#applyHiddenAfterTransition(content);
1897
2070
  } else {
1898
2071
  content.hidden = true;
1899
2072
  }
1900
2073
  }
1901
2074
  /**
1902
- * Re-applies `hidden` once the close transition finishes. Guarded against a
1903
- * reopen mid-transition: if the region is open again by the time the
1904
- * transition ends, `hidden` is left off.
2075
+ * Re-applies `hidden` once the close transition settles. Guarded against a
2076
+ * reopen mid-transition; the shared waiter also guarantees a bounded fallback
2077
+ * when the browser emits no terminal transition event.
1905
2078
  */
1906
2079
  #applyHiddenAfterTransition(content) {
1907
- const handler = (event) => {
1908
- if (event.target !== content) return;
1909
- this.#detachTransitionEnd();
2080
+ this.#transition.wait(content, () => {
1910
2081
  if (content.getAttribute("data-state") === "closed") {
1911
2082
  content.hidden = true;
1912
2083
  }
1913
- };
1914
- this.#pendingTransitionEnd = handler;
1915
- content.addEventListener("transitionend", handler);
1916
- }
1917
- #detachTransitionEnd() {
1918
- if (this.#pendingTransitionEnd && this.hasContentTarget) {
1919
- this.contentTarget.removeEventListener("transitionend", this.#pendingTransitionEnd);
1920
- }
1921
- this.#pendingTransitionEnd = null;
1922
- }
1923
- /**
1924
- * First `transition-duration` of `element` in milliseconds. Browsers normalize
1925
- * computed `<time>` to seconds (`0.2s`), but `ms` is parsed defensively. A zero
1926
- * here — including the consumer's reduced-motion CSS — takes the immediate path.
1927
- */
1928
- #transitionMs(element) {
1929
- const first = window.getComputedStyle(element).transitionDuration.split(",")[0]?.trim() ?? "";
1930
- const amount = Number.parseFloat(first);
1931
- if (Number.isNaN(amount)) return 0;
1932
- return first.endsWith("ms") ? amount : amount * 1e3;
2084
+ });
1933
2085
  }
1934
2086
  };
1935
2087
  var COLOR_PROPERTY = "--stimeo-color";
@@ -2988,6 +3140,13 @@ var ContextMenuController = class extends Controller {
2988
3140
  return this.hasMenuTarget && !this.menuTarget.hidden;
2989
3141
  }
2990
3142
  };
3143
+
3144
+ // src/utils/reduced_motion.ts
3145
+ function prefersReducedMotion() {
3146
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3147
+ }
3148
+
3149
+ // src/controllers/count_up_controller.ts
2991
3150
  var CountUpController = class extends Controller {
2992
3151
  static values = {
2993
3152
  duration: { type: Number, default: 1200 },
@@ -3019,7 +3178,7 @@ var CountUpController = class extends Controller {
3019
3178
  this.#finalText = this.element.textContent ?? "";
3020
3179
  const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, ""), 10);
3021
3180
  if (Number.isNaN(target)) return;
3022
- if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
3181
+ if (prefersReducedMotion()) {
3023
3182
  this.element.setAttribute("data-count-up-done", "true");
3024
3183
  this.dispatch("end", { detail: { value: target } });
3025
3184
  return;
@@ -4255,15 +4414,6 @@ var DismissibleController = class extends Controller {
4255
4414
  return true;
4256
4415
  }
4257
4416
  };
4258
- function maxTransitionMs(value) {
4259
- const durations = value.split(",").map((part) => {
4260
- const trimmed = part.trim();
4261
- if (trimmed.endsWith("ms")) return Number.parseFloat(trimmed);
4262
- if (trimmed.endsWith("s")) return Number.parseFloat(trimmed) * 1e3;
4263
- return 0;
4264
- });
4265
- return durations.length === 0 ? 0 : Math.max(...durations);
4266
- }
4267
4417
  var DrawerController = class extends Controller {
4268
4418
  static targets = ["trigger", "overlay", "panel"];
4269
4419
  static values = {
@@ -4271,13 +4421,17 @@ var DrawerController = class extends Controller {
4271
4421
  open: { type: Boolean, default: false }
4272
4422
  };
4273
4423
  static actions = ["close", "closeOnBackdrop", "open"];
4424
+ /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
4425
+ #activePanel = null;
4274
4426
  /** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
4275
- #trap = new FocusTrap(() => this.panelTarget, {
4427
+ #trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
4276
4428
  onEscape: () => this.close(),
4277
4429
  fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
4278
4430
  });
4279
- /** Pending `transitionend` hide listener, kept so it can be cancelled on reopen. */
4280
- #pendingHide = null;
4431
+ /** Owns the cancellable close-transition wait and its bounded fallback. */
4432
+ #transition = new TransitionCompletion();
4433
+ /** Distinguishes dynamic target churn from callbacks around controller teardown. */
4434
+ #connected = false;
4281
4435
  /**
4282
4436
  * Reflects placement and establishes the initial open/closed state.
4283
4437
  *
@@ -4290,6 +4444,8 @@ var DrawerController = class extends Controller {
4290
4444
  * after a reconnect and must be re-activated.
4291
4445
  */
4292
4446
  connect() {
4447
+ this.#connected = true;
4448
+ this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
4293
4449
  this.#reflectPlacement();
4294
4450
  const shouldOpen = this.#isOpen || this.openValue;
4295
4451
  this.#applyClosedState();
@@ -4297,8 +4453,31 @@ var DrawerController = class extends Controller {
4297
4453
  }
4298
4454
  /** Reverts the modal side effects and pending hide if torn down while open. */
4299
4455
  disconnect() {
4300
- this.#cancelPendingHide();
4456
+ this.#connected = false;
4457
+ this.#transition.cancel();
4301
4458
  this.#trap.deactivate({ restoreFocus: false });
4459
+ this.#activePanel = null;
4460
+ }
4461
+ /** Adopts a panel target added by a Turbo morph after the controller connected. */
4462
+ panelTargetConnected(panel) {
4463
+ if (!this.#connected || this.#activePanel?.isConnected && this.#activePanel !== panel) return;
4464
+ this.#adoptPanel(panel);
4465
+ }
4466
+ /** Closes and releases modal side effects when the actively trapped panel disappears. */
4467
+ panelTargetDisconnected(panel) {
4468
+ if (panel !== this.#activePanel) return;
4469
+ this.#transition.cancel();
4470
+ this.#activePanel = null;
4471
+ if (!this.#connected) return;
4472
+ panel.setAttribute("data-state", "closed");
4473
+ panel.hidden = true;
4474
+ if (this.hasOverlayTarget) {
4475
+ this.overlayTarget.setAttribute("data-state", "closed");
4476
+ this.overlayTarget.hidden = true;
4477
+ }
4478
+ this.openValue = false;
4479
+ this.#trap.deactivate();
4480
+ if (this.hasPanelTarget) this.#adoptPanel(this.panelTarget);
4302
4481
  }
4303
4482
  /** Keeps `data-placement` in sync if the value changes at runtime. */
4304
4483
  placementValueChanged() {
@@ -4307,7 +4486,8 @@ var DrawerController = class extends Controller {
4307
4486
  /** Opens the drawer: reveals it, syncs `data-state`, traps focus. */
4308
4487
  open() {
4309
4488
  if (!this.hasPanelTarget || this.#isOpen) return;
4310
- this.#cancelPendingHide();
4489
+ this.#transition.cancel();
4490
+ this.#activePanel = this.panelTarget;
4311
4491
  this.panelTarget.hidden = false;
4312
4492
  if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
4313
4493
  void this.panelTarget.offsetWidth;
@@ -4337,6 +4517,32 @@ var DrawerController = class extends Controller {
4337
4517
  #reflectPlacement() {
4338
4518
  if (this.hasPanelTarget) this.panelTarget.setAttribute("data-placement", this.#placement);
4339
4519
  }
4520
+ /** Reconciles a replacement panel and companion overlay from its explicit DOM state. */
4521
+ #adoptPanel(panel) {
4522
+ this.#transition.cancel();
4523
+ const trapWasActive = this.#trap.active;
4524
+ this.#activePanel = panel;
4525
+ panel.setAttribute("data-placement", this.#placement);
4526
+ if (panel.getAttribute("data-state") === "open") {
4527
+ panel.hidden = false;
4528
+ if (this.hasOverlayTarget) {
4529
+ this.overlayTarget.setAttribute("data-state", "open");
4530
+ this.overlayTarget.hidden = false;
4531
+ }
4532
+ this.openValue = true;
4533
+ if (trapWasActive) this.#trap.deactivate({ restoreFocus: false });
4534
+ this.#trap.activate();
4535
+ return;
4536
+ }
4537
+ panel.setAttribute("data-state", "closed");
4538
+ panel.hidden = true;
4539
+ if (this.hasOverlayTarget) {
4540
+ this.overlayTarget.setAttribute("data-state", "closed");
4541
+ this.overlayTarget.hidden = true;
4542
+ }
4543
+ this.openValue = false;
4544
+ this.#trap.deactivate();
4545
+ }
4340
4546
  /** Validated placement (`left`/`right`/`top`/`bottom`), defaulting to `right`. */
4341
4547
  get #placement() {
4342
4548
  const value = this.placementValue;
@@ -4355,24 +4561,13 @@ var DrawerController = class extends Controller {
4355
4561
  }
4356
4562
  /**
4357
4563
  * Applies `hidden` once the panel's close transition ends, so the exit slide
4358
- * can play. When the panel has no transition (the duration is `0`, as in tests
4359
- * or unstyled usage), it hides synchronously rather than waiting for an event
4360
- * that would never fire.
4564
+ * can play. The shared waiter hides synchronously for 0ms transitions and
4565
+ * supplies a bounded fallback when the browser emits no terminal event.
4361
4566
  */
4362
4567
  #hideAfterTransition() {
4363
4568
  const panel = this.panelTarget;
4364
- const duration = maxTransitionMs(getComputedStyle(panel).transitionDuration);
4365
- if (duration === 0) {
4366
- this.#applyHidden();
4367
- return;
4368
- }
4369
- const onEnd = (event) => {
4370
- if (event.target !== panel) return;
4371
- this.#cancelPendingHide();
4372
- this.#applyHidden();
4373
- };
4374
- this.#pendingHide = () => panel.removeEventListener("transitionend", onEnd);
4375
- panel.addEventListener("transitionend", onEnd);
4569
+ this.#activePanel = panel;
4570
+ this.#transition.wait(panel, () => this.#applyHidden(panel));
4376
4571
  }
4377
4572
  /**
4378
4573
  * Runs once the close transition has finished: applies `hidden` to the panel
@@ -4381,16 +4576,11 @@ var DrawerController = class extends Controller {
4381
4576
  * {@link FocusTrap} teardown to here — rather than at {@link close} time — keeps
4382
4577
  * the background unreachable and focus trapped for the whole exit animation.
4383
4578
  */
4384
- #applyHidden() {
4385
- if (this.hasPanelTarget) this.panelTarget.hidden = true;
4579
+ #applyHidden(panel) {
4580
+ panel.hidden = true;
4386
4581
  if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
4387
4582
  this.#trap.deactivate();
4388
4583
  }
4389
- /** Removes any pending `transitionend` hide listener. */
4390
- #cancelPendingHide() {
4391
- this.#pendingHide?.();
4392
- this.#pendingHide = null;
4393
- }
4394
4584
  /** Whether the drawer is open (tracked via `data-state`, not `hidden`). */
4395
4585
  get #isOpen() {
4396
4586
  return this.hasPanelTarget && this.panelTarget.getAttribute("data-state") === "open";
@@ -5047,13 +5237,10 @@ var FlashController = class extends Controller {
5047
5237
  finalize();
5048
5238
  }
5049
5239
  }
5050
- /** First `transition-duration` of `el` in ms (0 when none / unsupported). */
5240
+ /** Maximum transition total (duration + delay) of `el` in ms (0 when none / unsupported). */
5051
5241
  #transitionMs(el) {
5052
5242
  if (typeof window.getComputedStyle !== "function") return 0;
5053
- const first = window.getComputedStyle(el).transitionDuration.split(",")[0]?.trim() ?? "";
5054
- const amount = Number.parseFloat(first);
5055
- if (Number.isNaN(amount)) return 0;
5056
- return first.endsWith("ms") ? amount : amount * 1e3;
5243
+ return maxTransitionTotalMs(window.getComputedStyle(el));
5057
5244
  }
5058
5245
  };
5059
5246
  var FocusController = class extends Controller {
@@ -5634,7 +5821,7 @@ var HighlightController = class extends Controller {
5634
5821
  }
5635
5822
  /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
5636
5823
  #highlight(el) {
5637
- if (this.#prefersReducedMotion()) return;
5824
+ if (prefersReducedMotion()) return;
5638
5825
  el.setAttribute("data-highlight", "true");
5639
5826
  this.dispatch("start", { target: el, detail: { element: el } });
5640
5827
  this.#timeouts.set(() => {
@@ -5642,9 +5829,6 @@ var HighlightController = class extends Controller {
5642
5829
  this.dispatch("end", { target: el, detail: { element: el } });
5643
5830
  }, this.durationValue);
5644
5831
  }
5645
- #prefersReducedMotion() {
5646
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
5647
- }
5648
5832
  };
5649
5833
 
5650
5834
  // src/utils/scroll_dismiss.ts
@@ -6018,6 +6202,12 @@ var InputMaskController = class extends Controller {
6018
6202
  };
6019
6203
 
6020
6204
  // src/utils/intersection_watcher.ts
6205
+ function isBeforeRootStart(entry) {
6206
+ const rect = entry.boundingClientRect;
6207
+ if (rect.width === 0 && rect.height === 0) return false;
6208
+ const rootTop = entry.rootBounds?.top ?? 0;
6209
+ return rect.bottom <= rootTop;
6210
+ }
6021
6211
  var IntersectionWatcher = class {
6022
6212
  #onEntries;
6023
6213
  #observer = null;
@@ -6033,6 +6223,11 @@ var IntersectionWatcher = class {
6033
6223
  * (Re)creates the observer and observes `targets`. Returns `false` — leaving
6034
6224
  * the watcher inert — without `IntersectionObserver` support (very old
6035
6225
  * browsers; the caller's no-JS fallback stays in charge) or with no targets.
6226
+ *
6227
+ * @throws Whatever the platform throws for an invalid `rootMargin`/`threshold`
6228
+ * or a failing `observe()`. The exception is passed through unchanged, but
6229
+ * the watcher rolls back first: every target observed so far is released and
6230
+ * `active` stays `false`, so a caller that retries starts from a clean slate.
6036
6231
  */
6037
6232
  start(targets, options = {}) {
6038
6233
  this.stop();
@@ -6040,25 +6235,42 @@ var IntersectionWatcher = class {
6040
6235
  const list = Array.isArray(targets) ? targets : [targets];
6041
6236
  if (list.length === 0) return false;
6042
6237
  const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
6043
- this.#active = true;
6044
- this.#observer = new IntersectionObserver(
6045
- (entries) => {
6046
- if (this.#active) this.#onEntries(entries);
6047
- },
6048
- { root, rootMargin: options.rootMargin, threshold: options.threshold }
6049
- );
6050
- for (const target of list) this.#observer.observe(target);
6051
- return true;
6238
+ let observer = null;
6239
+ try {
6240
+ observer = new IntersectionObserver(
6241
+ (entries) => {
6242
+ if (this.#active && this.#observer === observer) this.#onEntries(entries);
6243
+ },
6244
+ { root, rootMargin: options.rootMargin, threshold: options.threshold }
6245
+ );
6246
+ for (const target of list) observer.observe(target);
6247
+ this.#observer = observer;
6248
+ this.#active = true;
6249
+ return true;
6250
+ } catch (error) {
6251
+ observer?.disconnect();
6252
+ this.#observer = null;
6253
+ this.#active = false;
6254
+ throw error;
6255
+ }
6052
6256
  }
6053
6257
  /**
6054
6258
  * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
6055
6259
  * only reports *changes*, but `observe()` always reports the present state,
6056
6260
  * so unobserve→observe turns "still intersecting" into a fresh callback.
6261
+ *
6262
+ * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
6263
+ * first, so it never stays live with a half-rearmed target.
6057
6264
  */
6058
6265
  rearm(target) {
6059
6266
  if (!this.#observer) return;
6060
- this.#observer.unobserve(target);
6061
- this.#observer.observe(target);
6267
+ try {
6268
+ this.#observer.unobserve(target);
6269
+ this.#observer.observe(target);
6270
+ } catch (error) {
6271
+ this.stop();
6272
+ throw error;
6273
+ }
6062
6274
  }
6063
6275
  /** Severs the observer; late queued callbacks become no-ops via the guard. */
6064
6276
  stop() {
@@ -6095,7 +6307,7 @@ var IntersectionController = class extends Controller {
6095
6307
  this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
6096
6308
  this.dispatch("change", { detail: { intersecting, ratio } });
6097
6309
  this.#syncIntersecting(intersecting, ratio, entry);
6098
- this.#syncPassed(!intersecting && this.#isBefore(entry));
6310
+ this.#syncPassed(!intersecting && isBeforeRootStart(entry));
6099
6311
  }
6100
6312
  }
6101
6313
  connect() {
@@ -6143,7 +6355,7 @@ var IntersectionController = class extends Controller {
6143
6355
  if (this.onceValue) this.#watcher.stop();
6144
6356
  } else if (!intersecting && previous === "true") {
6145
6357
  this.dispatch("exit", {
6146
- detail: { ratio, position: this.#isBefore(entry) ? "before" : "after" }
6358
+ detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
6147
6359
  });
6148
6360
  }
6149
6361
  }
@@ -6159,11 +6371,6 @@ var IntersectionController = class extends Controller {
6159
6371
  const changed = previous === null ? passed : previous === "true" !== passed;
6160
6372
  if (changed) this.dispatch("passed", { detail: { passed } });
6161
6373
  }
6162
- /** True when the element sits entirely before the root's start (top) edge. */
6163
- #isBefore(entry) {
6164
- const rootTop = entry.rootBounds?.top ?? 0;
6165
- return entry.boundingClientRect.bottom <= rootTop;
6166
- }
6167
6374
  /** The configured `threshold`, clamped to the 0..1 the observer accepts. */
6168
6375
  #clampedThreshold() {
6169
6376
  return Math.min(1, Math.max(0, this.thresholdValue));
@@ -8317,6 +8524,26 @@ var OtpController = class extends Controller {
8317
8524
  }
8318
8525
  }
8319
8526
  };
8527
+
8528
+ // src/utils/logical_scroll.ts
8529
+ function isRtl(element) {
8530
+ return window.getComputedStyle(element).direction === "rtl";
8531
+ }
8532
+ function logicalScrollMetrics(element, horizontal) {
8533
+ const max = Math.max(
8534
+ 0,
8535
+ horizontal ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight
8536
+ );
8537
+ const raw = horizontal ? element.scrollLeft : element.scrollTop;
8538
+ const position = horizontal && isRtl(element) ? -raw : raw;
8539
+ return { position: Math.min(max, Math.max(0, position)), max };
8540
+ }
8541
+ function physicalScrollDelta(element, horizontal, logicalDelta) {
8542
+ return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;
8543
+ }
8544
+
8545
+ // src/controllers/overflow_indicator_controller.ts
8546
+ var DIRECTION_BUTTON_SELECTOR = "[data-stimeo--overflow-indicator-direction-param]";
8320
8547
  var OverflowIndicatorController = class extends Controller {
8321
8548
  static targets = ["viewport"];
8322
8549
  static values = {
@@ -8325,38 +8552,50 @@ var OverflowIndicatorController = class extends Controller {
8325
8552
  };
8326
8553
  static actions = ["scrollByPage", "update"];
8327
8554
  static events = ["change"];
8328
- #layout = new LayoutObserver(() => this.update());
8555
+ #layout = new LayoutObserver(() => {
8556
+ if (this.#connected) this.update();
8557
+ });
8558
+ #connected = false;
8559
+ #observedViewport = null;
8560
+ #observedContent = /* @__PURE__ */ new Set();
8329
8561
  #mutationObserver = null;
8562
+ #pendingButtonDisables = /* @__PURE__ */ new Map();
8330
8563
  /** Last reported room, so `change` fires only on transitions. */
8331
8564
  #state = null;
8332
8565
  connect() {
8333
- if (!this.hasViewportTarget) return;
8334
- this.#layout.observe(this.viewportTarget);
8335
- this.#layout.observeViewport();
8336
- if (typeof MutationObserver !== "undefined") {
8337
- this.#mutationObserver = new MutationObserver(() => this.update());
8338
- this.#mutationObserver.observe(this.viewportTarget, {
8339
- childList: true,
8340
- subtree: true,
8341
- characterData: true
8342
- });
8343
- }
8344
- this.update();
8566
+ this.#connected = true;
8567
+ this.#syncViewport();
8345
8568
  }
8346
8569
  disconnect() {
8570
+ this.#connected = false;
8571
+ this.#stopObservingViewport();
8347
8572
  this.#layout.disconnect();
8348
- this.#mutationObserver?.disconnect();
8349
- this.#mutationObserver = null;
8573
+ this.#clearPendingButtonDisables();
8350
8574
  this.#state = null;
8351
8575
  }
8352
- /** Re-measures remaining scroll room and reflects the state hooks. Public so it can be wired to the viewport's `scroll`. */
8576
+ viewportTargetConnected() {
8577
+ this.#syncViewport();
8578
+ }
8579
+ viewportTargetDisconnected(viewport) {
8580
+ if (this.#observedViewport === viewport) this.#stopObservingViewport();
8581
+ this.#syncViewport();
8582
+ }
8583
+ orientationValueChanged() {
8584
+ if (this.#connected) this.update();
8585
+ }
8586
+ thresholdValueChanged() {
8587
+ if (this.#connected) this.update();
8588
+ }
8589
+ /**
8590
+ * Re-measures remaining scroll room and reflects the state hooks.
8591
+ * Public so it can be wired to the viewport's `scroll`.
8592
+ */
8353
8593
  update() {
8354
8594
  if (!this.hasViewportTarget) return;
8355
8595
  const vp = this.viewportTarget;
8356
8596
  const horizontal = this.orientationValue !== "vertical";
8357
- const t = this.thresholdValue;
8358
- const scrollPos = horizontal ? vp.scrollLeft : vp.scrollTop;
8359
- const maxScroll = horizontal ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
8597
+ const t = this.#threshold;
8598
+ const { position: scrollPos, max: maxScroll } = logicalScrollMetrics(vp, horizontal);
8360
8599
  const start = scrollPos > t;
8361
8600
  const end = scrollPos < maxScroll - t;
8362
8601
  vp.setAttribute("data-overflow-start", start ? "true" : "false");
@@ -8372,11 +8611,13 @@ var OverflowIndicatorController = class extends Controller {
8372
8611
  if (!this.hasViewportTarget) return;
8373
8612
  const direction = this.#directionFromEvent(event);
8374
8613
  if (!direction) return;
8614
+ if (this.#state && !this.#state[direction]) return;
8375
8615
  const vp = this.viewportTarget;
8376
8616
  const horizontal = this.orientationValue !== "vertical";
8377
8617
  const page = horizontal ? vp.clientWidth : vp.clientHeight;
8378
- const delta = direction === "start" ? -page : page;
8379
- const behavior = this.#prefersReducedMotion() ? "auto" : "smooth";
8618
+ const logicalDelta = direction === "start" ? -page : page;
8619
+ const delta = physicalScrollDelta(vp, horizontal, logicalDelta);
8620
+ const behavior = prefersReducedMotion() ? "auto" : "smooth";
8380
8621
  if (horizontal) {
8381
8622
  vp.scrollBy({ left: delta, behavior });
8382
8623
  } else {
@@ -8385,10 +8626,16 @@ var OverflowIndicatorController = class extends Controller {
8385
8626
  }
8386
8627
  /** Mirrors remaining room onto any direction buttons by toggling `disabled`. */
8387
8628
  #syncButtons(start, end) {
8388
- const buttons = this.element.querySelectorAll(
8389
- "[data-stimeo--overflow-indicator-direction-param]"
8390
- );
8629
+ for (const button of [...this.#pendingButtonDisables.keys()]) {
8630
+ if (!button.isConnected || button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
8631
+ this.#cancelPendingButtonDisable(button);
8632
+ }
8633
+ }
8634
+ const buttons = this.element.querySelectorAll(DIRECTION_BUTTON_SELECTOR);
8391
8635
  for (const button of buttons) {
8636
+ if (button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
8637
+ continue;
8638
+ }
8392
8639
  const direction = button.getAttribute("data-stimeo--overflow-indicator-direction-param");
8393
8640
  if (direction === "start") this.#toggleButton(button, start);
8394
8641
  else if (direction === "end") this.#toggleButton(button, end);
@@ -8401,7 +8648,11 @@ var OverflowIndicatorController = class extends Controller {
8401
8648
  * whole control disabled) is therefore never blindly re-enabled.
8402
8649
  */
8403
8650
  #toggleButton(button, hasRoom) {
8651
+ if (!this.#pendingButtonDisables.has(button) && button.hasAttribute("data-overflow-indicator-pending-disabled")) {
8652
+ this.#cancelPendingButtonDisable(button);
8653
+ }
8404
8654
  if (hasRoom) {
8655
+ this.#cancelPendingButtonDisable(button);
8405
8656
  if (button.hasAttribute("data-overflow-indicator-disabled")) {
8406
8657
  button.disabled = false;
8407
8658
  button.removeAttribute("data-overflow-indicator-disabled");
@@ -8409,17 +8660,121 @@ var OverflowIndicatorController = class extends Controller {
8409
8660
  return;
8410
8661
  }
8411
8662
  if (button.disabled) return;
8663
+ if (document.activeElement === button) {
8664
+ this.#deferButtonDisable(button);
8665
+ } else {
8666
+ this.#disableButton(button);
8667
+ }
8668
+ }
8669
+ /**
8670
+ * Keeps a boundary button focusable until native blur, exposing its temporary
8671
+ * inoperability with `aria-disabled` and making its action a no-op meanwhile.
8672
+ */
8673
+ #deferButtonDisable(button) {
8674
+ if (this.#pendingButtonDisables.has(button)) return;
8675
+ button.setAttribute("data-overflow-indicator-pending-disabled", "");
8676
+ button.setAttribute(
8677
+ "data-overflow-indicator-aria-disabled",
8678
+ button.getAttribute("aria-disabled") ?? ""
8679
+ );
8680
+ button.setAttribute("aria-disabled", "true");
8681
+ const onBlur = () => {
8682
+ this.#cancelPendingButtonDisable(button);
8683
+ if (!button.disabled) this.#disableButton(button);
8684
+ };
8685
+ this.#pendingButtonDisables.set(button, { onBlur });
8686
+ button.addEventListener("blur", onBlur);
8687
+ }
8688
+ #disableButton(button) {
8412
8689
  button.disabled = true;
8413
8690
  button.setAttribute("data-overflow-indicator-disabled", "");
8414
8691
  }
8692
+ #cancelPendingButtonDisable(button) {
8693
+ const pending = this.#pendingButtonDisables.get(button);
8694
+ if (pending) button.removeEventListener("blur", pending.onBlur);
8695
+ this.#pendingButtonDisables.delete(button);
8696
+ button.removeAttribute("data-overflow-indicator-pending-disabled");
8697
+ const displaced = button.getAttribute("data-overflow-indicator-aria-disabled");
8698
+ if (displaced !== null) {
8699
+ button.removeAttribute("data-overflow-indicator-aria-disabled");
8700
+ if (button.getAttribute("aria-disabled") === "true") {
8701
+ if (displaced === "") button.removeAttribute("aria-disabled");
8702
+ else button.setAttribute("aria-disabled", displaced);
8703
+ }
8704
+ }
8705
+ }
8706
+ #clearPendingButtonDisables() {
8707
+ for (const button of [...this.#pendingButtonDisables.keys()]) {
8708
+ this.#cancelPendingButtonDisable(button);
8709
+ }
8710
+ }
8711
+ /** Moves resize/mutation/load observation to the current viewport target. */
8712
+ #syncViewport() {
8713
+ if (!this.#connected) return;
8714
+ const next = this.hasViewportTarget ? this.viewportTarget : null;
8715
+ if (next === this.#observedViewport) return;
8716
+ this.#stopObservingViewport();
8717
+ if (!next) return;
8718
+ this.#observedViewport = next;
8719
+ this.#layout.observe(next);
8720
+ this.#layout.observeViewport();
8721
+ next.addEventListener("load", this.#onContentLoad, true);
8722
+ this.#syncContentObservation();
8723
+ if (typeof MutationObserver !== "undefined") {
8724
+ this.#mutationObserver = new MutationObserver(() => {
8725
+ if (!this.#connected || this.#observedViewport !== next) return;
8726
+ this.#syncContentObservation();
8727
+ this.update();
8728
+ });
8729
+ this.#mutationObserver.observe(this.element, {
8730
+ childList: true,
8731
+ subtree: true,
8732
+ characterData: true,
8733
+ attributes: true,
8734
+ attributeFilter: ["class", "style", "hidden"]
8735
+ });
8736
+ }
8737
+ this.#state = null;
8738
+ this.update();
8739
+ }
8740
+ /** Observes direct content boxes whose resize can change the viewport's scroll extent. */
8741
+ #syncContentObservation() {
8742
+ const next = new Set(this.#observedViewport?.children ?? []);
8743
+ for (const content of this.#observedContent) {
8744
+ if (!next.has(content)) {
8745
+ this.#layout.unobserve(content);
8746
+ this.#observedContent.delete(content);
8747
+ }
8748
+ }
8749
+ for (const content of next) {
8750
+ if (!this.#observedContent.has(content)) {
8751
+ this.#observedContent.add(content);
8752
+ this.#layout.observe(content);
8753
+ }
8754
+ }
8755
+ }
8756
+ #stopObservingViewport() {
8757
+ this.#mutationObserver?.disconnect();
8758
+ this.#mutationObserver = null;
8759
+ this.#observedViewport?.removeEventListener("load", this.#onContentLoad, true);
8760
+ if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
8761
+ for (const content of this.#observedContent) this.#layout.unobserve(content);
8762
+ this.#observedContent.clear();
8763
+ this.#observedViewport = null;
8764
+ this.#layout.unobserveViewport();
8765
+ }
8766
+ #onContentLoad = () => {
8767
+ if (this.#connected) this.update();
8768
+ };
8769
+ get #threshold() {
8770
+ const value = this.thresholdValue;
8771
+ return Number.isFinite(value) ? Math.max(0, value) : 1;
8772
+ }
8415
8773
  #directionFromEvent(event) {
8416
8774
  const params = event.params;
8417
8775
  const direction = params?.direction;
8418
8776
  return direction === "start" || direction === "end" ? direction : null;
8419
8777
  }
8420
- #prefersReducedMotion() {
8421
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8422
- }
8423
8778
  };
8424
8779
  var OverflowMenuController = class extends Controller {
8425
8780
  static targets = ["items", "more"];
@@ -10110,29 +10465,49 @@ var ReadMoreController = class extends Controller {
10110
10465
  collapsed: { type: Boolean, default: true }
10111
10466
  };
10112
10467
  static actions = ["toggle"];
10113
- /** Re-checks overflow when the content box or the viewport resizes. */
10114
- #layout = new LayoutObserver(() => this.#evaluateOverflow());
10468
+ #connected = false;
10469
+ #collapsed = true;
10470
+ #observedContent = null;
10471
+ #contentMutationObserver = null;
10472
+ #deferredHideTrigger = null;
10473
+ #update = () => {
10474
+ if (this.#connected) this.#evaluateOverflow();
10475
+ };
10476
+ #layout = new LayoutObserver(this.#update);
10477
+ #onDeferredHideBlur = () => {
10478
+ this.#clearDeferredHide();
10479
+ this.#update();
10480
+ };
10115
10481
  connect() {
10116
- this.#reflect(this.#initialCollapsed());
10117
- this.#evaluateOverflow();
10118
- if (this.hasContentTarget) {
10119
- this.#layout.observe(this.contentTarget);
10120
- this.#layout.observeViewport();
10121
- }
10482
+ this.#connected = true;
10483
+ this.#collapsed = this.#initialCollapsed();
10484
+ this.#syncTargets();
10122
10485
  }
10123
10486
  disconnect() {
10487
+ this.#connected = false;
10488
+ this.#stopObservingContent();
10124
10489
  this.#layout.disconnect();
10125
10490
  }
10491
+ contentTargetConnected() {
10492
+ this.#syncTargets();
10493
+ }
10494
+ contentTargetDisconnected() {
10495
+ this.#syncTargets();
10496
+ }
10497
+ triggerTargetConnected() {
10498
+ this.#syncTargets();
10499
+ }
10500
+ triggerTargetDisconnected(trigger) {
10501
+ if (this.#deferredHideTrigger === trigger) this.#clearDeferredHide();
10502
+ this.#syncTargets();
10503
+ }
10126
10504
  /** Toggles between the collapsed (clamped) and expanded states. */
10127
10505
  toggle() {
10128
- this.#reflect(!this.#isCollapsed);
10506
+ if (!this.#connected) return;
10507
+ this.#collapsed = !this.#collapsed;
10508
+ this.#reflect();
10129
10509
  this.#evaluateOverflow();
10130
10510
  }
10131
- /** Whether the content is currently collapsed (clamped). */
10132
- get #isCollapsed() {
10133
- return this.hasContentTarget ? this.contentTarget.getAttribute("data-state") !== "expanded" : this.collapsedValue;
10134
- }
10135
- /** Connect-time state: an explicit `data-state` wins, else the `collapsed` Value. */
10136
10511
  #initialCollapsed() {
10137
10512
  if (this.hasContentTarget) {
10138
10513
  const state = this.contentTarget.getAttribute("data-state");
@@ -10141,29 +10516,76 @@ var ReadMoreController = class extends Controller {
10141
10516
  }
10142
10517
  return this.collapsedValue;
10143
10518
  }
10144
- /** Writes the collapsed/expanded state onto the content and trigger. */
10145
- #reflect(collapsed) {
10519
+ #reflect() {
10146
10520
  if (this.hasContentTarget) {
10147
- this.contentTarget.setAttribute("data-state", collapsed ? "collapsed" : "expanded");
10521
+ this.contentTarget.setAttribute("data-state", this.#collapsed ? "collapsed" : "expanded");
10148
10522
  }
10149
10523
  if (this.hasTriggerTarget) {
10150
- this.triggerTarget.setAttribute("aria-expanded", collapsed ? "false" : "true");
10524
+ this.triggerTarget.setAttribute("aria-expanded", this.#collapsed ? "false" : "true");
10151
10525
  }
10152
10526
  }
10153
- /**
10154
- * Shows the toggle only when it is useful: while expanded it is always shown
10155
- * (the user needs a way back), and while collapsed it is shown only if the
10156
- * text actually overflows its clamp (`scrollHeight > clientHeight`).
10157
- */
10527
+ #syncTargets() {
10528
+ if (!this.#connected) return;
10529
+ this.#syncContentObservation();
10530
+ this.#reflect();
10531
+ this.#evaluateOverflow();
10532
+ }
10533
+ #syncContentObservation() {
10534
+ const next = this.hasContentTarget ? this.contentTarget : null;
10535
+ if (next === this.#observedContent) return;
10536
+ this.#stopObservingContent();
10537
+ if (!next) return;
10538
+ this.#observedContent = next;
10539
+ this.#layout.observe(next);
10540
+ this.#layout.observeViewport();
10541
+ next.addEventListener("load", this.#update, true);
10542
+ if (typeof MutationObserver !== "undefined") {
10543
+ this.#contentMutationObserver = new MutationObserver(this.#update);
10544
+ this.#contentMutationObserver.observe(next, {
10545
+ childList: true,
10546
+ subtree: true,
10547
+ characterData: true
10548
+ });
10549
+ }
10550
+ }
10551
+ #stopObservingContent() {
10552
+ this.#clearDeferredHide();
10553
+ if (this.#observedContent) {
10554
+ this.#layout.unobserve(this.#observedContent);
10555
+ this.#observedContent.removeEventListener("load", this.#update, true);
10556
+ }
10557
+ this.#observedContent = null;
10558
+ this.#contentMutationObserver?.disconnect();
10559
+ this.#contentMutationObserver = null;
10560
+ this.#layout.unobserveViewport();
10561
+ }
10562
+ #deferHide(trigger) {
10563
+ if (this.#deferredHideTrigger === trigger) return;
10564
+ this.#clearDeferredHide();
10565
+ this.#deferredHideTrigger = trigger;
10566
+ trigger.addEventListener("blur", this.#onDeferredHideBlur);
10567
+ }
10568
+ #clearDeferredHide() {
10569
+ this.#deferredHideTrigger?.removeEventListener("blur", this.#onDeferredHideBlur);
10570
+ this.#deferredHideTrigger = null;
10571
+ }
10158
10572
  #evaluateOverflow() {
10159
10573
  if (!this.hasTriggerTarget || !this.hasContentTarget) return;
10160
- if (!this.#isCollapsed) {
10161
- this.triggerTarget.hidden = false;
10574
+ const trigger = this.triggerTarget;
10575
+ const content = this.contentTarget;
10576
+ const useful = !this.#collapsed || content.scrollHeight > content.clientHeight;
10577
+ if (useful) {
10578
+ this.#clearDeferredHide();
10579
+ trigger.hidden = false;
10162
10580
  return;
10163
10581
  }
10164
- const content = this.contentTarget;
10165
- const overflowing = content.scrollHeight > content.clientHeight;
10166
- this.triggerTarget.hidden = !overflowing;
10582
+ if (document.activeElement === trigger) {
10583
+ trigger.hidden = false;
10584
+ this.#deferHide(trigger);
10585
+ return;
10586
+ }
10587
+ this.#clearDeferredHide();
10588
+ trigger.hidden = true;
10167
10589
  }
10168
10590
  };
10169
10591
  var PROGRESS_PROPERTY = "--stimeo--reading-progress";
@@ -10627,8 +11049,7 @@ var ScrollAreaController = class extends Controller {
10627
11049
  */
10628
11050
  #measurePosition(vp) {
10629
11051
  const horizontalPrimary = this.orientationValue === "horizontal" || this.orientationValue === "both" && vp.scrollHeight <= vp.clientHeight + EDGE_EPSILON;
10630
- const scrollPos = horizontalPrimary ? vp.scrollLeft : vp.scrollTop;
10631
- const maxScroll = horizontalPrimary ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
11052
+ const { position: scrollPos, max: maxScroll } = logicalScrollMetrics(vp, horizontalPrimary);
10632
11053
  if (maxScroll <= EDGE_EPSILON) return { position: "start", progress: 0 };
10633
11054
  const progress = Math.min(1, Math.max(0, scrollPos / maxScroll));
10634
11055
  if (scrollPos <= EDGE_EPSILON) return { position: "start", progress };
@@ -10803,7 +11224,7 @@ var ScrollVisibilityController = class extends Controller {
10803
11224
  }
10804
11225
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
10805
11226
  toTop() {
10806
- const behavior = this.#prefersReducedMotion() ? "auto" : "smooth";
11227
+ const behavior = prefersReducedMotion() ? "auto" : "smooth";
10807
11228
  this.#scrollSource.scrollTo({ top: 0, behavior });
10808
11229
  if (this.focusSelectorValue) {
10809
11230
  const target = document.querySelector(this.focusSelectorValue);
@@ -10851,9 +11272,6 @@ var ScrollVisibilityController = class extends Controller {
10851
11272
  }
10852
11273
  return this.#scrollSource.scrollTop;
10853
11274
  }
10854
- #prefersReducedMotion() {
10855
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
10856
- }
10857
11275
  };
10858
11276
  var ScrollspyController = class extends Controller {
10859
11277
  static targets = ["link"];
@@ -11092,15 +11510,23 @@ var SeparatorController = class extends Controller {
11092
11510
  }
11093
11511
  }
11094
11512
  };
11095
- function maxTransitionMs2(value) {
11096
- const durations = value.split(",").map((part) => {
11097
- const trimmed = part.trim();
11098
- if (trimmed.endsWith("ms")) return Number.parseFloat(trimmed);
11099
- if (trimmed.endsWith("s")) return Number.parseFloat(trimmed) * 1e3;
11100
- return 0;
11101
- });
11102
- return durations.length === 0 ? 0 : Math.max(...durations);
11513
+
11514
+ // src/utils/safe_storage.ts
11515
+ function readLocalStorage(key) {
11516
+ try {
11517
+ return window.localStorage.getItem(key);
11518
+ } catch {
11519
+ return null;
11520
+ }
11521
+ }
11522
+ function writeLocalStorage(key, value) {
11523
+ try {
11524
+ window.localStorage.setItem(key, value);
11525
+ } catch {
11526
+ }
11103
11527
  }
11528
+
11529
+ // src/controllers/sidebar_controller.ts
11104
11530
  var SidebarController = class extends Controller {
11105
11531
  static targets = ["trigger", "panel", "backdrop"];
11106
11532
  static values = {
@@ -11108,9 +11534,11 @@ var SidebarController = class extends Controller {
11108
11534
  key: { type: String, default: "" },
11109
11535
  collapsed: { type: Boolean, default: false }
11110
11536
  };
11111
- static actions = ["close", "open", "toggle"];
11537
+ static actions = ["beforeCache", "close", "open", "toggle"];
11538
+ /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
11539
+ #activePanel = null;
11112
11540
  /** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
11113
- #trap = new FocusTrap(() => this.panelTarget, {
11541
+ #trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
11114
11542
  onEscape: () => this.close(),
11115
11543
  fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
11116
11544
  });
@@ -11120,18 +11548,79 @@ var SidebarController = class extends Controller {
11120
11548
  #collapsed = false;
11121
11549
  /** The matched media query (`min-width: breakpoint`), watched for mode changes. */
11122
11550
  #mql = null;
11123
- /** Pending `transitionend` hide listener, cancelled on reopen/teardown. */
11124
- #pendingHide = null;
11551
+ /** Exact normalized query currently represented by the active media-query listener. */
11552
+ #mqlQuery = null;
11553
+ /** Owns the cancellable close-transition wait and its bounded fallback. */
11554
+ #transition = new TransitionCompletion();
11555
+ /** Distinguishes dynamic target churn from callbacks around controller teardown. */
11556
+ #connected = false;
11125
11557
  connect() {
11558
+ this.#connected = true;
11559
+ this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
11126
11560
  this.#collapsed = this.#restoreCollapsed();
11127
- this.#mql = this.#matchBreakpoint();
11561
+ this.#mqlQuery = this.#breakpointQuery;
11562
+ this.#mql = this.#matchBreakpoint(this.#mqlQuery);
11128
11563
  this.#mql?.addEventListener("change", this.#onMediaChange);
11129
11564
  this.#applyMode(this.#computeMode());
11130
11565
  }
11131
11566
  disconnect() {
11567
+ this.#connected = false;
11132
11568
  this.#mql?.removeEventListener("change", this.#onMediaChange);
11133
- this.#cancelPendingHide();
11569
+ this.#mql = null;
11570
+ this.#mqlQuery = null;
11571
+ this.#transition.cancel();
11134
11572
  this.#trap.deactivate({ restoreFocus: false });
11573
+ this.#activePanel = null;
11574
+ }
11575
+ /** Adopts a panel target added by a Turbo morph after the controller connected. */
11576
+ panelTargetConnected(panel) {
11577
+ if (!this.#connected || this.#activePanel?.isConnected && this.#activePanel !== panel) return;
11578
+ this.#adoptPanel(panel);
11579
+ }
11580
+ /** Closes and releases overlay side effects when the actively trapped panel disappears. */
11581
+ panelTargetDisconnected(panel) {
11582
+ if (panel !== this.#activePanel) return;
11583
+ this.#transition.cancel();
11584
+ this.#activePanel = null;
11585
+ if (!this.#connected) return;
11586
+ panel.setAttribute("data-state", "closed");
11587
+ panel.hidden = true;
11588
+ this.#hideBackdrop();
11589
+ this.#setExpandedAttr(false);
11590
+ this.#trap.deactivate();
11591
+ if (this.hasPanelTarget) this.#adoptPanel(this.panelTarget);
11592
+ }
11593
+ /**
11594
+ * Rebinds responsive observation when `breakpoint` changes at runtime.
11595
+ *
11596
+ * Stimulus calls value callbacks before `connect()`, so the connected guard
11597
+ * prevents an eager subscription. Equivalent normalized queries retain the
11598
+ * existing listener instead of allocating duplicate `MediaQueryList` objects.
11599
+ */
11600
+ breakpointValueChanged() {
11601
+ if (!this.#connected) return;
11602
+ const query = this.#breakpointQuery;
11603
+ if (this.#mqlQuery === query) return;
11604
+ this.#mql?.removeEventListener("change", this.#onMediaChange);
11605
+ this.#mqlQuery = query;
11606
+ this.#mql = this.#matchBreakpoint(query);
11607
+ this.#mql?.addEventListener("change", this.#onMediaChange);
11608
+ const next = this.#computeMode();
11609
+ if (next !== this.#mode) this.#applyMode(next);
11610
+ }
11611
+ /**
11612
+ * Sanitizes overlay markup before Turbo snapshots the page.
11613
+ *
11614
+ * Inline state is durable and remains untouched. Overlay state is transient:
11615
+ * pending transitions are cancelled, controller-owned open attributes are
11616
+ * closed immediately, and modal side effects are released without moving
11617
+ * focus during navigation.
11618
+ */
11619
+ beforeCache() {
11620
+ if (!this.#connected || !this.#isOverlay) return;
11621
+ this.#transition.cancel();
11622
+ this.#trap.deactivate({ restoreFocus: false });
11623
+ this.#setOverlayClosedImmediate();
11135
11624
  }
11136
11625
  /** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
11137
11626
  toggle() {
@@ -11157,17 +11646,48 @@ var SidebarController = class extends Controller {
11157
11646
  this.#mode = mode;
11158
11647
  if (this.hasPanelTarget) this.panelTarget.setAttribute("data-mode", mode);
11159
11648
  if (mode === "inline") {
11160
- this.#cancelPendingHide();
11649
+ this.#transition.cancel();
11161
11650
  this.#trap.deactivate({ restoreFocus: false });
11162
11651
  if (this.hasPanelTarget) this.panelTarget.hidden = false;
11163
11652
  this.#hideBackdrop();
11164
11653
  this.#applyInlineState(this.#collapsed);
11165
11654
  } else {
11166
- this.#cancelPendingHide();
11655
+ this.#transition.cancel();
11167
11656
  this.#trap.deactivate({ restoreFocus: false });
11168
11657
  this.#setOverlayClosedImmediate();
11169
11658
  }
11170
11659
  }
11660
+ /** Reconciles a replacement panel with the current responsive mode and DOM state. */
11661
+ #adoptPanel(panel) {
11662
+ this.#transition.cancel();
11663
+ const trapWasActive = this.#trap.active;
11664
+ this.#activePanel = panel;
11665
+ panel.setAttribute("data-mode", this.#mode);
11666
+ if (this.#mode === "inline") {
11667
+ panel.hidden = false;
11668
+ panel.setAttribute("data-state", this.#collapsed ? "collapsed" : "expanded");
11669
+ this.#hideBackdrop();
11670
+ this.#setExpandedAttr(!this.#collapsed);
11671
+ this.#trap.deactivate({ restoreFocus: false });
11672
+ return;
11673
+ }
11674
+ if (panel.getAttribute("data-state") === "open") {
11675
+ panel.hidden = false;
11676
+ if (this.hasBackdropTarget) {
11677
+ this.backdropTarget.setAttribute("data-state", "open");
11678
+ this.backdropTarget.hidden = false;
11679
+ }
11680
+ this.#setExpandedAttr(true);
11681
+ if (trapWasActive) this.#trap.deactivate({ restoreFocus: false });
11682
+ this.#trap.activate();
11683
+ return;
11684
+ }
11685
+ panel.setAttribute("data-state", "closed");
11686
+ panel.hidden = true;
11687
+ this.#hideBackdrop();
11688
+ this.#setExpandedAttr(false);
11689
+ this.#trap.deactivate();
11690
+ }
11171
11691
  #onMediaChange = (event) => {
11172
11692
  const next = event.matches ? "inline" : "overlay";
11173
11693
  if (next !== this.#mode) this.#applyMode(next);
@@ -11175,13 +11695,14 @@ var SidebarController = class extends Controller {
11175
11695
  #computeMode() {
11176
11696
  return this.#mql?.matches ?? true ? "inline" : "overlay";
11177
11697
  }
11178
- #matchBreakpoint() {
11698
+ #matchBreakpoint(query = this.#breakpointQuery) {
11179
11699
  if (typeof window.matchMedia !== "function") return null;
11180
- return window.matchMedia(`(min-width: ${this.breakpointValue}px)`);
11700
+ return window.matchMedia(query);
11181
11701
  }
11182
11702
  // --- Inline (rail) ---------------------------------------------------------
11183
11703
  /** Sets, reflects, and persists the inline collapsed preference. */
11184
11704
  #setCollapsed(collapsed) {
11705
+ if (collapsed === this.#collapsed) return;
11185
11706
  this.#collapsed = collapsed;
11186
11707
  this.#applyInlineState(collapsed);
11187
11708
  this.#persistCollapsed(collapsed);
@@ -11197,7 +11718,8 @@ var SidebarController = class extends Controller {
11197
11718
  /** Opens the overlay: reveal it, commit a starting frame, then trap focus. */
11198
11719
  #openOverlay() {
11199
11720
  if (!this.hasPanelTarget || this.#isOverlayOpen) return;
11200
- this.#cancelPendingHide();
11721
+ this.#transition.cancel();
11722
+ this.#activePanel = this.panelTarget;
11201
11723
  this.panelTarget.hidden = false;
11202
11724
  if (this.hasBackdropTarget) this.backdropTarget.hidden = false;
11203
11725
  void this.panelTarget.offsetWidth;
@@ -11226,28 +11748,18 @@ var SidebarController = class extends Controller {
11226
11748
  }
11227
11749
  /**
11228
11750
  * Applies `hidden` once the close transition ends so the exit slide can play,
11229
- * then reverts the modal side effects. With no transition (0ms / reduced
11230
- * motion / unstyled) it runs synchronously rather than awaiting an event that
11231
- * would never fire.
11751
+ * then reverts the modal side effects. The shared waiter completes
11752
+ * synchronously for 0ms transitions and supplies a bounded fallback when the
11753
+ * browser emits no terminal event.
11232
11754
  */
11233
11755
  #hideAfterTransition() {
11234
11756
  const panel = this.panelTarget;
11235
- const duration = maxTransitionMs2(getComputedStyle(panel).transitionDuration);
11236
- if (duration === 0) {
11237
- this.#applyOverlayHidden();
11238
- return;
11239
- }
11240
- const onEnd = (event) => {
11241
- if (event.target !== panel) return;
11242
- this.#cancelPendingHide();
11243
- this.#applyOverlayHidden();
11244
- };
11245
- this.#pendingHide = () => panel.removeEventListener("transitionend", onEnd);
11246
- panel.addEventListener("transitionend", onEnd);
11757
+ this.#activePanel = panel;
11758
+ this.#transition.wait(panel, () => this.#applyOverlayHidden(panel));
11247
11759
  }
11248
11760
  /** Hides the panel/backdrop and tears down the trap after the exit transition. */
11249
- #applyOverlayHidden() {
11250
- if (this.hasPanelTarget) this.panelTarget.hidden = true;
11761
+ #applyOverlayHidden(panel) {
11762
+ panel.hidden = true;
11251
11763
  this.#hideBackdrop();
11252
11764
  this.#trap.deactivate();
11253
11765
  }
@@ -11256,10 +11768,6 @@ var SidebarController = class extends Controller {
11256
11768
  this.backdropTarget.setAttribute("data-state", "closed");
11257
11769
  this.backdropTarget.hidden = true;
11258
11770
  }
11259
- #cancelPendingHide() {
11260
- this.#pendingHide?.();
11261
- this.#pendingHide = null;
11262
- }
11263
11771
  // --- Shared helpers --------------------------------------------------------
11264
11772
  #setExpandedAttr(expanded) {
11265
11773
  if (this.hasTriggerTarget) {
@@ -11277,11 +11785,8 @@ var SidebarController = class extends Controller {
11277
11785
  #restoreCollapsed() {
11278
11786
  const key = this.#storageKey;
11279
11787
  if (key) {
11280
- try {
11281
- const stored = localStorage.getItem(key);
11282
- if (stored !== null) return stored === "1";
11283
- } catch {
11284
- }
11788
+ const stored = readLocalStorage(key);
11789
+ if (stored !== null) return stored === "1";
11285
11790
  }
11286
11791
  const domState = this.hasPanelTarget ? this.panelTarget.getAttribute("data-state") : null;
11287
11792
  if (domState === "collapsed") return true;
@@ -11292,14 +11797,17 @@ var SidebarController = class extends Controller {
11292
11797
  #persistCollapsed(collapsed) {
11293
11798
  const key = this.#storageKey;
11294
11799
  if (!key) return;
11295
- try {
11296
- localStorage.setItem(key, collapsed ? "1" : "0");
11297
- } catch {
11298
- }
11800
+ writeLocalStorage(key, collapsed ? "1" : "0");
11299
11801
  }
11300
11802
  get #storageKey() {
11301
11803
  return this.keyValue ? `stimeo--sidebar:${this.keyValue}` : "";
11302
11804
  }
11805
+ /** Valid breakpoint CSS query, defaulting malformed or negative values to 768px. */
11806
+ get #breakpointQuery() {
11807
+ const value = this.breakpointValue;
11808
+ const breakpoint = Number.isFinite(value) && value >= 0 ? value : 768;
11809
+ return `(min-width: ${breakpoint}px)`;
11810
+ }
11303
11811
  get #isOverlay() {
11304
11812
  return this.#mode === "overlay";
11305
11813
  }
@@ -11859,23 +12367,32 @@ var StepperController = class extends Controller {
11859
12367
  };
11860
12368
  static actions = ["goto", "next", "prev"];
11861
12369
  static events = ["change"];
12370
+ #isConnected = false;
11862
12371
  /** Normalizes an out-of-range initial `index` and renders the initial state. */
11863
12372
  connect() {
11864
- this.indexValue = this.#clampIndex(this.indexValue);
11865
- this.#render();
12373
+ this.#isConnected = true;
12374
+ this.#normalizeAndRender();
12375
+ }
12376
+ disconnect() {
12377
+ this.#isConnected = false;
12378
+ }
12379
+ /** Re-renders when Turbo Morph or application code changes `index` at runtime. */
12380
+ indexValueChanged() {
12381
+ if (!this.#isConnected) return;
12382
+ this.#normalizeAndRender();
11866
12383
  }
11867
12384
  /** Advances to the next step (ignored at the last step). */
11868
12385
  next() {
11869
- this.#moveTo(this.indexValue + 1);
12386
+ this.#moveTo(this.#clampIndex(this.indexValue) + 1);
11870
12387
  }
11871
12388
  /** Returns to the previous step (ignored at the first step). */
11872
12389
  prev() {
11873
- this.#moveTo(this.indexValue - 1);
12390
+ this.#moveTo(this.#clampIndex(this.indexValue) - 1);
11874
12391
  }
11875
12392
  /** Jumps to the step carried in the action's `index` param. */
11876
12393
  goto(event) {
11877
12394
  const target = Number(event.params.index);
11878
- if (!Number.isFinite(target)) return;
12395
+ if (!Number.isFinite(target) || !Number.isInteger(target)) return;
11879
12396
  this.#moveTo(target);
11880
12397
  }
11881
12398
  /**
@@ -11885,16 +12402,24 @@ var StepperController = class extends Controller {
11885
12402
  */
11886
12403
  #moveTo(target) {
11887
12404
  const total = this.stepTargets.length;
12405
+ if (!Number.isFinite(target) || !Number.isInteger(target)) return;
11888
12406
  if (target < 0 || target >= total) return;
11889
- if (target === this.indexValue) return;
11890
- if (this.linearValue && target > this.indexValue + 1) return;
11891
- const previous = this.indexValue;
12407
+ const current = this.#clampIndex(this.indexValue);
12408
+ if (target === current) return;
12409
+ if (this.linearValue && target > current + 1) return;
12410
+ const previous = current;
11892
12411
  this.indexValue = target;
11893
- this.#render();
12412
+ this.#render(target);
11894
12413
  this.dispatch("change", {
11895
12414
  detail: { index: target, previous, step: this.stepTargets[target] }
11896
12415
  });
11897
12416
  }
12417
+ /** Normalizes the public Value and reflects it without dispatching an action event. */
12418
+ #normalizeAndRender() {
12419
+ const normalized = this.#clampIndex(this.indexValue);
12420
+ if (!Object.is(normalized, this.indexValue)) this.indexValue = normalized;
12421
+ this.#render(normalized);
12422
+ }
11898
12423
  /**
11899
12424
  * Derives each step's `data-state` and the current button's `aria-current`.
11900
12425
  *
@@ -11902,8 +12427,7 @@ var StepperController = class extends Controller {
11902
12427
  * contract assumes one operable button per step. If a step needs multiple
11903
12428
  * buttons, mark the navigational one first (or this would target the wrong one).
11904
12429
  */
11905
- #render() {
11906
- const current = this.indexValue;
12430
+ #render(current) {
11907
12431
  this.stepTargets.forEach((step, index) => {
11908
12432
  step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
11909
12433
  const button = step.querySelector("button");
@@ -11918,7 +12442,7 @@ var StepperController = class extends Controller {
11918
12442
  /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
11919
12443
  #clampIndex(index) {
11920
12444
  const last = this.stepTargets.length - 1;
11921
- if (last < 0) return 0;
12445
+ if (last < 0 || !Number.isFinite(index)) return 0;
11922
12446
  return Math.min(last, Math.max(0, Math.trunc(index)));
11923
12447
  }
11924
12448
  };
@@ -12008,12 +12532,9 @@ var StickToBottomController = class extends Controller {
12008
12532
  return this.hasContentTarget ? this.contentTarget : this.element;
12009
12533
  }
12010
12534
  #behavior() {
12011
- if (this.#prefersReducedMotion()) return "auto";
12535
+ if (prefersReducedMotion()) return "auto";
12012
12536
  return this.behaviorValue === "smooth" ? "smooth" : "auto";
12013
12537
  }
12014
- #prefersReducedMotion() {
12015
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
12016
- }
12017
12538
  };
12018
12539
  var StickyObserverController = class extends Controller {
12019
12540
  static targets = ["sentinel", "element"];
@@ -12026,23 +12547,63 @@ var StickyObserverController = class extends Controller {
12026
12547
  #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
12027
12548
  /** Last reported stuck state, so `change` fires only on transitions. */
12028
12549
  #stuck = null;
12550
+ /** Target currently owned by the watcher, used to avoid duplicate restarts. */
12551
+ #observedSentinel = null;
12552
+ #connected = false;
12029
12553
  #onIntersect(entries) {
12030
- const entry = entries[entries.length - 1];
12031
- if (!entry) return;
12032
- this.#setStuck(!entry.isIntersecting);
12554
+ for (const entry of entries) {
12555
+ if (!this.#connected || !this.#watcher.active) return;
12556
+ this.#setStuck(!entry.isIntersecting && isBeforeRootStart(entry));
12557
+ }
12033
12558
  }
12034
12559
  connect() {
12035
- if (!this.hasSentinelTarget) return;
12036
- this.#watcher.start(this.sentinelTarget, {
12037
- rootSelector: this.rootSelectorValue,
12038
- rootMargin: `-${this.offsetValue}px 0px 0px 0px`,
12039
- threshold: [0]
12040
- });
12560
+ this.#connected = true;
12561
+ this.#stuck = null;
12562
+ this.#syncObserver();
12041
12563
  }
12042
12564
  disconnect() {
12565
+ this.#connected = false;
12043
12566
  this.#watcher.stop();
12567
+ this.#observedSentinel = null;
12044
12568
  this.#stuck = null;
12045
12569
  }
12570
+ /** Starts observation when a sentinel is inserted after connection. */
12571
+ sentinelTargetConnected() {
12572
+ if (this.#connected) this.#syncObserver();
12573
+ }
12574
+ /** Stops or transfers observation when the current sentinel is removed. */
12575
+ sentinelTargetDisconnected() {
12576
+ if (this.#connected) this.#syncObserver();
12577
+ }
12578
+ /** Reflects the last snapshot onto an element inserted after that snapshot. */
12579
+ elementTargetConnected(element) {
12580
+ if (this.#stuck !== null) {
12581
+ element.setAttribute("data-stuck", this.#stuck ? "true" : "false");
12582
+ }
12583
+ }
12584
+ /** Rebuilds the observer when Turbo morphs the configured root. */
12585
+ rootSelectorValueChanged() {
12586
+ if (this.#connected) this.#syncObserver(true);
12587
+ }
12588
+ /** Rebuilds the observer when Turbo morphs the configured top offset. */
12589
+ offsetValueChanged() {
12590
+ if (this.#connected) this.#syncObserver(true);
12591
+ }
12592
+ #syncObserver(force = false) {
12593
+ const sentinel = this.hasSentinelTarget ? this.sentinelTarget : null;
12594
+ if (!force && sentinel === this.#observedSentinel && this.#watcher.active) return;
12595
+ this.#watcher.stop();
12596
+ this.#observedSentinel = null;
12597
+ if (!sentinel) return;
12598
+ const configuredOffset = this.offsetValue;
12599
+ const offset = Number.isFinite(configuredOffset) ? configuredOffset : 0;
12600
+ const started = this.#watcher.start(sentinel, {
12601
+ rootSelector: this.rootSelectorValue,
12602
+ rootMargin: `${-offset}px 0px 0px 0px`,
12603
+ threshold: [0]
12604
+ });
12605
+ if (started) this.#observedSentinel = sentinel;
12606
+ }
12046
12607
  /** Reflects the stuck state onto the sticky element and emits `change`. */
12047
12608
  #setStuck(next) {
12048
12609
  if (next === this.#stuck) return;
@@ -12700,19 +13261,12 @@ var ThemeController = class extends Controller {
12700
13261
  }
12701
13262
  /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
12702
13263
  #readStored() {
12703
- try {
12704
- const value = window.localStorage.getItem(this.storageKeyValue);
12705
- return isMode(value) ? value : null;
12706
- } catch {
12707
- return null;
12708
- }
13264
+ const value = readLocalStorage(this.storageKeyValue);
13265
+ return isMode(value) ? value : null;
12709
13266
  }
12710
13267
  /** Persists the mode, swallowing storage errors (private mode / quota). */
12711
13268
  #writeStored(mode) {
12712
- try {
12713
- window.localStorage.setItem(this.storageKeyValue, mode);
12714
- } catch {
12715
- }
13269
+ writeLocalStorage(this.storageKeyValue, mode);
12716
13270
  }
12717
13271
  };
12718
13272
  var AM = 0;
@@ -13168,8 +13722,7 @@ var ToastController = class extends Controller {
13168
13722
  this.listTarget.removeChild(element);
13169
13723
  this.dispatch("dismiss", { detail: { item: element, reason } });
13170
13724
  };
13171
- const transitions = window.getComputedStyle(element).transitionDuration;
13172
- const duration = cssTimeToMs(transitions);
13725
+ const duration = maxTransitionTotalMs(window.getComputedStyle(element));
13173
13726
  if (duration > 0) {
13174
13727
  this.#timers.set(finalize, duration);
13175
13728
  } else {
@@ -13241,12 +13794,6 @@ var ToastController = class extends Controller {
13241
13794
  this.#rafHandles.delete(element);
13242
13795
  }
13243
13796
  };
13244
- function cssTimeToMs(value) {
13245
- const first = value.split(",")[0]?.trim() ?? "";
13246
- const amount = Number.parseFloat(first);
13247
- if (Number.isNaN(amount)) return 0;
13248
- return first.endsWith("ms") ? amount : amount * 1e3;
13249
- }
13250
13797
  var ToggleGroupController = class extends Controller {
13251
13798
  static targets = ["item"];
13252
13799
  static values = {
@@ -13535,12 +14082,6 @@ var TooltipController = class extends Controller {
13535
14082
  }
13536
14083
  };
13537
14084
  var tokensOf = (value) => value.split(/\s+/).filter(Boolean);
13538
- var firstTimeMs = (value) => {
13539
- const first = value.split(",")[0]?.trim() ?? "";
13540
- const amount = Number.parseFloat(first);
13541
- if (Number.isNaN(amount)) return 0;
13542
- return first.endsWith("ms") ? amount : amount * 1e3;
13543
- };
13544
14085
  var TransitionController = class extends Controller {
13545
14086
  static values = {
13546
14087
  enter: { type: String, default: "" },
@@ -13553,9 +14094,9 @@ var TransitionController = class extends Controller {
13553
14094
  };
13554
14095
  static actions = ["enter", "leave", "toggle"];
13555
14096
  static events = ["entered", "left"];
13556
- #timers = new SafeTimeout();
14097
+ /** Owns the cancellable completion wait (terminal events + bounded fallback). */
14098
+ #transition = new TransitionCompletion();
13557
14099
  #rafId = null;
13558
- #endListener = null;
13559
14100
  connect() {
13560
14101
  this.#strip();
13561
14102
  this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
@@ -13582,7 +14123,7 @@ var TransitionController = class extends Controller {
13582
14123
  const isEnter = kind === "enter";
13583
14124
  if (isEnter) this.element.hidden = false;
13584
14125
  this.element.setAttribute("data-transition-state", isEnter ? "entering" : "leaving");
13585
- if (this.#prefersReducedMotion()) {
14126
+ if (prefersReducedMotion()) {
13586
14127
  this.#finish(kind);
13587
14128
  return;
13588
14129
  }
@@ -13594,12 +14135,13 @@ var TransitionController = class extends Controller {
13594
14135
  this.#rafId = null;
13595
14136
  this.#remove(from);
13596
14137
  this.#add(to);
13597
- this.#awaitEnd(() => this.#finish(kind));
14138
+ this.#transition.wait(this.element, () => this.#finish(kind), {
14139
+ timeoutMs: this.timeoutValue
14140
+ });
13598
14141
  });
13599
14142
  }
13600
14143
  /** Settles the element into the completed state, clearing the stage classes. */
13601
14144
  #finish(kind) {
13602
- this.#cleanupEnd();
13603
14145
  this.#strip();
13604
14146
  if (kind === "enter") {
13605
14147
  this.element.setAttribute("data-transition-state", "entered");
@@ -13610,29 +14152,13 @@ var TransitionController = class extends Controller {
13610
14152
  this.dispatch("left", { detail: {} });
13611
14153
  }
13612
14154
  }
13613
- /** Resolves on the element's own `transitionend`, with a safety timeout fallback. */
13614
- #awaitEnd(done) {
13615
- this.#endListener = (event) => {
13616
- if (event.target === this.element) done();
13617
- };
13618
- this.element.addEventListener("transitionend", this.#endListener);
13619
- const ms = this.timeoutValue > 0 ? this.timeoutValue : this.#duration();
13620
- this.#timers.set(done, ms);
13621
- }
13622
- #cleanupEnd() {
13623
- if (this.#endListener) {
13624
- this.element.removeEventListener("transitionend", this.#endListener);
13625
- this.#endListener = null;
13626
- }
13627
- this.#timers.clearAll();
13628
- }
13629
14155
  /** Cancels any in-flight transition (interruption / teardown). */
13630
14156
  #cancel() {
13631
14157
  if (this.#rafId !== null) {
13632
14158
  this.#cancelRaf(this.#rafId);
13633
14159
  this.#rafId = null;
13634
14160
  }
13635
- this.#cleanupEnd();
14161
+ this.#transition.cancel();
13636
14162
  this.#strip();
13637
14163
  }
13638
14164
  #add(...lists) {
@@ -13654,13 +14180,6 @@ var TransitionController = class extends Controller {
13654
14180
  this.leaveToValue
13655
14181
  );
13656
14182
  }
13657
- /** Auto-computed safety duration (transition time + delay, with a small buffer). */
13658
- #duration() {
13659
- if (typeof window.getComputedStyle !== "function") return 0;
13660
- const style = window.getComputedStyle(this.element);
13661
- const total = firstTimeMs(style.transitionDuration) + firstTimeMs(style.transitionDelay);
13662
- return total > 0 ? total + 50 : 0;
13663
- }
13664
14183
  #raf(callback) {
13665
14184
  if (typeof window.requestAnimationFrame === "function") {
13666
14185
  return window.requestAnimationFrame(() => callback());
@@ -13671,9 +14190,6 @@ var TransitionController = class extends Controller {
13671
14190
  if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(id);
13672
14191
  else window.clearTimeout(id);
13673
14192
  }
13674
- #prefersReducedMotion() {
13675
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
13676
- }
13677
14193
  };
13678
14194
  var TYPEAHEAD_TIMEOUT2 = 500;
13679
14195
  var TreeViewController = class extends Controller {