stimeo-ui 0.11.0 → 0.13.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
@@ -1886,6 +1886,15 @@ function parseISOMonthString(monthStr) {
1886
1886
  function toISOMonthString(date) {
1887
1887
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
1888
1888
  }
1889
+ function monthLabelFormatter(locale) {
1890
+ const options = { month: "long", year: "numeric" };
1891
+ try {
1892
+ return new Intl.DateTimeFormat(locale, options);
1893
+ } catch (error) {
1894
+ if (!(error instanceof RangeError)) throw error;
1895
+ return new Intl.DateTimeFormat("en", options);
1896
+ }
1897
+ }
1889
1898
 
1890
1899
  // src/controllers/calendar_controller.ts
1891
1900
  var OWNED_DISABLED = "data-stimeo--calendar-owns-disabled";
@@ -2070,8 +2079,7 @@ var CalendarController = class extends Controller {
2070
2079
  const month = monthStart.getMonth() + 1;
2071
2080
  if (this.hasLabelTarget) {
2072
2081
  const lang = document.documentElement.lang || "en";
2073
- const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
2074
- this.labelTarget.textContent = formatter.format(monthStart);
2082
+ this.labelTarget.textContent = monthLabelFormatter(lang).format(monthStart);
2075
2083
  }
2076
2084
  const days = this.#calculateGridDays(year, month);
2077
2085
  const dayElements = this.dayTargets;
@@ -2323,7 +2331,7 @@ var CarouselController = class extends Controller {
2323
2331
  #handledEvents = /* @__PURE__ */ new WeakSet();
2324
2332
  /**
2325
2333
  * Whether `connect()` has run. Scheduling is already inert outside that window
2326
- * ({@link MicrotaskCoalescer}), so this gates the Value callbacks Stimulus
2334
+ * (`MicrotaskCoalescer`), so this gates the Value callbacks Stimulus
2327
2335
  * delivers ahead of `connect()`.
2328
2336
  */
2329
2337
  #connected = false;
@@ -4383,7 +4391,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
4383
4391
  * re-deriving from the declarative `open` Value (which would slam a user-opened
4384
4392
  * palette shut). The `open` Value only seeds the initial state of a genuinely
4385
4393
  * fresh render. We normalize to a clean closed baseline first so {@link open}
4386
- * runs its full setup — the {@link FocusTrap} is a fresh instance after a
4394
+ * runs its full setup — the `FocusTrap` is a fresh instance after a
4387
4395
  * reconnect and must be re-activated.
4388
4396
  */
4389
4397
  connect() {
@@ -4488,7 +4496,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
4488
4496
  }
4489
4497
  /**
4490
4498
  * Combobox navigation keys (arrows / Home / End / Enter), bound to the input.
4491
- * `Tab` (focus trap) and `Escape` (close) are owned by the {@link FocusTrap} at
4499
+ * `Tab` (focus trap) and `Escape` (close) are owned by the shared `FocusTrap` at
4492
4500
  * the document level, so they work no matter which element inside the dialog has
4493
4501
  * focus — not only the input.
4494
4502
  */
@@ -5332,23 +5340,54 @@ var ContextMenuController = class extends Controller {
5332
5340
  return this.hasMenuTarget && !this.menuTarget.hidden;
5333
5341
  }
5334
5342
  };
5343
+
5344
+ // src/utils/authored_integer.ts
5345
+ var TOKEN_BODY = /\d[\d.,'’’    _]*\d|\d/;
5346
+ var SIGN_OPENER = /[\s([]/;
5347
+ function authoredInteger(text) {
5348
+ const firstDigit = /\d/.exec(text);
5349
+ if (firstDigit === null) return null;
5350
+ const at = firstDigit.index;
5351
+ const signed = at > 0 && text[at - 1] === "-" && (at === 1 || SIGN_OPENER.test(text[at - 2]));
5352
+ const body = TOKEN_BODY.exec(text.slice(at));
5353
+ const runs = body[0].split(/\D+/);
5354
+ const separators = body[0].match(/\D+/g) ?? [];
5355
+ const lead = runs[0];
5356
+ const heads = lead.length <= 3 && !lead.startsWith("0");
5357
+ let digits = lead;
5358
+ for (let i = 1; heads && i < runs.length; i += 1) {
5359
+ if (separators[i - 1]?.length !== 1 || runs[i]?.length !== 3) break;
5360
+ digits += runs[i];
5361
+ }
5362
+ const magnitude = Number.parseInt(digits, 10);
5363
+ return signed ? -magnitude || 0 : magnitude;
5364
+ }
5365
+
5366
+ // src/controllers/count_up_controller.ts
5367
+ var DEFAULT_DURATION = 1200;
5335
5368
  var CountUpController = class extends Controller {
5336
5369
  static values = {
5337
- duration: { type: Number, default: 1200 },
5370
+ duration: { type: Number, default: DEFAULT_DURATION },
5338
5371
  from: { type: Number, default: 0 },
5339
5372
  once: { type: Boolean, default: true }
5340
5373
  };
5341
5374
  static actions = ["start"];
5342
5375
  static events = ["end"];
5343
5376
  #frame = null;
5344
- /** The authored final text, restored verbatim when the run settles. */
5345
- #finalText = "";
5377
+ /** The animation length, with a declaration outside its domain read as the default. */
5378
+ get #duration() {
5379
+ return Number.isFinite(this.durationValue) && this.durationValue > 0 ? this.durationValue : DEFAULT_DURATION;
5380
+ }
5381
+ /** The starting value, with a declaration that is not a finite number read as zero. */
5382
+ get #from() {
5383
+ return Number.isFinite(this.fromValue) ? this.fromValue : 0;
5384
+ }
5346
5385
  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
- }
5386
+ const ticker = this.#ownedTicker();
5387
+ if (ticker === null) return;
5388
+ const authored = ticker.getAttribute("aria-label");
5389
+ this.#unwrap(ticker, authored);
5390
+ if (authored !== null) this.element.setAttribute("data-count-up-done", "true");
5352
5391
  }
5353
5392
  disconnect() {
5354
5393
  if (this.#frame !== null) this.#settle();
@@ -5360,27 +5399,22 @@ var CountUpController = class extends Controller {
5360
5399
  start() {
5361
5400
  if (this.#frame !== null) return;
5362
5401
  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;
5402
+ const node = this.#numericNode();
5403
+ if (node === null) return;
5404
+ const authored = node.data;
5405
+ const target = authoredInteger(authored);
5366
5406
  if (prefersReducedMotion()) {
5367
5407
  this.element.setAttribute("data-count-up-done", "true");
5368
5408
  this.dispatch("end", { detail: { value: target } });
5369
5409
  return;
5370
5410
  }
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);
5411
+ const ticker = this.#wrap(node, authored);
5412
+ const from = this.#from;
5377
5413
  const started = performance.now();
5378
5414
  const step = (now) => {
5379
- const t = Math.min((now - started) / this.durationValue, 1);
5415
+ const t = Math.min((now - started) / this.#duration, 1);
5380
5416
  const eased = 1 - (1 - t) ** 3;
5381
- this.element.textContent = String(
5382
- Math.round(this.fromValue + (target - this.fromValue) * eased)
5383
- );
5417
+ ticker.textContent = String(Math.round(from + (target - from) * eased));
5384
5418
  if (t < 1) {
5385
5419
  this.#frame = requestAnimationFrame(step);
5386
5420
  } else {
@@ -5390,26 +5424,41 @@ var CountUpController = class extends Controller {
5390
5424
  };
5391
5425
  this.#frame = requestAnimationFrame(step);
5392
5426
  }
5427
+ /** The first text node that displays a number, or null when the host has none. */
5428
+ #numericNode() {
5429
+ const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
5430
+ let node = walker.nextNode();
5431
+ while (node !== null && authoredInteger(node.data) === null) {
5432
+ node = walker.nextNode();
5433
+ }
5434
+ return node;
5435
+ }
5436
+ /** The wrapper this controller published, if one outlived its run. */
5437
+ #ownedTicker() {
5438
+ return this.element.querySelector("[data-count-up-label]");
5439
+ }
5440
+ /** Publishes the ticking number inside a named wrapper, replacing `node`. */
5441
+ #wrap(node, authored) {
5442
+ const ticker = document.createElement("span");
5443
+ ticker.setAttribute("data-count-up-label", "true");
5444
+ ticker.setAttribute("role", "img");
5445
+ ticker.setAttribute("aria-label", authored);
5446
+ node.replaceWith(ticker);
5447
+ ticker.append(node);
5448
+ return ticker;
5449
+ }
5450
+ /** Puts `text` back where the wrapper stood, leaving the rest of the host alone. */
5451
+ #unwrap(ticker, text) {
5452
+ ticker.replaceWith(document.createTextNode(text ?? ticker.textContent ?? ""));
5453
+ }
5393
5454
  /** Ends the run: cancels the frame and restores the authored presentation. */
5394
5455
  #settle() {
5395
5456
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
5396
5457
  this.#frame = null;
5397
- this.element.textContent = this.#finalText;
5398
- this.#restoreLabel();
5458
+ const ticker = this.#ownedTicker();
5459
+ if (ticker !== null) this.#unwrap(ticker, ticker.getAttribute("aria-label"));
5399
5460
  this.element.setAttribute("data-count-up-done", "true");
5400
5461
  }
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
5462
  };
5414
5463
  var SECOND_MS = 1e3;
5415
5464
  var CountdownController = class extends Controller {
@@ -6525,7 +6574,7 @@ var DateRangePickerController = class extends Controller {
6525
6574
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
6526
6575
  if (this.hasMonthLabelTarget) {
6527
6576
  const lang = document.documentElement.lang || "en";
6528
- const formatter = monthFormatter(lang);
6577
+ const formatter = monthLabelFormatter(lang);
6529
6578
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
6530
6579
  }
6531
6580
  const [rangeStart, rangeEnd] = this.#visualRange();
@@ -6680,15 +6729,6 @@ function normalizeISO(value) {
6680
6729
  function orderRange(start, end) {
6681
6730
  return start && end && end < start ? [end, start] : [start, end];
6682
6731
  }
6683
- function monthFormatter(locale) {
6684
- const options = { month: "long", year: "numeric" };
6685
- try {
6686
- return new Intl.DateTimeFormat(locale, options);
6687
- } catch (error) {
6688
- if (!(error instanceof RangeError)) throw error;
6689
- return new Intl.DateTimeFormat("en", options);
6690
- }
6691
- }
6692
6732
  function computePreset(name) {
6693
6733
  const today = /* @__PURE__ */ new Date();
6694
6734
  const todayStr = toISODateString(today);
@@ -7370,7 +7410,7 @@ var DrawerController = class extends Controller {
7370
7410
  * rather than being re-derived from the declarative `open` Value (which would
7371
7411
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
7372
7412
  * render. We normalize to a clean closed baseline first so {@link open} runs its
7373
- * full reveal + trap activation — the {@link FocusTrap} is inactive after a
7413
+ * full reveal + trap activation — the `FocusTrap` is inactive after a
7374
7414
  * disconnect and must be re-activated.
7375
7415
  */
7376
7416
  connect() {
@@ -7503,7 +7543,7 @@ var DrawerController = class extends Controller {
7503
7543
  * Runs once the close transition has finished: applies `hidden` to the panel
7504
7544
  * and overlay, then reverts the modal side effects (scroll lock, background
7505
7545
  * `inert`, keydown listener) and restores focus to the opener. Deferring the
7506
- * {@link FocusTrap} teardown to here — rather than at {@link close} time — keeps
7546
+ * `FocusTrap` teardown to here — rather than at {@link close} time — keeps
7507
7547
  * the background unreachable and focus trapped for the whole exit animation.
7508
7548
  */
7509
7549
  #applyHidden(panel) {
@@ -10193,6 +10233,14 @@ function isBeforeRootStart(entry) {
10193
10233
  const rootTop = entry.rootBounds?.top ?? 0;
10194
10234
  return rect.bottom <= rootTop;
10195
10235
  }
10236
+ function queryRoot(selector) {
10237
+ if (!selector) return null;
10238
+ try {
10239
+ return document.querySelector(selector);
10240
+ } catch {
10241
+ }
10242
+ return null;
10243
+ }
10196
10244
  var IntersectionWatcher = class {
10197
10245
  #onEntries;
10198
10246
  #observer = null;
@@ -10214,7 +10262,10 @@ var IntersectionWatcher = class {
10214
10262
  * the watcher inert — without `IntersectionObserver` support (very old
10215
10263
  * browsers; the caller's no-JS fallback stays in charge) or with no targets.
10216
10264
  * If initial construction with the configured options fails, the watcher
10217
- * warns and retries once with the same root and platform defaults.
10265
+ * warns and retries once with the same root and platform defaults. A
10266
+ * `rootSelector` that does not parse resolves to the viewport (see
10267
+ * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
10268
+ * call.
10218
10269
  *
10219
10270
  * @throws The fallback constructor error if both construction attempts fail,
10220
10271
  * or whatever the platform throws from `observe()`. The exception is passed
@@ -10227,7 +10278,7 @@ var IntersectionWatcher = class {
10227
10278
  if (typeof IntersectionObserver === "undefined") return false;
10228
10279
  const list = Array.isArray(targets) ? targets : [targets];
10229
10280
  if (list.length === 0) return false;
10230
- const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
10281
+ const root = "root" in options ? options.root ?? null : queryRoot(options.rootSelector);
10231
10282
  let observer = null;
10232
10283
  try {
10233
10284
  const onEntries = (entries) => {
@@ -10320,6 +10371,25 @@ var IntersectionController = class extends Controller {
10320
10371
  }
10321
10372
  connect() {
10322
10373
  if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
10374
+ this.#observe();
10375
+ }
10376
+ disconnect() {
10377
+ this.#watcher.stop();
10378
+ }
10379
+ /**
10380
+ * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing
10381
+ * rewrites the attribute in place without a reconnect, and the line is what
10382
+ * the intersection callback compares every ratio against, so a value frozen at
10383
+ * connect time would decide `data-intersecting` wrongly for the rest of the
10384
+ * page's life. Nothing to rebuild before the first `connect()`; after a spent
10385
+ * one-shot the watcher is deliberately stopped, and re-observing would deliver
10386
+ * the current state and fire `enter` a second time.
10387
+ */
10388
+ thresholdValueChanged() {
10389
+ if (this.#watcher.active) this.#observe();
10390
+ }
10391
+ /** (Re)installs the observer from the current Values. */
10392
+ #observe() {
10323
10393
  this.#effectiveThreshold = this.#clampedThreshold();
10324
10394
  this.#watcher.start(this.element, {
10325
10395
  rootSelector: this.rootSelectorValue,
@@ -10328,9 +10398,6 @@ var IntersectionController = class extends Controller {
10328
10398
  });
10329
10399
  if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
10330
10400
  }
10331
- disconnect() {
10332
- this.#watcher.stop();
10333
- }
10334
10401
  /**
10335
10402
  * Re-delivers the current intersection state as a fresh transition. Bound via
10336
10403
  * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).
@@ -10361,14 +10428,29 @@ var IntersectionController = class extends Controller {
10361
10428
  const previous = this.element.getAttribute("data-intersecting");
10362
10429
  this.element.setAttribute("data-intersecting", intersecting ? "true" : "false");
10363
10430
  if (intersecting && previous !== "true") {
10364
- this.dispatch("enter", { detail: { ratio } });
10365
10431
  if (this.onceValue) this.#watcher.stop();
10432
+ this.dispatch("enter", { detail: { ratio } });
10366
10433
  } else if (!intersecting && previous === "true") {
10367
10434
  this.dispatch("exit", {
10368
- detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
10435
+ detail: { ratio, position: this.#leftViaStartEdge(entry) ? "before" : "after" }
10369
10436
  });
10370
10437
  }
10371
10438
  }
10439
+ /**
10440
+ * Which edge the element left across, for the `exit` detail. A non-zero
10441
+ * `threshold` withdraws visibility while the element still overlaps the root,
10442
+ * so the leaving rect can straddle the start edge — the direction is the
10443
+ * element's own top against that edge, not whether it has cleared the root
10444
+ * entirely (that is what `passed` reports). An element with no layout box
10445
+ * (`display: none`, a collapsed `<details>`) is reported with an empty rect
10446
+ * that carries no position at all, so it is deliberately neither direction
10447
+ * and takes the "still ahead" reading.
10448
+ */
10449
+ #leftViaStartEdge(entry) {
10450
+ const rect = entry.boundingClientRect;
10451
+ if (rect.width === 0 && rect.height === 0) return false;
10452
+ return rect.top < (entry.rootBounds?.top ?? 0);
10453
+ }
10372
10454
  /**
10373
10455
  * Reflects the "scrolled past" state onto `data-passed` and fires `passed` on
10374
10456
  * transitions — the line sticky headers and reading progress key off. Like
@@ -10388,9 +10470,14 @@ var IntersectionController = class extends Controller {
10388
10470
  /**
10389
10471
  * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly
10390
10472
  * spaced steps when fine-grained `change` ratios are wanted (progress bars).
10473
+ *
10474
+ * 0 is always observed. An observer notifies only at the lines it was given,
10475
+ * so a non-zero `threshold` on its own delivers its last callback while the
10476
+ * element is still partly visible: the element leaving for good would never be
10477
+ * reported, freezing the ratio and `data-passed` mid-departure.
10391
10478
  */
10392
10479
  #thresholds() {
10393
- const thresholds = /* @__PURE__ */ new Set([this.#clampedThreshold()]);
10480
+ const thresholds = /* @__PURE__ */ new Set([0, this.#clampedThreshold()]);
10394
10481
  if (this.ratioStepsValue > 0) {
10395
10482
  for (let i = 0; i <= this.ratioStepsValue; i += 1) {
10396
10483
  thresholds.add(i / this.ratioStepsValue);
@@ -10408,47 +10495,98 @@ var LazyFrameController = class extends Controller {
10408
10495
  static events = ["load"];
10409
10496
  /** Shared IO plumbing (support guard, active guard, teardown). */
10410
10497
  #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
10498
+ #connected = false;
10411
10499
  #loaded = false;
10412
- /** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */
10413
- #onFocus = () => this.#trigger();
10500
+ /**
10501
+ * Whether the observer last saw the frame inside the observed area — `null`
10502
+ * until it has reported. A re-entry needs a frame that was seen inside and
10503
+ * then left, so an unknown position is never a departure.
10504
+ */
10505
+ #inside = null;
10506
+ /**
10507
+ * Focus reaching the frame starts the FIRST load before it intersects (keyboard / AT).
10508
+ * `#load` releases this listener, so focus never reaches an already-loaded frame.
10509
+ */
10510
+ #onFocus = () => this.#load();
10511
+ /**
10512
+ * Follows the held URL at runtime: one that arrives arms the frame, one that is
10513
+ * taken away disarms it, so an empty `url` holds no triggers either way.
10514
+ *
10515
+ * Stimulus runs value callbacks before `connect()`, so the connected guard keeps
10516
+ * arming in one place. A frame that already loaded keeps its held URL for the next
10517
+ * re-entry instead of fetching on the spot.
10518
+ */
10519
+ urlValueChanged() {
10520
+ if (!this.#connected || this.#loaded) return;
10521
+ if (this.urlValue) this.#arm();
10522
+ else this.#stopObserving();
10523
+ }
10524
+ /** Rebuilds the observer when the early-load margin changes at runtime. */
10525
+ rootMarginValueChanged() {
10526
+ if (this.#connected && this.#watcher.active) this.#observe();
10527
+ }
10414
10528
  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 });
10529
+ this.#loaded = this.element.hasAttribute("data-lazy-loaded");
10530
+ this.#inside = null;
10531
+ this.#connected = true;
10532
+ if (this.#loaded && this.onceValue) return;
10533
+ this.#arm();
10422
10534
  }
10423
10535
  disconnect() {
10536
+ this.#connected = false;
10424
10537
  this.#stopObserving();
10425
10538
  }
10539
+ /** Attaches the triggers that still have a job to do. */
10540
+ #arm() {
10541
+ if (!this.urlValue) return;
10542
+ if (!this.#loaded) this.element.addEventListener("focusin", this.#onFocus);
10543
+ this.#observe();
10544
+ }
10545
+ #observe() {
10546
+ this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });
10547
+ }
10426
10548
  #onIntersect(entries) {
10427
10549
  for (const entry of entries) {
10428
10550
  if (entry.isIntersecting) {
10429
- this.#trigger();
10430
- return;
10551
+ const returning = this.#inside === false;
10552
+ this.#inside = true;
10553
+ this.#enter(returning);
10554
+ } else if (this.#inside) {
10555
+ this.#inside = false;
10431
10556
  }
10432
10557
  }
10433
10558
  }
10434
- #trigger() {
10435
- if (!this.#loaded) this.#load();
10436
- else if (!this.onceValue) this.#reload();
10559
+ /** Applies an intersecting entry: the first one loads, a return re-fetches. */
10560
+ #enter(returning) {
10561
+ if (!this.#loaded) {
10562
+ this.#load();
10563
+ return;
10564
+ }
10565
+ if (this.onceValue || !returning) return;
10566
+ this.#reload();
10437
10567
  }
10438
10568
  /** Starts the load by writing the held URL to `src`. */
10439
10569
  #load() {
10570
+ if (!this.urlValue) return;
10440
10571
  this.#loaded = true;
10441
10572
  this.element.setAttribute("src", this.urlValue);
10442
10573
  this.element.setAttribute("data-lazy-loaded", "true");
10443
10574
  this.dispatch("load", { detail: { url: this.urlValue } });
10444
- if (this.onceValue) this.#stopObserving();
10575
+ this.element.removeEventListener("focusin", this.#onFocus);
10576
+ if (this.onceValue) this.#watcher.stop();
10445
10577
  }
10446
- /** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */
10578
+ /** Re-entry while `once` is off: fetch the held URL again. */
10447
10579
  #reload() {
10580
+ const src = this.element.getAttribute("src");
10581
+ if (this.urlValue && this.urlValue !== src) {
10582
+ this.element.setAttribute("src", this.urlValue);
10583
+ this.dispatch("load", { detail: { url: this.urlValue } });
10584
+ return;
10585
+ }
10448
10586
  const frame = this.element;
10449
10587
  if (typeof frame.reload !== "function") return;
10450
10588
  frame.reload();
10451
- this.dispatch("load", { detail: { url: this.urlValue } });
10589
+ this.dispatch("load", { detail: { url: src ?? "" } });
10452
10590
  }
10453
10591
  #stopObserving() {
10454
10592
  this.#watcher.stop();
@@ -14082,32 +14220,58 @@ var NumberInputController = class _NumberInputController extends Controller {
14082
14220
  this.#ariaValueMax.returnAll();
14083
14221
  }
14084
14222
  };
14085
- var TOGGLED_MARKER = "data-optimistic-toggled";
14223
+ var HIDDEN_MARKER = "data-optimistic-toggled";
14224
+ var BUSY_MARKER = "data-optimistic-busy";
14225
+ var AUTHORED_ABSENT = "absent";
14226
+ var AUTHORED_VALUE = "value:";
14227
+ var SYNTHETIC_SUBMISSION = {};
14228
+ function submissionOf(event) {
14229
+ const detail = event.detail;
14230
+ return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;
14231
+ }
14086
14232
  var OptimisticController = class extends Controller {
14087
14233
  static targets = ["show", "hide"];
14088
14234
  static events = ["commit", "rollback"];
14235
+ /** The submission that owns the applied optimistic state, if any. */
14236
+ #pending = null;
14237
+ #gate = new DetachGate();
14238
+ /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
14239
+ #connected = false;
14089
14240
  connect() {
14090
- if (this.element.hasAttribute("data-optimistic")) this.#revert();
14241
+ const restored = !this.#connected && !this.#gate.pending;
14242
+ this.#gate.cancel();
14243
+ this.#connected = true;
14244
+ if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
14091
14245
  this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
14092
14246
  this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
14093
14247
  }
14094
14248
  disconnect() {
14249
+ this.#connected = false;
14095
14250
  this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
14096
14251
  this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
14252
+ this.#gate.disconnected(this, () => {
14253
+ this.#pending = null;
14254
+ });
14097
14255
  }
14098
- #onSubmitStart = () => {
14256
+ #onSubmitStart = (event) => {
14257
+ this.#pending = submissionOf(event);
14258
+ if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {
14259
+ this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);
14260
+ }
14261
+ this.#write(this.element, "aria-busy", "true", BUSY_MARKER);
14099
14262
  this.element.setAttribute("data-optimistic", "true");
14100
- this.element.setAttribute("aria-busy", "true");
14101
- for (const target of this.showTargets) this.#toggleHidden(target, false);
14102
- for (const target of this.hideTargets) this.#toggleHidden(target, true);
14263
+ for (const target of this.showTargets) this.#setHidden(target, false);
14264
+ for (const target of this.hideTargets) this.#setHidden(target, true);
14103
14265
  };
14104
14266
  #onSubmitEnd = (event) => {
14267
+ if (submissionOf(event) !== this.#pending) return;
14268
+ this.#pending = null;
14105
14269
  const success = event.detail?.success === true;
14106
- this.element.removeAttribute("aria-busy");
14107
14270
  if (success) {
14108
14271
  this.element.removeAttribute("data-optimistic");
14272
+ this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
14109
14273
  for (const target of [...this.showTargets, ...this.hideTargets]) {
14110
- target.removeAttribute(TOGGLED_MARKER);
14274
+ target.removeAttribute(HIDDEN_MARKER);
14111
14275
  }
14112
14276
  this.dispatch("commit");
14113
14277
  } else {
@@ -14115,21 +14279,59 @@ var OptimisticController = class extends Controller {
14115
14279
  this.dispatch("rollback");
14116
14280
  }
14117
14281
  };
14118
- /** Sets `hidden` only when it actually changes, marker-tracking the mutation. */
14119
- #toggleHidden(target, hidden) {
14120
- if (target.hidden === hidden) return;
14121
- target.hidden = hidden;
14122
- target.setAttribute(TOGGLED_MARKER, "true");
14282
+ /** Hides or reveals a target, recording the authored `hidden` on first write. */
14283
+ #setHidden(target, hidden) {
14284
+ if (target.hasAttribute("hidden") === hidden) return;
14285
+ this.#write(target, "hidden", hidden ? "" : null, HIDDEN_MARKER);
14286
+ }
14287
+ /** Writes an attribute, recording what it displaced under `marker` once. */
14288
+ #write(element, attribute, value, marker) {
14289
+ if (!element.hasAttribute(marker)) {
14290
+ const authored = element.getAttribute(attribute);
14291
+ element.setAttribute(
14292
+ marker,
14293
+ authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`
14294
+ );
14295
+ }
14296
+ if (value === null) element.removeAttribute(attribute);
14297
+ else element.setAttribute(attribute, value);
14123
14298
  }
14124
- /** Reverts exactly the mutations this controller made (marker-owned only). */
14299
+ /**
14300
+ * Puts back the value `marker` recorded and drops the record.
14301
+ *
14302
+ * `written` is what this controller last put in the attribute. Anything else
14303
+ * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and
14304
+ * clears it before the terminal arrives, and a consumer may have moved a face
14305
+ * mid-flight — so the record is dropped without touching the attribute. A
14306
+ * marker this build did not write names no authored value, so the attribute
14307
+ * goes back to being absent.
14308
+ */
14309
+ #restore(element, attribute, marker, written) {
14310
+ const recorded = element.getAttribute(marker);
14311
+ if (recorded === null) return;
14312
+ element.removeAttribute(marker);
14313
+ if (element.getAttribute(attribute) !== written) return;
14314
+ if (recorded.startsWith(AUTHORED_VALUE)) {
14315
+ element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));
14316
+ return;
14317
+ }
14318
+ element.removeAttribute(attribute);
14319
+ }
14320
+ /**
14321
+ * Puts back exactly the markup this controller displaced (record-owned only).
14322
+ *
14323
+ * A face can be registered on both sides, and the hide pass runs last, so what
14324
+ * this controller wrote there is `hidden` — matching it against the reveal's
14325
+ * absent value instead would read as someone else's write and leave the face
14326
+ * hidden. Each target is therefore restored once, against the write it last
14327
+ * received.
14328
+ */
14125
14329
  #revert() {
14126
14330
  this.element.removeAttribute("data-optimistic");
14127
- this.element.removeAttribute("aria-busy");
14128
- for (const target of [...this.showTargets, ...this.hideTargets]) {
14129
- if (target.hasAttribute(TOGGLED_MARKER)) {
14130
- target.hidden = !target.hidden;
14131
- target.removeAttribute(TOGGLED_MARKER);
14132
- }
14331
+ this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
14332
+ const hidden = new Set(this.hideTargets);
14333
+ for (const target of /* @__PURE__ */ new Set([...this.showTargets, ...this.hideTargets])) {
14334
+ this.#restore(target, "hidden", HIDDEN_MARKER, hidden.has(target) ? "" : null);
14133
14335
  }
14134
14336
  }
14135
14337
  };
@@ -16480,6 +16682,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16480
16682
  return;
16481
16683
  }
16482
16684
  if (!this.#keyboard) return;
16685
+ if (event.key === "Home" || event.key === "End") {
16686
+ event.preventDefault();
16687
+ return;
16688
+ }
16483
16689
  const step = this.#keyboardDelta(event.key);
16484
16690
  if (!step) return;
16485
16691
  event.preventDefault();
@@ -18373,9 +18579,16 @@ var ReadMoreController = class extends Controller {
18373
18579
  var PROGRESS_PROPERTY = "--stimeo--reading-progress";
18374
18580
  var ReadingProgressController = class extends Controller {
18375
18581
  static events = ["change", "complete"];
18582
+ /** Owns both faces of the published property so teardown can hand them back. */
18583
+ #lease = new StylePropertyLease(PROGRESS_PROPERTY);
18584
+ /** The article's own box and the viewport: either changes the span. */
18585
+ #layout = new LayoutObserver(() => this.#onScroll());
18586
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
18376
18587
  #frame = null;
18377
18588
  /** Last published progress, so `change`/`complete` fire only on movement. */
18378
18589
  #progress = -1;
18590
+ /** False until the connect frame has run: `complete` needs real reading. */
18591
+ #baselined = false;
18379
18592
  #onScroll = () => {
18380
18593
  if (this.#frame !== null) return;
18381
18594
  this.#frame = requestAnimationFrame(() => {
@@ -18385,20 +18598,45 @@ var ReadingProgressController = class extends Controller {
18385
18598
  };
18386
18599
  connect() {
18387
18600
  this.#progress = -1;
18601
+ this.#baselined = false;
18388
18602
  window.addEventListener("scroll", this.#onScroll, { passive: true, capture: true });
18389
- window.addEventListener("resize", this.#onScroll, { passive: true });
18603
+ this.#layout.observe(this.element);
18604
+ this.#layout.observeViewport();
18605
+ this.#beforeCache.activate();
18390
18606
  this.#measure();
18607
+ this.#frame = requestAnimationFrame(() => {
18608
+ this.#frame = null;
18609
+ this.#measure();
18610
+ this.#baselined = true;
18611
+ });
18391
18612
  }
18392
18613
  disconnect() {
18393
18614
  window.removeEventListener("scroll", this.#onScroll, { capture: true });
18394
- window.removeEventListener("resize", this.#onScroll);
18615
+ this.#layout.disconnect();
18616
+ this.#beforeCache.deactivate();
18617
+ this.#cancelFrame();
18618
+ this.#lease.returnAll();
18619
+ }
18620
+ /**
18621
+ * Hands both declarations back before the page is snapshotted, so a restored
18622
+ * page starts from the authored DOM rather than from someone else's progress.
18623
+ * The baseline goes back with them: a cancelled visit leaves this page on
18624
+ * screen, and the next measurement has to publish afresh rather than match a
18625
+ * value that has already been handed back.
18626
+ */
18627
+ #rewindForCache() {
18628
+ this.#cancelFrame();
18629
+ this.#lease.returnAll();
18630
+ this.#progress = -1;
18631
+ }
18632
+ #cancelFrame() {
18395
18633
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
18396
18634
  this.#frame = null;
18397
- document.documentElement.style.removeProperty(PROGRESS_PROPERTY);
18398
18635
  }
18399
18636
  /** Computes and publishes the progress; emits on movement only. */
18400
18637
  #measure() {
18401
18638
  const rect = this.element.getBoundingClientRect();
18639
+ if (rect.width === 0 && rect.height === 0) return;
18402
18640
  const span = rect.height - window.innerHeight;
18403
18641
  const raw = span > 0 ? -rect.top / span : rect.top <= 0 ? 1 : 0;
18404
18642
  const progress = Math.min(1, Math.max(0, raw));
@@ -18406,10 +18644,10 @@ var ReadingProgressController = class extends Controller {
18406
18644
  const previous = this.#progress;
18407
18645
  this.#progress = progress;
18408
18646
  const value = String(progress);
18409
- this.element.style.setProperty(PROGRESS_PROPERTY, value);
18410
- document.documentElement.style.setProperty(PROGRESS_PROPERTY, value);
18647
+ this.#lease.write(this.element, value);
18648
+ this.#lease.write(document.documentElement, value);
18411
18649
  this.dispatch("change", { detail: { progress } });
18412
- if (progress === 1 && previous !== -1) this.dispatch("complete");
18650
+ if (progress === 1 && previous !== -1 && this.#baselined) this.dispatch("complete");
18413
18651
  }
18414
18652
  };
18415
18653
  var YEAR_SCALE = { limit: Number.POSITIVE_INFINITY, unit: "year", ms: 315576e5 };
@@ -20933,19 +21171,32 @@ var SliderController = class extends Controller {
20933
21171
  drag?.pointer?.end();
20934
21172
  }
20935
21173
  };
21174
+ var DEFAULT_OFFSET2 = 80;
20936
21175
  var SmartStickyHeaderController = class extends Controller {
20937
21176
  static values = {
20938
21177
  containerSelector: { type: String, default: "" },
20939
- offset: { type: Number, default: 80 },
21178
+ offset: { type: Number, default: DEFAULT_OFFSET2 },
20940
21179
  tolerance: { type: Number, default: 4 }
20941
21180
  };
20942
21181
  static events = ["change"];
21182
+ #connected = false;
20943
21183
  #frame = null;
20944
21184
  /** The scroll source resolved at connect — disconnect must unbind the SAME node. */
20945
21185
  #scrollerEl = window;
21186
+ /** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
21187
+ #containerSelector = "";
20946
21188
  #lastY = 0;
20947
21189
  /** Last published state, so `change` fires only on transitions. */
20948
21190
  #hidden = null;
21191
+ /**
21192
+ * The depth that never hides. A declaration that is not a finite number reads
21193
+ * as the default, so the comparison path never sees `NaN` — which would
21194
+ * answer `false` to every comparison and hide the header inside the very
21195
+ * zone the value exists to protect.
21196
+ */
21197
+ get #offset() {
21198
+ return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET2;
21199
+ }
20949
21200
  #onScroll = () => {
20950
21201
  if (this.#frame !== null) return;
20951
21202
  this.#frame = requestAnimationFrame(() => {
@@ -20958,15 +21209,25 @@ var SmartStickyHeaderController = class extends Controller {
20958
21209
  * hold while focus *stays* inside is the `#apply` hide invariant.
20959
21210
  */
20960
21211
  #onFocusin = () => this.#apply(false);
21212
+ /** Validates `containerSelector` once so connect never parses a selector that throws. */
21213
+ containerSelectorValueChanged() {
21214
+ this.#containerSelector = this.#validSelector(this.containerSelectorValue);
21215
+ }
21216
+ /** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
21217
+ offsetValueChanged() {
21218
+ if (this.#connected) this.#measure();
21219
+ }
20961
21220
  connect() {
20962
21221
  this.#hidden = null;
20963
21222
  this.#scrollerEl = this.#resolveScroller();
20964
21223
  this.#lastY = this.#scrollY;
20965
21224
  this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
20966
21225
  this.element.addEventListener("focusin", this.#onFocusin);
20967
- this.#apply(false);
21226
+ this.#apply(false, false);
21227
+ this.#connected = true;
20968
21228
  }
20969
21229
  disconnect() {
21230
+ this.#connected = false;
20970
21231
  this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
20971
21232
  this.element.removeEventListener("focusin", this.#onFocusin);
20972
21233
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
@@ -20974,43 +21235,69 @@ var SmartStickyHeaderController = class extends Controller {
20974
21235
  }
20975
21236
  /** Resolves the scroll source: the `containerSelector` match, else the window. */
20976
21237
  #resolveScroller() {
20977
- if (this.containerSelectorValue) {
20978
- const container = document.querySelector(this.containerSelectorValue);
21238
+ if (this.#containerSelector) {
21239
+ const container = document.querySelector(this.#containerSelector);
20979
21240
  if (container) return container;
20980
21241
  }
20981
21242
  return window;
20982
21243
  }
21244
+ /** Returns `declared` when it parses as a selector, and `""` when it does not. */
21245
+ #validSelector(declared) {
21246
+ if (declared.length > 0) {
21247
+ try {
21248
+ this.element.matches(declared);
21249
+ return declared;
21250
+ } catch {
21251
+ }
21252
+ }
21253
+ return "";
21254
+ }
20983
21255
  get #scrollY() {
20984
21256
  const scroller = this.#scrollerEl;
20985
21257
  return scroller === window ? window.scrollY : scroller.scrollTop;
20986
21258
  }
20987
21259
  #measure() {
20988
21260
  const y = this.#scrollY;
21261
+ if (y <= this.#offset) {
21262
+ this.#lastY = y;
21263
+ this.#apply(false);
21264
+ return;
21265
+ }
20989
21266
  const delta = y - this.#lastY;
20990
21267
  if (Math.abs(delta) < this.toleranceValue) return;
20991
21268
  this.#lastY = y;
20992
- if (y <= this.offsetValue) this.#apply(false);
20993
- else if (delta > 0) this.#apply(true);
20994
- else this.#apply(false);
21269
+ this.#apply(delta > 0);
20995
21270
  }
20996
- /** Reflects the state onto the hook and emits `change` on transitions. */
20997
- #apply(hidden) {
21271
+ /**
21272
+ * Reflects the state onto the hook and emits `change` on transitions.
21273
+ *
21274
+ * @param notify - whether a transition announces itself. The reflection
21275
+ * `connect()` performs is the current state, not a change.
21276
+ */
21277
+ #apply(hidden, notify = true) {
20998
21278
  if (hidden && this.element.contains(document.activeElement)) return;
20999
21279
  if (hidden === this.#hidden) return;
21000
21280
  this.#hidden = hidden;
21001
21281
  this.element.setAttribute("data-header-hidden", hidden ? "true" : "false");
21002
- this.dispatch("change", { detail: { hidden } });
21282
+ if (notify) this.dispatch("change", { detail: { hidden } });
21003
21283
  }
21004
21284
  };
21005
21285
  var SortableController = class extends Controller {
21006
- static targets = ["list", "item", "status"];
21286
+ static targets = ["list", "item"];
21007
21287
  static values = {
21008
- orientation: { type: String, default: "vertical" }
21288
+ orientation: { type: String, default: "vertical" },
21289
+ announceGrabbedText: { type: String, default: "" },
21290
+ announceMovedText: { type: String, default: "" },
21291
+ announceDroppedText: { type: String, default: "" },
21292
+ announceCanceledText: { type: String, default: "" }
21009
21293
  };
21010
21294
  static events = ["reorder"];
21011
21295
  #session = null;
21296
+ /** Defers the lost-item check to after the mutation batch (see below). */
21297
+ #settle = new MicrotaskCoalescer(() => this.#dropLostSession());
21012
21298
  connect() {
21013
21299
  this.element.removeAttribute("data-sortable-dragging");
21300
+ this.#settle.activate();
21014
21301
  this.element.addEventListener("stimeo--pointer-drag:start", this.#onDragStart);
21015
21302
  this.element.addEventListener("stimeo--pointer-drag:move", this.#onDragMove);
21016
21303
  this.element.addEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
@@ -21021,49 +21308,121 @@ var SortableController = class extends Controller {
21021
21308
  this.element.removeEventListener("stimeo--pointer-drag:move", this.#onDragMove);
21022
21309
  this.element.removeEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
21023
21310
  this.element.removeEventListener("stimeo--pointer-drag:cancel", this.#onDragCancel);
21311
+ this.#settle.cancel();
21312
+ this.#session = null;
21313
+ this.element.removeAttribute("data-sortable-dragging");
21314
+ }
21315
+ /**
21316
+ * Ends a session whose item left the item set.
21317
+ *
21318
+ * The controller's own reorder detaches and reattaches the item inside one
21319
+ * mutation batch, so the loss is only real once the batch has settled — the
21320
+ * check therefore runs a microtask later and asks whether the item is a target
21321
+ * again. Without it a deleted row (a broadcast that drops it from the board)
21322
+ * or a morph that strips the item's target attribute would hold the
21323
+ * one-at-a-time session for the rest of the page's life, with the root hook
21324
+ * stuck on and every later grab refused with no way out.
21325
+ */
21326
+ itemTargetDisconnected(item) {
21327
+ if (this.#session?.item !== item) return;
21328
+ this.#settle.schedule();
21329
+ }
21330
+ #dropLostSession() {
21331
+ const session = this.#session;
21332
+ if (!session) return;
21333
+ const items = this.#items();
21334
+ if (items.includes(session.item)) return;
21024
21335
  this.#session = null;
21025
21336
  this.element.removeAttribute("data-sortable-dragging");
21337
+ if (session.item.isConnected) this.#restore(session, items);
21026
21338
  }
21027
- /** Picks the item up: remembers its origin and announces the grab. */
21339
+ /** Picks the item up: remembers its neighbours and announces the grab. */
21028
21340
  #onDragStart = (event) => {
21029
21341
  if (this.#session) return;
21030
- const item = this.#itemFor(event.target);
21342
+ const items = this.#items();
21343
+ const item = this.#itemFor(event.target, items);
21031
21344
  if (!item) return;
21032
- this.#session = { item, from: this.#items().indexOf(item), lastPrimary: 0 };
21345
+ const index = items.indexOf(item);
21346
+ this.#session = {
21347
+ item,
21348
+ anchor: items[index + 1] ?? null,
21349
+ predecessor: items[index - 1] ?? null,
21350
+ lastPrimary: 0
21351
+ };
21033
21352
  this.element.setAttribute("data-sortable-dragging", "true");
21034
21353
  this.#announce("grabbed", item);
21035
21354
  };
21036
21355
  #onDragMove = (event) => {
21037
21356
  const session = this.#session;
21357
+ if (!session) return;
21358
+ const items = this.#items();
21359
+ if (this.#itemFor(event.target, items) !== session.item) return;
21038
21360
  const detail = event.detail;
21039
- if (!session || this.#itemFor(event.target) !== session.item) return;
21040
21361
  if (detail.pointerType === "keyboard") {
21041
- this.#stepFromKeyboard(session, detail);
21362
+ this.#stepFromKeyboard(session, detail, items);
21042
21363
  } else {
21043
- this.#followPointer(session, detail);
21364
+ this.#followPointer(session, detail, items);
21044
21365
  }
21045
21366
  };
21046
21367
  /** Drops the item: announces, then reports `reorder` if the position changed. */
21047
21368
  #onDragEnd = (event) => {
21048
21369
  const session = this.#session;
21049
- if (!session || this.#itemFor(event.target) !== session.item) return;
21370
+ if (!session) return;
21371
+ const items = this.#items();
21372
+ if (this.#itemFor(event.target, items) !== session.item) return;
21050
21373
  this.#session = null;
21051
21374
  this.element.removeAttribute("data-sortable-dragging");
21052
21375
  this.#announce("dropped", session.item);
21053
- const to = this.#items().indexOf(session.item);
21054
- if (to !== session.from) {
21055
- this.dispatch("reorder", { detail: { item: session.item, from: session.from, to } });
21376
+ const to = items.indexOf(session.item);
21377
+ const from = this.#pickupSlot(session, items) ?? to;
21378
+ if (to !== from) {
21379
+ this.dispatch("reorder", { detail: { item: session.item, from, to } });
21056
21380
  }
21057
21381
  };
21058
21382
  /** Restores the pickup position (Escape / OS `pointercancel`). */
21059
21383
  #onDragCancel = (event) => {
21060
21384
  const session = this.#session;
21061
- if (!session || this.#itemFor(event.target) !== session.item) return;
21385
+ if (!session) return;
21386
+ const items = this.#items();
21387
+ if (this.#itemFor(event.target, items) !== session.item) return;
21062
21388
  this.#session = null;
21063
21389
  this.element.removeAttribute("data-sortable-dragging");
21064
- this.#moveTo(session.item, session.from);
21390
+ this.#restore(session, items);
21065
21391
  this.#announce("canceled", session.item);
21066
21392
  };
21393
+ /**
21394
+ * The slot the item was picked up from, read back through the neighbours it
21395
+ * had then — `null` when neither survives and the item itself is gone.
21396
+ *
21397
+ * The anchor is the item that followed it, so restoring means "before that one
21398
+ * again". When the anchor was deleted mid-drag the predecessor answers the
21399
+ * same question from the other side. With both gone the item's current slot is
21400
+ * the honest answer: nothing is known to have moved, so no reorder is reported
21401
+ * and a cancel leaves the item where it is.
21402
+ */
21403
+ #pickupSlot(session, items) {
21404
+ const others = items.filter((candidate) => candidate !== session.item);
21405
+ if (session.anchor) {
21406
+ const at = others.indexOf(session.anchor);
21407
+ if (at !== -1) return at;
21408
+ }
21409
+ if (session.predecessor) {
21410
+ const at = others.indexOf(session.predecessor);
21411
+ if (at !== -1) return at + 1;
21412
+ }
21413
+ const here = items.indexOf(session.item);
21414
+ return here === -1 ? null : here;
21415
+ }
21416
+ /** Puts the item back where it was picked up from. */
21417
+ #restore(session, items) {
21418
+ const slot = this.#pickupSlot(session, items);
21419
+ if (slot === null) return;
21420
+ this.#insertAt(
21421
+ session.item,
21422
+ items.filter((candidate) => candidate !== session.item),
21423
+ slot
21424
+ );
21425
+ }
21067
21426
  /**
21068
21427
  * Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
21069
21428
  * the difference from the last consumed value is one arrow press — its sign is
@@ -21075,77 +21434,116 @@ var SortableController = class extends Controller {
21075
21434
  * `roving` already moves focus logically, so the same arrow would send the
21076
21435
  * focus and the grabbed item opposite ways.
21077
21436
  */
21078
- #stepFromKeyboard(session, detail) {
21437
+ #stepFromKeyboard(session, detail, items) {
21079
21438
  const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
21080
21439
  const delta = primary - session.lastPrimary;
21081
21440
  session.lastPrimary = primary;
21082
21441
  if (delta === 0) return;
21083
- const items = this.#items();
21084
21442
  const index = items.indexOf(session.item);
21085
21443
  const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
21086
21444
  const next = Math.max(0, Math.min(index + step, items.length - 1));
21087
21445
  if (next === index) return;
21088
- this.#moveTo(session.item, next);
21446
+ this.#insertAt(
21447
+ session.item,
21448
+ items.filter((candidate) => candidate !== session.item),
21449
+ next
21450
+ );
21089
21451
  this.#announce("moved", session.item);
21090
21452
  }
21091
21453
  /**
21092
21454
  * Pointer following: the item moves to the slot whose siblings' midpoints the
21093
- * pointer has passed (per `orientation`). Skipped when the list has no layout
21094
- * geometry (every rect is zero — nothing meaningful to compare against).
21095
- */
21096
- #followPointer(session, detail) {
21455
+ * pointer has passed (per `orientation`).
21456
+ *
21457
+ * Only siblings that occupy space take part. A row with no layout box — a
21458
+ * filtered-out item, a `display: none` ancestor, a collapsed `<details>` — is
21459
+ * reported with an empty rect at the document origin, and its midpoint of `0`
21460
+ * sits below every pointer position: counted, it would read as passed on the
21461
+ * very first move and send the item across a slot the pointer never crossed.
21462
+ * With no laid-out sibling at all there is nothing to compare against and the
21463
+ * move is skipped entirely.
21464
+ */
21465
+ #followPointer(session, detail, items) {
21097
21466
  const pointer = Number(this.#isVertical ? detail.y : detail.x) || 0;
21098
- const others = this.#items().filter((item) => item !== session.item);
21099
- if (others.length === 0) return;
21100
- let laidOut = false;
21101
- let target = 0;
21467
+ const vertical = this.#isVertical;
21102
21468
  const reversed = this.#isReversed;
21103
- for (const other of others) {
21104
- const rect = other.getBoundingClientRect();
21105
- if (rect.width > 0 || rect.height > 0) laidOut = true;
21106
- const midpoint = this.#isVertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
21107
- const precedes = reversed ? pointer < midpoint : pointer > midpoint;
21108
- if (precedes) target += 1;
21109
- }
21110
- if (!laidOut) return;
21111
- const current = this.#items().indexOf(session.item);
21112
- if (target !== current) {
21113
- this.#moveTo(session.item, target);
21114
- this.#announce("moved", session.item);
21115
- }
21116
- }
21117
- /** Reinserts `item` so it lands at `index` among the list's items. */
21118
- #moveTo(item, index) {
21119
- const others = this.#items().filter((candidate) => candidate !== item);
21120
- const clamped = Math.max(0, Math.min(index, others.length));
21121
- const reference = others[clamped] ?? null;
21469
+ const here = items.indexOf(session.item);
21470
+ const laidOut = [];
21471
+ let target = 0;
21472
+ let current = 0;
21473
+ items.forEach((item, index) => {
21474
+ if (item === session.item) return;
21475
+ const rect = item.getBoundingClientRect();
21476
+ if (rect.width === 0 && rect.height === 0) return;
21477
+ if (index < here) current += 1;
21478
+ laidOut.push(item);
21479
+ const midpoint = vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
21480
+ if (reversed ? pointer < midpoint : pointer > midpoint) target += 1;
21481
+ });
21482
+ if (laidOut.length === 0 || target === current) return;
21483
+ this.#insertAt(session.item, laidOut, target);
21484
+ this.#announce("moved", session.item);
21485
+ }
21486
+ /**
21487
+ * Reinserts `item` at `index` among `scope`, relative to the sibling already
21488
+ * standing there.
21489
+ *
21490
+ * The reorder is defined against the items, not against a container: the
21491
+ * neighbour's own parent is where the item belongs. A `list` target that is
21492
+ * not the items' parent — or none at all, which the markup contract allows —
21493
+ * therefore still lands the move in the right place instead of throwing, and
21494
+ * the last slot is *after the last item* rather than after whatever else the
21495
+ * container holds (a live region, a footer), which would put the row outside
21496
+ * the reading order the list publishes.
21497
+ */
21498
+ #insertAt(item, scope, index) {
21499
+ const clamped = Math.max(0, Math.min(index, scope.length));
21500
+ const ahead = scope[clamped] ?? null;
21501
+ const behind = ahead ? null : scope[scope.length - 1] ?? null;
21502
+ const neighbour = ahead ?? behind;
21503
+ if (!neighbour) return;
21504
+ const parent = neighbour.parentNode;
21505
+ if (!parent) return;
21122
21506
  const active = document.activeElement;
21123
21507
  const hadFocus = active instanceof HTMLElement && item.contains(active);
21124
- this.#list.insertBefore(item, reference);
21508
+ parent.insertBefore(item, ahead ?? neighbour.nextSibling);
21125
21509
  if (hadFocus) active.focus();
21126
21510
  }
21127
21511
  /**
21128
- * Mirrors a step into the `status` live region. Copy is localizable through
21129
- * `data-grabbed` / `data-moved` / `data-dropped` / `data-canceled` templates on
21130
- * the status element (`%{name}` / `%{position}` / `%{total}` placeholders);
21131
- * terse English is the fallback.
21512
+ * Hands one step to the page's shared announcer.
21513
+ *
21514
+ * The library carries no live region and no English copy: the wording is the
21515
+ * consumer's, written into `announceGrabbedText` / `announceMovedText` /
21516
+ * `announceDroppedText` / `announceCanceledText` with `{name}` / `{position}` /
21517
+ * `{total}` placeholders, and an unset one announces nothing.
21518
+ *
21519
+ * Only transitions reach here — the pickup, a step that actually changed the
21520
+ * landing slot, and the single end of the session — so a pointer crossing the
21521
+ * same slot twice or an arrow clamped at an end stays silent.
21132
21522
  */
21133
21523
  #announce(key, item) {
21134
- if (!this.hasStatusTarget) return;
21135
- const position = String(this.#items().indexOf(item) + 1);
21136
- const total = String(this.#items().length);
21137
- const name = this.#nameOf(item);
21138
- const fallback = {
21139
- grabbed: `Grabbed ${name}, position ${position} of ${total}`,
21140
- moved: `${name}, position ${position} of ${total}`,
21141
- dropped: `Dropped ${name} at position ${position} of ${total}`,
21142
- canceled: `Reorder canceled, ${name} returned to position ${position} of ${total}`
21143
- };
21144
- const values = { name, position, total };
21145
- const template = this.statusTarget.dataset[key];
21146
- this.statusTarget.textContent = template ? template.replace(/%\{(name|position|total)\}/g, (match, token) => {
21147
- return values[token] ?? match;
21148
- }) : fallback[key];
21524
+ const template = this.#announceTemplate(key);
21525
+ if (template.length === 0) return;
21526
+ const items = this.#items();
21527
+ announce(
21528
+ fillTemplate(template, {
21529
+ name: this.#nameOf(item),
21530
+ position: items.indexOf(item) + 1,
21531
+ total: items.length
21532
+ })
21533
+ );
21534
+ }
21535
+ /** The consumer's wording for one step, or `""` when they authored none. */
21536
+ #announceTemplate(key) {
21537
+ switch (key) {
21538
+ case "grabbed":
21539
+ return this.announceGrabbedTextValue;
21540
+ case "moved":
21541
+ return this.announceMovedTextValue;
21542
+ case "dropped":
21543
+ return this.announceDroppedTextValue;
21544
+ case "canceled":
21545
+ return this.announceCanceledTextValue;
21546
+ }
21149
21547
  }
21150
21548
  /** The announced item name: the authored override, else its collapsed text. */
21151
21549
  #nameOf(item) {
@@ -21153,11 +21551,17 @@ var SortableController = class extends Controller {
21153
21551
  if (authored) return authored;
21154
21552
  return (item.textContent ?? "").replace(/\s+/g, " ").trim();
21155
21553
  }
21156
- /** Resolves the sortable item owning a bubbled `pointer-drag` event. */
21157
- #itemFor(target) {
21158
- const node = target;
21159
- if (!node) return null;
21160
- return this.#items().find((item) => item === node || item.contains(node)) ?? null;
21554
+ /**
21555
+ * Resolves the sortable item owning a bubbled `pointer-drag` event.
21556
+ *
21557
+ * `pointer-drag` dispatches on its own element, and the markup contract puts
21558
+ * one on each item, so the owner is the item that **is** the target. Matching
21559
+ * an ancestor instead would make a card's own inner draggable — a knob, a
21560
+ * split pane, anything the primitive is composed into — drive the card.
21561
+ */
21562
+ #itemFor(target, items) {
21563
+ if (!target) return null;
21564
+ return items.find((item) => item === target) ?? null;
21161
21565
  }
21162
21566
  /** The items in live DOM order (targets re-query the DOM on every access). */
21163
21567
  #items() {
@@ -23294,7 +23698,7 @@ var ToastController = class extends Controller {
23294
23698
  static events = ["dismiss", "show"];
23295
23699
  /**
23296
23700
  * Registry for every auto-dismiss and transition-finalize timer the controller
23297
- * schedules. {@link SafeTimeout} owns *registration and teardown only*; the
23701
+ * schedules. `SafeTimeout` owns *registration and teardown only*; the
23298
23702
  * pause/resume remaining-time accounting stays in `#activeTimeouts` so the
23299
23703
  * per-widget WCAG 2.2.1 semantics are not flattened into the helper.
23300
23704
  */