stimeo-ui 0.10.0 → 0.11.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.
data/dist/index.js CHANGED
@@ -16289,6 +16289,7 @@ var PersistController = class extends Controller {
16289
16289
  this.dispatch("error", { detail: { key, operation, reason } });
16290
16290
  }
16291
16291
  };
16292
+ var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
16292
16293
  var PointerDragController = class _PointerDragController extends Controller {
16293
16294
  static targets = ["handle"];
16294
16295
  static values = {
@@ -16298,6 +16299,7 @@ var PointerDragController = class _PointerDragController extends Controller {
16298
16299
  disabled: { type: Boolean, default: false },
16299
16300
  follow: { type: Boolean, default: false }
16300
16301
  };
16302
+ static actions = ["reset"];
16301
16303
  static events = ["start", "move", "end", "cancel"];
16302
16304
  /** Marker attribute recording that this controller set the touch-action. */
16303
16305
  static #TOUCH_ACTION_MARKER = "data-pointer-drag-touch-action";
@@ -16331,23 +16333,49 @@ var PointerDragController = class _PointerDragController extends Controller {
16331
16333
  }
16332
16334
  this.#teardown();
16333
16335
  }
16334
- /** Prepares handles added at runtime (touch-action + focusability). */
16336
+ /**
16337
+ * Returns the element to its origin: drops the committed follow offset and the
16338
+ * inline `translate` that carries it.
16339
+ *
16340
+ * In follow mode the inline `translate` belongs to this controller, and the
16341
+ * committed offset lives in a field the DOM cannot reach — so a consumer that
16342
+ * wants the element back at the start, or that has written a position of its
16343
+ * own, needs this to make the two agree again. An in-flight drag is cancelled
16344
+ * first, or its deltas would land on top of the offset just cleared.
16345
+ */
16346
+ reset() {
16347
+ const live = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
16348
+ if (this.#pointer || this.#keyboard) this.#teardownSessions();
16349
+ if (live) this.#dispatchCancel(live);
16350
+ this.#followBase = { x: 0, y: 0 };
16351
+ this.#applyFollow(0, 0);
16352
+ }
16335
16353
  handleTargetConnected(handle) {
16354
+ if (handle !== this.element) this.#restoreHandle(this.element);
16336
16355
  this.#prepareHandle(handle);
16337
16356
  }
16338
16357
  handleTargetDisconnected(handle) {
16339
- if (!this.element.contains(handle)) {
16340
- if (this.#pointer?.handle === handle) this.#endPointerSession();
16341
- if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
16342
- }
16343
16358
  this.#restoreHandle(handle);
16359
+ if (this.element.contains(handle)) return;
16360
+ const interrupted = this.#pointer?.handle === handle && this.#pointer.started ? this.#pointer.pointerType : this.#keyboard?.handle === handle ? "keyboard" : null;
16361
+ if (this.#pointer?.handle === handle) this.#endPointerSession();
16362
+ if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
16363
+ if (!this.hasHandleTarget) this.#prepareHandle(this.element);
16364
+ if (interrupted) this.#dispatchCancel(interrupted);
16344
16365
  }
16345
- /** Re-derives the handles' touch-action when the axis changes. */
16346
- axisValueChanged() {
16366
+ /**
16367
+ * Re-derives the handles' touch-action when the axis changes.
16368
+ *
16369
+ * Two layers, like the `tabindex` loan: the marker says the value was once
16370
+ * ours, and the current inline value says it still is. A consumer that wrote
16371
+ * its own `touch-action` after connect keeps it.
16372
+ */
16373
+ axisValueChanged(_value, previousValue) {
16374
+ const lent = this.#touchActionFor(previousValue ?? this.axisValue);
16347
16375
  for (const handle of this.#handles()) {
16348
- if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16349
- handle.style.touchAction = this.#touchActionForAxis();
16350
- }
16376
+ if (!handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) continue;
16377
+ if (handle.style.touchAction !== lent) continue;
16378
+ handle.style.touchAction = this.#touchActionForAxis();
16351
16379
  }
16352
16380
  }
16353
16381
  /** Cancels any in-flight session when the controller is disabled mid-drag. */
@@ -16428,9 +16456,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16428
16456
  const handle = this.#handleFor(event.target);
16429
16457
  if (!handle) return;
16430
16458
  if (event.defaultPrevented) return;
16459
+ if (event.isComposing) return;
16460
+ if (this.#ownsNativeKeys(event.target, handle)) return;
16431
16461
  if (isReservedArrowChord(event)) return;
16432
16462
  if (event.key === "Escape") {
16433
- if (event.isComposing) return;
16434
16463
  if (this.#pointer?.started) {
16435
16464
  const { pointerType } = this.#pointer;
16436
16465
  this.#teardownSessions();
@@ -16539,6 +16568,7 @@ var PointerDragController = class _PointerDragController extends Controller {
16539
16568
  const interrupted = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
16540
16569
  this.#teardownSessions();
16541
16570
  for (const handle of this.#handles()) this.#restoreHandle(handle);
16571
+ this.#followReset();
16542
16572
  if (interrupted && this.element.isConnected) this.#dispatchCancel(interrupted);
16543
16573
  }
16544
16574
  /**
@@ -16608,8 +16638,12 @@ var PointerDragController = class _PointerDragController extends Controller {
16608
16638
  }
16609
16639
  /** `touch-action` that lets the page keep panning on the locked axis only. */
16610
16640
  #touchActionForAxis() {
16611
- if (this.axisValue === "x") return "pan-y";
16612
- if (this.axisValue === "y") return "pan-x";
16641
+ return this.#touchActionFor(this.axisValue);
16642
+ }
16643
+ /** The `touch-action` an `axis` declaration lends, for any axis value. */
16644
+ #touchActionFor(axis) {
16645
+ if (axis === "x") return "pan-y";
16646
+ if (axis === "y") return "pan-x";
16613
16647
  return "none";
16614
16648
  }
16615
16649
  /**
@@ -16620,8 +16654,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16620
16654
  * are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
16621
16655
  */
16622
16656
  #prepareHandle(handle) {
16623
- if (handle.style.touchAction === "" || handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16624
- handle.style.touchAction = this.#touchActionForAxis();
16657
+ const lent = this.#touchActionForAxis();
16658
+ const marked = handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
16659
+ if (handle.style.touchAction === "" || marked && handle.style.touchAction === lent) {
16660
+ handle.style.touchAction = lent;
16625
16661
  handle.setAttribute(_PointerDragController.#TOUCH_ACTION_MARKER, "true");
16626
16662
  }
16627
16663
  if (handle.tabIndex < 0 && !handle.hasAttribute("tabindex")) {
@@ -16629,17 +16665,27 @@ var PointerDragController = class _PointerDragController extends Controller {
16629
16665
  handle.setAttribute(_PointerDragController.#TABINDEX_MARKER, "true");
16630
16666
  }
16631
16667
  }
16668
+ /** Whether the key belongs to a native control or editing surface in the handle. */
16669
+ #ownsNativeKeys(target, handle) {
16670
+ const owner = target?.closest(NATIVE_KEY_OWNERS) ?? null;
16671
+ return owner !== null && owner !== handle && handle.contains(owner);
16672
+ }
16632
16673
  /** Reverts the marker-owned touch-action + tabindex (authored values untouched). */
16633
16674
  #restoreHandle(handle) {
16634
16675
  if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16635
- handle.style.touchAction = "";
16676
+ if (handle.style.touchAction === this.#touchActionForAxis()) {
16677
+ handle.style.touchAction = "";
16678
+ }
16636
16679
  handle.removeAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
16637
16680
  }
16638
16681
  if (handle.hasAttribute(_PointerDragController.#TABINDEX_MARKER)) {
16639
- handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16640
- if (handle.getAttribute("tabindex") === "0" && document.activeElement !== handle) {
16682
+ const ours = handle.getAttribute("tabindex") === "0";
16683
+ if (ours && document.activeElement !== handle) {
16641
16684
  handle.removeAttribute("tabindex");
16685
+ handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16686
+ return;
16642
16687
  }
16688
+ if (!ours) handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16643
16689
  }
16644
16690
  }
16645
16691
  };
@@ -16752,15 +16798,42 @@ var PortalController = class extends Controller {
16752
16798
  static events = ["mount", "unmount"];
16753
16799
  /** Decides whether a `disconnect()` is an in-page move or a real detach. */
16754
16800
  #gate = new DetachGate();
16801
+ /** The `to` declaration after validation; the default when it cannot be parsed. */
16802
+ #toSelector = "body";
16803
+ /**
16804
+ * Validates the destination declaration once, keeping only a selector the engine can
16805
+ * read.
16806
+ *
16807
+ * A selector reads back as an ordinary string, so a malformed one survives until it is
16808
+ * handed to the DOM — where it would take the whole teleport down and leave the part
16809
+ * silently doing nothing. Falling back to the default puts the node somewhere visible
16810
+ * instead, which reads as a mistake and can be traced back to the declaration.
16811
+ */
16812
+ toValueChanged() {
16813
+ const selector = this.toValue;
16814
+ if (selector.length > 0) {
16815
+ try {
16816
+ this.element.matches(selector);
16817
+ this.#toSelector = selector;
16818
+ return;
16819
+ } catch {
16820
+ }
16821
+ }
16822
+ this.#toSelector = "body";
16823
+ }
16755
16824
  connect() {
16756
16825
  this.#gate.cancel();
16757
- if (portalState.has(this.element)) return;
16826
+ const existing = portalState.get(this.element);
16827
+ if (existing) {
16828
+ existing.owner = this;
16829
+ return;
16830
+ }
16758
16831
  const node = this.hasContentTarget ? this.contentTarget : this.element;
16759
16832
  const destination = this.#destination();
16760
16833
  if (!destination || destination === node || node.contains(destination)) return;
16761
16834
  const placeholder = document.createComment("stimeo--portal");
16762
16835
  node.parentNode?.insertBefore(placeholder, node);
16763
- portalState.set(this.element, { node, placeholder });
16836
+ portalState.set(this.element, { node, placeholder, owner: this });
16764
16837
  if (this.positionValue === "prepend") {
16765
16838
  destination.prepend(node);
16766
16839
  } else {
@@ -16779,7 +16852,7 @@ var PortalController = class extends Controller {
16779
16852
  }
16780
16853
  this.#gate.disconnected(this, () => {
16781
16854
  const current = portalState.get(this.element);
16782
- if (current) this.#restore(current);
16855
+ if (current?.owner === this) this.#restore(current);
16783
16856
  });
16784
16857
  }
16785
16858
  /** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
@@ -16795,30 +16868,84 @@ var PortalController = class extends Controller {
16795
16868
  placeholder.remove();
16796
16869
  this.dispatch("unmount", { detail: {} });
16797
16870
  }
16798
- /** Resolves the destination for `to`, tolerating an invalid selector. */
16871
+ /** Resolves the destination from the validated `to` selector. */
16799
16872
  #destination() {
16800
- const selector = this.toValue.trim();
16801
- if (!selector) return null;
16802
- try {
16803
- return document.querySelector(selector);
16804
- } catch {
16805
- return null;
16873
+ return document.querySelector(this.#toSelector);
16874
+ }
16875
+ };
16876
+
16877
+ // src/utils/style_property_lease.ts
16878
+ var StylePropertyLease = class {
16879
+ #property;
16880
+ #records = /* @__PURE__ */ new Map();
16881
+ /** @param property - The CSS property whose temporary values this lease owns. */
16882
+ constructor(property) {
16883
+ this.#property = property;
16884
+ }
16885
+ /** Writes or removes the leased declaration while preserving its authored value. */
16886
+ write(element, value, priority = "") {
16887
+ const existing = this.#records.get(element);
16888
+ if (existing) {
16889
+ existing.writtenValue = value;
16890
+ existing.writtenPriority = value === null ? "" : priority;
16891
+ } else {
16892
+ this.#records.set(element, {
16893
+ originalValue: element.style.getPropertyValue(this.#property),
16894
+ originalPriority: element.style.getPropertyPriority(this.#property),
16895
+ writtenValue: value,
16896
+ writtenPriority: value === null ? "" : priority
16897
+ });
16806
16898
  }
16899
+ this.#reflect(element, value, priority);
16900
+ }
16901
+ /** Returns one lease without overwriting a later consumer declaration. */
16902
+ return(element) {
16903
+ const record = this.#records.get(element);
16904
+ if (!record) return;
16905
+ this.#records.delete(element);
16906
+ const style = element.style;
16907
+ const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
16908
+ if (stillOwned) {
16909
+ this.#reflect(element, record.originalValue, record.originalPriority);
16910
+ }
16911
+ }
16912
+ /** Returns every outstanding declaration lease. */
16913
+ returnAll() {
16914
+ for (const element of Array.from(this.#records.keys())) this.return(element);
16915
+ }
16916
+ /** Reflects only a real declaration transition. */
16917
+ #reflect(element, value, priority) {
16918
+ const style = element.style;
16919
+ const nextValue = value ?? "";
16920
+ const nextPriority = value === null ? "" : priority;
16921
+ if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
16922
+ return;
16923
+ }
16924
+ if (value === null) style.removeProperty(this.#property);
16925
+ else style.setProperty(this.#property, value, priority);
16807
16926
  }
16808
16927
  };
16928
+
16929
+ // src/controllers/preview_guard_controller.ts
16809
16930
  var PreviewGuardController = class extends Controller {
16810
16931
  static values = {
16811
- placeholder: { type: String, default: "" },
16812
- mode: { type: String, default: "hide" }
16932
+ placeholder: { type: String, default: "" }
16813
16933
  };
16814
16934
  static events = ["hide", "show"];
16935
+ #visibility = new StylePropertyLease("visibility");
16936
+ #beforeCache = new BeforeCacheReset(() => this.#restore());
16815
16937
  #observer = null;
16938
+ #connected = false;
16816
16939
  #hidden = false;
16817
- /** Saved inline visibility (hide mode), restored on show. */
16818
- #savedVisibility = "";
16819
- /** Saved text (placeholder mode); non-null marks that text — not visibility — was swapped. */
16820
- #savedText = null;
16940
+ /** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */
16941
+ #savedNodes = null;
16942
+ /** The descendant that held focus when the guard went up, so show can hand it back. */
16943
+ #focused = null;
16821
16944
  connect() {
16945
+ this.#connected = true;
16946
+ if (this.#hidden) this.#reguard();
16947
+ else this.element.removeAttribute("data-preview-hidden");
16948
+ this.#beforeCache.activate();
16822
16949
  if (typeof MutationObserver !== "undefined") {
16823
16950
  this.#observer = new MutationObserver(() => this.#sync());
16824
16951
  this.#observer.observe(document.documentElement, {
@@ -16829,44 +16956,115 @@ var PreviewGuardController = class extends Controller {
16829
16956
  this.#sync();
16830
16957
  }
16831
16958
  disconnect() {
16959
+ this.#connected = false;
16960
+ this.#beforeCache.deactivate();
16832
16961
  this.#observer?.disconnect();
16833
16962
  this.#observer = null;
16834
- this.#restore();
16835
16963
  }
16836
- /** Reflects the current `data-turbo-preview` state onto the element. */
16964
+ /**
16965
+ * Re-guards to match a `placeholder` changed at runtime — the value is the content on
16966
+ * display while the guard is up, so a morph that swaps it must not leave the old one.
16967
+ */
16968
+ placeholderValueChanged() {
16969
+ if (!this.#connected || !this.#hidden) return;
16970
+ this.#reguard();
16971
+ }
16972
+ /**
16973
+ * Reflects the current `data-turbo-preview` state onto the element.
16974
+ *
16975
+ * @stimeoRenderRoot
16976
+ */
16837
16977
  #sync() {
16838
16978
  const previewing = document.documentElement.hasAttribute("data-turbo-preview");
16839
16979
  if (previewing && !this.#hidden) this.#hide();
16840
16980
  else if (!previewing && this.#hidden) this.#show();
16841
16981
  }
16842
16982
  #hide() {
16983
+ this.#focused = this.#focusedInside();
16984
+ this.#applyGuard();
16985
+ this.dispatch("hide", { detail: {} });
16986
+ }
16987
+ /** Puts the guard up in the form the current `placeholder` calls for. */
16988
+ #applyGuard() {
16843
16989
  this.#hidden = true;
16844
- if (this.modeValue === "placeholder") {
16845
- this.#savedText = this.element.textContent;
16846
- this.element.textContent = this.placeholderValue;
16990
+ if (this.placeholderValue === "") {
16991
+ this.#visibility.write(this.element, "hidden");
16847
16992
  } else {
16848
- this.#savedVisibility = this.element.style.visibility;
16849
- this.element.style.visibility = "hidden";
16993
+ this.#savedNodes = document.createDocumentFragment();
16994
+ this.#savedNodes.append(...this.element.childNodes);
16995
+ this.element.textContent = this.placeholderValue;
16850
16996
  }
16851
16997
  this.element.setAttribute("data-preview-hidden", "true");
16852
- this.dispatch("hide", { detail: {} });
16998
+ }
16999
+ /**
17000
+ * Re-forms a guard that is already up so it matches the current `placeholder`.
17001
+ *
17002
+ * No event: `hide` reports that the guard went up, and it has not come down. Swapping
17003
+ * one stand-in text for another writes only that text — putting the held content back
17004
+ * first would reconnect the whole subtree for an instant. Only a change of *form* —
17005
+ * to or from the empty placeholder — has to revert, and the focus the guard is holding
17006
+ * carries across it rather than being handed back and taken again.
17007
+ */
17008
+ #reguard() {
17009
+ if (this.#savedNodes && this.placeholderValue !== "") {
17010
+ this.element.textContent = this.placeholderValue;
17011
+ return;
17012
+ }
17013
+ const held = this.#focused;
17014
+ this.#revert();
17015
+ this.#applyGuard();
17016
+ this.#focused = held;
16853
17017
  }
16854
17018
  #show() {
16855
17019
  this.#restore();
16856
17020
  this.dispatch("show", { detail: {} });
16857
17021
  }
16858
- /** Reverts the guard. Safe to call when not hidden (no-op) — used by show and teardown. */
17022
+ /** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */
16859
17023
  #restore() {
16860
- if (!this.#hidden) return;
17024
+ this.#revert();
17025
+ this.#refocus();
17026
+ }
17027
+ /**
17028
+ * Puts the element back the way the guard found it.
17029
+ *
17030
+ * Every step is a no-op on an element this controller never guarded: the lease returns
17031
+ * only declarations it recorded, and the hook is removed whether or not it is there.
17032
+ */
17033
+ #revert() {
16861
17034
  this.#hidden = false;
16862
- if (this.#savedText !== null) {
16863
- this.element.textContent = this.#savedText;
16864
- this.#savedText = null;
17035
+ if (this.#savedNodes) {
17036
+ this.element.textContent = "";
17037
+ this.element.append(this.#savedNodes);
17038
+ this.#savedNodes = null;
16865
17039
  } else {
16866
- this.element.style.visibility = this.#savedVisibility;
17040
+ this.#visibility.return(this.element);
16867
17041
  }
16868
17042
  this.element.removeAttribute("data-preview-hidden");
16869
17043
  }
17044
+ /**
17045
+ * The focused element the guard is about to make unfocusable, if any.
17046
+ *
17047
+ * The element itself counts as well as its descendants: guarding takes focus either way
17048
+ * — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes
17049
+ * outright — so the browser drops focus to `<body>` the moment the guard goes up.
17050
+ */
17051
+ #focusedInside() {
17052
+ const active = document.activeElement;
17053
+ return active instanceof HTMLElement && this.element.contains(active) ? active : null;
17054
+ }
17055
+ /**
17056
+ * Hands focus back to the element the guard took it from.
17057
+ *
17058
+ * Only when that element is still in the document and focus has not moved on since —
17059
+ * anything else is the user's or another controller's, and putting it back would be
17060
+ * taking it.
17061
+ */
17062
+ #refocus() {
17063
+ const target = this.#focused;
17064
+ this.#focused = null;
17065
+ if (!target?.isConnected || document.activeElement !== document.body) return;
17066
+ target.focus();
17067
+ }
16870
17068
  };
16871
17069
  var OWNED_VALUE_TEXT2 = "data-stimeo--progress-owns-valuetext";
16872
17070
  var ProgressController = class extends Controller {
@@ -18668,6 +18866,7 @@ var ResizableController = class extends Controller {
18668
18866
  return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
18669
18867
  }
18670
18868
  };
18869
+ var STATE_ATTRIBUTES2 = ["disabled", "hidden"];
18671
18870
  var RovingController = class extends Controller {
18672
18871
  static targets = ["item"];
18673
18872
  static values = {
@@ -18677,12 +18876,14 @@ var RovingController = class extends Controller {
18677
18876
  };
18678
18877
  static events = ["change"];
18679
18878
  #roving = new RovingTabindex(() => this.itemTargets);
18680
- #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
18879
+ #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop(true));
18681
18880
  #connected = false;
18881
+ #observer = null;
18682
18882
  connect() {
18683
- this.#ensureTabStop();
18883
+ this.#ensureTabStop(false);
18684
18884
  this.element.addEventListener("keydown", this.#onKeydown);
18685
18885
  this.element.addEventListener("focusin", this.#onFocusin);
18886
+ this.#watchState();
18686
18887
  this.#connected = true;
18687
18888
  this.#reconcile.activate();
18688
18889
  }
@@ -18691,6 +18892,8 @@ var RovingController = class extends Controller {
18691
18892
  this.#reconcile.cancel();
18692
18893
  this.element.removeEventListener("keydown", this.#onKeydown);
18693
18894
  this.element.removeEventListener("focusin", this.#onFocusin);
18895
+ this.#observer?.disconnect();
18896
+ this.#observer = null;
18694
18897
  }
18695
18898
  /** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
18696
18899
  itemTargetConnected(item) {
@@ -18706,10 +18909,9 @@ var RovingController = class extends Controller {
18706
18909
  #onKeydown = (event) => {
18707
18910
  if (event.defaultPrevented) return;
18708
18911
  if (isReservedArrowChord(event)) return;
18709
- const items = this.itemTargets;
18912
+ if (event.isComposing) return;
18710
18913
  const current = this.#indexOf(event.target);
18711
18914
  if (current === -1) return;
18712
- const length = items.length;
18713
18915
  const wrap = this.wrapValue ? "wrap" : "clamp";
18714
18916
  const orientation = this.orientationValue;
18715
18917
  const horizontal = orientation === "horizontal" || orientation === "both";
@@ -18719,16 +18921,16 @@ var RovingController = class extends Controller {
18719
18921
  const backwardKey = rtl ? "ArrowRight" : "ArrowLeft";
18720
18922
  let next;
18721
18923
  if (horizontal && event.key === forwardKey || vertical && event.key === "ArrowDown") {
18722
- next = rovingMove(current, length, 1, wrap);
18924
+ next = this.#step(current, 1, wrap);
18723
18925
  } else if (horizontal && event.key === backwardKey || vertical && event.key === "ArrowUp") {
18724
- next = rovingMove(current, length, -1, wrap);
18725
- } else if (this.homeEndValue && event.key === "Home") {
18726
- next = 0;
18727
- } else if (this.homeEndValue && event.key === "End") {
18728
- next = length - 1;
18926
+ next = this.#step(current, -1, wrap);
18927
+ } else if (this.homeEndValue && (event.key === "Home" || event.key === "End")) {
18928
+ if (hasModifierChord(event)) return;
18929
+ next = event.key === "Home" ? this.#firstReachable() : this.#lastReachable();
18729
18930
  } else {
18730
18931
  return;
18731
18932
  }
18933
+ if (next === -1) return;
18732
18934
  event.preventDefault();
18733
18935
  this.#activate(next, true);
18734
18936
  };
@@ -18739,7 +18941,8 @@ var RovingController = class extends Controller {
18739
18941
  */
18740
18942
  #onFocusin = (event) => {
18741
18943
  const index = this.#indexOf(event.target);
18742
- if (index !== -1) this.#activate(index, false);
18944
+ if (index === -1 || !this.#reachable(index)) return;
18945
+ this.#activate(index, false);
18743
18946
  };
18744
18947
  /** Resolves the item index owning an event target (the item or a descendant). */
18745
18948
  #indexOf(target) {
@@ -18755,66 +18958,106 @@ var RovingController = class extends Controller {
18755
18958
  this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
18756
18959
  }
18757
18960
  }
18758
- /** Keeps the first existing Tab stop, falling back to the first live item. */
18759
- #ensureTabStop() {
18961
+ /**
18962
+ * Keeps the Tab stop on a reachable item.
18963
+ *
18964
+ * `followFocus` is on for re-establishment only: an item added and focused in
18965
+ * the same task has already claimed the stop through `focusin`, and the batch
18966
+ * that follows must not hand it back to the first item. `connect()` passes it
18967
+ * off so the authored DOM decides the initial stop. With nothing reachable the
18968
+ * DOM is left as it is — a group inside a collapsed region gets its stop back
18969
+ * when the region opens, instead of losing it for good.
18970
+ */
18971
+ #ensureTabStop(followFocus) {
18972
+ const items = this.itemTargets;
18973
+ const focused = followFocus ? items.findIndex((item, i) => item === document.activeElement && this.#reachable(i)) : -1;
18760
18974
  const active = this.#roving.activeIndex;
18761
- this.#roving.setActive(active === -1 ? 0 : active);
18762
- }
18763
- };
18764
-
18765
- // src/utils/style_property_lease.ts
18766
- var StylePropertyLease = class {
18767
- #property;
18768
- #records = /* @__PURE__ */ new Map();
18769
- /** @param property - The CSS property whose temporary values this lease owns. */
18770
- constructor(property) {
18771
- this.#property = property;
18975
+ const kept = active !== -1 && this.#reachable(active) ? active : -1;
18976
+ const index = focused !== -1 ? focused : kept !== -1 ? kept : this.#firstReachable();
18977
+ if (index === -1) return;
18978
+ this.#roving.setActive(index);
18772
18979
  }
18773
- /** Writes or removes the leased declaration while preserving its authored value. */
18774
- write(element, value, priority = "") {
18775
- const existing = this.#records.get(element);
18776
- if (existing) {
18777
- existing.writtenValue = value;
18778
- existing.writtenPriority = value === null ? "" : priority;
18779
- } else {
18780
- this.#records.set(element, {
18781
- originalValue: element.style.getPropertyValue(this.#property),
18782
- originalPriority: element.style.getPropertyPriority(this.#property),
18783
- writtenValue: value,
18784
- writtenPriority: value === null ? "" : priority
18785
- });
18980
+ /**
18981
+ * Resolves the next reachable index in `delta`'s direction, honouring `wrap`.
18982
+ *
18983
+ * An arrow on the widget's own axis is the widget's to consume even when the
18984
+ * position does not change, so a clamped end resolves to the current item
18985
+ * rather than to nothing. An unreachable origin escapes to the first reachable
18986
+ * item instead of sitting in a dead end, and `-1` is left for the one case that
18987
+ * really is not ours: no reachable item anywhere.
18988
+ */
18989
+ #step(current, delta, wrap) {
18990
+ const length = this.itemTargets.length;
18991
+ let index = current;
18992
+ for (let taken = 0; taken < length; taken += 1) {
18993
+ const candidate = rovingMove(index, length, delta, wrap);
18994
+ if (candidate === index) break;
18995
+ index = candidate;
18996
+ if (this.#reachable(index)) return index;
18997
+ }
18998
+ return this.#reachable(current) ? current : this.#firstReachable();
18999
+ }
19000
+ /** Index of the first reachable item, or `-1`. */
19001
+ #firstReachable() {
19002
+ return this.itemTargets.findIndex((_item, i) => this.#reachable(i));
19003
+ }
19004
+ /** Index of the last reachable item, or `-1`. */
19005
+ #lastReachable() {
19006
+ for (let i = this.itemTargets.length - 1; i >= 0; i -= 1) {
19007
+ if (this.#reachable(i)) return i;
18786
19008
  }
18787
- this.#reflect(element, value, priority);
19009
+ return -1;
18788
19010
  }
18789
- /** Returns one lease without overwriting a later consumer declaration. */
18790
- return(element) {
18791
- const record = this.#records.get(element);
18792
- if (!record) return;
18793
- this.#records.delete(element);
18794
- const style = element.style;
18795
- const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
18796
- if (stillOwned) {
18797
- this.#reflect(element, record.originalValue, record.originalPriority);
18798
- }
19011
+ /**
19012
+ * Whether the item at `index` can hold the Tab stop and take focus.
19013
+ *
19014
+ * `aria-disabled` is deliberately not consulted: it keeps an item reachable and
19015
+ * only suppresses activation, which this part does not own.
19016
+ */
19017
+ #reachable(index) {
19018
+ const item = this.itemTargets[index];
19019
+ if (!item) return false;
19020
+ if (this.#hidden(item)) return false;
19021
+ if (!("disabled" in item)) return true;
19022
+ if (item.disabled) return false;
19023
+ return !inheritsFieldsetDisabled(item);
18799
19024
  }
18800
- /** Returns every outstanding declaration lease. */
18801
- returnAll() {
18802
- for (const element of Array.from(this.#records.keys())) this.return(element);
19025
+ /**
19026
+ * Whether `item`, or anything between it and the container, is `hidden`.
19027
+ *
19028
+ * The walk stops at the container on purpose: a group inside a hidden region is
19029
+ * already out of the page's Tab order, and calling every item unreachable there
19030
+ * would drop the stop with nothing left to restore it — the ancestor lies
19031
+ * outside the subtree whose state attributes are watched.
19032
+ */
19033
+ #hidden(item) {
19034
+ let node = item;
19035
+ while (node && node !== this.element) {
19036
+ if (node.hasAttribute("hidden")) return true;
19037
+ node = node.parentElement;
19038
+ }
19039
+ return false;
18803
19040
  }
18804
- /** Reflects only a real declaration transition. */
18805
- #reflect(element, value, priority) {
18806
- const style = element.style;
18807
- const nextValue = value ?? "";
18808
- const nextPriority = value === null ? "" : priority;
18809
- if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
18810
- return;
19041
+ /**
19042
+ * Watches the attributes that decide reachability, so disabling the item that
19043
+ * holds the Tab stop hands it to another one instead of taking the whole set
19044
+ * out of the Tab sequence. An enclosing `fieldset` disables items from outside
19045
+ * the observed subtree, so each one's own `disabled` is watched too.
19046
+ */
19047
+ #watchState() {
19048
+ if (typeof MutationObserver === "undefined") return;
19049
+ const observer = new MutationObserver(() => this.#reconcile.schedule());
19050
+ observer.observe(this.element, {
19051
+ subtree: true,
19052
+ attributes: true,
19053
+ attributeFilter: STATE_ATTRIBUTES2
19054
+ });
19055
+ for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
19056
+ observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
18811
19057
  }
18812
- if (value === null) style.removeProperty(this.#property);
18813
- else style.setProperty(this.#property, value, priority);
19058
+ this.#observer = observer;
18814
19059
  }
18815
19060
  };
18816
-
18817
- // src/controllers/scroll_area_controller.ts
18818
19061
  var EDGE_EPSILON = 1;
18819
19062
  var ScrollAreaController = class extends Controller {
18820
19063
  static targets = ["viewport"];
@@ -21283,6 +21526,7 @@ var StepperController = class extends Controller {
21283
21526
  return Math.min(last, Math.max(0, Math.trunc(index)));
21284
21527
  }
21285
21528
  };
21529
+ var DEFAULT_THRESHOLD = 80;
21286
21530
  var countElements = (nodes) => {
21287
21531
  let n = 0;
21288
21532
  for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
@@ -21291,34 +21535,53 @@ var countElements = (nodes) => {
21291
21535
  var StickToBottomController = class extends Controller {
21292
21536
  static targets = ["content"];
21293
21537
  static values = {
21294
- threshold: { type: Number, default: 80 },
21538
+ threshold: { type: Number, default: DEFAULT_THRESHOLD },
21295
21539
  behavior: { type: String, default: "auto" },
21296
21540
  pinOnConnect: { type: Boolean, default: false }
21297
21541
  };
21298
21542
  static actions = ["scrollToBottom"];
21299
21543
  static events = ["pin", "new"];
21300
21544
  #observer = null;
21301
- /** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
21302
- #layout = null;
21545
+ /** The element the append observer currently holds, so a swap can be detected. */
21546
+ #watched = null;
21547
+ /** Watches for the box a container connected without one is still waiting on. */
21548
+ #layout = new LayoutObserver(() => this.#onLaidOut());
21549
+ #awaitingLayout = false;
21550
+ #connected = false;
21303
21551
  #pinned = false;
21304
21552
  #onScroll = () => this.#updatePinned();
21305
21553
  connect() {
21554
+ this.#connected = true;
21306
21555
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
21556
+ this.element.removeAttribute("data-has-new");
21307
21557
  this.#pinned = this.#isPinned();
21308
21558
  this.#reflectPinned();
21309
21559
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
21310
- if (typeof MutationObserver !== "undefined") {
21311
- this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
21312
- this.#observer.observe(this.#watched(), { childList: true });
21313
- }
21314
- if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
21560
+ this.#syncWatched();
21561
+ if (!this.#measurable()) this.#waitForLayout();
21315
21562
  }
21316
21563
  disconnect() {
21564
+ this.#connected = false;
21317
21565
  this.element.removeEventListener("scroll", this.#onScroll);
21318
- this.#observer?.disconnect();
21319
- this.#observer = null;
21566
+ this.#stopWatching();
21320
21567
  this.#stopWaitingForLayout();
21321
21568
  }
21569
+ /** Moves the append watch onto a `content` target that arrived at runtime. */
21570
+ contentTargetConnected() {
21571
+ this.#syncWatched();
21572
+ }
21573
+ /** Moves the append watch off a `content` target that left, back onto the container. */
21574
+ contentTargetDisconnected() {
21575
+ this.#syncWatched();
21576
+ }
21577
+ /**
21578
+ * Re-derives pinned when the distance that counts as the bottom is changed at runtime
21579
+ * (a morph that swaps the attribute on a retained element).
21580
+ */
21581
+ thresholdValueChanged() {
21582
+ if (!this.#connected) return;
21583
+ this.#updatePinned();
21584
+ }
21322
21585
  /**
21323
21586
  * Jumps to the bottom and re-pins (wired to a "new messages" button).
21324
21587
  *
@@ -21347,7 +21610,11 @@ var StickToBottomController = class extends Controller {
21347
21610
  this.dispatch("new", { detail: { count: added } });
21348
21611
  }
21349
21612
  }
21350
- /** Recomputes pinned from the scroll position and reflects it on a transition. */
21613
+ /**
21614
+ * Recomputes pinned from the scroll position and reflects it on a transition.
21615
+ *
21616
+ * @stimeoRenderRoot
21617
+ */
21351
21618
  #updatePinned() {
21352
21619
  const pinned = this.#isPinned();
21353
21620
  if (pinned === this.#pinned) return;
@@ -21364,9 +21631,25 @@ var StickToBottomController = class extends Controller {
21364
21631
  this.element.removeAttribute("data-pinned");
21365
21632
  }
21366
21633
  }
21634
+ /** Whether the container currently sits within `threshold` of its bottom. */
21367
21635
  #isPinned() {
21636
+ if (!this.#measurable()) return false;
21368
21637
  const el = this.element;
21369
- return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
21638
+ return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
21639
+ }
21640
+ /**
21641
+ * The distance from the bottom that counts as pinned: a finite, non-negative number of
21642
+ * pixels. Anything else names no distance the container can be at, and settles the
21643
+ * comparison the same way at every scroll position, so it falls back to the default.
21644
+ * `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
21645
+ * distance sits below the closest the container ever gets; `Infinity` is never
21646
+ * exceeded. The first two stop following and flag every append as new, and the last
21647
+ * never stops following — it takes the reading position the flag exists to protect.
21648
+ * Zero is a real declaration: it pins at the exact bottom only.
21649
+ */
21650
+ get #threshold() {
21651
+ const declared = this.thresholdValue;
21652
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THRESHOLD;
21370
21653
  }
21371
21654
  /**
21372
21655
  * Whether the container has a box to scroll and to measure. One that is not rendered
@@ -21377,23 +21660,25 @@ var StickToBottomController = class extends Controller {
21377
21660
  return this.element.clientHeight > 0;
21378
21661
  }
21379
21662
  /**
21380
- * Holds the `pinOnConnect` jump until the container is laid out, then runs it and
21381
- * re-reads the state — otherwise the panel opens at the top still claiming the bottom.
21663
+ * Holds the pinned decision until the container is laid out — otherwise the panel opens
21664
+ * at the top while the state claims the bottom, and the appends that arrived meanwhile
21665
+ * were followed into a box that could not move rather than flagged.
21382
21666
  */
21383
- #pinWhenLaidOut() {
21384
- if (typeof ResizeObserver === "undefined") return;
21385
- this.#layout = new ResizeObserver(() => {
21386
- if (!this.#measurable()) return;
21387
- this.#stopWaitingForLayout();
21388
- this.#scrollToBottom("instant");
21389
- this.#updatePinned();
21390
- });
21667
+ #waitForLayout() {
21668
+ this.#awaitingLayout = true;
21391
21669
  this.#layout.observe(this.element);
21392
21670
  }
21393
- /** Releases the layout watch, whether or not the deferred jump ever ran. */
21671
+ /** Runs the held decision once the container has the box it was waiting for. */
21672
+ #onLaidOut() {
21673
+ if (!this.#awaitingLayout || !this.#measurable()) return;
21674
+ this.#stopWaitingForLayout();
21675
+ if (this.pinOnConnectValue) this.#scrollToBottom("instant");
21676
+ this.#updatePinned();
21677
+ }
21678
+ /** Releases the layout watch, whether or not the held decision ever ran. */
21394
21679
  #stopWaitingForLayout() {
21395
- this.#layout?.disconnect();
21396
- this.#layout = null;
21680
+ this.#awaitingLayout = false;
21681
+ this.#layout.disconnect();
21397
21682
  }
21398
21683
  /**
21399
21684
  * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
@@ -21409,9 +21694,32 @@ var StickToBottomController = class extends Controller {
21409
21694
  this.element.scrollTop = top;
21410
21695
  }
21411
21696
  }
21412
- /** The append-watched element: the `content` target, or the container itself. */
21413
- #watched() {
21414
- return this.hasContentTarget ? this.contentTarget : this.element;
21697
+ /**
21698
+ * Points the append watch at the current `content` target, or at the container when
21699
+ * there is none. Re-resolved whenever that target changes, so a swap does not leave the
21700
+ * observer holding a detached node whose appends nobody sees.
21701
+ *
21702
+ * Stimulus runs the target callbacks outside the connected window too — before
21703
+ * `connect()` for a target already in the DOM, and after `disconnect()` while the
21704
+ * element is torn down — where this would arm an observer nothing releases. Re-syncing
21705
+ * to the target already held is left alone, so an arrival still in flight is not
21706
+ * dropped with the observer that was about to deliver it.
21707
+ */
21708
+ #syncWatched() {
21709
+ if (!this.#connected) return;
21710
+ const next = this.hasContentTarget ? this.contentTarget : this.element;
21711
+ if (next === this.#watched) return;
21712
+ this.#stopWatching();
21713
+ this.#watched = next;
21714
+ if (typeof MutationObserver === "undefined") return;
21715
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
21716
+ this.#observer.observe(next, { childList: true });
21717
+ }
21718
+ /** Releases the append watch and the element it held. */
21719
+ #stopWatching() {
21720
+ this.#observer?.disconnect();
21721
+ this.#observer = null;
21722
+ this.#watched = null;
21415
21723
  }
21416
21724
  /**
21417
21725
  * The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
@@ -23673,7 +23981,7 @@ var ToggleGroupController = class extends Controller {
23673
23981
  return item.getAttribute("data-value") ?? "";
23674
23982
  }
23675
23983
  };
23676
- var STATE_ATTRIBUTES2 = ["disabled", "hidden"];
23984
+ var STATE_ATTRIBUTES3 = ["disabled", "hidden"];
23677
23985
  var ToolbarController = class extends Controller {
23678
23986
  static targets = ["control"];
23679
23987
  static values = {
@@ -23702,7 +24010,7 @@ var ToolbarController = class extends Controller {
23702
24010
  subtree: true,
23703
24011
  childList: true,
23704
24012
  attributes: true,
23705
- attributeFilter: STATE_ATTRIBUTES2
24013
+ attributeFilter: STATE_ATTRIBUTES3
23706
24014
  });
23707
24015
  for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
23708
24016
  observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });