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