stimeo-ui 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -0
- data/dist/cable/index.js +249 -45
- 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 +68 -18
- data/dist/controllers/portal_controller.js +32 -11
- data/dist/controllers/preview_guard_controller.js +180 -20
- data/dist/controllers/reading_progress_controller.js +177 -6
- data/dist/controllers/roving_controller.js +127 -14
- 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/stick_to_bottom_controller.js +149 -28
- data/dist/controllers/sticky_observer_controller.js +13 -2
- data/dist/index.js +934 -285
- 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();
|
|
@@ -16289,6 +16428,7 @@ var PersistController = class extends Controller {
|
|
|
16289
16428
|
this.dispatch("error", { detail: { key, operation, reason } });
|
|
16290
16429
|
}
|
|
16291
16430
|
};
|
|
16431
|
+
var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
|
|
16292
16432
|
var PointerDragController = class _PointerDragController extends Controller {
|
|
16293
16433
|
static targets = ["handle"];
|
|
16294
16434
|
static values = {
|
|
@@ -16298,6 +16438,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16298
16438
|
disabled: { type: Boolean, default: false },
|
|
16299
16439
|
follow: { type: Boolean, default: false }
|
|
16300
16440
|
};
|
|
16441
|
+
static actions = ["reset"];
|
|
16301
16442
|
static events = ["start", "move", "end", "cancel"];
|
|
16302
16443
|
/** Marker attribute recording that this controller set the touch-action. */
|
|
16303
16444
|
static #TOUCH_ACTION_MARKER = "data-pointer-drag-touch-action";
|
|
@@ -16331,23 +16472,49 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16331
16472
|
}
|
|
16332
16473
|
this.#teardown();
|
|
16333
16474
|
}
|
|
16334
|
-
/**
|
|
16475
|
+
/**
|
|
16476
|
+
* Returns the element to its origin: drops the committed follow offset and the
|
|
16477
|
+
* inline `translate` that carries it.
|
|
16478
|
+
*
|
|
16479
|
+
* In follow mode the inline `translate` belongs to this controller, and the
|
|
16480
|
+
* committed offset lives in a field the DOM cannot reach — so a consumer that
|
|
16481
|
+
* wants the element back at the start, or that has written a position of its
|
|
16482
|
+
* own, needs this to make the two agree again. An in-flight drag is cancelled
|
|
16483
|
+
* first, or its deltas would land on top of the offset just cleared.
|
|
16484
|
+
*/
|
|
16485
|
+
reset() {
|
|
16486
|
+
const live = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
|
|
16487
|
+
if (this.#pointer || this.#keyboard) this.#teardownSessions();
|
|
16488
|
+
if (live) this.#dispatchCancel(live);
|
|
16489
|
+
this.#followBase = { x: 0, y: 0 };
|
|
16490
|
+
this.#applyFollow(0, 0);
|
|
16491
|
+
}
|
|
16335
16492
|
handleTargetConnected(handle) {
|
|
16493
|
+
if (handle !== this.element) this.#restoreHandle(this.element);
|
|
16336
16494
|
this.#prepareHandle(handle);
|
|
16337
16495
|
}
|
|
16338
16496
|
handleTargetDisconnected(handle) {
|
|
16339
|
-
if (!this.element.contains(handle)) {
|
|
16340
|
-
if (this.#pointer?.handle === handle) this.#endPointerSession();
|
|
16341
|
-
if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
|
|
16342
|
-
}
|
|
16343
16497
|
this.#restoreHandle(handle);
|
|
16498
|
+
if (this.element.contains(handle)) return;
|
|
16499
|
+
const interrupted = this.#pointer?.handle === handle && this.#pointer.started ? this.#pointer.pointerType : this.#keyboard?.handle === handle ? "keyboard" : null;
|
|
16500
|
+
if (this.#pointer?.handle === handle) this.#endPointerSession();
|
|
16501
|
+
if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
|
|
16502
|
+
if (!this.hasHandleTarget) this.#prepareHandle(this.element);
|
|
16503
|
+
if (interrupted) this.#dispatchCancel(interrupted);
|
|
16344
16504
|
}
|
|
16345
|
-
/**
|
|
16346
|
-
|
|
16505
|
+
/**
|
|
16506
|
+
* Re-derives the handles' touch-action when the axis changes.
|
|
16507
|
+
*
|
|
16508
|
+
* Two layers, like the `tabindex` loan: the marker says the value was once
|
|
16509
|
+
* ours, and the current inline value says it still is. A consumer that wrote
|
|
16510
|
+
* its own `touch-action` after connect keeps it.
|
|
16511
|
+
*/
|
|
16512
|
+
axisValueChanged(_value, previousValue) {
|
|
16513
|
+
const lent = this.#touchActionFor(previousValue ?? this.axisValue);
|
|
16347
16514
|
for (const handle of this.#handles()) {
|
|
16348
|
-
if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER))
|
|
16349
|
-
|
|
16350
|
-
|
|
16515
|
+
if (!handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) continue;
|
|
16516
|
+
if (handle.style.touchAction !== lent) continue;
|
|
16517
|
+
handle.style.touchAction = this.#touchActionForAxis();
|
|
16351
16518
|
}
|
|
16352
16519
|
}
|
|
16353
16520
|
/** Cancels any in-flight session when the controller is disabled mid-drag. */
|
|
@@ -16428,9 +16595,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16428
16595
|
const handle = this.#handleFor(event.target);
|
|
16429
16596
|
if (!handle) return;
|
|
16430
16597
|
if (event.defaultPrevented) return;
|
|
16598
|
+
if (event.isComposing) return;
|
|
16599
|
+
if (this.#ownsNativeKeys(event.target, handle)) return;
|
|
16431
16600
|
if (isReservedArrowChord(event)) return;
|
|
16432
16601
|
if (event.key === "Escape") {
|
|
16433
|
-
if (event.isComposing) return;
|
|
16434
16602
|
if (this.#pointer?.started) {
|
|
16435
16603
|
const { pointerType } = this.#pointer;
|
|
16436
16604
|
this.#teardownSessions();
|
|
@@ -16451,6 +16619,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16451
16619
|
return;
|
|
16452
16620
|
}
|
|
16453
16621
|
if (!this.#keyboard) return;
|
|
16622
|
+
if (event.key === "Home" || event.key === "End") {
|
|
16623
|
+
event.preventDefault();
|
|
16624
|
+
return;
|
|
16625
|
+
}
|
|
16454
16626
|
const step = this.#keyboardDelta(event.key);
|
|
16455
16627
|
if (!step) return;
|
|
16456
16628
|
event.preventDefault();
|
|
@@ -16539,6 +16711,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16539
16711
|
const interrupted = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
|
|
16540
16712
|
this.#teardownSessions();
|
|
16541
16713
|
for (const handle of this.#handles()) this.#restoreHandle(handle);
|
|
16714
|
+
this.#followReset();
|
|
16542
16715
|
if (interrupted && this.element.isConnected) this.#dispatchCancel(interrupted);
|
|
16543
16716
|
}
|
|
16544
16717
|
/**
|
|
@@ -16608,8 +16781,12 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16608
16781
|
}
|
|
16609
16782
|
/** `touch-action` that lets the page keep panning on the locked axis only. */
|
|
16610
16783
|
#touchActionForAxis() {
|
|
16611
|
-
|
|
16612
|
-
|
|
16784
|
+
return this.#touchActionFor(this.axisValue);
|
|
16785
|
+
}
|
|
16786
|
+
/** The `touch-action` an `axis` declaration lends, for any axis value. */
|
|
16787
|
+
#touchActionFor(axis) {
|
|
16788
|
+
if (axis === "x") return "pan-y";
|
|
16789
|
+
if (axis === "y") return "pan-x";
|
|
16613
16790
|
return "none";
|
|
16614
16791
|
}
|
|
16615
16792
|
/**
|
|
@@ -16620,8 +16797,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16620
16797
|
* are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
|
|
16621
16798
|
*/
|
|
16622
16799
|
#prepareHandle(handle) {
|
|
16623
|
-
|
|
16624
|
-
|
|
16800
|
+
const lent = this.#touchActionForAxis();
|
|
16801
|
+
const marked = handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
|
|
16802
|
+
if (handle.style.touchAction === "" || marked && handle.style.touchAction === lent) {
|
|
16803
|
+
handle.style.touchAction = lent;
|
|
16625
16804
|
handle.setAttribute(_PointerDragController.#TOUCH_ACTION_MARKER, "true");
|
|
16626
16805
|
}
|
|
16627
16806
|
if (handle.tabIndex < 0 && !handle.hasAttribute("tabindex")) {
|
|
@@ -16629,17 +16808,27 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16629
16808
|
handle.setAttribute(_PointerDragController.#TABINDEX_MARKER, "true");
|
|
16630
16809
|
}
|
|
16631
16810
|
}
|
|
16811
|
+
/** Whether the key belongs to a native control or editing surface in the handle. */
|
|
16812
|
+
#ownsNativeKeys(target, handle) {
|
|
16813
|
+
const owner = target?.closest(NATIVE_KEY_OWNERS) ?? null;
|
|
16814
|
+
return owner !== null && owner !== handle && handle.contains(owner);
|
|
16815
|
+
}
|
|
16632
16816
|
/** Reverts the marker-owned touch-action + tabindex (authored values untouched). */
|
|
16633
16817
|
#restoreHandle(handle) {
|
|
16634
16818
|
if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
|
|
16635
|
-
handle.style.touchAction
|
|
16819
|
+
if (handle.style.touchAction === this.#touchActionForAxis()) {
|
|
16820
|
+
handle.style.touchAction = "";
|
|
16821
|
+
}
|
|
16636
16822
|
handle.removeAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
|
|
16637
16823
|
}
|
|
16638
16824
|
if (handle.hasAttribute(_PointerDragController.#TABINDEX_MARKER)) {
|
|
16639
|
-
handle.
|
|
16640
|
-
if (
|
|
16825
|
+
const ours = handle.getAttribute("tabindex") === "0";
|
|
16826
|
+
if (ours && document.activeElement !== handle) {
|
|
16641
16827
|
handle.removeAttribute("tabindex");
|
|
16828
|
+
handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
|
|
16829
|
+
return;
|
|
16642
16830
|
}
|
|
16831
|
+
if (!ours) handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
|
|
16643
16832
|
}
|
|
16644
16833
|
}
|
|
16645
16834
|
};
|
|
@@ -16752,15 +16941,42 @@ var PortalController = class extends Controller {
|
|
|
16752
16941
|
static events = ["mount", "unmount"];
|
|
16753
16942
|
/** Decides whether a `disconnect()` is an in-page move or a real detach. */
|
|
16754
16943
|
#gate = new DetachGate();
|
|
16944
|
+
/** The `to` declaration after validation; the default when it cannot be parsed. */
|
|
16945
|
+
#toSelector = "body";
|
|
16946
|
+
/**
|
|
16947
|
+
* Validates the destination declaration once, keeping only a selector the engine can
|
|
16948
|
+
* read.
|
|
16949
|
+
*
|
|
16950
|
+
* A selector reads back as an ordinary string, so a malformed one survives until it is
|
|
16951
|
+
* handed to the DOM — where it would take the whole teleport down and leave the part
|
|
16952
|
+
* silently doing nothing. Falling back to the default puts the node somewhere visible
|
|
16953
|
+
* instead, which reads as a mistake and can be traced back to the declaration.
|
|
16954
|
+
*/
|
|
16955
|
+
toValueChanged() {
|
|
16956
|
+
const selector = this.toValue;
|
|
16957
|
+
if (selector.length > 0) {
|
|
16958
|
+
try {
|
|
16959
|
+
this.element.matches(selector);
|
|
16960
|
+
this.#toSelector = selector;
|
|
16961
|
+
return;
|
|
16962
|
+
} catch {
|
|
16963
|
+
}
|
|
16964
|
+
}
|
|
16965
|
+
this.#toSelector = "body";
|
|
16966
|
+
}
|
|
16755
16967
|
connect() {
|
|
16756
16968
|
this.#gate.cancel();
|
|
16757
|
-
|
|
16969
|
+
const existing = portalState.get(this.element);
|
|
16970
|
+
if (existing) {
|
|
16971
|
+
existing.owner = this;
|
|
16972
|
+
return;
|
|
16973
|
+
}
|
|
16758
16974
|
const node = this.hasContentTarget ? this.contentTarget : this.element;
|
|
16759
16975
|
const destination = this.#destination();
|
|
16760
16976
|
if (!destination || destination === node || node.contains(destination)) return;
|
|
16761
16977
|
const placeholder = document.createComment("stimeo--portal");
|
|
16762
16978
|
node.parentNode?.insertBefore(placeholder, node);
|
|
16763
|
-
portalState.set(this.element, { node, placeholder });
|
|
16979
|
+
portalState.set(this.element, { node, placeholder, owner: this });
|
|
16764
16980
|
if (this.positionValue === "prepend") {
|
|
16765
16981
|
destination.prepend(node);
|
|
16766
16982
|
} else {
|
|
@@ -16779,7 +16995,7 @@ var PortalController = class extends Controller {
|
|
|
16779
16995
|
}
|
|
16780
16996
|
this.#gate.disconnected(this, () => {
|
|
16781
16997
|
const current = portalState.get(this.element);
|
|
16782
|
-
if (current) this.#restore(current);
|
|
16998
|
+
if (current?.owner === this) this.#restore(current);
|
|
16783
16999
|
});
|
|
16784
17000
|
}
|
|
16785
17001
|
/** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
|
|
@@ -16795,30 +17011,84 @@ var PortalController = class extends Controller {
|
|
|
16795
17011
|
placeholder.remove();
|
|
16796
17012
|
this.dispatch("unmount", { detail: {} });
|
|
16797
17013
|
}
|
|
16798
|
-
/** Resolves the destination
|
|
17014
|
+
/** Resolves the destination from the validated `to` selector. */
|
|
16799
17015
|
#destination() {
|
|
16800
|
-
|
|
16801
|
-
|
|
16802
|
-
|
|
16803
|
-
|
|
16804
|
-
|
|
16805
|
-
|
|
17016
|
+
return document.querySelector(this.#toSelector);
|
|
17017
|
+
}
|
|
17018
|
+
};
|
|
17019
|
+
|
|
17020
|
+
// src/utils/style_property_lease.ts
|
|
17021
|
+
var StylePropertyLease = class {
|
|
17022
|
+
#property;
|
|
17023
|
+
#records = /* @__PURE__ */ new Map();
|
|
17024
|
+
/** @param property - The CSS property whose temporary values this lease owns. */
|
|
17025
|
+
constructor(property) {
|
|
17026
|
+
this.#property = property;
|
|
17027
|
+
}
|
|
17028
|
+
/** Writes or removes the leased declaration while preserving its authored value. */
|
|
17029
|
+
write(element, value, priority = "") {
|
|
17030
|
+
const existing = this.#records.get(element);
|
|
17031
|
+
if (existing) {
|
|
17032
|
+
existing.writtenValue = value;
|
|
17033
|
+
existing.writtenPriority = value === null ? "" : priority;
|
|
17034
|
+
} else {
|
|
17035
|
+
this.#records.set(element, {
|
|
17036
|
+
originalValue: element.style.getPropertyValue(this.#property),
|
|
17037
|
+
originalPriority: element.style.getPropertyPriority(this.#property),
|
|
17038
|
+
writtenValue: value,
|
|
17039
|
+
writtenPriority: value === null ? "" : priority
|
|
17040
|
+
});
|
|
16806
17041
|
}
|
|
17042
|
+
this.#reflect(element, value, priority);
|
|
17043
|
+
}
|
|
17044
|
+
/** Returns one lease without overwriting a later consumer declaration. */
|
|
17045
|
+
return(element) {
|
|
17046
|
+
const record = this.#records.get(element);
|
|
17047
|
+
if (!record) return;
|
|
17048
|
+
this.#records.delete(element);
|
|
17049
|
+
const style = element.style;
|
|
17050
|
+
const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
|
|
17051
|
+
if (stillOwned) {
|
|
17052
|
+
this.#reflect(element, record.originalValue, record.originalPriority);
|
|
17053
|
+
}
|
|
17054
|
+
}
|
|
17055
|
+
/** Returns every outstanding declaration lease. */
|
|
17056
|
+
returnAll() {
|
|
17057
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
17058
|
+
}
|
|
17059
|
+
/** Reflects only a real declaration transition. */
|
|
17060
|
+
#reflect(element, value, priority) {
|
|
17061
|
+
const style = element.style;
|
|
17062
|
+
const nextValue = value ?? "";
|
|
17063
|
+
const nextPriority = value === null ? "" : priority;
|
|
17064
|
+
if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
|
|
17065
|
+
return;
|
|
17066
|
+
}
|
|
17067
|
+
if (value === null) style.removeProperty(this.#property);
|
|
17068
|
+
else style.setProperty(this.#property, value, priority);
|
|
16807
17069
|
}
|
|
16808
17070
|
};
|
|
17071
|
+
|
|
17072
|
+
// src/controllers/preview_guard_controller.ts
|
|
16809
17073
|
var PreviewGuardController = class extends Controller {
|
|
16810
17074
|
static values = {
|
|
16811
|
-
placeholder: { type: String, default: "" }
|
|
16812
|
-
mode: { type: String, default: "hide" }
|
|
17075
|
+
placeholder: { type: String, default: "" }
|
|
16813
17076
|
};
|
|
16814
17077
|
static events = ["hide", "show"];
|
|
17078
|
+
#visibility = new StylePropertyLease("visibility");
|
|
17079
|
+
#beforeCache = new BeforeCacheReset(() => this.#restore());
|
|
16815
17080
|
#observer = null;
|
|
17081
|
+
#connected = false;
|
|
16816
17082
|
#hidden = false;
|
|
16817
|
-
/**
|
|
16818
|
-
#
|
|
16819
|
-
/**
|
|
16820
|
-
#
|
|
17083
|
+
/** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */
|
|
17084
|
+
#savedNodes = null;
|
|
17085
|
+
/** The descendant that held focus when the guard went up, so show can hand it back. */
|
|
17086
|
+
#focused = null;
|
|
16821
17087
|
connect() {
|
|
17088
|
+
this.#connected = true;
|
|
17089
|
+
if (this.#hidden) this.#reguard();
|
|
17090
|
+
else this.element.removeAttribute("data-preview-hidden");
|
|
17091
|
+
this.#beforeCache.activate();
|
|
16822
17092
|
if (typeof MutationObserver !== "undefined") {
|
|
16823
17093
|
this.#observer = new MutationObserver(() => this.#sync());
|
|
16824
17094
|
this.#observer.observe(document.documentElement, {
|
|
@@ -16829,44 +17099,115 @@ var PreviewGuardController = class extends Controller {
|
|
|
16829
17099
|
this.#sync();
|
|
16830
17100
|
}
|
|
16831
17101
|
disconnect() {
|
|
17102
|
+
this.#connected = false;
|
|
17103
|
+
this.#beforeCache.deactivate();
|
|
16832
17104
|
this.#observer?.disconnect();
|
|
16833
17105
|
this.#observer = null;
|
|
16834
|
-
this.#restore();
|
|
16835
17106
|
}
|
|
16836
|
-
/**
|
|
17107
|
+
/**
|
|
17108
|
+
* Re-guards to match a `placeholder` changed at runtime — the value is the content on
|
|
17109
|
+
* display while the guard is up, so a morph that swaps it must not leave the old one.
|
|
17110
|
+
*/
|
|
17111
|
+
placeholderValueChanged() {
|
|
17112
|
+
if (!this.#connected || !this.#hidden) return;
|
|
17113
|
+
this.#reguard();
|
|
17114
|
+
}
|
|
17115
|
+
/**
|
|
17116
|
+
* Reflects the current `data-turbo-preview` state onto the element.
|
|
17117
|
+
*
|
|
17118
|
+
* @stimeoRenderRoot
|
|
17119
|
+
*/
|
|
16837
17120
|
#sync() {
|
|
16838
17121
|
const previewing = document.documentElement.hasAttribute("data-turbo-preview");
|
|
16839
17122
|
if (previewing && !this.#hidden) this.#hide();
|
|
16840
17123
|
else if (!previewing && this.#hidden) this.#show();
|
|
16841
17124
|
}
|
|
16842
17125
|
#hide() {
|
|
17126
|
+
this.#focused = this.#focusedInside();
|
|
17127
|
+
this.#applyGuard();
|
|
17128
|
+
this.dispatch("hide", { detail: {} });
|
|
17129
|
+
}
|
|
17130
|
+
/** Puts the guard up in the form the current `placeholder` calls for. */
|
|
17131
|
+
#applyGuard() {
|
|
16843
17132
|
this.#hidden = true;
|
|
16844
|
-
if (this.
|
|
16845
|
-
this.#
|
|
16846
|
-
this.element.textContent = this.placeholderValue;
|
|
17133
|
+
if (this.placeholderValue === "") {
|
|
17134
|
+
this.#visibility.write(this.element, "hidden");
|
|
16847
17135
|
} else {
|
|
16848
|
-
this.#
|
|
16849
|
-
this.element.
|
|
17136
|
+
this.#savedNodes = document.createDocumentFragment();
|
|
17137
|
+
this.#savedNodes.append(...this.element.childNodes);
|
|
17138
|
+
this.element.textContent = this.placeholderValue;
|
|
16850
17139
|
}
|
|
16851
17140
|
this.element.setAttribute("data-preview-hidden", "true");
|
|
16852
|
-
|
|
17141
|
+
}
|
|
17142
|
+
/**
|
|
17143
|
+
* Re-forms a guard that is already up so it matches the current `placeholder`.
|
|
17144
|
+
*
|
|
17145
|
+
* No event: `hide` reports that the guard went up, and it has not come down. Swapping
|
|
17146
|
+
* one stand-in text for another writes only that text — putting the held content back
|
|
17147
|
+
* first would reconnect the whole subtree for an instant. Only a change of *form* —
|
|
17148
|
+
* to or from the empty placeholder — has to revert, and the focus the guard is holding
|
|
17149
|
+
* carries across it rather than being handed back and taken again.
|
|
17150
|
+
*/
|
|
17151
|
+
#reguard() {
|
|
17152
|
+
if (this.#savedNodes && this.placeholderValue !== "") {
|
|
17153
|
+
this.element.textContent = this.placeholderValue;
|
|
17154
|
+
return;
|
|
17155
|
+
}
|
|
17156
|
+
const held = this.#focused;
|
|
17157
|
+
this.#revert();
|
|
17158
|
+
this.#applyGuard();
|
|
17159
|
+
this.#focused = held;
|
|
16853
17160
|
}
|
|
16854
17161
|
#show() {
|
|
16855
17162
|
this.#restore();
|
|
16856
17163
|
this.dispatch("show", { detail: {} });
|
|
16857
17164
|
}
|
|
16858
|
-
/** Reverts the guard
|
|
17165
|
+
/** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */
|
|
16859
17166
|
#restore() {
|
|
16860
|
-
|
|
17167
|
+
this.#revert();
|
|
17168
|
+
this.#refocus();
|
|
17169
|
+
}
|
|
17170
|
+
/**
|
|
17171
|
+
* Puts the element back the way the guard found it.
|
|
17172
|
+
*
|
|
17173
|
+
* Every step is a no-op on an element this controller never guarded: the lease returns
|
|
17174
|
+
* only declarations it recorded, and the hook is removed whether or not it is there.
|
|
17175
|
+
*/
|
|
17176
|
+
#revert() {
|
|
16861
17177
|
this.#hidden = false;
|
|
16862
|
-
if (this.#
|
|
16863
|
-
this.element.textContent =
|
|
16864
|
-
this.#
|
|
17178
|
+
if (this.#savedNodes) {
|
|
17179
|
+
this.element.textContent = "";
|
|
17180
|
+
this.element.append(this.#savedNodes);
|
|
17181
|
+
this.#savedNodes = null;
|
|
16865
17182
|
} else {
|
|
16866
|
-
this.element
|
|
17183
|
+
this.#visibility.return(this.element);
|
|
16867
17184
|
}
|
|
16868
17185
|
this.element.removeAttribute("data-preview-hidden");
|
|
16869
17186
|
}
|
|
17187
|
+
/**
|
|
17188
|
+
* The focused element the guard is about to make unfocusable, if any.
|
|
17189
|
+
*
|
|
17190
|
+
* The element itself counts as well as its descendants: guarding takes focus either way
|
|
17191
|
+
* — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes
|
|
17192
|
+
* outright — so the browser drops focus to `<body>` the moment the guard goes up.
|
|
17193
|
+
*/
|
|
17194
|
+
#focusedInside() {
|
|
17195
|
+
const active = document.activeElement;
|
|
17196
|
+
return active instanceof HTMLElement && this.element.contains(active) ? active : null;
|
|
17197
|
+
}
|
|
17198
|
+
/**
|
|
17199
|
+
* Hands focus back to the element the guard took it from.
|
|
17200
|
+
*
|
|
17201
|
+
* Only when that element is still in the document and focus has not moved on since —
|
|
17202
|
+
* anything else is the user's or another controller's, and putting it back would be
|
|
17203
|
+
* taking it.
|
|
17204
|
+
*/
|
|
17205
|
+
#refocus() {
|
|
17206
|
+
const target = this.#focused;
|
|
17207
|
+
this.#focused = null;
|
|
17208
|
+
if (!target?.isConnected || document.activeElement !== document.body) return;
|
|
17209
|
+
target.focus();
|
|
17210
|
+
}
|
|
16870
17211
|
};
|
|
16871
17212
|
var OWNED_VALUE_TEXT2 = "data-stimeo--progress-owns-valuetext";
|
|
16872
17213
|
var ProgressController = class extends Controller {
|
|
@@ -18175,9 +18516,16 @@ var ReadMoreController = class extends Controller {
|
|
|
18175
18516
|
var PROGRESS_PROPERTY = "--stimeo--reading-progress";
|
|
18176
18517
|
var ReadingProgressController = class extends Controller {
|
|
18177
18518
|
static events = ["change", "complete"];
|
|
18519
|
+
/** Owns both faces of the published property so teardown can hand them back. */
|
|
18520
|
+
#lease = new StylePropertyLease(PROGRESS_PROPERTY);
|
|
18521
|
+
/** The article's own box and the viewport: either changes the span. */
|
|
18522
|
+
#layout = new LayoutObserver(() => this.#onScroll());
|
|
18523
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
18178
18524
|
#frame = null;
|
|
18179
18525
|
/** Last published progress, so `change`/`complete` fire only on movement. */
|
|
18180
18526
|
#progress = -1;
|
|
18527
|
+
/** False until the connect frame has run: `complete` needs real reading. */
|
|
18528
|
+
#baselined = false;
|
|
18181
18529
|
#onScroll = () => {
|
|
18182
18530
|
if (this.#frame !== null) return;
|
|
18183
18531
|
this.#frame = requestAnimationFrame(() => {
|
|
@@ -18187,20 +18535,45 @@ var ReadingProgressController = class extends Controller {
|
|
|
18187
18535
|
};
|
|
18188
18536
|
connect() {
|
|
18189
18537
|
this.#progress = -1;
|
|
18538
|
+
this.#baselined = false;
|
|
18190
18539
|
window.addEventListener("scroll", this.#onScroll, { passive: true, capture: true });
|
|
18191
|
-
|
|
18540
|
+
this.#layout.observe(this.element);
|
|
18541
|
+
this.#layout.observeViewport();
|
|
18542
|
+
this.#beforeCache.activate();
|
|
18192
18543
|
this.#measure();
|
|
18544
|
+
this.#frame = requestAnimationFrame(() => {
|
|
18545
|
+
this.#frame = null;
|
|
18546
|
+
this.#measure();
|
|
18547
|
+
this.#baselined = true;
|
|
18548
|
+
});
|
|
18193
18549
|
}
|
|
18194
18550
|
disconnect() {
|
|
18195
18551
|
window.removeEventListener("scroll", this.#onScroll, { capture: true });
|
|
18196
|
-
|
|
18552
|
+
this.#layout.disconnect();
|
|
18553
|
+
this.#beforeCache.deactivate();
|
|
18554
|
+
this.#cancelFrame();
|
|
18555
|
+
this.#lease.returnAll();
|
|
18556
|
+
}
|
|
18557
|
+
/**
|
|
18558
|
+
* Hands both declarations back before the page is snapshotted, so a restored
|
|
18559
|
+
* page starts from the authored DOM rather than from someone else's progress.
|
|
18560
|
+
* The baseline goes back with them: a cancelled visit leaves this page on
|
|
18561
|
+
* screen, and the next measurement has to publish afresh rather than match a
|
|
18562
|
+
* value that has already been handed back.
|
|
18563
|
+
*/
|
|
18564
|
+
#rewindForCache() {
|
|
18565
|
+
this.#cancelFrame();
|
|
18566
|
+
this.#lease.returnAll();
|
|
18567
|
+
this.#progress = -1;
|
|
18568
|
+
}
|
|
18569
|
+
#cancelFrame() {
|
|
18197
18570
|
if (this.#frame !== null) cancelAnimationFrame(this.#frame);
|
|
18198
18571
|
this.#frame = null;
|
|
18199
|
-
document.documentElement.style.removeProperty(PROGRESS_PROPERTY);
|
|
18200
18572
|
}
|
|
18201
18573
|
/** Computes and publishes the progress; emits on movement only. */
|
|
18202
18574
|
#measure() {
|
|
18203
18575
|
const rect = this.element.getBoundingClientRect();
|
|
18576
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
18204
18577
|
const span = rect.height - window.innerHeight;
|
|
18205
18578
|
const raw = span > 0 ? -rect.top / span : rect.top <= 0 ? 1 : 0;
|
|
18206
18579
|
const progress = Math.min(1, Math.max(0, raw));
|
|
@@ -18208,10 +18581,10 @@ var ReadingProgressController = class extends Controller {
|
|
|
18208
18581
|
const previous = this.#progress;
|
|
18209
18582
|
this.#progress = progress;
|
|
18210
18583
|
const value = String(progress);
|
|
18211
|
-
this.element
|
|
18212
|
-
document.documentElement
|
|
18584
|
+
this.#lease.write(this.element, value);
|
|
18585
|
+
this.#lease.write(document.documentElement, value);
|
|
18213
18586
|
this.dispatch("change", { detail: { progress } });
|
|
18214
|
-
if (progress === 1 && previous !== -1) this.dispatch("complete");
|
|
18587
|
+
if (progress === 1 && previous !== -1 && this.#baselined) this.dispatch("complete");
|
|
18215
18588
|
}
|
|
18216
18589
|
};
|
|
18217
18590
|
var YEAR_SCALE = { limit: Number.POSITIVE_INFINITY, unit: "year", ms: 315576e5 };
|
|
@@ -18668,6 +19041,7 @@ var ResizableController = class extends Controller {
|
|
|
18668
19041
|
return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
|
|
18669
19042
|
}
|
|
18670
19043
|
};
|
|
19044
|
+
var STATE_ATTRIBUTES2 = ["disabled", "hidden"];
|
|
18671
19045
|
var RovingController = class extends Controller {
|
|
18672
19046
|
static targets = ["item"];
|
|
18673
19047
|
static values = {
|
|
@@ -18677,12 +19051,14 @@ var RovingController = class extends Controller {
|
|
|
18677
19051
|
};
|
|
18678
19052
|
static events = ["change"];
|
|
18679
19053
|
#roving = new RovingTabindex(() => this.itemTargets);
|
|
18680
|
-
#reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
|
|
19054
|
+
#reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop(true));
|
|
18681
19055
|
#connected = false;
|
|
19056
|
+
#observer = null;
|
|
18682
19057
|
connect() {
|
|
18683
|
-
this.#ensureTabStop();
|
|
19058
|
+
this.#ensureTabStop(false);
|
|
18684
19059
|
this.element.addEventListener("keydown", this.#onKeydown);
|
|
18685
19060
|
this.element.addEventListener("focusin", this.#onFocusin);
|
|
19061
|
+
this.#watchState();
|
|
18686
19062
|
this.#connected = true;
|
|
18687
19063
|
this.#reconcile.activate();
|
|
18688
19064
|
}
|
|
@@ -18691,6 +19067,8 @@ var RovingController = class extends Controller {
|
|
|
18691
19067
|
this.#reconcile.cancel();
|
|
18692
19068
|
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
18693
19069
|
this.element.removeEventListener("focusin", this.#onFocusin);
|
|
19070
|
+
this.#observer?.disconnect();
|
|
19071
|
+
this.#observer = null;
|
|
18694
19072
|
}
|
|
18695
19073
|
/** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
|
|
18696
19074
|
itemTargetConnected(item) {
|
|
@@ -18706,10 +19084,9 @@ var RovingController = class extends Controller {
|
|
|
18706
19084
|
#onKeydown = (event) => {
|
|
18707
19085
|
if (event.defaultPrevented) return;
|
|
18708
19086
|
if (isReservedArrowChord(event)) return;
|
|
18709
|
-
|
|
19087
|
+
if (event.isComposing) return;
|
|
18710
19088
|
const current = this.#indexOf(event.target);
|
|
18711
19089
|
if (current === -1) return;
|
|
18712
|
-
const length = items.length;
|
|
18713
19090
|
const wrap = this.wrapValue ? "wrap" : "clamp";
|
|
18714
19091
|
const orientation = this.orientationValue;
|
|
18715
19092
|
const horizontal = orientation === "horizontal" || orientation === "both";
|
|
@@ -18719,16 +19096,16 @@ var RovingController = class extends Controller {
|
|
|
18719
19096
|
const backwardKey = rtl ? "ArrowRight" : "ArrowLeft";
|
|
18720
19097
|
let next;
|
|
18721
19098
|
if (horizontal && event.key === forwardKey || vertical && event.key === "ArrowDown") {
|
|
18722
|
-
next =
|
|
19099
|
+
next = this.#step(current, 1, wrap);
|
|
18723
19100
|
} else if (horizontal && event.key === backwardKey || vertical && event.key === "ArrowUp") {
|
|
18724
|
-
next =
|
|
18725
|
-
} else if (this.homeEndValue && event.key === "Home") {
|
|
18726
|
-
|
|
18727
|
-
|
|
18728
|
-
next = length - 1;
|
|
19101
|
+
next = this.#step(current, -1, wrap);
|
|
19102
|
+
} else if (this.homeEndValue && (event.key === "Home" || event.key === "End")) {
|
|
19103
|
+
if (hasModifierChord(event)) return;
|
|
19104
|
+
next = event.key === "Home" ? this.#firstReachable() : this.#lastReachable();
|
|
18729
19105
|
} else {
|
|
18730
19106
|
return;
|
|
18731
19107
|
}
|
|
19108
|
+
if (next === -1) return;
|
|
18732
19109
|
event.preventDefault();
|
|
18733
19110
|
this.#activate(next, true);
|
|
18734
19111
|
};
|
|
@@ -18739,7 +19116,8 @@ var RovingController = class extends Controller {
|
|
|
18739
19116
|
*/
|
|
18740
19117
|
#onFocusin = (event) => {
|
|
18741
19118
|
const index = this.#indexOf(event.target);
|
|
18742
|
-
if (index
|
|
19119
|
+
if (index === -1 || !this.#reachable(index)) return;
|
|
19120
|
+
this.#activate(index, false);
|
|
18743
19121
|
};
|
|
18744
19122
|
/** Resolves the item index owning an event target (the item or a descendant). */
|
|
18745
19123
|
#indexOf(target) {
|
|
@@ -18755,66 +19133,106 @@ var RovingController = class extends Controller {
|
|
|
18755
19133
|
this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
|
|
18756
19134
|
}
|
|
18757
19135
|
}
|
|
18758
|
-
/**
|
|
18759
|
-
|
|
19136
|
+
/**
|
|
19137
|
+
* Keeps the Tab stop on a reachable item.
|
|
19138
|
+
*
|
|
19139
|
+
* `followFocus` is on for re-establishment only: an item added and focused in
|
|
19140
|
+
* the same task has already claimed the stop through `focusin`, and the batch
|
|
19141
|
+
* that follows must not hand it back to the first item. `connect()` passes it
|
|
19142
|
+
* off so the authored DOM decides the initial stop. With nothing reachable the
|
|
19143
|
+
* DOM is left as it is — a group inside a collapsed region gets its stop back
|
|
19144
|
+
* when the region opens, instead of losing it for good.
|
|
19145
|
+
*/
|
|
19146
|
+
#ensureTabStop(followFocus) {
|
|
19147
|
+
const items = this.itemTargets;
|
|
19148
|
+
const focused = followFocus ? items.findIndex((item, i) => item === document.activeElement && this.#reachable(i)) : -1;
|
|
18760
19149
|
const active = this.#roving.activeIndex;
|
|
18761
|
-
|
|
18762
|
-
|
|
18763
|
-
|
|
18764
|
-
|
|
18765
|
-
// src/utils/style_property_lease.ts
|
|
18766
|
-
var StylePropertyLease = class {
|
|
18767
|
-
#property;
|
|
18768
|
-
#records = /* @__PURE__ */ new Map();
|
|
18769
|
-
/** @param property - The CSS property whose temporary values this lease owns. */
|
|
18770
|
-
constructor(property) {
|
|
18771
|
-
this.#property = property;
|
|
19150
|
+
const kept = active !== -1 && this.#reachable(active) ? active : -1;
|
|
19151
|
+
const index = focused !== -1 ? focused : kept !== -1 ? kept : this.#firstReachable();
|
|
19152
|
+
if (index === -1) return;
|
|
19153
|
+
this.#roving.setActive(index);
|
|
18772
19154
|
}
|
|
18773
|
-
/**
|
|
18774
|
-
|
|
18775
|
-
|
|
18776
|
-
|
|
18777
|
-
|
|
18778
|
-
|
|
18779
|
-
|
|
18780
|
-
|
|
18781
|
-
|
|
18782
|
-
|
|
18783
|
-
|
|
18784
|
-
|
|
18785
|
-
|
|
19155
|
+
/**
|
|
19156
|
+
* Resolves the next reachable index in `delta`'s direction, honouring `wrap`.
|
|
19157
|
+
*
|
|
19158
|
+
* An arrow on the widget's own axis is the widget's to consume even when the
|
|
19159
|
+
* position does not change, so a clamped end resolves to the current item
|
|
19160
|
+
* rather than to nothing. An unreachable origin escapes to the first reachable
|
|
19161
|
+
* item instead of sitting in a dead end, and `-1` is left for the one case that
|
|
19162
|
+
* really is not ours: no reachable item anywhere.
|
|
19163
|
+
*/
|
|
19164
|
+
#step(current, delta, wrap) {
|
|
19165
|
+
const length = this.itemTargets.length;
|
|
19166
|
+
let index = current;
|
|
19167
|
+
for (let taken = 0; taken < length; taken += 1) {
|
|
19168
|
+
const candidate = rovingMove(index, length, delta, wrap);
|
|
19169
|
+
if (candidate === index) break;
|
|
19170
|
+
index = candidate;
|
|
19171
|
+
if (this.#reachable(index)) return index;
|
|
19172
|
+
}
|
|
19173
|
+
return this.#reachable(current) ? current : this.#firstReachable();
|
|
19174
|
+
}
|
|
19175
|
+
/** Index of the first reachable item, or `-1`. */
|
|
19176
|
+
#firstReachable() {
|
|
19177
|
+
return this.itemTargets.findIndex((_item, i) => this.#reachable(i));
|
|
19178
|
+
}
|
|
19179
|
+
/** Index of the last reachable item, or `-1`. */
|
|
19180
|
+
#lastReachable() {
|
|
19181
|
+
for (let i = this.itemTargets.length - 1; i >= 0; i -= 1) {
|
|
19182
|
+
if (this.#reachable(i)) return i;
|
|
18786
19183
|
}
|
|
18787
|
-
|
|
19184
|
+
return -1;
|
|
18788
19185
|
}
|
|
18789
|
-
/**
|
|
18790
|
-
|
|
18791
|
-
|
|
18792
|
-
|
|
18793
|
-
|
|
18794
|
-
|
|
18795
|
-
|
|
18796
|
-
|
|
18797
|
-
|
|
18798
|
-
|
|
19186
|
+
/**
|
|
19187
|
+
* Whether the item at `index` can hold the Tab stop and take focus.
|
|
19188
|
+
*
|
|
19189
|
+
* `aria-disabled` is deliberately not consulted: it keeps an item reachable and
|
|
19190
|
+
* only suppresses activation, which this part does not own.
|
|
19191
|
+
*/
|
|
19192
|
+
#reachable(index) {
|
|
19193
|
+
const item = this.itemTargets[index];
|
|
19194
|
+
if (!item) return false;
|
|
19195
|
+
if (this.#hidden(item)) return false;
|
|
19196
|
+
if (!("disabled" in item)) return true;
|
|
19197
|
+
if (item.disabled) return false;
|
|
19198
|
+
return !inheritsFieldsetDisabled(item);
|
|
18799
19199
|
}
|
|
18800
|
-
/**
|
|
18801
|
-
|
|
18802
|
-
|
|
19200
|
+
/**
|
|
19201
|
+
* Whether `item`, or anything between it and the container, is `hidden`.
|
|
19202
|
+
*
|
|
19203
|
+
* The walk stops at the container on purpose: a group inside a hidden region is
|
|
19204
|
+
* already out of the page's Tab order, and calling every item unreachable there
|
|
19205
|
+
* would drop the stop with nothing left to restore it — the ancestor lies
|
|
19206
|
+
* outside the subtree whose state attributes are watched.
|
|
19207
|
+
*/
|
|
19208
|
+
#hidden(item) {
|
|
19209
|
+
let node = item;
|
|
19210
|
+
while (node && node !== this.element) {
|
|
19211
|
+
if (node.hasAttribute("hidden")) return true;
|
|
19212
|
+
node = node.parentElement;
|
|
19213
|
+
}
|
|
19214
|
+
return false;
|
|
18803
19215
|
}
|
|
18804
|
-
/**
|
|
18805
|
-
|
|
18806
|
-
|
|
18807
|
-
|
|
18808
|
-
|
|
18809
|
-
|
|
18810
|
-
|
|
19216
|
+
/**
|
|
19217
|
+
* Watches the attributes that decide reachability, so disabling the item that
|
|
19218
|
+
* holds the Tab stop hands it to another one instead of taking the whole set
|
|
19219
|
+
* out of the Tab sequence. An enclosing `fieldset` disables items from outside
|
|
19220
|
+
* the observed subtree, so each one's own `disabled` is watched too.
|
|
19221
|
+
*/
|
|
19222
|
+
#watchState() {
|
|
19223
|
+
if (typeof MutationObserver === "undefined") return;
|
|
19224
|
+
const observer = new MutationObserver(() => this.#reconcile.schedule());
|
|
19225
|
+
observer.observe(this.element, {
|
|
19226
|
+
subtree: true,
|
|
19227
|
+
attributes: true,
|
|
19228
|
+
attributeFilter: STATE_ATTRIBUTES2
|
|
19229
|
+
});
|
|
19230
|
+
for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
|
|
19231
|
+
observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
|
|
18811
19232
|
}
|
|
18812
|
-
|
|
18813
|
-
else style.setProperty(this.#property, value, priority);
|
|
19233
|
+
this.#observer = observer;
|
|
18814
19234
|
}
|
|
18815
19235
|
};
|
|
18816
|
-
|
|
18817
|
-
// src/controllers/scroll_area_controller.ts
|
|
18818
19236
|
var EDGE_EPSILON = 1;
|
|
18819
19237
|
var ScrollAreaController = class extends Controller {
|
|
18820
19238
|
static targets = ["viewport"];
|
|
@@ -20690,19 +21108,32 @@ var SliderController = class extends Controller {
|
|
|
20690
21108
|
drag?.pointer?.end();
|
|
20691
21109
|
}
|
|
20692
21110
|
};
|
|
21111
|
+
var DEFAULT_OFFSET2 = 80;
|
|
20693
21112
|
var SmartStickyHeaderController = class extends Controller {
|
|
20694
21113
|
static values = {
|
|
20695
21114
|
containerSelector: { type: String, default: "" },
|
|
20696
|
-
offset: { type: Number, default:
|
|
21115
|
+
offset: { type: Number, default: DEFAULT_OFFSET2 },
|
|
20697
21116
|
tolerance: { type: Number, default: 4 }
|
|
20698
21117
|
};
|
|
20699
21118
|
static events = ["change"];
|
|
21119
|
+
#connected = false;
|
|
20700
21120
|
#frame = null;
|
|
20701
21121
|
/** The scroll source resolved at connect — disconnect must unbind the SAME node. */
|
|
20702
21122
|
#scrollerEl = window;
|
|
21123
|
+
/** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
|
|
21124
|
+
#containerSelector = "";
|
|
20703
21125
|
#lastY = 0;
|
|
20704
21126
|
/** Last published state, so `change` fires only on transitions. */
|
|
20705
21127
|
#hidden = null;
|
|
21128
|
+
/**
|
|
21129
|
+
* The depth that never hides. A declaration that is not a finite number reads
|
|
21130
|
+
* as the default, so the comparison path never sees `NaN` — which would
|
|
21131
|
+
* answer `false` to every comparison and hide the header inside the very
|
|
21132
|
+
* zone the value exists to protect.
|
|
21133
|
+
*/
|
|
21134
|
+
get #offset() {
|
|
21135
|
+
return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET2;
|
|
21136
|
+
}
|
|
20706
21137
|
#onScroll = () => {
|
|
20707
21138
|
if (this.#frame !== null) return;
|
|
20708
21139
|
this.#frame = requestAnimationFrame(() => {
|
|
@@ -20715,15 +21146,25 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
20715
21146
|
* hold while focus *stays* inside is the `#apply` hide invariant.
|
|
20716
21147
|
*/
|
|
20717
21148
|
#onFocusin = () => this.#apply(false);
|
|
21149
|
+
/** Validates `containerSelector` once so connect never parses a selector that throws. */
|
|
21150
|
+
containerSelectorValueChanged() {
|
|
21151
|
+
this.#containerSelector = this.#validSelector(this.containerSelectorValue);
|
|
21152
|
+
}
|
|
21153
|
+
/** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
|
|
21154
|
+
offsetValueChanged() {
|
|
21155
|
+
if (this.#connected) this.#measure();
|
|
21156
|
+
}
|
|
20718
21157
|
connect() {
|
|
20719
21158
|
this.#hidden = null;
|
|
20720
21159
|
this.#scrollerEl = this.#resolveScroller();
|
|
20721
21160
|
this.#lastY = this.#scrollY;
|
|
20722
21161
|
this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
20723
21162
|
this.element.addEventListener("focusin", this.#onFocusin);
|
|
20724
|
-
this.#apply(false);
|
|
21163
|
+
this.#apply(false, false);
|
|
21164
|
+
this.#connected = true;
|
|
20725
21165
|
}
|
|
20726
21166
|
disconnect() {
|
|
21167
|
+
this.#connected = false;
|
|
20727
21168
|
this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
|
|
20728
21169
|
this.element.removeEventListener("focusin", this.#onFocusin);
|
|
20729
21170
|
if (this.#frame !== null) cancelAnimationFrame(this.#frame);
|
|
@@ -20731,43 +21172,69 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
20731
21172
|
}
|
|
20732
21173
|
/** Resolves the scroll source: the `containerSelector` match, else the window. */
|
|
20733
21174
|
#resolveScroller() {
|
|
20734
|
-
if (this
|
|
20735
|
-
const container = document.querySelector(this
|
|
21175
|
+
if (this.#containerSelector) {
|
|
21176
|
+
const container = document.querySelector(this.#containerSelector);
|
|
20736
21177
|
if (container) return container;
|
|
20737
21178
|
}
|
|
20738
21179
|
return window;
|
|
20739
21180
|
}
|
|
21181
|
+
/** Returns `declared` when it parses as a selector, and `""` when it does not. */
|
|
21182
|
+
#validSelector(declared) {
|
|
21183
|
+
if (declared.length > 0) {
|
|
21184
|
+
try {
|
|
21185
|
+
this.element.matches(declared);
|
|
21186
|
+
return declared;
|
|
21187
|
+
} catch {
|
|
21188
|
+
}
|
|
21189
|
+
}
|
|
21190
|
+
return "";
|
|
21191
|
+
}
|
|
20740
21192
|
get #scrollY() {
|
|
20741
21193
|
const scroller = this.#scrollerEl;
|
|
20742
21194
|
return scroller === window ? window.scrollY : scroller.scrollTop;
|
|
20743
21195
|
}
|
|
20744
21196
|
#measure() {
|
|
20745
21197
|
const y = this.#scrollY;
|
|
21198
|
+
if (y <= this.#offset) {
|
|
21199
|
+
this.#lastY = y;
|
|
21200
|
+
this.#apply(false);
|
|
21201
|
+
return;
|
|
21202
|
+
}
|
|
20746
21203
|
const delta = y - this.#lastY;
|
|
20747
21204
|
if (Math.abs(delta) < this.toleranceValue) return;
|
|
20748
21205
|
this.#lastY = y;
|
|
20749
|
-
|
|
20750
|
-
else if (delta > 0) this.#apply(true);
|
|
20751
|
-
else this.#apply(false);
|
|
21206
|
+
this.#apply(delta > 0);
|
|
20752
21207
|
}
|
|
20753
|
-
/**
|
|
20754
|
-
|
|
21208
|
+
/**
|
|
21209
|
+
* Reflects the state onto the hook and emits `change` on transitions.
|
|
21210
|
+
*
|
|
21211
|
+
* @param notify - whether a transition announces itself. The reflection
|
|
21212
|
+
* `connect()` performs is the current state, not a change.
|
|
21213
|
+
*/
|
|
21214
|
+
#apply(hidden, notify = true) {
|
|
20755
21215
|
if (hidden && this.element.contains(document.activeElement)) return;
|
|
20756
21216
|
if (hidden === this.#hidden) return;
|
|
20757
21217
|
this.#hidden = hidden;
|
|
20758
21218
|
this.element.setAttribute("data-header-hidden", hidden ? "true" : "false");
|
|
20759
|
-
this.dispatch("change", { detail: { hidden } });
|
|
21219
|
+
if (notify) this.dispatch("change", { detail: { hidden } });
|
|
20760
21220
|
}
|
|
20761
21221
|
};
|
|
20762
21222
|
var SortableController = class extends Controller {
|
|
20763
|
-
static targets = ["list", "item"
|
|
21223
|
+
static targets = ["list", "item"];
|
|
20764
21224
|
static values = {
|
|
20765
|
-
orientation: { type: String, default: "vertical" }
|
|
21225
|
+
orientation: { type: String, default: "vertical" },
|
|
21226
|
+
announceGrabbedText: { type: String, default: "" },
|
|
21227
|
+
announceMovedText: { type: String, default: "" },
|
|
21228
|
+
announceDroppedText: { type: String, default: "" },
|
|
21229
|
+
announceCanceledText: { type: String, default: "" }
|
|
20766
21230
|
};
|
|
20767
21231
|
static events = ["reorder"];
|
|
20768
21232
|
#session = null;
|
|
21233
|
+
/** Defers the lost-item check to after the mutation batch (see below). */
|
|
21234
|
+
#settle = new MicrotaskCoalescer(() => this.#dropLostSession());
|
|
20769
21235
|
connect() {
|
|
20770
21236
|
this.element.removeAttribute("data-sortable-dragging");
|
|
21237
|
+
this.#settle.activate();
|
|
20771
21238
|
this.element.addEventListener("stimeo--pointer-drag:start", this.#onDragStart);
|
|
20772
21239
|
this.element.addEventListener("stimeo--pointer-drag:move", this.#onDragMove);
|
|
20773
21240
|
this.element.addEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
|
|
@@ -20778,49 +21245,121 @@ var SortableController = class extends Controller {
|
|
|
20778
21245
|
this.element.removeEventListener("stimeo--pointer-drag:move", this.#onDragMove);
|
|
20779
21246
|
this.element.removeEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
|
|
20780
21247
|
this.element.removeEventListener("stimeo--pointer-drag:cancel", this.#onDragCancel);
|
|
21248
|
+
this.#settle.cancel();
|
|
21249
|
+
this.#session = null;
|
|
21250
|
+
this.element.removeAttribute("data-sortable-dragging");
|
|
21251
|
+
}
|
|
21252
|
+
/**
|
|
21253
|
+
* Ends a session whose item left the item set.
|
|
21254
|
+
*
|
|
21255
|
+
* The controller's own reorder detaches and reattaches the item inside one
|
|
21256
|
+
* mutation batch, so the loss is only real once the batch has settled — the
|
|
21257
|
+
* check therefore runs a microtask later and asks whether the item is a target
|
|
21258
|
+
* again. Without it a deleted row (a broadcast that drops it from the board)
|
|
21259
|
+
* or a morph that strips the item's target attribute would hold the
|
|
21260
|
+
* one-at-a-time session for the rest of the page's life, with the root hook
|
|
21261
|
+
* stuck on and every later grab refused with no way out.
|
|
21262
|
+
*/
|
|
21263
|
+
itemTargetDisconnected(item) {
|
|
21264
|
+
if (this.#session?.item !== item) return;
|
|
21265
|
+
this.#settle.schedule();
|
|
21266
|
+
}
|
|
21267
|
+
#dropLostSession() {
|
|
21268
|
+
const session = this.#session;
|
|
21269
|
+
if (!session) return;
|
|
21270
|
+
const items = this.#items();
|
|
21271
|
+
if (items.includes(session.item)) return;
|
|
20781
21272
|
this.#session = null;
|
|
20782
21273
|
this.element.removeAttribute("data-sortable-dragging");
|
|
21274
|
+
if (session.item.isConnected) this.#restore(session, items);
|
|
20783
21275
|
}
|
|
20784
|
-
/** Picks the item up: remembers its
|
|
21276
|
+
/** Picks the item up: remembers its neighbours and announces the grab. */
|
|
20785
21277
|
#onDragStart = (event) => {
|
|
20786
21278
|
if (this.#session) return;
|
|
20787
|
-
const
|
|
21279
|
+
const items = this.#items();
|
|
21280
|
+
const item = this.#itemFor(event.target, items);
|
|
20788
21281
|
if (!item) return;
|
|
20789
|
-
|
|
21282
|
+
const index = items.indexOf(item);
|
|
21283
|
+
this.#session = {
|
|
21284
|
+
item,
|
|
21285
|
+
anchor: items[index + 1] ?? null,
|
|
21286
|
+
predecessor: items[index - 1] ?? null,
|
|
21287
|
+
lastPrimary: 0
|
|
21288
|
+
};
|
|
20790
21289
|
this.element.setAttribute("data-sortable-dragging", "true");
|
|
20791
21290
|
this.#announce("grabbed", item);
|
|
20792
21291
|
};
|
|
20793
21292
|
#onDragMove = (event) => {
|
|
20794
21293
|
const session = this.#session;
|
|
21294
|
+
if (!session) return;
|
|
21295
|
+
const items = this.#items();
|
|
21296
|
+
if (this.#itemFor(event.target, items) !== session.item) return;
|
|
20795
21297
|
const detail = event.detail;
|
|
20796
|
-
if (!session || this.#itemFor(event.target) !== session.item) return;
|
|
20797
21298
|
if (detail.pointerType === "keyboard") {
|
|
20798
|
-
this.#stepFromKeyboard(session, detail);
|
|
21299
|
+
this.#stepFromKeyboard(session, detail, items);
|
|
20799
21300
|
} else {
|
|
20800
|
-
this.#followPointer(session, detail);
|
|
21301
|
+
this.#followPointer(session, detail, items);
|
|
20801
21302
|
}
|
|
20802
21303
|
};
|
|
20803
21304
|
/** Drops the item: announces, then reports `reorder` if the position changed. */
|
|
20804
21305
|
#onDragEnd = (event) => {
|
|
20805
21306
|
const session = this.#session;
|
|
20806
|
-
if (!session
|
|
21307
|
+
if (!session) return;
|
|
21308
|
+
const items = this.#items();
|
|
21309
|
+
if (this.#itemFor(event.target, items) !== session.item) return;
|
|
20807
21310
|
this.#session = null;
|
|
20808
21311
|
this.element.removeAttribute("data-sortable-dragging");
|
|
20809
21312
|
this.#announce("dropped", session.item);
|
|
20810
|
-
const to =
|
|
20811
|
-
|
|
20812
|
-
|
|
21313
|
+
const to = items.indexOf(session.item);
|
|
21314
|
+
const from = this.#pickupSlot(session, items) ?? to;
|
|
21315
|
+
if (to !== from) {
|
|
21316
|
+
this.dispatch("reorder", { detail: { item: session.item, from, to } });
|
|
20813
21317
|
}
|
|
20814
21318
|
};
|
|
20815
21319
|
/** Restores the pickup position (Escape / OS `pointercancel`). */
|
|
20816
21320
|
#onDragCancel = (event) => {
|
|
20817
21321
|
const session = this.#session;
|
|
20818
|
-
if (!session
|
|
21322
|
+
if (!session) return;
|
|
21323
|
+
const items = this.#items();
|
|
21324
|
+
if (this.#itemFor(event.target, items) !== session.item) return;
|
|
20819
21325
|
this.#session = null;
|
|
20820
21326
|
this.element.removeAttribute("data-sortable-dragging");
|
|
20821
|
-
this.#
|
|
21327
|
+
this.#restore(session, items);
|
|
20822
21328
|
this.#announce("canceled", session.item);
|
|
20823
21329
|
};
|
|
21330
|
+
/**
|
|
21331
|
+
* The slot the item was picked up from, read back through the neighbours it
|
|
21332
|
+
* had then — `null` when neither survives and the item itself is gone.
|
|
21333
|
+
*
|
|
21334
|
+
* The anchor is the item that followed it, so restoring means "before that one
|
|
21335
|
+
* again". When the anchor was deleted mid-drag the predecessor answers the
|
|
21336
|
+
* same question from the other side. With both gone the item's current slot is
|
|
21337
|
+
* the honest answer: nothing is known to have moved, so no reorder is reported
|
|
21338
|
+
* and a cancel leaves the item where it is.
|
|
21339
|
+
*/
|
|
21340
|
+
#pickupSlot(session, items) {
|
|
21341
|
+
const others = items.filter((candidate) => candidate !== session.item);
|
|
21342
|
+
if (session.anchor) {
|
|
21343
|
+
const at = others.indexOf(session.anchor);
|
|
21344
|
+
if (at !== -1) return at;
|
|
21345
|
+
}
|
|
21346
|
+
if (session.predecessor) {
|
|
21347
|
+
const at = others.indexOf(session.predecessor);
|
|
21348
|
+
if (at !== -1) return at + 1;
|
|
21349
|
+
}
|
|
21350
|
+
const here = items.indexOf(session.item);
|
|
21351
|
+
return here === -1 ? null : here;
|
|
21352
|
+
}
|
|
21353
|
+
/** Puts the item back where it was picked up from. */
|
|
21354
|
+
#restore(session, items) {
|
|
21355
|
+
const slot = this.#pickupSlot(session, items);
|
|
21356
|
+
if (slot === null) return;
|
|
21357
|
+
this.#insertAt(
|
|
21358
|
+
session.item,
|
|
21359
|
+
items.filter((candidate) => candidate !== session.item),
|
|
21360
|
+
slot
|
|
21361
|
+
);
|
|
21362
|
+
}
|
|
20824
21363
|
/**
|
|
20825
21364
|
* Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
|
|
20826
21365
|
* the difference from the last consumed value is one arrow press — its sign is
|
|
@@ -20832,77 +21371,116 @@ var SortableController = class extends Controller {
|
|
|
20832
21371
|
* `roving` already moves focus logically, so the same arrow would send the
|
|
20833
21372
|
* focus and the grabbed item opposite ways.
|
|
20834
21373
|
*/
|
|
20835
|
-
#stepFromKeyboard(session, detail) {
|
|
21374
|
+
#stepFromKeyboard(session, detail, items) {
|
|
20836
21375
|
const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
|
|
20837
21376
|
const delta = primary - session.lastPrimary;
|
|
20838
21377
|
session.lastPrimary = primary;
|
|
20839
21378
|
if (delta === 0) return;
|
|
20840
|
-
const items = this.#items();
|
|
20841
21379
|
const index = items.indexOf(session.item);
|
|
20842
21380
|
const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
|
|
20843
21381
|
const next = Math.max(0, Math.min(index + step, items.length - 1));
|
|
20844
21382
|
if (next === index) return;
|
|
20845
|
-
this.#
|
|
21383
|
+
this.#insertAt(
|
|
21384
|
+
session.item,
|
|
21385
|
+
items.filter((candidate) => candidate !== session.item),
|
|
21386
|
+
next
|
|
21387
|
+
);
|
|
20846
21388
|
this.#announce("moved", session.item);
|
|
20847
21389
|
}
|
|
20848
21390
|
/**
|
|
20849
21391
|
* Pointer following: the item moves to the slot whose siblings' midpoints the
|
|
20850
|
-
* pointer has passed (per `orientation`).
|
|
20851
|
-
*
|
|
20852
|
-
|
|
20853
|
-
|
|
21392
|
+
* pointer has passed (per `orientation`).
|
|
21393
|
+
*
|
|
21394
|
+
* Only siblings that occupy space take part. A row with no layout box — a
|
|
21395
|
+
* filtered-out item, a `display: none` ancestor, a collapsed `<details>` — is
|
|
21396
|
+
* reported with an empty rect at the document origin, and its midpoint of `0`
|
|
21397
|
+
* sits below every pointer position: counted, it would read as passed on the
|
|
21398
|
+
* very first move and send the item across a slot the pointer never crossed.
|
|
21399
|
+
* With no laid-out sibling at all there is nothing to compare against and the
|
|
21400
|
+
* move is skipped entirely.
|
|
21401
|
+
*/
|
|
21402
|
+
#followPointer(session, detail, items) {
|
|
20854
21403
|
const pointer = Number(this.#isVertical ? detail.y : detail.x) || 0;
|
|
20855
|
-
const
|
|
20856
|
-
if (others.length === 0) return;
|
|
20857
|
-
let laidOut = false;
|
|
20858
|
-
let target = 0;
|
|
21404
|
+
const vertical = this.#isVertical;
|
|
20859
21405
|
const reversed = this.#isReversed;
|
|
20860
|
-
|
|
20861
|
-
|
|
20862
|
-
|
|
20863
|
-
|
|
20864
|
-
|
|
20865
|
-
if (
|
|
20866
|
-
|
|
20867
|
-
|
|
20868
|
-
|
|
20869
|
-
|
|
20870
|
-
|
|
20871
|
-
|
|
20872
|
-
}
|
|
20873
|
-
|
|
20874
|
-
|
|
20875
|
-
|
|
20876
|
-
|
|
20877
|
-
|
|
20878
|
-
|
|
21406
|
+
const here = items.indexOf(session.item);
|
|
21407
|
+
const laidOut = [];
|
|
21408
|
+
let target = 0;
|
|
21409
|
+
let current = 0;
|
|
21410
|
+
items.forEach((item, index) => {
|
|
21411
|
+
if (item === session.item) return;
|
|
21412
|
+
const rect = item.getBoundingClientRect();
|
|
21413
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
21414
|
+
if (index < here) current += 1;
|
|
21415
|
+
laidOut.push(item);
|
|
21416
|
+
const midpoint = vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
|
|
21417
|
+
if (reversed ? pointer < midpoint : pointer > midpoint) target += 1;
|
|
21418
|
+
});
|
|
21419
|
+
if (laidOut.length === 0 || target === current) return;
|
|
21420
|
+
this.#insertAt(session.item, laidOut, target);
|
|
21421
|
+
this.#announce("moved", session.item);
|
|
21422
|
+
}
|
|
21423
|
+
/**
|
|
21424
|
+
* Reinserts `item` at `index` among `scope`, relative to the sibling already
|
|
21425
|
+
* standing there.
|
|
21426
|
+
*
|
|
21427
|
+
* The reorder is defined against the items, not against a container: the
|
|
21428
|
+
* neighbour's own parent is where the item belongs. A `list` target that is
|
|
21429
|
+
* not the items' parent — or none at all, which the markup contract allows —
|
|
21430
|
+
* therefore still lands the move in the right place instead of throwing, and
|
|
21431
|
+
* the last slot is *after the last item* rather than after whatever else the
|
|
21432
|
+
* container holds (a live region, a footer), which would put the row outside
|
|
21433
|
+
* the reading order the list publishes.
|
|
21434
|
+
*/
|
|
21435
|
+
#insertAt(item, scope, index) {
|
|
21436
|
+
const clamped = Math.max(0, Math.min(index, scope.length));
|
|
21437
|
+
const ahead = scope[clamped] ?? null;
|
|
21438
|
+
const behind = ahead ? null : scope[scope.length - 1] ?? null;
|
|
21439
|
+
const neighbour = ahead ?? behind;
|
|
21440
|
+
if (!neighbour) return;
|
|
21441
|
+
const parent = neighbour.parentNode;
|
|
21442
|
+
if (!parent) return;
|
|
20879
21443
|
const active = document.activeElement;
|
|
20880
21444
|
const hadFocus = active instanceof HTMLElement && item.contains(active);
|
|
20881
|
-
|
|
21445
|
+
parent.insertBefore(item, ahead ?? neighbour.nextSibling);
|
|
20882
21446
|
if (hadFocus) active.focus();
|
|
20883
21447
|
}
|
|
20884
21448
|
/**
|
|
20885
|
-
*
|
|
20886
|
-
*
|
|
20887
|
-
*
|
|
20888
|
-
*
|
|
21449
|
+
* Hands one step to the page's shared announcer.
|
|
21450
|
+
*
|
|
21451
|
+
* The library carries no live region and no English copy: the wording is the
|
|
21452
|
+
* consumer's, written into `announceGrabbedText` / `announceMovedText` /
|
|
21453
|
+
* `announceDroppedText` / `announceCanceledText` with `{name}` / `{position}` /
|
|
21454
|
+
* `{total}` placeholders, and an unset one announces nothing.
|
|
21455
|
+
*
|
|
21456
|
+
* Only transitions reach here — the pickup, a step that actually changed the
|
|
21457
|
+
* landing slot, and the single end of the session — so a pointer crossing the
|
|
21458
|
+
* same slot twice or an arrow clamped at an end stays silent.
|
|
20889
21459
|
*/
|
|
20890
21460
|
#announce(key, item) {
|
|
20891
|
-
|
|
20892
|
-
|
|
20893
|
-
const
|
|
20894
|
-
|
|
20895
|
-
|
|
20896
|
-
|
|
20897
|
-
|
|
20898
|
-
|
|
20899
|
-
|
|
20900
|
-
|
|
20901
|
-
|
|
20902
|
-
|
|
20903
|
-
|
|
20904
|
-
|
|
20905
|
-
|
|
21461
|
+
const template = this.#announceTemplate(key);
|
|
21462
|
+
if (template.length === 0) return;
|
|
21463
|
+
const items = this.#items();
|
|
21464
|
+
announce(
|
|
21465
|
+
fillTemplate(template, {
|
|
21466
|
+
name: this.#nameOf(item),
|
|
21467
|
+
position: items.indexOf(item) + 1,
|
|
21468
|
+
total: items.length
|
|
21469
|
+
})
|
|
21470
|
+
);
|
|
21471
|
+
}
|
|
21472
|
+
/** The consumer's wording for one step, or `""` when they authored none. */
|
|
21473
|
+
#announceTemplate(key) {
|
|
21474
|
+
switch (key) {
|
|
21475
|
+
case "grabbed":
|
|
21476
|
+
return this.announceGrabbedTextValue;
|
|
21477
|
+
case "moved":
|
|
21478
|
+
return this.announceMovedTextValue;
|
|
21479
|
+
case "dropped":
|
|
21480
|
+
return this.announceDroppedTextValue;
|
|
21481
|
+
case "canceled":
|
|
21482
|
+
return this.announceCanceledTextValue;
|
|
21483
|
+
}
|
|
20906
21484
|
}
|
|
20907
21485
|
/** The announced item name: the authored override, else its collapsed text. */
|
|
20908
21486
|
#nameOf(item) {
|
|
@@ -20910,11 +21488,17 @@ var SortableController = class extends Controller {
|
|
|
20910
21488
|
if (authored) return authored;
|
|
20911
21489
|
return (item.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
20912
21490
|
}
|
|
20913
|
-
/**
|
|
20914
|
-
|
|
20915
|
-
|
|
20916
|
-
|
|
20917
|
-
|
|
21491
|
+
/**
|
|
21492
|
+
* Resolves the sortable item owning a bubbled `pointer-drag` event.
|
|
21493
|
+
*
|
|
21494
|
+
* `pointer-drag` dispatches on its own element, and the markup contract puts
|
|
21495
|
+
* one on each item, so the owner is the item that **is** the target. Matching
|
|
21496
|
+
* an ancestor instead would make a card's own inner draggable — a knob, a
|
|
21497
|
+
* split pane, anything the primitive is composed into — drive the card.
|
|
21498
|
+
*/
|
|
21499
|
+
#itemFor(target, items) {
|
|
21500
|
+
if (!target) return null;
|
|
21501
|
+
return items.find((item) => item === target) ?? null;
|
|
20918
21502
|
}
|
|
20919
21503
|
/** The items in live DOM order (targets re-query the DOM on every access). */
|
|
20920
21504
|
#items() {
|
|
@@ -21283,6 +21867,7 @@ var StepperController = class extends Controller {
|
|
|
21283
21867
|
return Math.min(last, Math.max(0, Math.trunc(index)));
|
|
21284
21868
|
}
|
|
21285
21869
|
};
|
|
21870
|
+
var DEFAULT_THRESHOLD = 80;
|
|
21286
21871
|
var countElements = (nodes) => {
|
|
21287
21872
|
let n = 0;
|
|
21288
21873
|
for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
|
|
@@ -21291,34 +21876,53 @@ var countElements = (nodes) => {
|
|
|
21291
21876
|
var StickToBottomController = class extends Controller {
|
|
21292
21877
|
static targets = ["content"];
|
|
21293
21878
|
static values = {
|
|
21294
|
-
threshold: { type: Number, default:
|
|
21879
|
+
threshold: { type: Number, default: DEFAULT_THRESHOLD },
|
|
21295
21880
|
behavior: { type: String, default: "auto" },
|
|
21296
21881
|
pinOnConnect: { type: Boolean, default: false }
|
|
21297
21882
|
};
|
|
21298
21883
|
static actions = ["scrollToBottom"];
|
|
21299
21884
|
static events = ["pin", "new"];
|
|
21300
21885
|
#observer = null;
|
|
21301
|
-
/**
|
|
21302
|
-
#
|
|
21886
|
+
/** The element the append observer currently holds, so a swap can be detected. */
|
|
21887
|
+
#watched = null;
|
|
21888
|
+
/** Watches for the box a container connected without one is still waiting on. */
|
|
21889
|
+
#layout = new LayoutObserver(() => this.#onLaidOut());
|
|
21890
|
+
#awaitingLayout = false;
|
|
21891
|
+
#connected = false;
|
|
21303
21892
|
#pinned = false;
|
|
21304
21893
|
#onScroll = () => this.#updatePinned();
|
|
21305
21894
|
connect() {
|
|
21895
|
+
this.#connected = true;
|
|
21306
21896
|
if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
|
|
21897
|
+
this.element.removeAttribute("data-has-new");
|
|
21307
21898
|
this.#pinned = this.#isPinned();
|
|
21308
21899
|
this.#reflectPinned();
|
|
21309
21900
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
21310
|
-
|
|
21311
|
-
|
|
21312
|
-
this.#observer.observe(this.#watched(), { childList: true });
|
|
21313
|
-
}
|
|
21314
|
-
if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
|
|
21901
|
+
this.#syncWatched();
|
|
21902
|
+
if (!this.#measurable()) this.#waitForLayout();
|
|
21315
21903
|
}
|
|
21316
21904
|
disconnect() {
|
|
21905
|
+
this.#connected = false;
|
|
21317
21906
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
21318
|
-
this.#
|
|
21319
|
-
this.#observer = null;
|
|
21907
|
+
this.#stopWatching();
|
|
21320
21908
|
this.#stopWaitingForLayout();
|
|
21321
21909
|
}
|
|
21910
|
+
/** Moves the append watch onto a `content` target that arrived at runtime. */
|
|
21911
|
+
contentTargetConnected() {
|
|
21912
|
+
this.#syncWatched();
|
|
21913
|
+
}
|
|
21914
|
+
/** Moves the append watch off a `content` target that left, back onto the container. */
|
|
21915
|
+
contentTargetDisconnected() {
|
|
21916
|
+
this.#syncWatched();
|
|
21917
|
+
}
|
|
21918
|
+
/**
|
|
21919
|
+
* Re-derives pinned when the distance that counts as the bottom is changed at runtime
|
|
21920
|
+
* (a morph that swaps the attribute on a retained element).
|
|
21921
|
+
*/
|
|
21922
|
+
thresholdValueChanged() {
|
|
21923
|
+
if (!this.#connected) return;
|
|
21924
|
+
this.#updatePinned();
|
|
21925
|
+
}
|
|
21322
21926
|
/**
|
|
21323
21927
|
* Jumps to the bottom and re-pins (wired to a "new messages" button).
|
|
21324
21928
|
*
|
|
@@ -21347,7 +21951,11 @@ var StickToBottomController = class extends Controller {
|
|
|
21347
21951
|
this.dispatch("new", { detail: { count: added } });
|
|
21348
21952
|
}
|
|
21349
21953
|
}
|
|
21350
|
-
/**
|
|
21954
|
+
/**
|
|
21955
|
+
* Recomputes pinned from the scroll position and reflects it on a transition.
|
|
21956
|
+
*
|
|
21957
|
+
* @stimeoRenderRoot
|
|
21958
|
+
*/
|
|
21351
21959
|
#updatePinned() {
|
|
21352
21960
|
const pinned = this.#isPinned();
|
|
21353
21961
|
if (pinned === this.#pinned) return;
|
|
@@ -21364,9 +21972,25 @@ var StickToBottomController = class extends Controller {
|
|
|
21364
21972
|
this.element.removeAttribute("data-pinned");
|
|
21365
21973
|
}
|
|
21366
21974
|
}
|
|
21975
|
+
/** Whether the container currently sits within `threshold` of its bottom. */
|
|
21367
21976
|
#isPinned() {
|
|
21977
|
+
if (!this.#measurable()) return false;
|
|
21368
21978
|
const el = this.element;
|
|
21369
|
-
return el.scrollHeight - el.clientHeight - el.scrollTop <= this
|
|
21979
|
+
return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
|
|
21980
|
+
}
|
|
21981
|
+
/**
|
|
21982
|
+
* The distance from the bottom that counts as pinned: a finite, non-negative number of
|
|
21983
|
+
* pixels. Anything else names no distance the container can be at, and settles the
|
|
21984
|
+
* comparison the same way at every scroll position, so it falls back to the default.
|
|
21985
|
+
* `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
|
|
21986
|
+
* distance sits below the closest the container ever gets; `Infinity` is never
|
|
21987
|
+
* exceeded. The first two stop following and flag every append as new, and the last
|
|
21988
|
+
* never stops following — it takes the reading position the flag exists to protect.
|
|
21989
|
+
* Zero is a real declaration: it pins at the exact bottom only.
|
|
21990
|
+
*/
|
|
21991
|
+
get #threshold() {
|
|
21992
|
+
const declared = this.thresholdValue;
|
|
21993
|
+
return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THRESHOLD;
|
|
21370
21994
|
}
|
|
21371
21995
|
/**
|
|
21372
21996
|
* Whether the container has a box to scroll and to measure. One that is not rendered
|
|
@@ -21377,23 +22001,25 @@ var StickToBottomController = class extends Controller {
|
|
|
21377
22001
|
return this.element.clientHeight > 0;
|
|
21378
22002
|
}
|
|
21379
22003
|
/**
|
|
21380
|
-
* Holds the
|
|
21381
|
-
*
|
|
22004
|
+
* Holds the pinned decision until the container is laid out — otherwise the panel opens
|
|
22005
|
+
* at the top while the state claims the bottom, and the appends that arrived meanwhile
|
|
22006
|
+
* were followed into a box that could not move rather than flagged.
|
|
21382
22007
|
*/
|
|
21383
|
-
#
|
|
21384
|
-
|
|
21385
|
-
this.#layout = new ResizeObserver(() => {
|
|
21386
|
-
if (!this.#measurable()) return;
|
|
21387
|
-
this.#stopWaitingForLayout();
|
|
21388
|
-
this.#scrollToBottom("instant");
|
|
21389
|
-
this.#updatePinned();
|
|
21390
|
-
});
|
|
22008
|
+
#waitForLayout() {
|
|
22009
|
+
this.#awaitingLayout = true;
|
|
21391
22010
|
this.#layout.observe(this.element);
|
|
21392
22011
|
}
|
|
21393
|
-
/**
|
|
22012
|
+
/** Runs the held decision once the container has the box it was waiting for. */
|
|
22013
|
+
#onLaidOut() {
|
|
22014
|
+
if (!this.#awaitingLayout || !this.#measurable()) return;
|
|
22015
|
+
this.#stopWaitingForLayout();
|
|
22016
|
+
if (this.pinOnConnectValue) this.#scrollToBottom("instant");
|
|
22017
|
+
this.#updatePinned();
|
|
22018
|
+
}
|
|
22019
|
+
/** Releases the layout watch, whether or not the held decision ever ran. */
|
|
21394
22020
|
#stopWaitingForLayout() {
|
|
21395
|
-
this.#
|
|
21396
|
-
this.#layout
|
|
22021
|
+
this.#awaitingLayout = false;
|
|
22022
|
+
this.#layout.disconnect();
|
|
21397
22023
|
}
|
|
21398
22024
|
/**
|
|
21399
22025
|
* Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
|
|
@@ -21409,9 +22035,32 @@ var StickToBottomController = class extends Controller {
|
|
|
21409
22035
|
this.element.scrollTop = top;
|
|
21410
22036
|
}
|
|
21411
22037
|
}
|
|
21412
|
-
/**
|
|
21413
|
-
|
|
21414
|
-
|
|
22038
|
+
/**
|
|
22039
|
+
* Points the append watch at the current `content` target, or at the container when
|
|
22040
|
+
* there is none. Re-resolved whenever that target changes, so a swap does not leave the
|
|
22041
|
+
* observer holding a detached node whose appends nobody sees.
|
|
22042
|
+
*
|
|
22043
|
+
* Stimulus runs the target callbacks outside the connected window too — before
|
|
22044
|
+
* `connect()` for a target already in the DOM, and after `disconnect()` while the
|
|
22045
|
+
* element is torn down — where this would arm an observer nothing releases. Re-syncing
|
|
22046
|
+
* to the target already held is left alone, so an arrival still in flight is not
|
|
22047
|
+
* dropped with the observer that was about to deliver it.
|
|
22048
|
+
*/
|
|
22049
|
+
#syncWatched() {
|
|
22050
|
+
if (!this.#connected) return;
|
|
22051
|
+
const next = this.hasContentTarget ? this.contentTarget : this.element;
|
|
22052
|
+
if (next === this.#watched) return;
|
|
22053
|
+
this.#stopWatching();
|
|
22054
|
+
this.#watched = next;
|
|
22055
|
+
if (typeof MutationObserver === "undefined") return;
|
|
22056
|
+
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
22057
|
+
this.#observer.observe(next, { childList: true });
|
|
22058
|
+
}
|
|
22059
|
+
/** Releases the append watch and the element it held. */
|
|
22060
|
+
#stopWatching() {
|
|
22061
|
+
this.#observer?.disconnect();
|
|
22062
|
+
this.#observer = null;
|
|
22063
|
+
this.#watched = null;
|
|
21415
22064
|
}
|
|
21416
22065
|
/**
|
|
21417
22066
|
* The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
|
|
@@ -23673,7 +24322,7 @@ var ToggleGroupController = class extends Controller {
|
|
|
23673
24322
|
return item.getAttribute("data-value") ?? "";
|
|
23674
24323
|
}
|
|
23675
24324
|
};
|
|
23676
|
-
var
|
|
24325
|
+
var STATE_ATTRIBUTES3 = ["disabled", "hidden"];
|
|
23677
24326
|
var ToolbarController = class extends Controller {
|
|
23678
24327
|
static targets = ["control"];
|
|
23679
24328
|
static values = {
|
|
@@ -23702,7 +24351,7 @@ var ToolbarController = class extends Controller {
|
|
|
23702
24351
|
subtree: true,
|
|
23703
24352
|
childList: true,
|
|
23704
24353
|
attributes: true,
|
|
23705
|
-
attributeFilter:
|
|
24354
|
+
attributeFilter: STATE_ATTRIBUTES3
|
|
23706
24355
|
});
|
|
23707
24356
|
for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
|
|
23708
24357
|
observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
|