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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/dist/cable/index.js +23 -2
- data/dist/controllers/count_up_controller.js +74 -35
- data/dist/controllers/intersection_controller.js +55 -8
- data/dist/controllers/lazy_frame_controller.js +81 -19
- data/dist/controllers/pointer_drag_controller.js +4 -0
- data/dist/controllers/reading_progress_controller.js +177 -6
- data/dist/controllers/scrollspy_controller.js +13 -2
- data/dist/controllers/smart_sticky_header_controller.js +52 -10
- data/dist/controllers/sortable_controller.js +241 -67
- data/dist/controllers/sticky_observer_controller.js +13 -2
- data/dist/index.js +486 -145
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
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:
|
|
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
|
|
5345
|
-
#
|
|
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
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
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
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
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
|
|
5372
|
-
|
|
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
|
|
5407
|
+
const t = Math.min((now - started) / this.#duration, 1);
|
|
5380
5408
|
const eased = 1 - (1 - t) ** 3;
|
|
5381
|
-
|
|
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
|
-
|
|
5398
|
-
this.#
|
|
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 :
|
|
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:
|
|
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
|
-
/**
|
|
10413
|
-
|
|
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
|
-
|
|
10416
|
-
|
|
10417
|
-
|
|
10418
|
-
|
|
10419
|
-
|
|
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.#
|
|
10430
|
-
|
|
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
|
-
|
|
10435
|
-
|
|
10436
|
-
|
|
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
|
-
|
|
10576
|
+
this.element.removeEventListener("focusin", this.#onFocus);
|
|
10577
|
+
if (this.onceValue) this.#watcher.stop();
|
|
10445
10578
|
}
|
|
10446
|
-
/** Re-entry while `once` is off:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18410
|
-
document.documentElement
|
|
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:
|
|
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
|
|
20978
|
-
const container = document.querySelector(this
|
|
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
|
-
|
|
20993
|
-
else if (delta > 0) this.#apply(true);
|
|
20994
|
-
else this.#apply(false);
|
|
21206
|
+
this.#apply(delta > 0);
|
|
20995
21207
|
}
|
|
20996
|
-
/**
|
|
20997
|
-
|
|
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"
|
|
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
|
-
/**
|
|
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
|
|
21279
|
+
const items = this.#items();
|
|
21280
|
+
const item = this.#itemFor(event.target, items);
|
|
21031
21281
|
if (!item) return;
|
|
21032
|
-
|
|
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
|
|
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 =
|
|
21054
|
-
|
|
21055
|
-
|
|
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
|
|
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.#
|
|
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.#
|
|
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`).
|
|
21094
|
-
*
|
|
21095
|
-
|
|
21096
|
-
|
|
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
|
|
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
|
-
|
|
21104
|
-
|
|
21105
|
-
|
|
21106
|
-
|
|
21107
|
-
|
|
21108
|
-
if (
|
|
21109
|
-
|
|
21110
|
-
|
|
21111
|
-
|
|
21112
|
-
|
|
21113
|
-
|
|
21114
|
-
|
|
21115
|
-
}
|
|
21116
|
-
|
|
21117
|
-
|
|
21118
|
-
|
|
21119
|
-
|
|
21120
|
-
|
|
21121
|
-
|
|
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
|
-
|
|
21445
|
+
parent.insertBefore(item, ahead ?? neighbour.nextSibling);
|
|
21125
21446
|
if (hadFocus) active.focus();
|
|
21126
21447
|
}
|
|
21127
21448
|
/**
|
|
21128
|
-
*
|
|
21129
|
-
*
|
|
21130
|
-
*
|
|
21131
|
-
*
|
|
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
|
-
|
|
21135
|
-
|
|
21136
|
-
const
|
|
21137
|
-
|
|
21138
|
-
|
|
21139
|
-
|
|
21140
|
-
|
|
21141
|
-
|
|
21142
|
-
|
|
21143
|
-
|
|
21144
|
-
|
|
21145
|
-
|
|
21146
|
-
|
|
21147
|
-
|
|
21148
|
-
|
|
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
|
-
/**
|
|
21157
|
-
|
|
21158
|
-
|
|
21159
|
-
|
|
21160
|
-
|
|
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() {
|