stimeo-ui 0.10.0 → 0.12.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
@@ -5332,23 +5332,54 @@ var ContextMenuController = class extends Controller {
5332
5332
  return this.hasMenuTarget && !this.menuTarget.hidden;
5333
5333
  }
5334
5334
  };
5335
+
5336
+ // src/utils/authored_integer.ts
5337
+ var TOKEN_BODY = /\d[\d.,'’’    _]*\d|\d/;
5338
+ var SIGN_OPENER = /[\s([]/;
5339
+ function authoredInteger(text) {
5340
+ const firstDigit = /\d/.exec(text);
5341
+ if (firstDigit === null) return null;
5342
+ const at = firstDigit.index;
5343
+ const signed = at > 0 && text[at - 1] === "-" && (at === 1 || SIGN_OPENER.test(text[at - 2]));
5344
+ const body = TOKEN_BODY.exec(text.slice(at));
5345
+ const runs = body[0].split(/\D+/);
5346
+ const separators = body[0].match(/\D+/g) ?? [];
5347
+ const lead = runs[0];
5348
+ const heads = lead.length <= 3 && !lead.startsWith("0");
5349
+ let digits = lead;
5350
+ for (let i = 1; heads && i < runs.length; i += 1) {
5351
+ if (separators[i - 1]?.length !== 1 || runs[i]?.length !== 3) break;
5352
+ digits += runs[i];
5353
+ }
5354
+ const magnitude = Number.parseInt(digits, 10);
5355
+ return signed ? -magnitude || 0 : magnitude;
5356
+ }
5357
+
5358
+ // src/controllers/count_up_controller.ts
5359
+ var DEFAULT_DURATION = 1200;
5335
5360
  var CountUpController = class extends Controller {
5336
5361
  static values = {
5337
- duration: { type: Number, default: 1200 },
5362
+ duration: { type: Number, default: DEFAULT_DURATION },
5338
5363
  from: { type: Number, default: 0 },
5339
5364
  once: { type: Boolean, default: true }
5340
5365
  };
5341
5366
  static actions = ["start"];
5342
5367
  static events = ["end"];
5343
5368
  #frame = null;
5344
- /** The authored final text, restored verbatim when the run settles. */
5345
- #finalText = "";
5369
+ /** The animation length, with a declaration outside its domain read as the default. */
5370
+ get #duration() {
5371
+ return Number.isFinite(this.durationValue) && this.durationValue > 0 ? this.durationValue : DEFAULT_DURATION;
5372
+ }
5373
+ /** The starting value, with a declaration that is not a finite number read as zero. */
5374
+ get #from() {
5375
+ return Number.isFinite(this.fromValue) ? this.fromValue : 0;
5376
+ }
5346
5377
  connect() {
5347
- if (this.element.hasAttribute("data-count-up-label")) {
5348
- this.element.textContent = this.element.getAttribute("aria-label") ?? this.element.textContent;
5349
- this.#restoreLabel();
5350
- this.element.setAttribute("data-count-up-done", "true");
5351
- }
5378
+ const ticker = this.#ownedTicker();
5379
+ if (ticker === null) return;
5380
+ const authored = ticker.getAttribute("aria-label");
5381
+ this.#unwrap(ticker, authored);
5382
+ if (authored !== null) this.element.setAttribute("data-count-up-done", "true");
5352
5383
  }
5353
5384
  disconnect() {
5354
5385
  if (this.#frame !== null) this.#settle();
@@ -5360,27 +5391,22 @@ var CountUpController = class extends Controller {
5360
5391
  start() {
5361
5392
  if (this.#frame !== null) return;
5362
5393
  if (this.onceValue && this.element.hasAttribute("data-count-up-done")) return;
5363
- this.#finalText = this.element.textContent ?? "";
5364
- const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, ""), 10);
5365
- if (Number.isNaN(target)) return;
5394
+ const node = this.#numericNode();
5395
+ if (node === null) return;
5396
+ const authored = node.data;
5397
+ const target = authoredInteger(authored);
5366
5398
  if (prefersReducedMotion()) {
5367
5399
  this.element.setAttribute("data-count-up-done", "true");
5368
5400
  this.dispatch("end", { detail: { value: target } });
5369
5401
  return;
5370
5402
  }
5371
- const authored = this.element.getAttribute("aria-label");
5372
- if (authored !== null) {
5373
- this.element.setAttribute("data-count-up-original-label", authored);
5374
- }
5375
- this.element.setAttribute("data-count-up-label", "true");
5376
- this.element.setAttribute("aria-label", this.#finalText);
5403
+ const ticker = this.#wrap(node, authored);
5404
+ const from = this.#from;
5377
5405
  const started = performance.now();
5378
5406
  const step = (now) => {
5379
- const t = Math.min((now - started) / this.durationValue, 1);
5407
+ const t = Math.min((now - started) / this.#duration, 1);
5380
5408
  const eased = 1 - (1 - t) ** 3;
5381
- this.element.textContent = String(
5382
- Math.round(this.fromValue + (target - this.fromValue) * eased)
5383
- );
5409
+ ticker.textContent = String(Math.round(from + (target - from) * eased));
5384
5410
  if (t < 1) {
5385
5411
  this.#frame = requestAnimationFrame(step);
5386
5412
  } else {
@@ -5390,26 +5416,41 @@ var CountUpController = class extends Controller {
5390
5416
  };
5391
5417
  this.#frame = requestAnimationFrame(step);
5392
5418
  }
5419
+ /** The first text node that displays a number, or null when the host has none. */
5420
+ #numericNode() {
5421
+ const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
5422
+ let node = walker.nextNode();
5423
+ while (node !== null && authoredInteger(node.data) === null) {
5424
+ node = walker.nextNode();
5425
+ }
5426
+ return node;
5427
+ }
5428
+ /** The wrapper this controller published, if one outlived its run. */
5429
+ #ownedTicker() {
5430
+ return this.element.querySelector("[data-count-up-label]");
5431
+ }
5432
+ /** Publishes the ticking number inside a named wrapper, replacing `node`. */
5433
+ #wrap(node, authored) {
5434
+ const ticker = document.createElement("span");
5435
+ ticker.setAttribute("data-count-up-label", "true");
5436
+ ticker.setAttribute("role", "img");
5437
+ ticker.setAttribute("aria-label", authored);
5438
+ node.replaceWith(ticker);
5439
+ ticker.append(node);
5440
+ return ticker;
5441
+ }
5442
+ /** Puts `text` back where the wrapper stood, leaving the rest of the host alone. */
5443
+ #unwrap(ticker, text) {
5444
+ ticker.replaceWith(document.createTextNode(text ?? ticker.textContent ?? ""));
5445
+ }
5393
5446
  /** Ends the run: cancels the frame and restores the authored presentation. */
5394
5447
  #settle() {
5395
5448
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
5396
5449
  this.#frame = null;
5397
- this.element.textContent = this.#finalText;
5398
- this.#restoreLabel();
5450
+ const ticker = this.#ownedTicker();
5451
+ if (ticker !== null) this.#unwrap(ticker, ticker.getAttribute("aria-label"));
5399
5452
  this.element.setAttribute("data-count-up-done", "true");
5400
5453
  }
5401
- /** Releases the marker-owned aria-label, restoring any parked authored value. */
5402
- #restoreLabel() {
5403
- if (!this.element.hasAttribute("data-count-up-label")) return;
5404
- const original = this.element.getAttribute("data-count-up-original-label");
5405
- if (original !== null) {
5406
- this.element.setAttribute("aria-label", original);
5407
- this.element.removeAttribute("data-count-up-original-label");
5408
- } else {
5409
- this.element.removeAttribute("aria-label");
5410
- }
5411
- this.element.removeAttribute("data-count-up-label");
5412
- }
5413
5454
  };
5414
5455
  var SECOND_MS = 1e3;
5415
5456
  var CountdownController = class extends Controller {
@@ -10193,6 +10234,14 @@ function isBeforeRootStart(entry) {
10193
10234
  const rootTop = entry.rootBounds?.top ?? 0;
10194
10235
  return rect.bottom <= rootTop;
10195
10236
  }
10237
+ function queryRoot(selector) {
10238
+ if (!selector) return null;
10239
+ try {
10240
+ return document.querySelector(selector);
10241
+ } catch {
10242
+ }
10243
+ return null;
10244
+ }
10196
10245
  var IntersectionWatcher = class {
10197
10246
  #onEntries;
10198
10247
  #observer = null;
@@ -10214,7 +10263,10 @@ var IntersectionWatcher = class {
10214
10263
  * the watcher inert — without `IntersectionObserver` support (very old
10215
10264
  * browsers; the caller's no-JS fallback stays in charge) or with no targets.
10216
10265
  * If initial construction with the configured options fails, the watcher
10217
- * warns and retries once with the same root and platform defaults.
10266
+ * warns and retries once with the same root and platform defaults. A
10267
+ * `rootSelector` that does not parse resolves to the viewport (see
10268
+ * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
10269
+ * call.
10218
10270
  *
10219
10271
  * @throws The fallback constructor error if both construction attempts fail,
10220
10272
  * or whatever the platform throws from `observe()`. The exception is passed
@@ -10227,7 +10279,7 @@ var IntersectionWatcher = class {
10227
10279
  if (typeof IntersectionObserver === "undefined") return false;
10228
10280
  const list = Array.isArray(targets) ? targets : [targets];
10229
10281
  if (list.length === 0) return false;
10230
- const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
10282
+ const root = "root" in options ? options.root ?? null : queryRoot(options.rootSelector);
10231
10283
  let observer = null;
10232
10284
  try {
10233
10285
  const onEntries = (entries) => {
@@ -10320,6 +10372,25 @@ var IntersectionController = class extends Controller {
10320
10372
  }
10321
10373
  connect() {
10322
10374
  if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
10375
+ this.#observe();
10376
+ }
10377
+ disconnect() {
10378
+ this.#watcher.stop();
10379
+ }
10380
+ /**
10381
+ * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing
10382
+ * rewrites the attribute in place without a reconnect, and the line is what
10383
+ * the intersection callback compares every ratio against, so a value frozen at
10384
+ * connect time would decide `data-intersecting` wrongly for the rest of the
10385
+ * page's life. Nothing to rebuild before the first `connect()`; after a spent
10386
+ * one-shot the watcher is deliberately stopped, and re-observing would deliver
10387
+ * the current state and fire `enter` a second time.
10388
+ */
10389
+ thresholdValueChanged() {
10390
+ if (this.#watcher.active) this.#observe();
10391
+ }
10392
+ /** (Re)installs the observer from the current Values. */
10393
+ #observe() {
10323
10394
  this.#effectiveThreshold = this.#clampedThreshold();
10324
10395
  this.#watcher.start(this.element, {
10325
10396
  rootSelector: this.rootSelectorValue,
@@ -10328,9 +10399,6 @@ var IntersectionController = class extends Controller {
10328
10399
  });
10329
10400
  if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
10330
10401
  }
10331
- disconnect() {
10332
- this.#watcher.stop();
10333
- }
10334
10402
  /**
10335
10403
  * Re-delivers the current intersection state as a fresh transition. Bound via
10336
10404
  * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).
@@ -10361,14 +10429,29 @@ var IntersectionController = class extends Controller {
10361
10429
  const previous = this.element.getAttribute("data-intersecting");
10362
10430
  this.element.setAttribute("data-intersecting", intersecting ? "true" : "false");
10363
10431
  if (intersecting && previous !== "true") {
10364
- this.dispatch("enter", { detail: { ratio } });
10365
10432
  if (this.onceValue) this.#watcher.stop();
10433
+ this.dispatch("enter", { detail: { ratio } });
10366
10434
  } else if (!intersecting && previous === "true") {
10367
10435
  this.dispatch("exit", {
10368
- detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
10436
+ detail: { ratio, position: this.#leftViaStartEdge(entry) ? "before" : "after" }
10369
10437
  });
10370
10438
  }
10371
10439
  }
10440
+ /**
10441
+ * Which edge the element left across, for the `exit` detail. A non-zero
10442
+ * `threshold` withdraws visibility while the element still overlaps the root,
10443
+ * so the leaving rect can straddle the start edge — the direction is the
10444
+ * element's own top against that edge, not whether it has cleared the root
10445
+ * entirely (that is what `passed` reports). An element with no layout box
10446
+ * (`display: none`, a collapsed `<details>`) is reported with an empty rect
10447
+ * that carries no position at all, so it is deliberately neither direction
10448
+ * and takes the "still ahead" reading.
10449
+ */
10450
+ #leftViaStartEdge(entry) {
10451
+ const rect = entry.boundingClientRect;
10452
+ if (rect.width === 0 && rect.height === 0) return false;
10453
+ return rect.top < (entry.rootBounds?.top ?? 0);
10454
+ }
10372
10455
  /**
10373
10456
  * Reflects the "scrolled past" state onto `data-passed` and fires `passed` on
10374
10457
  * transitions — the line sticky headers and reading progress key off. Like
@@ -10388,9 +10471,14 @@ var IntersectionController = class extends Controller {
10388
10471
  /**
10389
10472
  * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly
10390
10473
  * spaced steps when fine-grained `change` ratios are wanted (progress bars).
10474
+ *
10475
+ * 0 is always observed. An observer notifies only at the lines it was given,
10476
+ * so a non-zero `threshold` on its own delivers its last callback while the
10477
+ * element is still partly visible: the element leaving for good would never be
10478
+ * reported, freezing the ratio and `data-passed` mid-departure.
10391
10479
  */
10392
10480
  #thresholds() {
10393
- const thresholds = /* @__PURE__ */ new Set([this.#clampedThreshold()]);
10481
+ const thresholds = /* @__PURE__ */ new Set([0, this.#clampedThreshold()]);
10394
10482
  if (this.ratioStepsValue > 0) {
10395
10483
  for (let i = 0; i <= this.ratioStepsValue; i += 1) {
10396
10484
  thresholds.add(i / this.ratioStepsValue);
@@ -10408,47 +10496,98 @@ var LazyFrameController = class extends Controller {
10408
10496
  static events = ["load"];
10409
10497
  /** Shared IO plumbing (support guard, active guard, teardown). */
10410
10498
  #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
10499
+ #connected = false;
10411
10500
  #loaded = false;
10412
- /** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */
10413
- #onFocus = () => this.#trigger();
10501
+ /**
10502
+ * Whether the observer last saw the frame inside the observed area — `null`
10503
+ * until it has reported. A re-entry needs a frame that was seen inside and
10504
+ * then left, so an unknown position is never a departure.
10505
+ */
10506
+ #inside = null;
10507
+ /**
10508
+ * Focus reaching the frame starts the FIRST load before it intersects (keyboard / AT).
10509
+ * `#load` releases this listener, so focus never reaches an already-loaded frame.
10510
+ */
10511
+ #onFocus = () => this.#load();
10512
+ /**
10513
+ * Follows the held URL at runtime: one that arrives arms the frame, one that is
10514
+ * taken away disarms it, so an empty `url` holds no triggers either way.
10515
+ *
10516
+ * Stimulus runs value callbacks before `connect()`, so the connected guard keeps
10517
+ * arming in one place. A frame that already loaded keeps its held URL for the next
10518
+ * re-entry instead of fetching on the spot.
10519
+ */
10520
+ urlValueChanged() {
10521
+ if (!this.#connected || this.#loaded) return;
10522
+ if (this.urlValue) this.#arm();
10523
+ else this.#stopObserving();
10524
+ }
10525
+ /** Rebuilds the observer when the early-load margin changes at runtime. */
10526
+ rootMarginValueChanged() {
10527
+ if (this.#connected && this.#watcher.active) this.#observe();
10528
+ }
10414
10529
  connect() {
10415
- if (this.element.hasAttribute("data-lazy-loaded")) {
10416
- this.#loaded = true;
10417
- return;
10418
- }
10419
- if (!this.urlValue) return;
10420
- this.element.addEventListener("focusin", this.#onFocus);
10421
- this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });
10530
+ this.#loaded = this.element.hasAttribute("data-lazy-loaded");
10531
+ this.#inside = null;
10532
+ this.#connected = true;
10533
+ if (this.#loaded && this.onceValue) return;
10534
+ this.#arm();
10422
10535
  }
10423
10536
  disconnect() {
10537
+ this.#connected = false;
10424
10538
  this.#stopObserving();
10425
10539
  }
10540
+ /** Attaches the triggers that still have a job to do. */
10541
+ #arm() {
10542
+ if (!this.urlValue) return;
10543
+ if (!this.#loaded) this.element.addEventListener("focusin", this.#onFocus);
10544
+ this.#observe();
10545
+ }
10546
+ #observe() {
10547
+ this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });
10548
+ }
10426
10549
  #onIntersect(entries) {
10427
10550
  for (const entry of entries) {
10428
10551
  if (entry.isIntersecting) {
10429
- this.#trigger();
10430
- return;
10552
+ const returning = this.#inside === false;
10553
+ this.#inside = true;
10554
+ this.#enter(returning);
10555
+ } else if (this.#inside) {
10556
+ this.#inside = false;
10431
10557
  }
10432
10558
  }
10433
10559
  }
10434
- #trigger() {
10435
- if (!this.#loaded) this.#load();
10436
- else if (!this.onceValue) this.#reload();
10560
+ /** Applies an intersecting entry: the first one loads, a return re-fetches. */
10561
+ #enter(returning) {
10562
+ if (!this.#loaded) {
10563
+ this.#load();
10564
+ return;
10565
+ }
10566
+ if (this.onceValue || !returning) return;
10567
+ this.#reload();
10437
10568
  }
10438
10569
  /** Starts the load by writing the held URL to `src`. */
10439
10570
  #load() {
10571
+ if (!this.urlValue) return;
10440
10572
  this.#loaded = true;
10441
10573
  this.element.setAttribute("src", this.urlValue);
10442
10574
  this.element.setAttribute("data-lazy-loaded", "true");
10443
10575
  this.dispatch("load", { detail: { url: this.urlValue } });
10444
- if (this.onceValue) this.#stopObserving();
10576
+ this.element.removeEventListener("focusin", this.#onFocus);
10577
+ if (this.onceValue) this.#watcher.stop();
10445
10578
  }
10446
- /** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */
10579
+ /** Re-entry while `once` is off: fetch the held URL again. */
10447
10580
  #reload() {
10581
+ const src = this.element.getAttribute("src");
10582
+ if (this.urlValue && this.urlValue !== src) {
10583
+ this.element.setAttribute("src", this.urlValue);
10584
+ this.dispatch("load", { detail: { url: this.urlValue } });
10585
+ return;
10586
+ }
10448
10587
  const frame = this.element;
10449
10588
  if (typeof frame.reload !== "function") return;
10450
10589
  frame.reload();
10451
- this.dispatch("load", { detail: { url: this.urlValue } });
10590
+ this.dispatch("load", { detail: { url: src ?? "" } });
10452
10591
  }
10453
10592
  #stopObserving() {
10454
10593
  this.#watcher.stop();
@@ -16289,6 +16428,7 @@ var PersistController = class extends Controller {
16289
16428
  this.dispatch("error", { detail: { key, operation, reason } });
16290
16429
  }
16291
16430
  };
16431
+ var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
16292
16432
  var PointerDragController = class _PointerDragController extends Controller {
16293
16433
  static targets = ["handle"];
16294
16434
  static values = {
@@ -16298,6 +16438,7 @@ var PointerDragController = class _PointerDragController extends Controller {
16298
16438
  disabled: { type: Boolean, default: false },
16299
16439
  follow: { type: Boolean, default: false }
16300
16440
  };
16441
+ static actions = ["reset"];
16301
16442
  static events = ["start", "move", "end", "cancel"];
16302
16443
  /** Marker attribute recording that this controller set the touch-action. */
16303
16444
  static #TOUCH_ACTION_MARKER = "data-pointer-drag-touch-action";
@@ -16331,23 +16472,49 @@ var PointerDragController = class _PointerDragController extends Controller {
16331
16472
  }
16332
16473
  this.#teardown();
16333
16474
  }
16334
- /** Prepares handles added at runtime (touch-action + focusability). */
16475
+ /**
16476
+ * Returns the element to its origin: drops the committed follow offset and the
16477
+ * inline `translate` that carries it.
16478
+ *
16479
+ * In follow mode the inline `translate` belongs to this controller, and the
16480
+ * committed offset lives in a field the DOM cannot reach — so a consumer that
16481
+ * wants the element back at the start, or that has written a position of its
16482
+ * own, needs this to make the two agree again. An in-flight drag is cancelled
16483
+ * first, or its deltas would land on top of the offset just cleared.
16484
+ */
16485
+ reset() {
16486
+ const live = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
16487
+ if (this.#pointer || this.#keyboard) this.#teardownSessions();
16488
+ if (live) this.#dispatchCancel(live);
16489
+ this.#followBase = { x: 0, y: 0 };
16490
+ this.#applyFollow(0, 0);
16491
+ }
16335
16492
  handleTargetConnected(handle) {
16493
+ if (handle !== this.element) this.#restoreHandle(this.element);
16336
16494
  this.#prepareHandle(handle);
16337
16495
  }
16338
16496
  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
16497
  this.#restoreHandle(handle);
16498
+ if (this.element.contains(handle)) return;
16499
+ const interrupted = this.#pointer?.handle === handle && this.#pointer.started ? this.#pointer.pointerType : this.#keyboard?.handle === handle ? "keyboard" : null;
16500
+ if (this.#pointer?.handle === handle) this.#endPointerSession();
16501
+ if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
16502
+ if (!this.hasHandleTarget) this.#prepareHandle(this.element);
16503
+ if (interrupted) this.#dispatchCancel(interrupted);
16344
16504
  }
16345
- /** Re-derives the handles' touch-action when the axis changes. */
16346
- axisValueChanged() {
16505
+ /**
16506
+ * Re-derives the handles' touch-action when the axis changes.
16507
+ *
16508
+ * Two layers, like the `tabindex` loan: the marker says the value was once
16509
+ * ours, and the current inline value says it still is. A consumer that wrote
16510
+ * its own `touch-action` after connect keeps it.
16511
+ */
16512
+ axisValueChanged(_value, previousValue) {
16513
+ const lent = this.#touchActionFor(previousValue ?? this.axisValue);
16347
16514
  for (const handle of this.#handles()) {
16348
- if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16349
- handle.style.touchAction = this.#touchActionForAxis();
16350
- }
16515
+ if (!handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) continue;
16516
+ if (handle.style.touchAction !== lent) continue;
16517
+ handle.style.touchAction = this.#touchActionForAxis();
16351
16518
  }
16352
16519
  }
16353
16520
  /** Cancels any in-flight session when the controller is disabled mid-drag. */
@@ -16428,9 +16595,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16428
16595
  const handle = this.#handleFor(event.target);
16429
16596
  if (!handle) return;
16430
16597
  if (event.defaultPrevented) return;
16598
+ if (event.isComposing) return;
16599
+ if (this.#ownsNativeKeys(event.target, handle)) return;
16431
16600
  if (isReservedArrowChord(event)) return;
16432
16601
  if (event.key === "Escape") {
16433
- if (event.isComposing) return;
16434
16602
  if (this.#pointer?.started) {
16435
16603
  const { pointerType } = this.#pointer;
16436
16604
  this.#teardownSessions();
@@ -16451,6 +16619,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16451
16619
  return;
16452
16620
  }
16453
16621
  if (!this.#keyboard) return;
16622
+ if (event.key === "Home" || event.key === "End") {
16623
+ event.preventDefault();
16624
+ return;
16625
+ }
16454
16626
  const step = this.#keyboardDelta(event.key);
16455
16627
  if (!step) return;
16456
16628
  event.preventDefault();
@@ -16539,6 +16711,7 @@ var PointerDragController = class _PointerDragController extends Controller {
16539
16711
  const interrupted = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
16540
16712
  this.#teardownSessions();
16541
16713
  for (const handle of this.#handles()) this.#restoreHandle(handle);
16714
+ this.#followReset();
16542
16715
  if (interrupted && this.element.isConnected) this.#dispatchCancel(interrupted);
16543
16716
  }
16544
16717
  /**
@@ -16608,8 +16781,12 @@ var PointerDragController = class _PointerDragController extends Controller {
16608
16781
  }
16609
16782
  /** `touch-action` that lets the page keep panning on the locked axis only. */
16610
16783
  #touchActionForAxis() {
16611
- if (this.axisValue === "x") return "pan-y";
16612
- if (this.axisValue === "y") return "pan-x";
16784
+ return this.#touchActionFor(this.axisValue);
16785
+ }
16786
+ /** The `touch-action` an `axis` declaration lends, for any axis value. */
16787
+ #touchActionFor(axis) {
16788
+ if (axis === "x") return "pan-y";
16789
+ if (axis === "y") return "pan-x";
16613
16790
  return "none";
16614
16791
  }
16615
16792
  /**
@@ -16620,8 +16797,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16620
16797
  * are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
16621
16798
  */
16622
16799
  #prepareHandle(handle) {
16623
- if (handle.style.touchAction === "" || handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16624
- handle.style.touchAction = this.#touchActionForAxis();
16800
+ const lent = this.#touchActionForAxis();
16801
+ const marked = handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
16802
+ if (handle.style.touchAction === "" || marked && handle.style.touchAction === lent) {
16803
+ handle.style.touchAction = lent;
16625
16804
  handle.setAttribute(_PointerDragController.#TOUCH_ACTION_MARKER, "true");
16626
16805
  }
16627
16806
  if (handle.tabIndex < 0 && !handle.hasAttribute("tabindex")) {
@@ -16629,17 +16808,27 @@ var PointerDragController = class _PointerDragController extends Controller {
16629
16808
  handle.setAttribute(_PointerDragController.#TABINDEX_MARKER, "true");
16630
16809
  }
16631
16810
  }
16811
+ /** Whether the key belongs to a native control or editing surface in the handle. */
16812
+ #ownsNativeKeys(target, handle) {
16813
+ const owner = target?.closest(NATIVE_KEY_OWNERS) ?? null;
16814
+ return owner !== null && owner !== handle && handle.contains(owner);
16815
+ }
16632
16816
  /** Reverts the marker-owned touch-action + tabindex (authored values untouched). */
16633
16817
  #restoreHandle(handle) {
16634
16818
  if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16635
- handle.style.touchAction = "";
16819
+ if (handle.style.touchAction === this.#touchActionForAxis()) {
16820
+ handle.style.touchAction = "";
16821
+ }
16636
16822
  handle.removeAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
16637
16823
  }
16638
16824
  if (handle.hasAttribute(_PointerDragController.#TABINDEX_MARKER)) {
16639
- handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16640
- if (handle.getAttribute("tabindex") === "0" && document.activeElement !== handle) {
16825
+ const ours = handle.getAttribute("tabindex") === "0";
16826
+ if (ours && document.activeElement !== handle) {
16641
16827
  handle.removeAttribute("tabindex");
16828
+ handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16829
+ return;
16642
16830
  }
16831
+ if (!ours) handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16643
16832
  }
16644
16833
  }
16645
16834
  };
@@ -16752,15 +16941,42 @@ var PortalController = class extends Controller {
16752
16941
  static events = ["mount", "unmount"];
16753
16942
  /** Decides whether a `disconnect()` is an in-page move or a real detach. */
16754
16943
  #gate = new DetachGate();
16944
+ /** The `to` declaration after validation; the default when it cannot be parsed. */
16945
+ #toSelector = "body";
16946
+ /**
16947
+ * Validates the destination declaration once, keeping only a selector the engine can
16948
+ * read.
16949
+ *
16950
+ * A selector reads back as an ordinary string, so a malformed one survives until it is
16951
+ * handed to the DOM — where it would take the whole teleport down and leave the part
16952
+ * silently doing nothing. Falling back to the default puts the node somewhere visible
16953
+ * instead, which reads as a mistake and can be traced back to the declaration.
16954
+ */
16955
+ toValueChanged() {
16956
+ const selector = this.toValue;
16957
+ if (selector.length > 0) {
16958
+ try {
16959
+ this.element.matches(selector);
16960
+ this.#toSelector = selector;
16961
+ return;
16962
+ } catch {
16963
+ }
16964
+ }
16965
+ this.#toSelector = "body";
16966
+ }
16755
16967
  connect() {
16756
16968
  this.#gate.cancel();
16757
- if (portalState.has(this.element)) return;
16969
+ const existing = portalState.get(this.element);
16970
+ if (existing) {
16971
+ existing.owner = this;
16972
+ return;
16973
+ }
16758
16974
  const node = this.hasContentTarget ? this.contentTarget : this.element;
16759
16975
  const destination = this.#destination();
16760
16976
  if (!destination || destination === node || node.contains(destination)) return;
16761
16977
  const placeholder = document.createComment("stimeo--portal");
16762
16978
  node.parentNode?.insertBefore(placeholder, node);
16763
- portalState.set(this.element, { node, placeholder });
16979
+ portalState.set(this.element, { node, placeholder, owner: this });
16764
16980
  if (this.positionValue === "prepend") {
16765
16981
  destination.prepend(node);
16766
16982
  } else {
@@ -16779,7 +16995,7 @@ var PortalController = class extends Controller {
16779
16995
  }
16780
16996
  this.#gate.disconnected(this, () => {
16781
16997
  const current = portalState.get(this.element);
16782
- if (current) this.#restore(current);
16998
+ if (current?.owner === this) this.#restore(current);
16783
16999
  });
16784
17000
  }
16785
17001
  /** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
@@ -16795,30 +17011,84 @@ var PortalController = class extends Controller {
16795
17011
  placeholder.remove();
16796
17012
  this.dispatch("unmount", { detail: {} });
16797
17013
  }
16798
- /** Resolves the destination for `to`, tolerating an invalid selector. */
17014
+ /** Resolves the destination from the validated `to` selector. */
16799
17015
  #destination() {
16800
- const selector = this.toValue.trim();
16801
- if (!selector) return null;
16802
- try {
16803
- return document.querySelector(selector);
16804
- } catch {
16805
- return null;
17016
+ return document.querySelector(this.#toSelector);
17017
+ }
17018
+ };
17019
+
17020
+ // src/utils/style_property_lease.ts
17021
+ var StylePropertyLease = class {
17022
+ #property;
17023
+ #records = /* @__PURE__ */ new Map();
17024
+ /** @param property - The CSS property whose temporary values this lease owns. */
17025
+ constructor(property) {
17026
+ this.#property = property;
17027
+ }
17028
+ /** Writes or removes the leased declaration while preserving its authored value. */
17029
+ write(element, value, priority = "") {
17030
+ const existing = this.#records.get(element);
17031
+ if (existing) {
17032
+ existing.writtenValue = value;
17033
+ existing.writtenPriority = value === null ? "" : priority;
17034
+ } else {
17035
+ this.#records.set(element, {
17036
+ originalValue: element.style.getPropertyValue(this.#property),
17037
+ originalPriority: element.style.getPropertyPriority(this.#property),
17038
+ writtenValue: value,
17039
+ writtenPriority: value === null ? "" : priority
17040
+ });
16806
17041
  }
17042
+ this.#reflect(element, value, priority);
17043
+ }
17044
+ /** Returns one lease without overwriting a later consumer declaration. */
17045
+ return(element) {
17046
+ const record = this.#records.get(element);
17047
+ if (!record) return;
17048
+ this.#records.delete(element);
17049
+ const style = element.style;
17050
+ const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
17051
+ if (stillOwned) {
17052
+ this.#reflect(element, record.originalValue, record.originalPriority);
17053
+ }
17054
+ }
17055
+ /** Returns every outstanding declaration lease. */
17056
+ returnAll() {
17057
+ for (const element of Array.from(this.#records.keys())) this.return(element);
17058
+ }
17059
+ /** Reflects only a real declaration transition. */
17060
+ #reflect(element, value, priority) {
17061
+ const style = element.style;
17062
+ const nextValue = value ?? "";
17063
+ const nextPriority = value === null ? "" : priority;
17064
+ if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
17065
+ return;
17066
+ }
17067
+ if (value === null) style.removeProperty(this.#property);
17068
+ else style.setProperty(this.#property, value, priority);
16807
17069
  }
16808
17070
  };
17071
+
17072
+ // src/controllers/preview_guard_controller.ts
16809
17073
  var PreviewGuardController = class extends Controller {
16810
17074
  static values = {
16811
- placeholder: { type: String, default: "" },
16812
- mode: { type: String, default: "hide" }
17075
+ placeholder: { type: String, default: "" }
16813
17076
  };
16814
17077
  static events = ["hide", "show"];
17078
+ #visibility = new StylePropertyLease("visibility");
17079
+ #beforeCache = new BeforeCacheReset(() => this.#restore());
16815
17080
  #observer = null;
17081
+ #connected = false;
16816
17082
  #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;
17083
+ /** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */
17084
+ #savedNodes = null;
17085
+ /** The descendant that held focus when the guard went up, so show can hand it back. */
17086
+ #focused = null;
16821
17087
  connect() {
17088
+ this.#connected = true;
17089
+ if (this.#hidden) this.#reguard();
17090
+ else this.element.removeAttribute("data-preview-hidden");
17091
+ this.#beforeCache.activate();
16822
17092
  if (typeof MutationObserver !== "undefined") {
16823
17093
  this.#observer = new MutationObserver(() => this.#sync());
16824
17094
  this.#observer.observe(document.documentElement, {
@@ -16829,44 +17099,115 @@ var PreviewGuardController = class extends Controller {
16829
17099
  this.#sync();
16830
17100
  }
16831
17101
  disconnect() {
17102
+ this.#connected = false;
17103
+ this.#beforeCache.deactivate();
16832
17104
  this.#observer?.disconnect();
16833
17105
  this.#observer = null;
16834
- this.#restore();
16835
17106
  }
16836
- /** Reflects the current `data-turbo-preview` state onto the element. */
17107
+ /**
17108
+ * Re-guards to match a `placeholder` changed at runtime — the value is the content on
17109
+ * display while the guard is up, so a morph that swaps it must not leave the old one.
17110
+ */
17111
+ placeholderValueChanged() {
17112
+ if (!this.#connected || !this.#hidden) return;
17113
+ this.#reguard();
17114
+ }
17115
+ /**
17116
+ * Reflects the current `data-turbo-preview` state onto the element.
17117
+ *
17118
+ * @stimeoRenderRoot
17119
+ */
16837
17120
  #sync() {
16838
17121
  const previewing = document.documentElement.hasAttribute("data-turbo-preview");
16839
17122
  if (previewing && !this.#hidden) this.#hide();
16840
17123
  else if (!previewing && this.#hidden) this.#show();
16841
17124
  }
16842
17125
  #hide() {
17126
+ this.#focused = this.#focusedInside();
17127
+ this.#applyGuard();
17128
+ this.dispatch("hide", { detail: {} });
17129
+ }
17130
+ /** Puts the guard up in the form the current `placeholder` calls for. */
17131
+ #applyGuard() {
16843
17132
  this.#hidden = true;
16844
- if (this.modeValue === "placeholder") {
16845
- this.#savedText = this.element.textContent;
16846
- this.element.textContent = this.placeholderValue;
17133
+ if (this.placeholderValue === "") {
17134
+ this.#visibility.write(this.element, "hidden");
16847
17135
  } else {
16848
- this.#savedVisibility = this.element.style.visibility;
16849
- this.element.style.visibility = "hidden";
17136
+ this.#savedNodes = document.createDocumentFragment();
17137
+ this.#savedNodes.append(...this.element.childNodes);
17138
+ this.element.textContent = this.placeholderValue;
16850
17139
  }
16851
17140
  this.element.setAttribute("data-preview-hidden", "true");
16852
- this.dispatch("hide", { detail: {} });
17141
+ }
17142
+ /**
17143
+ * Re-forms a guard that is already up so it matches the current `placeholder`.
17144
+ *
17145
+ * No event: `hide` reports that the guard went up, and it has not come down. Swapping
17146
+ * one stand-in text for another writes only that text — putting the held content back
17147
+ * first would reconnect the whole subtree for an instant. Only a change of *form* —
17148
+ * to or from the empty placeholder — has to revert, and the focus the guard is holding
17149
+ * carries across it rather than being handed back and taken again.
17150
+ */
17151
+ #reguard() {
17152
+ if (this.#savedNodes && this.placeholderValue !== "") {
17153
+ this.element.textContent = this.placeholderValue;
17154
+ return;
17155
+ }
17156
+ const held = this.#focused;
17157
+ this.#revert();
17158
+ this.#applyGuard();
17159
+ this.#focused = held;
16853
17160
  }
16854
17161
  #show() {
16855
17162
  this.#restore();
16856
17163
  this.dispatch("show", { detail: {} });
16857
17164
  }
16858
- /** Reverts the guard. Safe to call when not hidden (no-op) — used by show and teardown. */
17165
+ /** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */
16859
17166
  #restore() {
16860
- if (!this.#hidden) return;
17167
+ this.#revert();
17168
+ this.#refocus();
17169
+ }
17170
+ /**
17171
+ * Puts the element back the way the guard found it.
17172
+ *
17173
+ * Every step is a no-op on an element this controller never guarded: the lease returns
17174
+ * only declarations it recorded, and the hook is removed whether or not it is there.
17175
+ */
17176
+ #revert() {
16861
17177
  this.#hidden = false;
16862
- if (this.#savedText !== null) {
16863
- this.element.textContent = this.#savedText;
16864
- this.#savedText = null;
17178
+ if (this.#savedNodes) {
17179
+ this.element.textContent = "";
17180
+ this.element.append(this.#savedNodes);
17181
+ this.#savedNodes = null;
16865
17182
  } else {
16866
- this.element.style.visibility = this.#savedVisibility;
17183
+ this.#visibility.return(this.element);
16867
17184
  }
16868
17185
  this.element.removeAttribute("data-preview-hidden");
16869
17186
  }
17187
+ /**
17188
+ * The focused element the guard is about to make unfocusable, if any.
17189
+ *
17190
+ * The element itself counts as well as its descendants: guarding takes focus either way
17191
+ * — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes
17192
+ * outright — so the browser drops focus to `<body>` the moment the guard goes up.
17193
+ */
17194
+ #focusedInside() {
17195
+ const active = document.activeElement;
17196
+ return active instanceof HTMLElement && this.element.contains(active) ? active : null;
17197
+ }
17198
+ /**
17199
+ * Hands focus back to the element the guard took it from.
17200
+ *
17201
+ * Only when that element is still in the document and focus has not moved on since —
17202
+ * anything else is the user's or another controller's, and putting it back would be
17203
+ * taking it.
17204
+ */
17205
+ #refocus() {
17206
+ const target = this.#focused;
17207
+ this.#focused = null;
17208
+ if (!target?.isConnected || document.activeElement !== document.body) return;
17209
+ target.focus();
17210
+ }
16870
17211
  };
16871
17212
  var OWNED_VALUE_TEXT2 = "data-stimeo--progress-owns-valuetext";
16872
17213
  var ProgressController = class extends Controller {
@@ -18175,9 +18516,16 @@ var ReadMoreController = class extends Controller {
18175
18516
  var PROGRESS_PROPERTY = "--stimeo--reading-progress";
18176
18517
  var ReadingProgressController = class extends Controller {
18177
18518
  static events = ["change", "complete"];
18519
+ /** Owns both faces of the published property so teardown can hand them back. */
18520
+ #lease = new StylePropertyLease(PROGRESS_PROPERTY);
18521
+ /** The article's own box and the viewport: either changes the span. */
18522
+ #layout = new LayoutObserver(() => this.#onScroll());
18523
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
18178
18524
  #frame = null;
18179
18525
  /** Last published progress, so `change`/`complete` fire only on movement. */
18180
18526
  #progress = -1;
18527
+ /** False until the connect frame has run: `complete` needs real reading. */
18528
+ #baselined = false;
18181
18529
  #onScroll = () => {
18182
18530
  if (this.#frame !== null) return;
18183
18531
  this.#frame = requestAnimationFrame(() => {
@@ -18187,20 +18535,45 @@ var ReadingProgressController = class extends Controller {
18187
18535
  };
18188
18536
  connect() {
18189
18537
  this.#progress = -1;
18538
+ this.#baselined = false;
18190
18539
  window.addEventListener("scroll", this.#onScroll, { passive: true, capture: true });
18191
- window.addEventListener("resize", this.#onScroll, { passive: true });
18540
+ this.#layout.observe(this.element);
18541
+ this.#layout.observeViewport();
18542
+ this.#beforeCache.activate();
18192
18543
  this.#measure();
18544
+ this.#frame = requestAnimationFrame(() => {
18545
+ this.#frame = null;
18546
+ this.#measure();
18547
+ this.#baselined = true;
18548
+ });
18193
18549
  }
18194
18550
  disconnect() {
18195
18551
  window.removeEventListener("scroll", this.#onScroll, { capture: true });
18196
- window.removeEventListener("resize", this.#onScroll);
18552
+ this.#layout.disconnect();
18553
+ this.#beforeCache.deactivate();
18554
+ this.#cancelFrame();
18555
+ this.#lease.returnAll();
18556
+ }
18557
+ /**
18558
+ * Hands both declarations back before the page is snapshotted, so a restored
18559
+ * page starts from the authored DOM rather than from someone else's progress.
18560
+ * The baseline goes back with them: a cancelled visit leaves this page on
18561
+ * screen, and the next measurement has to publish afresh rather than match a
18562
+ * value that has already been handed back.
18563
+ */
18564
+ #rewindForCache() {
18565
+ this.#cancelFrame();
18566
+ this.#lease.returnAll();
18567
+ this.#progress = -1;
18568
+ }
18569
+ #cancelFrame() {
18197
18570
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
18198
18571
  this.#frame = null;
18199
- document.documentElement.style.removeProperty(PROGRESS_PROPERTY);
18200
18572
  }
18201
18573
  /** Computes and publishes the progress; emits on movement only. */
18202
18574
  #measure() {
18203
18575
  const rect = this.element.getBoundingClientRect();
18576
+ if (rect.width === 0 && rect.height === 0) return;
18204
18577
  const span = rect.height - window.innerHeight;
18205
18578
  const raw = span > 0 ? -rect.top / span : rect.top <= 0 ? 1 : 0;
18206
18579
  const progress = Math.min(1, Math.max(0, raw));
@@ -18208,10 +18581,10 @@ var ReadingProgressController = class extends Controller {
18208
18581
  const previous = this.#progress;
18209
18582
  this.#progress = progress;
18210
18583
  const value = String(progress);
18211
- this.element.style.setProperty(PROGRESS_PROPERTY, value);
18212
- document.documentElement.style.setProperty(PROGRESS_PROPERTY, value);
18584
+ this.#lease.write(this.element, value);
18585
+ this.#lease.write(document.documentElement, value);
18213
18586
  this.dispatch("change", { detail: { progress } });
18214
- if (progress === 1 && previous !== -1) this.dispatch("complete");
18587
+ if (progress === 1 && previous !== -1 && this.#baselined) this.dispatch("complete");
18215
18588
  }
18216
18589
  };
18217
18590
  var YEAR_SCALE = { limit: Number.POSITIVE_INFINITY, unit: "year", ms: 315576e5 };
@@ -18668,6 +19041,7 @@ var ResizableController = class extends Controller {
18668
19041
  return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
18669
19042
  }
18670
19043
  };
19044
+ var STATE_ATTRIBUTES2 = ["disabled", "hidden"];
18671
19045
  var RovingController = class extends Controller {
18672
19046
  static targets = ["item"];
18673
19047
  static values = {
@@ -18677,12 +19051,14 @@ var RovingController = class extends Controller {
18677
19051
  };
18678
19052
  static events = ["change"];
18679
19053
  #roving = new RovingTabindex(() => this.itemTargets);
18680
- #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
19054
+ #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop(true));
18681
19055
  #connected = false;
19056
+ #observer = null;
18682
19057
  connect() {
18683
- this.#ensureTabStop();
19058
+ this.#ensureTabStop(false);
18684
19059
  this.element.addEventListener("keydown", this.#onKeydown);
18685
19060
  this.element.addEventListener("focusin", this.#onFocusin);
19061
+ this.#watchState();
18686
19062
  this.#connected = true;
18687
19063
  this.#reconcile.activate();
18688
19064
  }
@@ -18691,6 +19067,8 @@ var RovingController = class extends Controller {
18691
19067
  this.#reconcile.cancel();
18692
19068
  this.element.removeEventListener("keydown", this.#onKeydown);
18693
19069
  this.element.removeEventListener("focusin", this.#onFocusin);
19070
+ this.#observer?.disconnect();
19071
+ this.#observer = null;
18694
19072
  }
18695
19073
  /** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
18696
19074
  itemTargetConnected(item) {
@@ -18706,10 +19084,9 @@ var RovingController = class extends Controller {
18706
19084
  #onKeydown = (event) => {
18707
19085
  if (event.defaultPrevented) return;
18708
19086
  if (isReservedArrowChord(event)) return;
18709
- const items = this.itemTargets;
19087
+ if (event.isComposing) return;
18710
19088
  const current = this.#indexOf(event.target);
18711
19089
  if (current === -1) return;
18712
- const length = items.length;
18713
19090
  const wrap = this.wrapValue ? "wrap" : "clamp";
18714
19091
  const orientation = this.orientationValue;
18715
19092
  const horizontal = orientation === "horizontal" || orientation === "both";
@@ -18719,16 +19096,16 @@ var RovingController = class extends Controller {
18719
19096
  const backwardKey = rtl ? "ArrowRight" : "ArrowLeft";
18720
19097
  let next;
18721
19098
  if (horizontal && event.key === forwardKey || vertical && event.key === "ArrowDown") {
18722
- next = rovingMove(current, length, 1, wrap);
19099
+ next = this.#step(current, 1, wrap);
18723
19100
  } 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;
19101
+ next = this.#step(current, -1, wrap);
19102
+ } else if (this.homeEndValue && (event.key === "Home" || event.key === "End")) {
19103
+ if (hasModifierChord(event)) return;
19104
+ next = event.key === "Home" ? this.#firstReachable() : this.#lastReachable();
18729
19105
  } else {
18730
19106
  return;
18731
19107
  }
19108
+ if (next === -1) return;
18732
19109
  event.preventDefault();
18733
19110
  this.#activate(next, true);
18734
19111
  };
@@ -18739,7 +19116,8 @@ var RovingController = class extends Controller {
18739
19116
  */
18740
19117
  #onFocusin = (event) => {
18741
19118
  const index = this.#indexOf(event.target);
18742
- if (index !== -1) this.#activate(index, false);
19119
+ if (index === -1 || !this.#reachable(index)) return;
19120
+ this.#activate(index, false);
18743
19121
  };
18744
19122
  /** Resolves the item index owning an event target (the item or a descendant). */
18745
19123
  #indexOf(target) {
@@ -18755,66 +19133,106 @@ var RovingController = class extends Controller {
18755
19133
  this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
18756
19134
  }
18757
19135
  }
18758
- /** Keeps the first existing Tab stop, falling back to the first live item. */
18759
- #ensureTabStop() {
19136
+ /**
19137
+ * Keeps the Tab stop on a reachable item.
19138
+ *
19139
+ * `followFocus` is on for re-establishment only: an item added and focused in
19140
+ * the same task has already claimed the stop through `focusin`, and the batch
19141
+ * that follows must not hand it back to the first item. `connect()` passes it
19142
+ * off so the authored DOM decides the initial stop. With nothing reachable the
19143
+ * DOM is left as it is — a group inside a collapsed region gets its stop back
19144
+ * when the region opens, instead of losing it for good.
19145
+ */
19146
+ #ensureTabStop(followFocus) {
19147
+ const items = this.itemTargets;
19148
+ const focused = followFocus ? items.findIndex((item, i) => item === document.activeElement && this.#reachable(i)) : -1;
18760
19149
  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;
19150
+ const kept = active !== -1 && this.#reachable(active) ? active : -1;
19151
+ const index = focused !== -1 ? focused : kept !== -1 ? kept : this.#firstReachable();
19152
+ if (index === -1) return;
19153
+ this.#roving.setActive(index);
18772
19154
  }
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
- });
19155
+ /**
19156
+ * Resolves the next reachable index in `delta`'s direction, honouring `wrap`.
19157
+ *
19158
+ * An arrow on the widget's own axis is the widget's to consume even when the
19159
+ * position does not change, so a clamped end resolves to the current item
19160
+ * rather than to nothing. An unreachable origin escapes to the first reachable
19161
+ * item instead of sitting in a dead end, and `-1` is left for the one case that
19162
+ * really is not ours: no reachable item anywhere.
19163
+ */
19164
+ #step(current, delta, wrap) {
19165
+ const length = this.itemTargets.length;
19166
+ let index = current;
19167
+ for (let taken = 0; taken < length; taken += 1) {
19168
+ const candidate = rovingMove(index, length, delta, wrap);
19169
+ if (candidate === index) break;
19170
+ index = candidate;
19171
+ if (this.#reachable(index)) return index;
19172
+ }
19173
+ return this.#reachable(current) ? current : this.#firstReachable();
19174
+ }
19175
+ /** Index of the first reachable item, or `-1`. */
19176
+ #firstReachable() {
19177
+ return this.itemTargets.findIndex((_item, i) => this.#reachable(i));
19178
+ }
19179
+ /** Index of the last reachable item, or `-1`. */
19180
+ #lastReachable() {
19181
+ for (let i = this.itemTargets.length - 1; i >= 0; i -= 1) {
19182
+ if (this.#reachable(i)) return i;
18786
19183
  }
18787
- this.#reflect(element, value, priority);
19184
+ return -1;
18788
19185
  }
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
- }
19186
+ /**
19187
+ * Whether the item at `index` can hold the Tab stop and take focus.
19188
+ *
19189
+ * `aria-disabled` is deliberately not consulted: it keeps an item reachable and
19190
+ * only suppresses activation, which this part does not own.
19191
+ */
19192
+ #reachable(index) {
19193
+ const item = this.itemTargets[index];
19194
+ if (!item) return false;
19195
+ if (this.#hidden(item)) return false;
19196
+ if (!("disabled" in item)) return true;
19197
+ if (item.disabled) return false;
19198
+ return !inheritsFieldsetDisabled(item);
18799
19199
  }
18800
- /** Returns every outstanding declaration lease. */
18801
- returnAll() {
18802
- for (const element of Array.from(this.#records.keys())) this.return(element);
19200
+ /**
19201
+ * Whether `item`, or anything between it and the container, is `hidden`.
19202
+ *
19203
+ * The walk stops at the container on purpose: a group inside a hidden region is
19204
+ * already out of the page's Tab order, and calling every item unreachable there
19205
+ * would drop the stop with nothing left to restore it — the ancestor lies
19206
+ * outside the subtree whose state attributes are watched.
19207
+ */
19208
+ #hidden(item) {
19209
+ let node = item;
19210
+ while (node && node !== this.element) {
19211
+ if (node.hasAttribute("hidden")) return true;
19212
+ node = node.parentElement;
19213
+ }
19214
+ return false;
18803
19215
  }
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;
19216
+ /**
19217
+ * Watches the attributes that decide reachability, so disabling the item that
19218
+ * holds the Tab stop hands it to another one instead of taking the whole set
19219
+ * out of the Tab sequence. An enclosing `fieldset` disables items from outside
19220
+ * the observed subtree, so each one's own `disabled` is watched too.
19221
+ */
19222
+ #watchState() {
19223
+ if (typeof MutationObserver === "undefined") return;
19224
+ const observer = new MutationObserver(() => this.#reconcile.schedule());
19225
+ observer.observe(this.element, {
19226
+ subtree: true,
19227
+ attributes: true,
19228
+ attributeFilter: STATE_ATTRIBUTES2
19229
+ });
19230
+ for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
19231
+ observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
18811
19232
  }
18812
- if (value === null) style.removeProperty(this.#property);
18813
- else style.setProperty(this.#property, value, priority);
19233
+ this.#observer = observer;
18814
19234
  }
18815
19235
  };
18816
-
18817
- // src/controllers/scroll_area_controller.ts
18818
19236
  var EDGE_EPSILON = 1;
18819
19237
  var ScrollAreaController = class extends Controller {
18820
19238
  static targets = ["viewport"];
@@ -20690,19 +21108,32 @@ var SliderController = class extends Controller {
20690
21108
  drag?.pointer?.end();
20691
21109
  }
20692
21110
  };
21111
+ var DEFAULT_OFFSET2 = 80;
20693
21112
  var SmartStickyHeaderController = class extends Controller {
20694
21113
  static values = {
20695
21114
  containerSelector: { type: String, default: "" },
20696
- offset: { type: Number, default: 80 },
21115
+ offset: { type: Number, default: DEFAULT_OFFSET2 },
20697
21116
  tolerance: { type: Number, default: 4 }
20698
21117
  };
20699
21118
  static events = ["change"];
21119
+ #connected = false;
20700
21120
  #frame = null;
20701
21121
  /** The scroll source resolved at connect — disconnect must unbind the SAME node. */
20702
21122
  #scrollerEl = window;
21123
+ /** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
21124
+ #containerSelector = "";
20703
21125
  #lastY = 0;
20704
21126
  /** Last published state, so `change` fires only on transitions. */
20705
21127
  #hidden = null;
21128
+ /**
21129
+ * The depth that never hides. A declaration that is not a finite number reads
21130
+ * as the default, so the comparison path never sees `NaN` — which would
21131
+ * answer `false` to every comparison and hide the header inside the very
21132
+ * zone the value exists to protect.
21133
+ */
21134
+ get #offset() {
21135
+ return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET2;
21136
+ }
20706
21137
  #onScroll = () => {
20707
21138
  if (this.#frame !== null) return;
20708
21139
  this.#frame = requestAnimationFrame(() => {
@@ -20715,15 +21146,25 @@ var SmartStickyHeaderController = class extends Controller {
20715
21146
  * hold while focus *stays* inside is the `#apply` hide invariant.
20716
21147
  */
20717
21148
  #onFocusin = () => this.#apply(false);
21149
+ /** Validates `containerSelector` once so connect never parses a selector that throws. */
21150
+ containerSelectorValueChanged() {
21151
+ this.#containerSelector = this.#validSelector(this.containerSelectorValue);
21152
+ }
21153
+ /** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
21154
+ offsetValueChanged() {
21155
+ if (this.#connected) this.#measure();
21156
+ }
20718
21157
  connect() {
20719
21158
  this.#hidden = null;
20720
21159
  this.#scrollerEl = this.#resolveScroller();
20721
21160
  this.#lastY = this.#scrollY;
20722
21161
  this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
20723
21162
  this.element.addEventListener("focusin", this.#onFocusin);
20724
- this.#apply(false);
21163
+ this.#apply(false, false);
21164
+ this.#connected = true;
20725
21165
  }
20726
21166
  disconnect() {
21167
+ this.#connected = false;
20727
21168
  this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
20728
21169
  this.element.removeEventListener("focusin", this.#onFocusin);
20729
21170
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
@@ -20731,43 +21172,69 @@ var SmartStickyHeaderController = class extends Controller {
20731
21172
  }
20732
21173
  /** Resolves the scroll source: the `containerSelector` match, else the window. */
20733
21174
  #resolveScroller() {
20734
- if (this.containerSelectorValue) {
20735
- const container = document.querySelector(this.containerSelectorValue);
21175
+ if (this.#containerSelector) {
21176
+ const container = document.querySelector(this.#containerSelector);
20736
21177
  if (container) return container;
20737
21178
  }
20738
21179
  return window;
20739
21180
  }
21181
+ /** Returns `declared` when it parses as a selector, and `""` when it does not. */
21182
+ #validSelector(declared) {
21183
+ if (declared.length > 0) {
21184
+ try {
21185
+ this.element.matches(declared);
21186
+ return declared;
21187
+ } catch {
21188
+ }
21189
+ }
21190
+ return "";
21191
+ }
20740
21192
  get #scrollY() {
20741
21193
  const scroller = this.#scrollerEl;
20742
21194
  return scroller === window ? window.scrollY : scroller.scrollTop;
20743
21195
  }
20744
21196
  #measure() {
20745
21197
  const y = this.#scrollY;
21198
+ if (y <= this.#offset) {
21199
+ this.#lastY = y;
21200
+ this.#apply(false);
21201
+ return;
21202
+ }
20746
21203
  const delta = y - this.#lastY;
20747
21204
  if (Math.abs(delta) < this.toleranceValue) return;
20748
21205
  this.#lastY = y;
20749
- if (y <= this.offsetValue) this.#apply(false);
20750
- else if (delta > 0) this.#apply(true);
20751
- else this.#apply(false);
21206
+ this.#apply(delta > 0);
20752
21207
  }
20753
- /** Reflects the state onto the hook and emits `change` on transitions. */
20754
- #apply(hidden) {
21208
+ /**
21209
+ * Reflects the state onto the hook and emits `change` on transitions.
21210
+ *
21211
+ * @param notify - whether a transition announces itself. The reflection
21212
+ * `connect()` performs is the current state, not a change.
21213
+ */
21214
+ #apply(hidden, notify = true) {
20755
21215
  if (hidden && this.element.contains(document.activeElement)) return;
20756
21216
  if (hidden === this.#hidden) return;
20757
21217
  this.#hidden = hidden;
20758
21218
  this.element.setAttribute("data-header-hidden", hidden ? "true" : "false");
20759
- this.dispatch("change", { detail: { hidden } });
21219
+ if (notify) this.dispatch("change", { detail: { hidden } });
20760
21220
  }
20761
21221
  };
20762
21222
  var SortableController = class extends Controller {
20763
- static targets = ["list", "item", "status"];
21223
+ static targets = ["list", "item"];
20764
21224
  static values = {
20765
- orientation: { type: String, default: "vertical" }
21225
+ orientation: { type: String, default: "vertical" },
21226
+ announceGrabbedText: { type: String, default: "" },
21227
+ announceMovedText: { type: String, default: "" },
21228
+ announceDroppedText: { type: String, default: "" },
21229
+ announceCanceledText: { type: String, default: "" }
20766
21230
  };
20767
21231
  static events = ["reorder"];
20768
21232
  #session = null;
21233
+ /** Defers the lost-item check to after the mutation batch (see below). */
21234
+ #settle = new MicrotaskCoalescer(() => this.#dropLostSession());
20769
21235
  connect() {
20770
21236
  this.element.removeAttribute("data-sortable-dragging");
21237
+ this.#settle.activate();
20771
21238
  this.element.addEventListener("stimeo--pointer-drag:start", this.#onDragStart);
20772
21239
  this.element.addEventListener("stimeo--pointer-drag:move", this.#onDragMove);
20773
21240
  this.element.addEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
@@ -20778,49 +21245,121 @@ var SortableController = class extends Controller {
20778
21245
  this.element.removeEventListener("stimeo--pointer-drag:move", this.#onDragMove);
20779
21246
  this.element.removeEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
20780
21247
  this.element.removeEventListener("stimeo--pointer-drag:cancel", this.#onDragCancel);
21248
+ this.#settle.cancel();
21249
+ this.#session = null;
21250
+ this.element.removeAttribute("data-sortable-dragging");
21251
+ }
21252
+ /**
21253
+ * Ends a session whose item left the item set.
21254
+ *
21255
+ * The controller's own reorder detaches and reattaches the item inside one
21256
+ * mutation batch, so the loss is only real once the batch has settled — the
21257
+ * check therefore runs a microtask later and asks whether the item is a target
21258
+ * again. Without it a deleted row (a broadcast that drops it from the board)
21259
+ * or a morph that strips the item's target attribute would hold the
21260
+ * one-at-a-time session for the rest of the page's life, with the root hook
21261
+ * stuck on and every later grab refused with no way out.
21262
+ */
21263
+ itemTargetDisconnected(item) {
21264
+ if (this.#session?.item !== item) return;
21265
+ this.#settle.schedule();
21266
+ }
21267
+ #dropLostSession() {
21268
+ const session = this.#session;
21269
+ if (!session) return;
21270
+ const items = this.#items();
21271
+ if (items.includes(session.item)) return;
20781
21272
  this.#session = null;
20782
21273
  this.element.removeAttribute("data-sortable-dragging");
21274
+ if (session.item.isConnected) this.#restore(session, items);
20783
21275
  }
20784
- /** Picks the item up: remembers its origin and announces the grab. */
21276
+ /** Picks the item up: remembers its neighbours and announces the grab. */
20785
21277
  #onDragStart = (event) => {
20786
21278
  if (this.#session) return;
20787
- const item = this.#itemFor(event.target);
21279
+ const items = this.#items();
21280
+ const item = this.#itemFor(event.target, items);
20788
21281
  if (!item) return;
20789
- this.#session = { item, from: this.#items().indexOf(item), lastPrimary: 0 };
21282
+ const index = items.indexOf(item);
21283
+ this.#session = {
21284
+ item,
21285
+ anchor: items[index + 1] ?? null,
21286
+ predecessor: items[index - 1] ?? null,
21287
+ lastPrimary: 0
21288
+ };
20790
21289
  this.element.setAttribute("data-sortable-dragging", "true");
20791
21290
  this.#announce("grabbed", item);
20792
21291
  };
20793
21292
  #onDragMove = (event) => {
20794
21293
  const session = this.#session;
21294
+ if (!session) return;
21295
+ const items = this.#items();
21296
+ if (this.#itemFor(event.target, items) !== session.item) return;
20795
21297
  const detail = event.detail;
20796
- if (!session || this.#itemFor(event.target) !== session.item) return;
20797
21298
  if (detail.pointerType === "keyboard") {
20798
- this.#stepFromKeyboard(session, detail);
21299
+ this.#stepFromKeyboard(session, detail, items);
20799
21300
  } else {
20800
- this.#followPointer(session, detail);
21301
+ this.#followPointer(session, detail, items);
20801
21302
  }
20802
21303
  };
20803
21304
  /** Drops the item: announces, then reports `reorder` if the position changed. */
20804
21305
  #onDragEnd = (event) => {
20805
21306
  const session = this.#session;
20806
- if (!session || this.#itemFor(event.target) !== session.item) return;
21307
+ if (!session) return;
21308
+ const items = this.#items();
21309
+ if (this.#itemFor(event.target, items) !== session.item) return;
20807
21310
  this.#session = null;
20808
21311
  this.element.removeAttribute("data-sortable-dragging");
20809
21312
  this.#announce("dropped", session.item);
20810
- const to = this.#items().indexOf(session.item);
20811
- if (to !== session.from) {
20812
- this.dispatch("reorder", { detail: { item: session.item, from: session.from, to } });
21313
+ const to = items.indexOf(session.item);
21314
+ const from = this.#pickupSlot(session, items) ?? to;
21315
+ if (to !== from) {
21316
+ this.dispatch("reorder", { detail: { item: session.item, from, to } });
20813
21317
  }
20814
21318
  };
20815
21319
  /** Restores the pickup position (Escape / OS `pointercancel`). */
20816
21320
  #onDragCancel = (event) => {
20817
21321
  const session = this.#session;
20818
- if (!session || this.#itemFor(event.target) !== session.item) return;
21322
+ if (!session) return;
21323
+ const items = this.#items();
21324
+ if (this.#itemFor(event.target, items) !== session.item) return;
20819
21325
  this.#session = null;
20820
21326
  this.element.removeAttribute("data-sortable-dragging");
20821
- this.#moveTo(session.item, session.from);
21327
+ this.#restore(session, items);
20822
21328
  this.#announce("canceled", session.item);
20823
21329
  };
21330
+ /**
21331
+ * The slot the item was picked up from, read back through the neighbours it
21332
+ * had then — `null` when neither survives and the item itself is gone.
21333
+ *
21334
+ * The anchor is the item that followed it, so restoring means "before that one
21335
+ * again". When the anchor was deleted mid-drag the predecessor answers the
21336
+ * same question from the other side. With both gone the item's current slot is
21337
+ * the honest answer: nothing is known to have moved, so no reorder is reported
21338
+ * and a cancel leaves the item where it is.
21339
+ */
21340
+ #pickupSlot(session, items) {
21341
+ const others = items.filter((candidate) => candidate !== session.item);
21342
+ if (session.anchor) {
21343
+ const at = others.indexOf(session.anchor);
21344
+ if (at !== -1) return at;
21345
+ }
21346
+ if (session.predecessor) {
21347
+ const at = others.indexOf(session.predecessor);
21348
+ if (at !== -1) return at + 1;
21349
+ }
21350
+ const here = items.indexOf(session.item);
21351
+ return here === -1 ? null : here;
21352
+ }
21353
+ /** Puts the item back where it was picked up from. */
21354
+ #restore(session, items) {
21355
+ const slot = this.#pickupSlot(session, items);
21356
+ if (slot === null) return;
21357
+ this.#insertAt(
21358
+ session.item,
21359
+ items.filter((candidate) => candidate !== session.item),
21360
+ slot
21361
+ );
21362
+ }
20824
21363
  /**
20825
21364
  * Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
20826
21365
  * the difference from the last consumed value is one arrow press — its sign is
@@ -20832,77 +21371,116 @@ var SortableController = class extends Controller {
20832
21371
  * `roving` already moves focus logically, so the same arrow would send the
20833
21372
  * focus and the grabbed item opposite ways.
20834
21373
  */
20835
- #stepFromKeyboard(session, detail) {
21374
+ #stepFromKeyboard(session, detail, items) {
20836
21375
  const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
20837
21376
  const delta = primary - session.lastPrimary;
20838
21377
  session.lastPrimary = primary;
20839
21378
  if (delta === 0) return;
20840
- const items = this.#items();
20841
21379
  const index = items.indexOf(session.item);
20842
21380
  const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
20843
21381
  const next = Math.max(0, Math.min(index + step, items.length - 1));
20844
21382
  if (next === index) return;
20845
- this.#moveTo(session.item, next);
21383
+ this.#insertAt(
21384
+ session.item,
21385
+ items.filter((candidate) => candidate !== session.item),
21386
+ next
21387
+ );
20846
21388
  this.#announce("moved", session.item);
20847
21389
  }
20848
21390
  /**
20849
21391
  * Pointer following: the item moves to the slot whose siblings' midpoints the
20850
- * pointer has passed (per `orientation`). Skipped when the list has no layout
20851
- * geometry (every rect is zero — nothing meaningful to compare against).
20852
- */
20853
- #followPointer(session, detail) {
21392
+ * pointer has passed (per `orientation`).
21393
+ *
21394
+ * Only siblings that occupy space take part. A row with no layout box — a
21395
+ * filtered-out item, a `display: none` ancestor, a collapsed `<details>` — is
21396
+ * reported with an empty rect at the document origin, and its midpoint of `0`
21397
+ * sits below every pointer position: counted, it would read as passed on the
21398
+ * very first move and send the item across a slot the pointer never crossed.
21399
+ * With no laid-out sibling at all there is nothing to compare against and the
21400
+ * move is skipped entirely.
21401
+ */
21402
+ #followPointer(session, detail, items) {
20854
21403
  const pointer = Number(this.#isVertical ? detail.y : detail.x) || 0;
20855
- const others = this.#items().filter((item) => item !== session.item);
20856
- if (others.length === 0) return;
20857
- let laidOut = false;
20858
- let target = 0;
21404
+ const vertical = this.#isVertical;
20859
21405
  const reversed = this.#isReversed;
20860
- for (const other of others) {
20861
- const rect = other.getBoundingClientRect();
20862
- if (rect.width > 0 || rect.height > 0) laidOut = true;
20863
- const midpoint = this.#isVertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
20864
- const precedes = reversed ? pointer < midpoint : pointer > midpoint;
20865
- if (precedes) target += 1;
20866
- }
20867
- if (!laidOut) return;
20868
- const current = this.#items().indexOf(session.item);
20869
- if (target !== current) {
20870
- this.#moveTo(session.item, target);
20871
- this.#announce("moved", session.item);
20872
- }
20873
- }
20874
- /** Reinserts `item` so it lands at `index` among the list's items. */
20875
- #moveTo(item, index) {
20876
- const others = this.#items().filter((candidate) => candidate !== item);
20877
- const clamped = Math.max(0, Math.min(index, others.length));
20878
- const reference = others[clamped] ?? null;
21406
+ const here = items.indexOf(session.item);
21407
+ const laidOut = [];
21408
+ let target = 0;
21409
+ let current = 0;
21410
+ items.forEach((item, index) => {
21411
+ if (item === session.item) return;
21412
+ const rect = item.getBoundingClientRect();
21413
+ if (rect.width === 0 && rect.height === 0) return;
21414
+ if (index < here) current += 1;
21415
+ laidOut.push(item);
21416
+ const midpoint = vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
21417
+ if (reversed ? pointer < midpoint : pointer > midpoint) target += 1;
21418
+ });
21419
+ if (laidOut.length === 0 || target === current) return;
21420
+ this.#insertAt(session.item, laidOut, target);
21421
+ this.#announce("moved", session.item);
21422
+ }
21423
+ /**
21424
+ * Reinserts `item` at `index` among `scope`, relative to the sibling already
21425
+ * standing there.
21426
+ *
21427
+ * The reorder is defined against the items, not against a container: the
21428
+ * neighbour's own parent is where the item belongs. A `list` target that is
21429
+ * not the items' parent — or none at all, which the markup contract allows —
21430
+ * therefore still lands the move in the right place instead of throwing, and
21431
+ * the last slot is *after the last item* rather than after whatever else the
21432
+ * container holds (a live region, a footer), which would put the row outside
21433
+ * the reading order the list publishes.
21434
+ */
21435
+ #insertAt(item, scope, index) {
21436
+ const clamped = Math.max(0, Math.min(index, scope.length));
21437
+ const ahead = scope[clamped] ?? null;
21438
+ const behind = ahead ? null : scope[scope.length - 1] ?? null;
21439
+ const neighbour = ahead ?? behind;
21440
+ if (!neighbour) return;
21441
+ const parent = neighbour.parentNode;
21442
+ if (!parent) return;
20879
21443
  const active = document.activeElement;
20880
21444
  const hadFocus = active instanceof HTMLElement && item.contains(active);
20881
- this.#list.insertBefore(item, reference);
21445
+ parent.insertBefore(item, ahead ?? neighbour.nextSibling);
20882
21446
  if (hadFocus) active.focus();
20883
21447
  }
20884
21448
  /**
20885
- * Mirrors a step into the `status` live region. Copy is localizable through
20886
- * `data-grabbed` / `data-moved` / `data-dropped` / `data-canceled` templates on
20887
- * the status element (`%{name}` / `%{position}` / `%{total}` placeholders);
20888
- * terse English is the fallback.
21449
+ * Hands one step to the page's shared announcer.
21450
+ *
21451
+ * The library carries no live region and no English copy: the wording is the
21452
+ * consumer's, written into `announceGrabbedText` / `announceMovedText` /
21453
+ * `announceDroppedText` / `announceCanceledText` with `{name}` / `{position}` /
21454
+ * `{total}` placeholders, and an unset one announces nothing.
21455
+ *
21456
+ * Only transitions reach here — the pickup, a step that actually changed the
21457
+ * landing slot, and the single end of the session — so a pointer crossing the
21458
+ * same slot twice or an arrow clamped at an end stays silent.
20889
21459
  */
20890
21460
  #announce(key, item) {
20891
- if (!this.hasStatusTarget) return;
20892
- const position = String(this.#items().indexOf(item) + 1);
20893
- const total = String(this.#items().length);
20894
- const name = this.#nameOf(item);
20895
- const fallback = {
20896
- grabbed: `Grabbed ${name}, position ${position} of ${total}`,
20897
- moved: `${name}, position ${position} of ${total}`,
20898
- dropped: `Dropped ${name} at position ${position} of ${total}`,
20899
- canceled: `Reorder canceled, ${name} returned to position ${position} of ${total}`
20900
- };
20901
- const values = { name, position, total };
20902
- const template = this.statusTarget.dataset[key];
20903
- this.statusTarget.textContent = template ? template.replace(/%\{(name|position|total)\}/g, (match, token) => {
20904
- return values[token] ?? match;
20905
- }) : fallback[key];
21461
+ const template = this.#announceTemplate(key);
21462
+ if (template.length === 0) return;
21463
+ const items = this.#items();
21464
+ announce(
21465
+ fillTemplate(template, {
21466
+ name: this.#nameOf(item),
21467
+ position: items.indexOf(item) + 1,
21468
+ total: items.length
21469
+ })
21470
+ );
21471
+ }
21472
+ /** The consumer's wording for one step, or `""` when they authored none. */
21473
+ #announceTemplate(key) {
21474
+ switch (key) {
21475
+ case "grabbed":
21476
+ return this.announceGrabbedTextValue;
21477
+ case "moved":
21478
+ return this.announceMovedTextValue;
21479
+ case "dropped":
21480
+ return this.announceDroppedTextValue;
21481
+ case "canceled":
21482
+ return this.announceCanceledTextValue;
21483
+ }
20906
21484
  }
20907
21485
  /** The announced item name: the authored override, else its collapsed text. */
20908
21486
  #nameOf(item) {
@@ -20910,11 +21488,17 @@ var SortableController = class extends Controller {
20910
21488
  if (authored) return authored;
20911
21489
  return (item.textContent ?? "").replace(/\s+/g, " ").trim();
20912
21490
  }
20913
- /** Resolves the sortable item owning a bubbled `pointer-drag` event. */
20914
- #itemFor(target) {
20915
- const node = target;
20916
- if (!node) return null;
20917
- return this.#items().find((item) => item === node || item.contains(node)) ?? null;
21491
+ /**
21492
+ * Resolves the sortable item owning a bubbled `pointer-drag` event.
21493
+ *
21494
+ * `pointer-drag` dispatches on its own element, and the markup contract puts
21495
+ * one on each item, so the owner is the item that **is** the target. Matching
21496
+ * an ancestor instead would make a card's own inner draggable — a knob, a
21497
+ * split pane, anything the primitive is composed into — drive the card.
21498
+ */
21499
+ #itemFor(target, items) {
21500
+ if (!target) return null;
21501
+ return items.find((item) => item === target) ?? null;
20918
21502
  }
20919
21503
  /** The items in live DOM order (targets re-query the DOM on every access). */
20920
21504
  #items() {
@@ -21283,6 +21867,7 @@ var StepperController = class extends Controller {
21283
21867
  return Math.min(last, Math.max(0, Math.trunc(index)));
21284
21868
  }
21285
21869
  };
21870
+ var DEFAULT_THRESHOLD = 80;
21286
21871
  var countElements = (nodes) => {
21287
21872
  let n = 0;
21288
21873
  for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
@@ -21291,34 +21876,53 @@ var countElements = (nodes) => {
21291
21876
  var StickToBottomController = class extends Controller {
21292
21877
  static targets = ["content"];
21293
21878
  static values = {
21294
- threshold: { type: Number, default: 80 },
21879
+ threshold: { type: Number, default: DEFAULT_THRESHOLD },
21295
21880
  behavior: { type: String, default: "auto" },
21296
21881
  pinOnConnect: { type: Boolean, default: false }
21297
21882
  };
21298
21883
  static actions = ["scrollToBottom"];
21299
21884
  static events = ["pin", "new"];
21300
21885
  #observer = null;
21301
- /** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
21302
- #layout = null;
21886
+ /** The element the append observer currently holds, so a swap can be detected. */
21887
+ #watched = null;
21888
+ /** Watches for the box a container connected without one is still waiting on. */
21889
+ #layout = new LayoutObserver(() => this.#onLaidOut());
21890
+ #awaitingLayout = false;
21891
+ #connected = false;
21303
21892
  #pinned = false;
21304
21893
  #onScroll = () => this.#updatePinned();
21305
21894
  connect() {
21895
+ this.#connected = true;
21306
21896
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
21897
+ this.element.removeAttribute("data-has-new");
21307
21898
  this.#pinned = this.#isPinned();
21308
21899
  this.#reflectPinned();
21309
21900
  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();
21901
+ this.#syncWatched();
21902
+ if (!this.#measurable()) this.#waitForLayout();
21315
21903
  }
21316
21904
  disconnect() {
21905
+ this.#connected = false;
21317
21906
  this.element.removeEventListener("scroll", this.#onScroll);
21318
- this.#observer?.disconnect();
21319
- this.#observer = null;
21907
+ this.#stopWatching();
21320
21908
  this.#stopWaitingForLayout();
21321
21909
  }
21910
+ /** Moves the append watch onto a `content` target that arrived at runtime. */
21911
+ contentTargetConnected() {
21912
+ this.#syncWatched();
21913
+ }
21914
+ /** Moves the append watch off a `content` target that left, back onto the container. */
21915
+ contentTargetDisconnected() {
21916
+ this.#syncWatched();
21917
+ }
21918
+ /**
21919
+ * Re-derives pinned when the distance that counts as the bottom is changed at runtime
21920
+ * (a morph that swaps the attribute on a retained element).
21921
+ */
21922
+ thresholdValueChanged() {
21923
+ if (!this.#connected) return;
21924
+ this.#updatePinned();
21925
+ }
21322
21926
  /**
21323
21927
  * Jumps to the bottom and re-pins (wired to a "new messages" button).
21324
21928
  *
@@ -21347,7 +21951,11 @@ var StickToBottomController = class extends Controller {
21347
21951
  this.dispatch("new", { detail: { count: added } });
21348
21952
  }
21349
21953
  }
21350
- /** Recomputes pinned from the scroll position and reflects it on a transition. */
21954
+ /**
21955
+ * Recomputes pinned from the scroll position and reflects it on a transition.
21956
+ *
21957
+ * @stimeoRenderRoot
21958
+ */
21351
21959
  #updatePinned() {
21352
21960
  const pinned = this.#isPinned();
21353
21961
  if (pinned === this.#pinned) return;
@@ -21364,9 +21972,25 @@ var StickToBottomController = class extends Controller {
21364
21972
  this.element.removeAttribute("data-pinned");
21365
21973
  }
21366
21974
  }
21975
+ /** Whether the container currently sits within `threshold` of its bottom. */
21367
21976
  #isPinned() {
21977
+ if (!this.#measurable()) return false;
21368
21978
  const el = this.element;
21369
- return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
21979
+ return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
21980
+ }
21981
+ /**
21982
+ * The distance from the bottom that counts as pinned: a finite, non-negative number of
21983
+ * pixels. Anything else names no distance the container can be at, and settles the
21984
+ * comparison the same way at every scroll position, so it falls back to the default.
21985
+ * `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
21986
+ * distance sits below the closest the container ever gets; `Infinity` is never
21987
+ * exceeded. The first two stop following and flag every append as new, and the last
21988
+ * never stops following — it takes the reading position the flag exists to protect.
21989
+ * Zero is a real declaration: it pins at the exact bottom only.
21990
+ */
21991
+ get #threshold() {
21992
+ const declared = this.thresholdValue;
21993
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THRESHOLD;
21370
21994
  }
21371
21995
  /**
21372
21996
  * Whether the container has a box to scroll and to measure. One that is not rendered
@@ -21377,23 +22001,25 @@ var StickToBottomController = class extends Controller {
21377
22001
  return this.element.clientHeight > 0;
21378
22002
  }
21379
22003
  /**
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.
22004
+ * Holds the pinned decision until the container is laid out — otherwise the panel opens
22005
+ * at the top while the state claims the bottom, and the appends that arrived meanwhile
22006
+ * were followed into a box that could not move rather than flagged.
21382
22007
  */
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
- });
22008
+ #waitForLayout() {
22009
+ this.#awaitingLayout = true;
21391
22010
  this.#layout.observe(this.element);
21392
22011
  }
21393
- /** Releases the layout watch, whether or not the deferred jump ever ran. */
22012
+ /** Runs the held decision once the container has the box it was waiting for. */
22013
+ #onLaidOut() {
22014
+ if (!this.#awaitingLayout || !this.#measurable()) return;
22015
+ this.#stopWaitingForLayout();
22016
+ if (this.pinOnConnectValue) this.#scrollToBottom("instant");
22017
+ this.#updatePinned();
22018
+ }
22019
+ /** Releases the layout watch, whether or not the held decision ever ran. */
21394
22020
  #stopWaitingForLayout() {
21395
- this.#layout?.disconnect();
21396
- this.#layout = null;
22021
+ this.#awaitingLayout = false;
22022
+ this.#layout.disconnect();
21397
22023
  }
21398
22024
  /**
21399
22025
  * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
@@ -21409,9 +22035,32 @@ var StickToBottomController = class extends Controller {
21409
22035
  this.element.scrollTop = top;
21410
22036
  }
21411
22037
  }
21412
- /** The append-watched element: the `content` target, or the container itself. */
21413
- #watched() {
21414
- return this.hasContentTarget ? this.contentTarget : this.element;
22038
+ /**
22039
+ * Points the append watch at the current `content` target, or at the container when
22040
+ * there is none. Re-resolved whenever that target changes, so a swap does not leave the
22041
+ * observer holding a detached node whose appends nobody sees.
22042
+ *
22043
+ * Stimulus runs the target callbacks outside the connected window too — before
22044
+ * `connect()` for a target already in the DOM, and after `disconnect()` while the
22045
+ * element is torn down — where this would arm an observer nothing releases. Re-syncing
22046
+ * to the target already held is left alone, so an arrival still in flight is not
22047
+ * dropped with the observer that was about to deliver it.
22048
+ */
22049
+ #syncWatched() {
22050
+ if (!this.#connected) return;
22051
+ const next = this.hasContentTarget ? this.contentTarget : this.element;
22052
+ if (next === this.#watched) return;
22053
+ this.#stopWatching();
22054
+ this.#watched = next;
22055
+ if (typeof MutationObserver === "undefined") return;
22056
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
22057
+ this.#observer.observe(next, { childList: true });
22058
+ }
22059
+ /** Releases the append watch and the element it held. */
22060
+ #stopWatching() {
22061
+ this.#observer?.disconnect();
22062
+ this.#observer = null;
22063
+ this.#watched = null;
21415
22064
  }
21416
22065
  /**
21417
22066
  * The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
@@ -23673,7 +24322,7 @@ var ToggleGroupController = class extends Controller {
23673
24322
  return item.getAttribute("data-value") ?? "";
23674
24323
  }
23675
24324
  };
23676
- var STATE_ATTRIBUTES2 = ["disabled", "hidden"];
24325
+ var STATE_ATTRIBUTES3 = ["disabled", "hidden"];
23677
24326
  var ToolbarController = class extends Controller {
23678
24327
  static targets = ["control"];
23679
24328
  static values = {
@@ -23702,7 +24351,7 @@ var ToolbarController = class extends Controller {
23702
24351
  subtree: true,
23703
24352
  childList: true,
23704
24353
  attributes: true,
23705
- attributeFilter: STATE_ATTRIBUTES2
24354
+ attributeFilter: STATE_ATTRIBUTES3
23706
24355
  });
23707
24356
  for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
23708
24357
  observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });