stimeo-ui 0.11.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();
@@ -16480,6 +16619,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16480
16619
  return;
16481
16620
  }
16482
16621
  if (!this.#keyboard) return;
16622
+ if (event.key === "Home" || event.key === "End") {
16623
+ event.preventDefault();
16624
+ return;
16625
+ }
16483
16626
  const step = this.#keyboardDelta(event.key);
16484
16627
  if (!step) return;
16485
16628
  event.preventDefault();
@@ -18373,9 +18516,16 @@ var ReadMoreController = class extends Controller {
18373
18516
  var PROGRESS_PROPERTY = "--stimeo--reading-progress";
18374
18517
  var ReadingProgressController = class extends Controller {
18375
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());
18376
18524
  #frame = null;
18377
18525
  /** Last published progress, so `change`/`complete` fire only on movement. */
18378
18526
  #progress = -1;
18527
+ /** False until the connect frame has run: `complete` needs real reading. */
18528
+ #baselined = false;
18379
18529
  #onScroll = () => {
18380
18530
  if (this.#frame !== null) return;
18381
18531
  this.#frame = requestAnimationFrame(() => {
@@ -18385,20 +18535,45 @@ var ReadingProgressController = class extends Controller {
18385
18535
  };
18386
18536
  connect() {
18387
18537
  this.#progress = -1;
18538
+ this.#baselined = false;
18388
18539
  window.addEventListener("scroll", this.#onScroll, { passive: true, capture: true });
18389
- window.addEventListener("resize", this.#onScroll, { passive: true });
18540
+ this.#layout.observe(this.element);
18541
+ this.#layout.observeViewport();
18542
+ this.#beforeCache.activate();
18390
18543
  this.#measure();
18544
+ this.#frame = requestAnimationFrame(() => {
18545
+ this.#frame = null;
18546
+ this.#measure();
18547
+ this.#baselined = true;
18548
+ });
18391
18549
  }
18392
18550
  disconnect() {
18393
18551
  window.removeEventListener("scroll", this.#onScroll, { capture: true });
18394
- 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() {
18395
18570
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
18396
18571
  this.#frame = null;
18397
- document.documentElement.style.removeProperty(PROGRESS_PROPERTY);
18398
18572
  }
18399
18573
  /** Computes and publishes the progress; emits on movement only. */
18400
18574
  #measure() {
18401
18575
  const rect = this.element.getBoundingClientRect();
18576
+ if (rect.width === 0 && rect.height === 0) return;
18402
18577
  const span = rect.height - window.innerHeight;
18403
18578
  const raw = span > 0 ? -rect.top / span : rect.top <= 0 ? 1 : 0;
18404
18579
  const progress = Math.min(1, Math.max(0, raw));
@@ -18406,10 +18581,10 @@ var ReadingProgressController = class extends Controller {
18406
18581
  const previous = this.#progress;
18407
18582
  this.#progress = progress;
18408
18583
  const value = String(progress);
18409
- this.element.style.setProperty(PROGRESS_PROPERTY, value);
18410
- document.documentElement.style.setProperty(PROGRESS_PROPERTY, value);
18584
+ this.#lease.write(this.element, value);
18585
+ this.#lease.write(document.documentElement, value);
18411
18586
  this.dispatch("change", { detail: { progress } });
18412
- if (progress === 1 && previous !== -1) this.dispatch("complete");
18587
+ if (progress === 1 && previous !== -1 && this.#baselined) this.dispatch("complete");
18413
18588
  }
18414
18589
  };
18415
18590
  var YEAR_SCALE = { limit: Number.POSITIVE_INFINITY, unit: "year", ms: 315576e5 };
@@ -20933,19 +21108,32 @@ var SliderController = class extends Controller {
20933
21108
  drag?.pointer?.end();
20934
21109
  }
20935
21110
  };
21111
+ var DEFAULT_OFFSET2 = 80;
20936
21112
  var SmartStickyHeaderController = class extends Controller {
20937
21113
  static values = {
20938
21114
  containerSelector: { type: String, default: "" },
20939
- offset: { type: Number, default: 80 },
21115
+ offset: { type: Number, default: DEFAULT_OFFSET2 },
20940
21116
  tolerance: { type: Number, default: 4 }
20941
21117
  };
20942
21118
  static events = ["change"];
21119
+ #connected = false;
20943
21120
  #frame = null;
20944
21121
  /** The scroll source resolved at connect — disconnect must unbind the SAME node. */
20945
21122
  #scrollerEl = window;
21123
+ /** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
21124
+ #containerSelector = "";
20946
21125
  #lastY = 0;
20947
21126
  /** Last published state, so `change` fires only on transitions. */
20948
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
+ }
20949
21137
  #onScroll = () => {
20950
21138
  if (this.#frame !== null) return;
20951
21139
  this.#frame = requestAnimationFrame(() => {
@@ -20958,15 +21146,25 @@ var SmartStickyHeaderController = class extends Controller {
20958
21146
  * hold while focus *stays* inside is the `#apply` hide invariant.
20959
21147
  */
20960
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
+ }
20961
21157
  connect() {
20962
21158
  this.#hidden = null;
20963
21159
  this.#scrollerEl = this.#resolveScroller();
20964
21160
  this.#lastY = this.#scrollY;
20965
21161
  this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
20966
21162
  this.element.addEventListener("focusin", this.#onFocusin);
20967
- this.#apply(false);
21163
+ this.#apply(false, false);
21164
+ this.#connected = true;
20968
21165
  }
20969
21166
  disconnect() {
21167
+ this.#connected = false;
20970
21168
  this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
20971
21169
  this.element.removeEventListener("focusin", this.#onFocusin);
20972
21170
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
@@ -20974,43 +21172,69 @@ var SmartStickyHeaderController = class extends Controller {
20974
21172
  }
20975
21173
  /** Resolves the scroll source: the `containerSelector` match, else the window. */
20976
21174
  #resolveScroller() {
20977
- if (this.containerSelectorValue) {
20978
- const container = document.querySelector(this.containerSelectorValue);
21175
+ if (this.#containerSelector) {
21176
+ const container = document.querySelector(this.#containerSelector);
20979
21177
  if (container) return container;
20980
21178
  }
20981
21179
  return window;
20982
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
+ }
20983
21192
  get #scrollY() {
20984
21193
  const scroller = this.#scrollerEl;
20985
21194
  return scroller === window ? window.scrollY : scroller.scrollTop;
20986
21195
  }
20987
21196
  #measure() {
20988
21197
  const y = this.#scrollY;
21198
+ if (y <= this.#offset) {
21199
+ this.#lastY = y;
21200
+ this.#apply(false);
21201
+ return;
21202
+ }
20989
21203
  const delta = y - this.#lastY;
20990
21204
  if (Math.abs(delta) < this.toleranceValue) return;
20991
21205
  this.#lastY = y;
20992
- if (y <= this.offsetValue) this.#apply(false);
20993
- else if (delta > 0) this.#apply(true);
20994
- else this.#apply(false);
21206
+ this.#apply(delta > 0);
20995
21207
  }
20996
- /** Reflects the state onto the hook and emits `change` on transitions. */
20997
- #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) {
20998
21215
  if (hidden && this.element.contains(document.activeElement)) return;
20999
21216
  if (hidden === this.#hidden) return;
21000
21217
  this.#hidden = hidden;
21001
21218
  this.element.setAttribute("data-header-hidden", hidden ? "true" : "false");
21002
- this.dispatch("change", { detail: { hidden } });
21219
+ if (notify) this.dispatch("change", { detail: { hidden } });
21003
21220
  }
21004
21221
  };
21005
21222
  var SortableController = class extends Controller {
21006
- static targets = ["list", "item", "status"];
21223
+ static targets = ["list", "item"];
21007
21224
  static values = {
21008
- 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: "" }
21009
21230
  };
21010
21231
  static events = ["reorder"];
21011
21232
  #session = null;
21233
+ /** Defers the lost-item check to after the mutation batch (see below). */
21234
+ #settle = new MicrotaskCoalescer(() => this.#dropLostSession());
21012
21235
  connect() {
21013
21236
  this.element.removeAttribute("data-sortable-dragging");
21237
+ this.#settle.activate();
21014
21238
  this.element.addEventListener("stimeo--pointer-drag:start", this.#onDragStart);
21015
21239
  this.element.addEventListener("stimeo--pointer-drag:move", this.#onDragMove);
21016
21240
  this.element.addEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
@@ -21021,49 +21245,121 @@ var SortableController = class extends Controller {
21021
21245
  this.element.removeEventListener("stimeo--pointer-drag:move", this.#onDragMove);
21022
21246
  this.element.removeEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
21023
21247
  this.element.removeEventListener("stimeo--pointer-drag:cancel", this.#onDragCancel);
21248
+ this.#settle.cancel();
21024
21249
  this.#session = null;
21025
21250
  this.element.removeAttribute("data-sortable-dragging");
21026
21251
  }
21027
- /** Picks the item up: remembers its origin and announces the grab. */
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;
21272
+ this.#session = null;
21273
+ this.element.removeAttribute("data-sortable-dragging");
21274
+ if (session.item.isConnected) this.#restore(session, items);
21275
+ }
21276
+ /** Picks the item up: remembers its neighbours and announces the grab. */
21028
21277
  #onDragStart = (event) => {
21029
21278
  if (this.#session) return;
21030
- const item = this.#itemFor(event.target);
21279
+ const items = this.#items();
21280
+ const item = this.#itemFor(event.target, items);
21031
21281
  if (!item) return;
21032
- 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
+ };
21033
21289
  this.element.setAttribute("data-sortable-dragging", "true");
21034
21290
  this.#announce("grabbed", item);
21035
21291
  };
21036
21292
  #onDragMove = (event) => {
21037
21293
  const session = this.#session;
21294
+ if (!session) return;
21295
+ const items = this.#items();
21296
+ if (this.#itemFor(event.target, items) !== session.item) return;
21038
21297
  const detail = event.detail;
21039
- if (!session || this.#itemFor(event.target) !== session.item) return;
21040
21298
  if (detail.pointerType === "keyboard") {
21041
- this.#stepFromKeyboard(session, detail);
21299
+ this.#stepFromKeyboard(session, detail, items);
21042
21300
  } else {
21043
- this.#followPointer(session, detail);
21301
+ this.#followPointer(session, detail, items);
21044
21302
  }
21045
21303
  };
21046
21304
  /** Drops the item: announces, then reports `reorder` if the position changed. */
21047
21305
  #onDragEnd = (event) => {
21048
21306
  const session = this.#session;
21049
- 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;
21050
21310
  this.#session = null;
21051
21311
  this.element.removeAttribute("data-sortable-dragging");
21052
21312
  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 } });
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 } });
21056
21317
  }
21057
21318
  };
21058
21319
  /** Restores the pickup position (Escape / OS `pointercancel`). */
21059
21320
  #onDragCancel = (event) => {
21060
21321
  const session = this.#session;
21061
- 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;
21062
21325
  this.#session = null;
21063
21326
  this.element.removeAttribute("data-sortable-dragging");
21064
- this.#moveTo(session.item, session.from);
21327
+ this.#restore(session, items);
21065
21328
  this.#announce("canceled", session.item);
21066
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
+ }
21067
21363
  /**
21068
21364
  * Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
21069
21365
  * the difference from the last consumed value is one arrow press — its sign is
@@ -21075,77 +21371,116 @@ var SortableController = class extends Controller {
21075
21371
  * `roving` already moves focus logically, so the same arrow would send the
21076
21372
  * focus and the grabbed item opposite ways.
21077
21373
  */
21078
- #stepFromKeyboard(session, detail) {
21374
+ #stepFromKeyboard(session, detail, items) {
21079
21375
  const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
21080
21376
  const delta = primary - session.lastPrimary;
21081
21377
  session.lastPrimary = primary;
21082
21378
  if (delta === 0) return;
21083
- const items = this.#items();
21084
21379
  const index = items.indexOf(session.item);
21085
21380
  const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
21086
21381
  const next = Math.max(0, Math.min(index + step, items.length - 1));
21087
21382
  if (next === index) return;
21088
- this.#moveTo(session.item, next);
21383
+ this.#insertAt(
21384
+ session.item,
21385
+ items.filter((candidate) => candidate !== session.item),
21386
+ next
21387
+ );
21089
21388
  this.#announce("moved", session.item);
21090
21389
  }
21091
21390
  /**
21092
21391
  * 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) {
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) {
21097
21403
  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;
21404
+ const vertical = this.#isVertical;
21102
21405
  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;
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;
21122
21443
  const active = document.activeElement;
21123
21444
  const hadFocus = active instanceof HTMLElement && item.contains(active);
21124
- this.#list.insertBefore(item, reference);
21445
+ parent.insertBefore(item, ahead ?? neighbour.nextSibling);
21125
21446
  if (hadFocus) active.focus();
21126
21447
  }
21127
21448
  /**
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.
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.
21132
21459
  */
21133
21460
  #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];
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
+ }
21149
21484
  }
21150
21485
  /** The announced item name: the authored override, else its collapsed text. */
21151
21486
  #nameOf(item) {
@@ -21153,11 +21488,17 @@ var SortableController = class extends Controller {
21153
21488
  if (authored) return authored;
21154
21489
  return (item.textContent ?? "").replace(/\s+/g, " ").trim();
21155
21490
  }
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;
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;
21161
21502
  }
21162
21503
  /** The items in live DOM order (targets re-query the DOM on every access). */
21163
21504
  #items() {