stimeo-ui 0.8.0 → 0.10.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 +163 -0
- data/dist/controllers/alert_dialog_controller.js +17 -7
- data/dist/controllers/announcer_controller.js +1 -1
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/collapsible_controller.js +1 -1
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/command_palette_controller.js +15 -5
- data/dist/controllers/confirm_controller.js +15 -5
- data/dist/controllers/countdown_controller.js +1 -1
- data/dist/controllers/data_grid_controller.js +151 -25
- data/dist/controllers/dialog_controller.js +15 -5
- data/dist/controllers/direct_upload_controller.js +1 -5
- data/dist/controllers/drawer_controller.js +17 -7
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/focus_controller.js +45 -7
- data/dist/controllers/form_field_controller.js +1 -1
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/menubar_controller.js +2 -2
- data/dist/controllers/multi_select_controller.js +1 -1
- data/dist/controllers/otp_controller.js +29 -16
- data/dist/controllers/overflow_menu_controller.js +2 -2
- data/dist/controllers/password_reveal_controller.js +102 -3
- data/dist/controllers/password_strength_controller.js +287 -42
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/scroll_restore_controller.js +127 -27
- data/dist/controllers/scroll_visibility_controller.js +167 -14
- data/dist/controllers/sidebar_controller.js +15 -5
- data/dist/controllers/step_indicator_controller.js +0 -5
- data/dist/controllers/theme_controller.js +151 -40
- data/dist/controllers/toast_controller.js +2 -2
- data/dist/controllers/transition_controller.js +79 -15
- data/dist/index.js +1612 -529
- data/dist/positioning/index.js +84 -25
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
data/dist/index.js
CHANGED
|
@@ -36,6 +36,9 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
36
36
|
if (!event.key.startsWith("Arrow")) return false;
|
|
37
37
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
38
38
|
}
|
|
39
|
+
function hasModifierChord(event) {
|
|
40
|
+
return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
41
|
+
}
|
|
39
42
|
|
|
40
43
|
// src/controllers/accordion_controller.ts
|
|
41
44
|
var AccordionController = class extends Controller {
|
|
@@ -438,15 +441,25 @@ var FocusTrap = class {
|
|
|
438
441
|
* content cannot be focused or reached by assistive technology, honoring the
|
|
439
442
|
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
440
443
|
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
444
|
+
*
|
|
445
|
+
* The walk climbs from the container to `body` and inerts each ancestor's other
|
|
446
|
+
* children. Scanning only `body`'s children would skip the branch the container
|
|
447
|
+
* sits in — everything beside it inside that branch is background too, and a
|
|
448
|
+
* nested container is the ordinary case.
|
|
441
449
|
*/
|
|
442
450
|
#isolateBackground() {
|
|
443
451
|
const container = this.#getContainer();
|
|
444
452
|
this.#inertedSiblings = [];
|
|
445
|
-
for (
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
sibling.
|
|
449
|
-
|
|
453
|
+
for (let node = container; node !== document.body; ) {
|
|
454
|
+
const parent = node.parentElement;
|
|
455
|
+
if (!parent) break;
|
|
456
|
+
for (const sibling of Array.from(parent.children)) {
|
|
457
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
458
|
+
if (sibling === node || sibling.inert) continue;
|
|
459
|
+
sibling.inert = true;
|
|
460
|
+
this.#inertedSiblings.push(sibling);
|
|
461
|
+
}
|
|
462
|
+
node = parent;
|
|
450
463
|
}
|
|
451
464
|
}
|
|
452
465
|
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
@@ -484,8 +497,8 @@ var AlertDialogController = class extends Controller {
|
|
|
484
497
|
static actions = ["cancel", "confirm", "open"];
|
|
485
498
|
static events = ["cancel", "confirm"];
|
|
486
499
|
/**
|
|
487
|
-
* Owns the modal side effects. Escape
|
|
488
|
-
* emits the same event
|
|
500
|
+
* Owns the modal side effects. Escape takes the same cancel path as
|
|
501
|
+
* {@link cancel} and emits the same event, tagged `"escape"` instead of `"user"`; focus falls
|
|
489
502
|
* back to the trigger when nothing was focused before opening.
|
|
490
503
|
*/
|
|
491
504
|
#trap = new FocusTrap(() => this.dialogTarget, {
|
|
@@ -855,7 +868,7 @@ var AnnouncerController = class extends Controller {
|
|
|
855
868
|
}
|
|
856
869
|
/**
|
|
857
870
|
* Arms `region`'s single pending timer. Callers reach here with the slot
|
|
858
|
-
* already free — `#
|
|
871
|
+
* already free — `#drain` releases it before writing, and a fired timer clears its own
|
|
859
872
|
* entry below — so this does not cancel again.
|
|
860
873
|
*/
|
|
861
874
|
#schedule(region, callback, delay) {
|
|
@@ -1664,72 +1677,156 @@ var BreadcrumbController = class extends Controller {
|
|
|
1664
1677
|
return active instanceof HTMLElement && element.contains(active);
|
|
1665
1678
|
}
|
|
1666
1679
|
};
|
|
1680
|
+
|
|
1681
|
+
// src/utils/announce.ts
|
|
1682
|
+
function announce(message, options = {}) {
|
|
1683
|
+
const text = message.trim();
|
|
1684
|
+
if (text.length === 0) return;
|
|
1685
|
+
window.dispatchEvent(
|
|
1686
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
1687
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
1688
|
+
})
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
function fillTemplate(template, values) {
|
|
1692
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
1693
|
+
const replacement = values[name];
|
|
1694
|
+
return replacement === void 0 ? match : String(replacement);
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// src/controllers/bulk_select_controller.ts
|
|
1667
1699
|
var BulkSelectController = class extends Controller {
|
|
1668
1700
|
static targets = ["all", "item", "bar", "count", "selectAllPages"];
|
|
1669
1701
|
static values = {
|
|
1670
1702
|
totalCount: { type: Number, default: 0 },
|
|
1671
|
-
|
|
1703
|
+
announceText: { type: String, default: "" }
|
|
1672
1704
|
};
|
|
1673
1705
|
static actions = ["clear", "selectAllPages"];
|
|
1674
|
-
static events = ["change"];
|
|
1675
|
-
/** All-pages mode is a transient UI state
|
|
1676
|
-
*
|
|
1706
|
+
static events = ["change", "reconcile"];
|
|
1707
|
+
/** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
|
|
1708
|
+
* `connect()` over markup that already carries the attribute rehydrates the
|
|
1709
|
+
* mode — a morph, a Turbo Stream, a server that renders it back, or a restore
|
|
1710
|
+
* visit, whose cached snapshot carries the attribute too. */
|
|
1677
1711
|
#allPagesMode = false;
|
|
1678
|
-
/** Last emitted figures, so a recompute
|
|
1712
|
+
/** Last emitted figures, so a recompute reports only on a real change. */
|
|
1679
1713
|
#lastCount = -1;
|
|
1680
1714
|
#lastAllPages = false;
|
|
1715
|
+
/** Collapses every signal from one DOM or Value update into one repair. */
|
|
1716
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
|
|
1681
1717
|
/** Delegated `change` handler covering the select-all box and every row. */
|
|
1682
1718
|
#onChange = (event) => {
|
|
1683
1719
|
const target = event.target;
|
|
1684
1720
|
if (!target) return;
|
|
1685
1721
|
if (this.hasAllTarget && target === this.allTarget) {
|
|
1686
1722
|
this.#applyAll();
|
|
1687
|
-
} else if (
|
|
1723
|
+
} else if (this.itemTargets.some((item) => item === target)) {
|
|
1688
1724
|
this.#exitAllPages();
|
|
1689
|
-
this.#recompute(
|
|
1725
|
+
this.#reportChange(this.#recompute());
|
|
1690
1726
|
}
|
|
1691
1727
|
};
|
|
1692
1728
|
connect() {
|
|
1693
1729
|
this.#allPagesMode = this.element.dataset.allPages === "true";
|
|
1730
|
+
this.#reconcile.activate();
|
|
1694
1731
|
this.element.addEventListener("change", this.#onChange);
|
|
1695
|
-
this.#recompute(
|
|
1732
|
+
this.#recompute();
|
|
1696
1733
|
}
|
|
1697
1734
|
disconnect() {
|
|
1698
1735
|
this.element.removeEventListener("change", this.#onChange);
|
|
1736
|
+
this.#reconcile.cancel();
|
|
1737
|
+
}
|
|
1738
|
+
/** Repairs the figures for a row that arrived at runtime. */
|
|
1739
|
+
itemTargetConnected() {
|
|
1740
|
+
this.#reconcile.schedule();
|
|
1741
|
+
}
|
|
1742
|
+
/** Repairs the figures after a row leaves, so a removed selection stops counting. */
|
|
1743
|
+
itemTargetDisconnected() {
|
|
1744
|
+
this.#reconcile.schedule();
|
|
1745
|
+
}
|
|
1746
|
+
/** Reflects the current selection onto a select-all box added at runtime. */
|
|
1747
|
+
allTargetConnected() {
|
|
1748
|
+
this.#reconcile.schedule();
|
|
1749
|
+
}
|
|
1750
|
+
/** Repairs the figures after the select-all box leaves. */
|
|
1751
|
+
allTargetDisconnected() {
|
|
1752
|
+
this.#reconcile.schedule();
|
|
1753
|
+
}
|
|
1754
|
+
/** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
|
|
1755
|
+
totalCountValueChanged() {
|
|
1756
|
+
if (!Number.isFinite(this.totalCountValue)) {
|
|
1757
|
+
this.totalCountValue = 0;
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
this.#reconcile.schedule();
|
|
1761
|
+
}
|
|
1762
|
+
/** Repaints so wording changed at runtime is used by the next announcement. */
|
|
1763
|
+
announceTextValueChanged() {
|
|
1764
|
+
this.#reconcile.schedule();
|
|
1699
1765
|
}
|
|
1700
1766
|
/** Clears every selection (rows + select-all) and exits all-pages mode. */
|
|
1701
1767
|
clear() {
|
|
1702
|
-
for (const item of this
|
|
1768
|
+
for (const item of this.itemTargets) item.checked = false;
|
|
1703
1769
|
if (this.hasAllTarget) {
|
|
1704
1770
|
this.allTarget.checked = false;
|
|
1705
1771
|
this.allTarget.indeterminate = false;
|
|
1706
1772
|
}
|
|
1707
1773
|
this.#exitAllPages();
|
|
1708
|
-
this.#recompute(
|
|
1774
|
+
this.#reportChange(this.#recompute());
|
|
1709
1775
|
}
|
|
1710
|
-
/**
|
|
1776
|
+
/**
|
|
1777
|
+
* Enters "select all across pages" mode: the count shows `totalCount`, and every
|
|
1778
|
+
* row on this page is checked.
|
|
1779
|
+
*
|
|
1780
|
+
* The mode's claim is that the whole set is selected, so leaving a visible row
|
|
1781
|
+
* unchecked would put the page and the count in open disagreement.
|
|
1782
|
+
*/
|
|
1711
1783
|
selectAllPages() {
|
|
1712
1784
|
this.#allPagesMode = true;
|
|
1713
|
-
this.#
|
|
1785
|
+
this.#checkEveryRow();
|
|
1786
|
+
this.#reportChange(this.#recompute());
|
|
1787
|
+
}
|
|
1788
|
+
/** Marks every row on this page selected. */
|
|
1789
|
+
#checkEveryRow() {
|
|
1790
|
+
for (const item of this.itemTargets) item.checked = true;
|
|
1714
1791
|
}
|
|
1715
1792
|
/** Mirrors the select-all box to every row, then recomputes. */
|
|
1716
1793
|
#applyAll() {
|
|
1717
1794
|
if (!this.hasAllTarget) return;
|
|
1718
1795
|
const { checked } = this.allTarget;
|
|
1719
|
-
for (const item of this
|
|
1796
|
+
for (const item of this.itemTargets) item.checked = checked;
|
|
1720
1797
|
this.#exitAllPages();
|
|
1721
|
-
this.#recompute(
|
|
1798
|
+
this.#reportChange(this.#recompute());
|
|
1722
1799
|
}
|
|
1723
1800
|
#exitAllPages() {
|
|
1724
1801
|
this.#allPagesMode = false;
|
|
1725
1802
|
}
|
|
1803
|
+
/** Repairs the derived state after the page moved rows or a render input. */
|
|
1804
|
+
#reconcileNow() {
|
|
1805
|
+
if (this.#allPagesMode) this.#checkEveryRow();
|
|
1806
|
+
const detail = this.#recompute();
|
|
1807
|
+
if (!detail) return;
|
|
1808
|
+
this.dispatch("reconcile", { detail });
|
|
1809
|
+
this.#announce(detail);
|
|
1810
|
+
}
|
|
1811
|
+
/** Reports a selection the user moved. */
|
|
1812
|
+
#reportChange(detail) {
|
|
1813
|
+
if (!detail) return;
|
|
1814
|
+
this.dispatch("change", { detail });
|
|
1815
|
+
this.#announce(detail);
|
|
1816
|
+
}
|
|
1817
|
+
/** Hands the count to the shared announcer, worded by the consumer. */
|
|
1818
|
+
#announce(detail) {
|
|
1819
|
+
announce(fillTemplate(this.announceTextValue, { count: detail.count }));
|
|
1820
|
+
}
|
|
1726
1821
|
/**
|
|
1727
1822
|
* Recomputes the count, the select-all checked/indeterminate state, and the bar
|
|
1728
|
-
* visibility from the current DOM.
|
|
1729
|
-
*
|
|
1823
|
+
* visibility from the current DOM. Returns the figures when the emitted count or
|
|
1824
|
+
* all-pages flag actually moved, and `null` when they did not.
|
|
1825
|
+
*
|
|
1826
|
+
* @stimeoRenderRoot
|
|
1730
1827
|
*/
|
|
1731
|
-
#recompute(
|
|
1732
|
-
const items = this
|
|
1828
|
+
#recompute() {
|
|
1829
|
+
const items = this.itemTargets;
|
|
1733
1830
|
const total = items.length;
|
|
1734
1831
|
const checked = items.filter((item) => item.checked).length;
|
|
1735
1832
|
const allPages = this.#allPagesMode;
|
|
@@ -1740,8 +1837,10 @@ var BulkSelectController = class extends Controller {
|
|
|
1740
1837
|
const count = allPages ? this.totalCountValue : checked;
|
|
1741
1838
|
const show = allPages || checked > 0;
|
|
1742
1839
|
if (this.hasBarTarget) {
|
|
1840
|
+
if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {
|
|
1841
|
+
this.allTarget.focus();
|
|
1842
|
+
}
|
|
1743
1843
|
this.barTarget.hidden = !show;
|
|
1744
|
-
this.barTarget.setAttribute("aria-live", this.announceValue ? "polite" : "off");
|
|
1745
1844
|
}
|
|
1746
1845
|
if (this.hasCountTarget) this.countTarget.textContent = String(count);
|
|
1747
1846
|
this.element.setAttribute("data-selected-count", String(checked));
|
|
@@ -1750,15 +1849,7 @@ var BulkSelectController = class extends Controller {
|
|
|
1750
1849
|
const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;
|
|
1751
1850
|
this.#lastCount = count;
|
|
1752
1851
|
this.#lastAllPages = allPages;
|
|
1753
|
-
|
|
1754
|
-
this.dispatch("change", { detail: { count, allPages } });
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
/** Live list of row checkboxes, queried from the DOM so dynamic rows count. */
|
|
1758
|
-
get #items() {
|
|
1759
|
-
return Array.from(
|
|
1760
|
-
this.element.querySelectorAll('[data-stimeo--bulk-select-target="item"]')
|
|
1761
|
-
);
|
|
1852
|
+
return changed ? { count, allPages } : null;
|
|
1762
1853
|
}
|
|
1763
1854
|
};
|
|
1764
1855
|
|
|
@@ -2718,25 +2809,6 @@ function hits(elements, node) {
|
|
|
2718
2809
|
function setAttributeIfChanged(element, name, value) {
|
|
2719
2810
|
if (element.getAttribute(name) !== value) element.setAttribute(name, value);
|
|
2720
2811
|
}
|
|
2721
|
-
|
|
2722
|
-
// src/utils/announce.ts
|
|
2723
|
-
function announce(message, options = {}) {
|
|
2724
|
-
const text = message.trim();
|
|
2725
|
-
if (text.length === 0) return;
|
|
2726
|
-
window.dispatchEvent(
|
|
2727
|
-
new CustomEvent("stimeo--announcer:announce", {
|
|
2728
|
-
detail: { message: text, assertive: options.assertive === true }
|
|
2729
|
-
})
|
|
2730
|
-
);
|
|
2731
|
-
}
|
|
2732
|
-
function fillTemplate(template, values) {
|
|
2733
|
-
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
2734
|
-
const replacement = values[name];
|
|
2735
|
-
return replacement === void 0 ? match : String(replacement);
|
|
2736
|
-
});
|
|
2737
|
-
}
|
|
2738
|
-
|
|
2739
|
-
// src/controllers/character_counter_controller.ts
|
|
2740
2812
|
var CharacterCounterController = class _CharacterCounterController extends Controller {
|
|
2741
2813
|
static targets = ["input", "output"];
|
|
2742
2814
|
static values = {
|
|
@@ -3150,45 +3222,59 @@ function setDefaultAttribute(element, name, value) {
|
|
|
3150
3222
|
}
|
|
3151
3223
|
|
|
3152
3224
|
// src/controllers/clipboard_controller.ts
|
|
3225
|
+
var TRANSIENT_STATES = /* @__PURE__ */ new Set(["copied", "error"]);
|
|
3153
3226
|
var ClipboardController = class extends Controller {
|
|
3154
3227
|
static targets = ["source", "button", "feedback"];
|
|
3155
3228
|
static values = {
|
|
3156
3229
|
text: { type: String, default: "" },
|
|
3157
3230
|
feedbackDuration: { type: Number, default: 2e3 },
|
|
3158
3231
|
copiedLabel: { type: String, default: "Copied" },
|
|
3159
|
-
errorLabel: { type: String, default: "Copy failed" }
|
|
3232
|
+
errorLabel: { type: String, default: "Copy failed" },
|
|
3233
|
+
announceCopiedText: { type: String, default: "" },
|
|
3234
|
+
announceErrorText: { type: String, default: "" }
|
|
3160
3235
|
};
|
|
3161
3236
|
static actions = ["copy"];
|
|
3162
3237
|
static events = ["copy"];
|
|
3163
|
-
/**
|
|
3238
|
+
/**
|
|
3239
|
+
* The pending return to idle — the only timer this controller schedules, so
|
|
3240
|
+
* `clearAll()` is exactly "drop the auto-clear" and needs no id of its own.
|
|
3241
|
+
*/
|
|
3164
3242
|
#timers = new SafeTimeout();
|
|
3243
|
+
/** Returns the completion state to idle for the snapshot Turbo takes. */
|
|
3244
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewind());
|
|
3165
3245
|
/**
|
|
3166
|
-
*
|
|
3167
|
-
* a
|
|
3168
|
-
*
|
|
3246
|
+
* Whether this connection is still live. `copy()` suspends on the Clipboard API,
|
|
3247
|
+
* and a teardown that lands while it is suspended must win: the continuation
|
|
3248
|
+
* would otherwise write to an element nobody owns and arm a timer past the
|
|
3249
|
+
* `clearAll()` that was supposed to be the last word.
|
|
3169
3250
|
*/
|
|
3170
|
-
#
|
|
3251
|
+
#connected = false;
|
|
3171
3252
|
connect() {
|
|
3172
|
-
|
|
3253
|
+
this.#connected = true;
|
|
3254
|
+
this.#adopt();
|
|
3255
|
+
this.#beforeCache.activate();
|
|
3173
3256
|
}
|
|
3174
3257
|
disconnect() {
|
|
3258
|
+
this.#connected = false;
|
|
3259
|
+
this.#beforeCache.deactivate();
|
|
3175
3260
|
this.#timers.clearAll();
|
|
3176
3261
|
}
|
|
3177
3262
|
/**
|
|
3178
3263
|
* Copies the resolved text and reports the outcome. Bound via `data-action`
|
|
3179
|
-
* (click).
|
|
3180
|
-
* — including on failure — so consumers can react either way.
|
|
3264
|
+
* (click). Dispatches `stimeo--clipboard:copy` with `{ success, text }` once per
|
|
3265
|
+
* completed attempt — including on failure — so consumers can react either way.
|
|
3266
|
+
* An attempt whose connection ended while it was in flight reports nothing.
|
|
3181
3267
|
*/
|
|
3182
3268
|
async copy() {
|
|
3183
3269
|
const text = this.#resolveText();
|
|
3184
3270
|
let success = false;
|
|
3185
3271
|
try {
|
|
3186
|
-
if (!navigator.clipboard?.writeText) throw new Error("Clipboard API unavailable");
|
|
3187
3272
|
await navigator.clipboard.writeText(text);
|
|
3188
3273
|
success = true;
|
|
3189
3274
|
} catch {
|
|
3190
3275
|
success = false;
|
|
3191
3276
|
}
|
|
3277
|
+
if (!this.#connected) return;
|
|
3192
3278
|
this.#reportResult(success);
|
|
3193
3279
|
this.dispatch("copy", { detail: { success, text } });
|
|
3194
3280
|
}
|
|
@@ -3205,24 +3291,52 @@ var ClipboardController = class extends Controller {
|
|
|
3205
3291
|
}
|
|
3206
3292
|
return source.textContent ?? "";
|
|
3207
3293
|
}
|
|
3208
|
-
/**
|
|
3294
|
+
/**
|
|
3295
|
+
* Reads the current state back from the DOM.
|
|
3296
|
+
*
|
|
3297
|
+
* A `copied` or `error` found at connect time is this controller's own output
|
|
3298
|
+
* from a connection that is gone, and so is the timer that would have cleared it
|
|
3299
|
+
* — nothing else would ever return the element to `idle`. Any other authored
|
|
3300
|
+
* value belongs to the consumer and only a missing attribute takes the default.
|
|
3301
|
+
*/
|
|
3302
|
+
#adopt() {
|
|
3303
|
+
if (this.#inTransientState()) {
|
|
3304
|
+
this.#reset();
|
|
3305
|
+
return;
|
|
3306
|
+
}
|
|
3307
|
+
setDefaultAttribute(this.element, "data-state", "idle");
|
|
3308
|
+
}
|
|
3309
|
+
/** Whether `data-state` currently holds one of the values this controller writes. */
|
|
3310
|
+
#inTransientState() {
|
|
3311
|
+
const state = this.element.getAttribute("data-state");
|
|
3312
|
+
return state !== null && TRANSIENT_STATES.has(state);
|
|
3313
|
+
}
|
|
3314
|
+
/**
|
|
3315
|
+
* Returns the completion state to idle for the snapshot Turbo is about to take,
|
|
3316
|
+
* so a page reached with the Back button does not report a copy that happened
|
|
3317
|
+
* before the navigation. Only a state this controller wrote is rewound — an
|
|
3318
|
+
* authored one is the consumer's and has to survive into the snapshot, exactly as
|
|
3319
|
+
* `connect()` leaves it alone. State only — no `copy` is dispatched, which would
|
|
3320
|
+
* claim a fresh copy ran.
|
|
3321
|
+
*/
|
|
3322
|
+
#rewind() {
|
|
3323
|
+
if (!this.#inTransientState()) return;
|
|
3324
|
+
this.#timers.clearAll();
|
|
3325
|
+
this.#reset();
|
|
3326
|
+
}
|
|
3327
|
+
/** Reflects the result, announces it, and schedules the return to idle. */
|
|
3209
3328
|
#reportResult(success) {
|
|
3210
3329
|
this.element.setAttribute("data-state", success ? "copied" : "error");
|
|
3211
3330
|
if (this.hasFeedbackTarget) {
|
|
3212
3331
|
this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
|
|
3213
3332
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
this.#resetTimerId = null;
|
|
3217
|
-
}
|
|
3333
|
+
announce(success ? this.announceCopiedTextValue : this.announceErrorTextValue);
|
|
3334
|
+
this.#timers.clearAll();
|
|
3218
3335
|
if (this.feedbackDurationValue > 0) {
|
|
3219
|
-
this.#
|
|
3220
|
-
this.#resetTimerId = null;
|
|
3221
|
-
this.#reset();
|
|
3222
|
-
}, this.feedbackDurationValue);
|
|
3336
|
+
this.#timers.set(() => this.#reset(), this.feedbackDurationValue);
|
|
3223
3337
|
}
|
|
3224
3338
|
}
|
|
3225
|
-
/** Returns to the idle state and
|
|
3339
|
+
/** Returns to the idle state and empties the completion slot. */
|
|
3226
3340
|
#reset() {
|
|
3227
3341
|
this.element.setAttribute("data-state", "idle");
|
|
3228
3342
|
if (this.hasFeedbackTarget) {
|
|
@@ -3396,7 +3510,7 @@ var CollapsibleController = class extends Controller {
|
|
|
3396
3510
|
* `open` Value disagrees. An already-open region remains open without a
|
|
3397
3511
|
* close/reopen cycle. The Value only seeds a genuinely fresh render where no
|
|
3398
3512
|
* state attribute is present yet; any opening animation in that case belongs to
|
|
3399
|
-
* the consumer's CSS.
|
|
3513
|
+
* the consumer's CSS.
|
|
3400
3514
|
*/
|
|
3401
3515
|
connect() {
|
|
3402
3516
|
this.#connected = true;
|
|
@@ -3511,8 +3625,62 @@ function toFiniteNumber(raw) {
|
|
|
3511
3625
|
return Number.isFinite(value) ? value : null;
|
|
3512
3626
|
}
|
|
3513
3627
|
|
|
3628
|
+
// src/utils/owned_pointer_session.ts
|
|
3629
|
+
var OwnedPointerSession = class {
|
|
3630
|
+
pointerId;
|
|
3631
|
+
#owner;
|
|
3632
|
+
#handlers;
|
|
3633
|
+
#abort = new AbortController();
|
|
3634
|
+
#active = true;
|
|
3635
|
+
constructor(start, owner, handlers) {
|
|
3636
|
+
this.pointerId = start.pointerId;
|
|
3637
|
+
this.#owner = owner;
|
|
3638
|
+
this.#handlers = handlers;
|
|
3639
|
+
const { signal } = this.#abort;
|
|
3640
|
+
owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
|
|
3641
|
+
owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
|
|
3642
|
+
owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
|
|
3643
|
+
owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
|
|
3644
|
+
try {
|
|
3645
|
+
owner.setPointerCapture?.(this.pointerId);
|
|
3646
|
+
} catch {
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
/** Whether this session still owns its pointer and listeners. */
|
|
3650
|
+
get active() {
|
|
3651
|
+
return this.#active;
|
|
3652
|
+
}
|
|
3653
|
+
/** Whether `event` belongs to the initiating pointer of the live session. */
|
|
3654
|
+
owns(event) {
|
|
3655
|
+
return this.#active && event.pointerId === this.pointerId;
|
|
3656
|
+
}
|
|
3657
|
+
/** Releases capture/listeners and invokes the end callback exactly once. */
|
|
3658
|
+
end() {
|
|
3659
|
+
if (!this.#active) return;
|
|
3660
|
+
this.#active = false;
|
|
3661
|
+
this.#abort.abort();
|
|
3662
|
+
try {
|
|
3663
|
+
this.#owner.releasePointerCapture?.(this.pointerId);
|
|
3664
|
+
} catch {
|
|
3665
|
+
}
|
|
3666
|
+
this.#handlers.end?.();
|
|
3667
|
+
}
|
|
3668
|
+
#onMove = (event) => {
|
|
3669
|
+
if (this.owns(event)) this.#handlers.move(event);
|
|
3670
|
+
};
|
|
3671
|
+
#onEndEvent = (event) => {
|
|
3672
|
+
if (this.owns(event)) this.end();
|
|
3673
|
+
};
|
|
3674
|
+
#onLostCapture = (event) => {
|
|
3675
|
+
const pointerId = event.pointerId;
|
|
3676
|
+
if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
|
|
3677
|
+
this.end();
|
|
3678
|
+
};
|
|
3679
|
+
};
|
|
3680
|
+
|
|
3514
3681
|
// src/controllers/color_picker_controller.ts
|
|
3515
3682
|
var COLOR_PROPERTY = "--stimeo--color";
|
|
3683
|
+
var VALUE_TEXT_ATTRIBUTE = "data-value-text";
|
|
3516
3684
|
var CHANNEL_RANGE = {
|
|
3517
3685
|
hue: [0, 360],
|
|
3518
3686
|
saturation: [0, 100],
|
|
@@ -3532,10 +3700,10 @@ var ColorPickerController = class extends Controller {
|
|
|
3532
3700
|
get #mirrored() {
|
|
3533
3701
|
return this.logicalTrackValue && isRtl(this.element);
|
|
3534
3702
|
}
|
|
3535
|
-
/** The current color in the editing model. */
|
|
3703
|
+
/** The current color in the editing model; its alpha is 100 while `alpha` is off. */
|
|
3536
3704
|
#color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
|
|
3537
|
-
/**
|
|
3538
|
-
#
|
|
3705
|
+
/** The pointer that owns the live drag, with the slider whose geometry maps it. */
|
|
3706
|
+
#drag = null;
|
|
3539
3707
|
/** Color the last repaint settled on, so a configuration-driven move is reported once. */
|
|
3540
3708
|
#committedHex = null;
|
|
3541
3709
|
/**
|
|
@@ -3546,25 +3714,48 @@ var ColorPickerController = class extends Controller {
|
|
|
3546
3714
|
/** Seeds the model from the initial hex value and renders every surface. */
|
|
3547
3715
|
connect() {
|
|
3548
3716
|
this.#repaint.activate();
|
|
3549
|
-
|
|
3550
|
-
if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
3717
|
+
this.#adoptValue();
|
|
3551
3718
|
this.#render();
|
|
3552
3719
|
}
|
|
3553
3720
|
/** Cancels any active pointer drag so document listeners never leak. */
|
|
3554
3721
|
disconnect() {
|
|
3555
3722
|
this.#repaint.cancel();
|
|
3556
|
-
this.#
|
|
3557
|
-
this.#dragAbort = null;
|
|
3723
|
+
this.#endDrag();
|
|
3558
3724
|
}
|
|
3559
3725
|
/** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
|
|
3560
3726
|
alphaValueChanged() {
|
|
3561
3727
|
this.#repaint.schedule();
|
|
3562
3728
|
}
|
|
3729
|
+
/** Adopts a color application code (or a Turbo morph) put in `value` at runtime. */
|
|
3730
|
+
valueValueChanged() {
|
|
3731
|
+
if (this.valueValue === this.#committedHex) return;
|
|
3732
|
+
this.#repaint.schedule();
|
|
3733
|
+
}
|
|
3734
|
+
/** Hydrates a channel slider inserted or replaced at runtime. */
|
|
3735
|
+
sliderTargetConnected(slider) {
|
|
3736
|
+
this.#renderSlider(slider);
|
|
3737
|
+
}
|
|
3738
|
+
/** Ends a gesture whose geometry target disappeared or ceased being a target. */
|
|
3739
|
+
sliderTargetDisconnected(slider) {
|
|
3740
|
+
if (this.#drag?.slider === slider) this.#endDrag();
|
|
3741
|
+
}
|
|
3742
|
+
/** Fills a hex input inserted or replaced at runtime with the current color. */
|
|
3743
|
+
hexTargetConnected(hex) {
|
|
3744
|
+
this.#mirrorColor(hex, this.#hexString());
|
|
3745
|
+
}
|
|
3746
|
+
/** Fills a form field inserted or replaced at runtime with the current color. */
|
|
3747
|
+
fieldTargetConnected(field) {
|
|
3748
|
+
this.#mirrorColor(field, this.#hexString());
|
|
3749
|
+
}
|
|
3750
|
+
/** Publishes the current color on a preview inserted or replaced at runtime. */
|
|
3751
|
+
previewTargetConnected(preview) {
|
|
3752
|
+
this.#publishColor(preview, this.#hexString());
|
|
3753
|
+
}
|
|
3563
3754
|
/** Keyboard stepping on the focused channel slider (APG Slider model). */
|
|
3564
3755
|
onKeydown(event) {
|
|
3565
3756
|
if (isReservedArrowChord(event)) return;
|
|
3566
3757
|
const slider = event.currentTarget;
|
|
3567
|
-
const channel = this.#
|
|
3758
|
+
const channel = this.#editableChannel(slider);
|
|
3568
3759
|
if (!channel) return;
|
|
3569
3760
|
const [min, max] = this.#rangeOf(slider, channel);
|
|
3570
3761
|
const value = this.#color[channel];
|
|
@@ -3596,34 +3787,36 @@ var ColorPickerController = class extends Controller {
|
|
|
3596
3787
|
event.preventDefault();
|
|
3597
3788
|
this.#setChannel(channel, next, min, max);
|
|
3598
3789
|
}
|
|
3599
|
-
/** Begins a
|
|
3790
|
+
/** Begins a primary-button drag on a channel slider, owned by its own pointer. */
|
|
3600
3791
|
onPointerDown(event) {
|
|
3792
|
+
if (event.button !== 0 || this.#drag) return;
|
|
3601
3793
|
const slider = event.currentTarget;
|
|
3602
|
-
const channel = this.#
|
|
3794
|
+
const channel = this.#editableChannel(slider);
|
|
3603
3795
|
if (!channel) return;
|
|
3604
|
-
event.preventDefault();
|
|
3605
|
-
slider.focus();
|
|
3606
3796
|
const [min, max] = this.#rangeOf(slider, channel);
|
|
3607
3797
|
const mirrored = this.#mirrored;
|
|
3608
3798
|
const update = (clientX) => {
|
|
3609
3799
|
const rect = slider.getBoundingClientRect();
|
|
3610
|
-
if (rect.width === 0) return;
|
|
3800
|
+
if (rect.width === 0) return false;
|
|
3611
3801
|
const offset = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
|
3612
3802
|
const fraction = mirrored ? 1 - offset : offset;
|
|
3613
3803
|
this.#setChannel(channel, min + fraction * (max - min), min, max);
|
|
3804
|
+
return true;
|
|
3614
3805
|
};
|
|
3615
|
-
update(event.clientX);
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3806
|
+
if (!update(event.clientX)) return;
|
|
3807
|
+
event.preventDefault();
|
|
3808
|
+
slider.focus();
|
|
3809
|
+
const drag = { pointer: null, slider };
|
|
3810
|
+
drag.pointer = new OwnedPointerSession(event, slider, {
|
|
3811
|
+
move: (move) => {
|
|
3812
|
+
if (slider.isConnected) update(move.clientX);
|
|
3813
|
+
else this.#endDrag();
|
|
3814
|
+
},
|
|
3815
|
+
end: () => {
|
|
3816
|
+
if (this.#drag === drag) this.#drag = null;
|
|
3817
|
+
}
|
|
3818
|
+
});
|
|
3819
|
+
this.#drag = drag;
|
|
3627
3820
|
}
|
|
3628
3821
|
/** Parses the hex input on confirm and syncs every channel + surface. */
|
|
3629
3822
|
onHexInput() {
|
|
@@ -3633,9 +3826,22 @@ var ColorPickerController = class extends Controller {
|
|
|
3633
3826
|
this.hexTarget.value = this.#hexString();
|
|
3634
3827
|
return;
|
|
3635
3828
|
}
|
|
3636
|
-
this.#color = this
|
|
3829
|
+
this.#color = this.#opaqueUnlessEnabled(parsed);
|
|
3637
3830
|
this.#commitColor();
|
|
3638
3831
|
}
|
|
3832
|
+
/** Replaces the model with the color `value` names, leaving an unparsable one alone. */
|
|
3833
|
+
#adoptValue() {
|
|
3834
|
+
const parsed = hexToHsla(this.valueValue);
|
|
3835
|
+
if (parsed) this.#color = this.#opaqueUnlessEnabled(parsed);
|
|
3836
|
+
}
|
|
3837
|
+
/**
|
|
3838
|
+
* The model a parsed color implies: alpha only survives while its channel is
|
|
3839
|
+
* enabled, because `hexString()` would otherwise emit `#RRGGBB` while `change`
|
|
3840
|
+
* reported `rgba.a < 1`.
|
|
3841
|
+
*/
|
|
3842
|
+
#opaqueUnlessEnabled(parsed) {
|
|
3843
|
+
return this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
3844
|
+
}
|
|
3639
3845
|
/** Clamps and snaps one channel to an integer, then re-renders + emits change. */
|
|
3640
3846
|
#setChannel(channel, raw, min, max) {
|
|
3641
3847
|
this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
|
|
@@ -3654,32 +3860,57 @@ var ColorPickerController = class extends Controller {
|
|
|
3654
3860
|
}
|
|
3655
3861
|
}
|
|
3656
3862
|
/**
|
|
3657
|
-
* Reflects the model onto sliders, the hex input, preview,
|
|
3863
|
+
* Reflects the model onto sliders, the hex input, preview, form field, and the
|
|
3864
|
+
* `value` Value it serializes into.
|
|
3658
3865
|
*
|
|
3659
3866
|
* @stimeoRenderRoot
|
|
3660
3867
|
*/
|
|
3661
3868
|
#render() {
|
|
3662
|
-
for (const slider of this.sliderTargets)
|
|
3663
|
-
const channel = this.#channelOf(slider);
|
|
3664
|
-
if (!channel) continue;
|
|
3665
|
-
const value = this.#color[channel];
|
|
3666
|
-
slider.setAttribute("aria-valuenow", String(value));
|
|
3667
|
-
slider.setAttribute("aria-valuetext", valueText(channel, value));
|
|
3668
|
-
}
|
|
3869
|
+
for (const slider of this.sliderTargets) this.#renderSlider(slider);
|
|
3669
3870
|
const hex = this.#hexString();
|
|
3670
3871
|
this.#committedHex = hex;
|
|
3671
|
-
if (this.
|
|
3672
|
-
|
|
3673
|
-
for (const
|
|
3674
|
-
this.
|
|
3872
|
+
if (this.valueValue !== hex) this.valueValue = hex;
|
|
3873
|
+
if (this.hasHexTarget) this.#mirrorColor(this.hexTarget, hex);
|
|
3874
|
+
for (const field of this.fieldTargets) this.#mirrorColor(field, hex);
|
|
3875
|
+
for (const preview of this.previewTargets) this.#publishColor(preview, hex);
|
|
3876
|
+
this.#publishColor(this.element, hex);
|
|
3877
|
+
}
|
|
3878
|
+
/** Writes one slider's announced range, value, and value text, skipping equal ones. */
|
|
3879
|
+
#renderSlider(slider) {
|
|
3880
|
+
const channel = this.#channelOf(slider);
|
|
3881
|
+
if (!channel) return;
|
|
3882
|
+
const [min, max] = this.#rangeOf(slider, channel);
|
|
3883
|
+
const value = this.#color[channel];
|
|
3884
|
+
const attributes = {
|
|
3885
|
+
"aria-valuemin": String(min),
|
|
3886
|
+
"aria-valuemax": String(max),
|
|
3887
|
+
"aria-valuenow": String(value),
|
|
3888
|
+
"aria-valuetext": this.#valueText(slider, channel, value)
|
|
3889
|
+
};
|
|
3890
|
+
for (const [name, next] of Object.entries(attributes)) {
|
|
3891
|
+
if (slider.getAttribute(name) !== next) slider.setAttribute(name, next);
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
/** Mirrors the color into an input, leaving an already-equal value untouched. */
|
|
3895
|
+
#mirrorColor(input, hex) {
|
|
3896
|
+
if (input.value !== hex) input.value = hex;
|
|
3897
|
+
}
|
|
3898
|
+
/** Publishes the color as the consumer's CSS hook, skipping an equal value. */
|
|
3899
|
+
#publishColor(element, hex) {
|
|
3900
|
+
if (element.style.getPropertyValue(COLOR_PROPERTY) !== hex) {
|
|
3901
|
+
element.style.setProperty(COLOR_PROPERTY, hex);
|
|
3902
|
+
}
|
|
3675
3903
|
}
|
|
3676
3904
|
/**
|
|
3677
|
-
* Repaints after
|
|
3678
|
-
* settled on. Disabling alpha drops it from the model
|
|
3679
|
-
*
|
|
3905
|
+
* Repaints after a declarative input changed at runtime and reports a color this
|
|
3906
|
+
* controller settled on. Disabling alpha drops it from the model and an outside
|
|
3907
|
+
* `value` names another color, so the committed color can move without a user
|
|
3908
|
+
* edit; `change` stays reserved for the picker's own actions.
|
|
3680
3909
|
*/
|
|
3681
3910
|
#reconcileColor() {
|
|
3682
3911
|
const previous = this.#committedHex;
|
|
3912
|
+
if (previous !== null && this.valueValue !== previous) this.#adoptValue();
|
|
3913
|
+
if (!this.alphaValue) this.#color.alpha = 100;
|
|
3683
3914
|
this.#render();
|
|
3684
3915
|
if (previous !== null && this.#committedHex !== previous) {
|
|
3685
3916
|
this.dispatch("reconcile", { detail: this.#settledDetail() });
|
|
@@ -3700,7 +3931,27 @@ var ColorPickerController = class extends Controller {
|
|
|
3700
3931
|
/** Reads a slider's `data-channel`, if it is a known channel. */
|
|
3701
3932
|
#channelOf(slider) {
|
|
3702
3933
|
const channel = slider.getAttribute("data-channel");
|
|
3703
|
-
return channel && channel
|
|
3934
|
+
return channel && Object.hasOwn(CHANNEL_RANGE, channel) ? channel : null;
|
|
3935
|
+
}
|
|
3936
|
+
/**
|
|
3937
|
+
* The channel a slider edits, or null when this picker edits none through it. An
|
|
3938
|
+
* alpha slider authored while `alpha` is off edits nothing: moving it would leave
|
|
3939
|
+
* the model translucent behind an opaque `#RRGGBB`.
|
|
3940
|
+
*/
|
|
3941
|
+
#editableChannel(slider) {
|
|
3942
|
+
const channel = this.#channelOf(slider);
|
|
3943
|
+
return channel === "alpha" && !this.alphaValue ? null : channel;
|
|
3944
|
+
}
|
|
3945
|
+
/** The channel's announced text: the slider's template, or the built-in English. */
|
|
3946
|
+
#valueText(slider, channel, value) {
|
|
3947
|
+
const template = slider.getAttribute(VALUE_TEXT_ATTRIBUTE);
|
|
3948
|
+
return template ? template.replaceAll("{value}", String(value)) : defaultValueText(channel, value);
|
|
3949
|
+
}
|
|
3950
|
+
/** Ends the live drag so no further movement of that pointer reaches the model. */
|
|
3951
|
+
#endDrag() {
|
|
3952
|
+
const drag = this.#drag;
|
|
3953
|
+
this.#drag = null;
|
|
3954
|
+
drag?.pointer?.end();
|
|
3704
3955
|
}
|
|
3705
3956
|
/**
|
|
3706
3957
|
* A slider's `[min, max]` from aria-valuemin/max, falling back per channel.
|
|
@@ -3716,7 +3967,7 @@ var ColorPickerController = class extends Controller {
|
|
|
3716
3967
|
];
|
|
3717
3968
|
}
|
|
3718
3969
|
};
|
|
3719
|
-
function
|
|
3970
|
+
function defaultValueText(channel, value) {
|
|
3720
3971
|
const label = channel.charAt(0).toUpperCase() + channel.slice(1);
|
|
3721
3972
|
const unit = channel === "hue" ? "degrees" : "percent";
|
|
3722
3973
|
return `${label} ${value} ${unit}`;
|
|
@@ -5183,7 +5434,7 @@ var CountdownController = class extends Controller {
|
|
|
5183
5434
|
#pausedAmount = 0;
|
|
5184
5435
|
/**
|
|
5185
5436
|
* The amount the slots are currently showing, floored to the second they render.
|
|
5186
|
-
* It lags {@link currentAmount} by up to one tick, and it — not the live reading —
|
|
5437
|
+
* It lags {@link #currentAmount} by up to one tick, and it — not the live reading —
|
|
5187
5438
|
* is what a pause has to preserve: storing the fraction behind the display instead
|
|
5188
5439
|
* makes the first tick after a resume step by two units.
|
|
5189
5440
|
*/
|
|
@@ -5691,6 +5942,25 @@ function round(value, precision) {
|
|
|
5691
5942
|
if (!Number.isFinite(rounded)) return value;
|
|
5692
5943
|
return rounded === 0 ? 0 : rounded;
|
|
5693
5944
|
}
|
|
5945
|
+
|
|
5946
|
+
// src/utils/interactive_host.ts
|
|
5947
|
+
var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
|
|
5948
|
+
function isInteractiveHost(element) {
|
|
5949
|
+
if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
|
|
5950
|
+
let current = element;
|
|
5951
|
+
while (current) {
|
|
5952
|
+
const raw = current.getAttribute("contenteditable");
|
|
5953
|
+
if (raw !== null) {
|
|
5954
|
+
const value = raw.trim().toLowerCase();
|
|
5955
|
+
if (value === "false") return false;
|
|
5956
|
+
if (value === "" || value === "true" || value === "plaintext-only") return true;
|
|
5957
|
+
}
|
|
5958
|
+
current = current.parentElement;
|
|
5959
|
+
}
|
|
5960
|
+
return false;
|
|
5961
|
+
}
|
|
5962
|
+
|
|
5963
|
+
// src/controllers/data_grid_controller.ts
|
|
5694
5964
|
var SORT_CYCLE = ["none", "ascending", "descending"];
|
|
5695
5965
|
function nextSortDirection(current) {
|
|
5696
5966
|
const index = SORT_CYCLE.indexOf(current);
|
|
@@ -5704,8 +5974,15 @@ var DataGridController = class extends Controller {
|
|
|
5704
5974
|
};
|
|
5705
5975
|
static actions = ["onKeydown", "sort", "toggleSelect"];
|
|
5706
5976
|
static events = ["selectionchange", "sort"];
|
|
5707
|
-
/**
|
|
5708
|
-
|
|
5977
|
+
/**
|
|
5978
|
+
* Collapses the per-element target callbacks of one DOM mutation into a single
|
|
5979
|
+
* baseline pass, and refuses to run before `connect()` or after `disconnect()`.
|
|
5980
|
+
*
|
|
5981
|
+
* Stimulus reports every target one at a time, so an ungated pass would re-walk
|
|
5982
|
+
* the whole grid once per authored cell on mount and once per streamed cell
|
|
5983
|
+
* afterwards — quadratic in the cell count both times.
|
|
5984
|
+
*/
|
|
5985
|
+
#reconcile = new MicrotaskCoalescer(() => this.#restoreBaseline());
|
|
5709
5986
|
/**
|
|
5710
5987
|
* Establishes a single tab stop across all navigable cells/headers and brings
|
|
5711
5988
|
* the rows to their baseline.
|
|
@@ -5716,15 +5993,30 @@ var DataGridController = class extends Controller {
|
|
|
5716
5993
|
* attribute, so the Value callback does not fire a second time.
|
|
5717
5994
|
*/
|
|
5718
5995
|
connect() {
|
|
5996
|
+
this.#restoreBaseline();
|
|
5997
|
+
this.#reconcile.activate();
|
|
5998
|
+
}
|
|
5999
|
+
/** Closes the reconcile window so a queued pass cannot run against a detached tree. */
|
|
6000
|
+
disconnect() {
|
|
6001
|
+
this.#reconcile.cancel();
|
|
6002
|
+
}
|
|
6003
|
+
/**
|
|
6004
|
+
* Rebuilds both DOM-owned baselines from the live grid: exactly one navigable
|
|
6005
|
+
* cell is in the Tab sequence, and every selectable row carries an explicit
|
|
6006
|
+
* `aria-selected`.
|
|
6007
|
+
*
|
|
6008
|
+
* The tab stop keeps whichever cell already holds it, so a rebuild triggered by
|
|
6009
|
+
* an unrelated row arriving does not throw the user's position away; only when
|
|
6010
|
+
* no cell holds it — the grid is fresh, or the holder was removed — does the
|
|
6011
|
+
* first navigable cell take over. Without that fallback a grid whose active row
|
|
6012
|
+
* is removed keeps every cell at `-1` and drops out of the Tab sequence
|
|
6013
|
+
* entirely.
|
|
6014
|
+
*/
|
|
6015
|
+
#restoreBaseline() {
|
|
5719
6016
|
const cells = this.#navigableCells();
|
|
5720
6017
|
const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
|
|
5721
|
-
this.#setActiveCell(active, { focus: false });
|
|
6018
|
+
if (active) this.#setActiveCell(active, { focus: false }, cells);
|
|
5722
6019
|
this.#normalizeSelection();
|
|
5723
|
-
this.#connected = true;
|
|
5724
|
-
}
|
|
5725
|
-
/** Reopens the row callback for the next mount. */
|
|
5726
|
-
disconnect() {
|
|
5727
|
-
this.#connected = false;
|
|
5728
6020
|
}
|
|
5729
6021
|
/**
|
|
5730
6022
|
* Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
|
|
@@ -5735,16 +6027,25 @@ var DataGridController = class extends Controller {
|
|
|
5735
6027
|
this.#syncSelectable();
|
|
5736
6028
|
this.#normalizeSelection();
|
|
5737
6029
|
}
|
|
5738
|
-
/**
|
|
5739
|
-
* Re-establishes the row baseline for a row added after connect.
|
|
5740
|
-
*
|
|
5741
|
-
* Each pass walks every row, and Stimulus reports the authored rows one by one
|
|
5742
|
-
* before `connect()`, so the mount is gated to keep it linear in the row count
|
|
5743
|
-
* rather than quadratic; `connect()` runs the single baseline pass instead.
|
|
5744
|
-
*/
|
|
6030
|
+
/** Re-establishes the baselines for a row added after connect. */
|
|
5745
6031
|
rowTargetConnected() {
|
|
5746
|
-
|
|
5747
|
-
|
|
6032
|
+
this.#reconcile.schedule();
|
|
6033
|
+
}
|
|
6034
|
+
/** Re-establishes the tab stop when a cell joins the grid after connect. */
|
|
6035
|
+
cellTargetConnected() {
|
|
6036
|
+
this.#reconcile.schedule();
|
|
6037
|
+
}
|
|
6038
|
+
/** Re-establishes the tab stop when a cell leaves the grid. */
|
|
6039
|
+
cellTargetDisconnected() {
|
|
6040
|
+
this.#reconcile.schedule();
|
|
6041
|
+
}
|
|
6042
|
+
/** Re-establishes the tab stop when a header joins the grid after connect. */
|
|
6043
|
+
columnHeaderTargetConnected() {
|
|
6044
|
+
this.#reconcile.schedule();
|
|
6045
|
+
}
|
|
6046
|
+
/** Re-establishes the tab stop when a header leaves the grid. */
|
|
6047
|
+
columnHeaderTargetDisconnected() {
|
|
6048
|
+
this.#reconcile.schedule();
|
|
5748
6049
|
}
|
|
5749
6050
|
/**
|
|
5750
6051
|
* Brings the authored rows to the shape the APG requires, without changing
|
|
@@ -5789,6 +6090,9 @@ var DataGridController = class extends Controller {
|
|
|
5789
6090
|
sort(event) {
|
|
5790
6091
|
const header = event.currentTarget;
|
|
5791
6092
|
if (!this.columnHeaderTargets.includes(header)) return;
|
|
6093
|
+
if (event.defaultPrevented) return;
|
|
6094
|
+
const control = this.#claimingControl(event, header);
|
|
6095
|
+
if (control && !(control instanceof HTMLButtonElement)) return;
|
|
5792
6096
|
const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
|
|
5793
6097
|
for (const other of this.columnHeaderTargets) {
|
|
5794
6098
|
other.setAttribute("aria-sort", other === header ? direction : "none");
|
|
@@ -5799,14 +6103,19 @@ var DataGridController = class extends Controller {
|
|
|
5799
6103
|
/** Toggles selection of the row owning the event target. Bound optionally. */
|
|
5800
6104
|
toggleSelect(event) {
|
|
5801
6105
|
if (this.selectionValue === "none") return;
|
|
5802
|
-
|
|
6106
|
+
if (event.defaultPrevented) return;
|
|
6107
|
+
const host = event.currentTarget;
|
|
6108
|
+
if (this.#claimedByDescendant(event, host)) return;
|
|
6109
|
+
const row = host.closest("[role='row']");
|
|
5803
6110
|
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
5804
6111
|
}
|
|
5805
6112
|
/** Grid navigation + sort/select activation. Bound to cells and headers. */
|
|
5806
6113
|
onKeydown(event) {
|
|
5807
6114
|
if (event.defaultPrevented) return;
|
|
5808
6115
|
if (isReservedArrowChord(event)) return;
|
|
6116
|
+
if (event.isComposing) return;
|
|
5809
6117
|
const cell = event.currentTarget;
|
|
6118
|
+
if (this.#claimedByDescendant(event, cell)) return;
|
|
5810
6119
|
const matrix = this.#matrix();
|
|
5811
6120
|
const position = this.#locate(matrix, cell);
|
|
5812
6121
|
if (!position) return;
|
|
@@ -5842,9 +6151,35 @@ var DataGridController = class extends Controller {
|
|
|
5842
6151
|
}
|
|
5843
6152
|
if (target) {
|
|
5844
6153
|
event.preventDefault();
|
|
5845
|
-
this.#setActiveCell(target, { focus: true });
|
|
6154
|
+
this.#setActiveCell(target, { focus: true }, matrix.flat());
|
|
5846
6155
|
}
|
|
5847
6156
|
}
|
|
6157
|
+
/**
|
|
6158
|
+
* Whether the event was addressed to a control inside `host` rather than to the
|
|
6159
|
+
* grid.
|
|
6160
|
+
*
|
|
6161
|
+
* Cells and headers hold consumer markup, and APG's grid pattern expects that
|
|
6162
|
+
* markup to include working controls — a row action button, an inline editor.
|
|
6163
|
+
* Those own their own keystrokes and clicks, so the grid stands down entirely
|
|
6164
|
+
* rather than acting in parallel. An editable host (its `contenteditable` state
|
|
6165
|
+
* is inherited, so the walk is explicit) counts the same way.
|
|
6166
|
+
*/
|
|
6167
|
+
#claimedByDescendant(event, host) {
|
|
6168
|
+
return this.#claimingControl(event, host) !== null;
|
|
6169
|
+
}
|
|
6170
|
+
/**
|
|
6171
|
+
* The nested control this event belongs to, or `null` when the host owns it.
|
|
6172
|
+
*
|
|
6173
|
+
* Naming the control, rather than answering yes or no, is what lets the click
|
|
6174
|
+
* path treat a sortable header's `<button>` as the activation it is while every
|
|
6175
|
+
* other control still takes the event away.
|
|
6176
|
+
*/
|
|
6177
|
+
#claimingControl(event, host) {
|
|
6178
|
+
const source = event.target;
|
|
6179
|
+
const control = source.closest(INTERACTIVE_HOST_SELECTOR);
|
|
6180
|
+
if (control && host.contains(control)) return control;
|
|
6181
|
+
return isInteractiveHost(source) ? source : null;
|
|
6182
|
+
}
|
|
5848
6183
|
/** Performs a header's sort or a cell row's selection toggle on activation. */
|
|
5849
6184
|
#activate(cell) {
|
|
5850
6185
|
if (this.columnHeaderTargets.includes(cell)) {
|
|
@@ -5855,7 +6190,7 @@ var DataGridController = class extends Controller {
|
|
|
5855
6190
|
const row = cell.closest("[role='row']");
|
|
5856
6191
|
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
5857
6192
|
}
|
|
5858
|
-
/**
|
|
6193
|
+
/** Cycles a header's sort on keyboard activation and emits `sort`. */
|
|
5859
6194
|
#cycleSort(header) {
|
|
5860
6195
|
const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
|
|
5861
6196
|
for (const other of this.columnHeaderTargets) {
|
|
@@ -5875,11 +6210,22 @@ var DataGridController = class extends Controller {
|
|
|
5875
6210
|
const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
|
|
5876
6211
|
this.dispatch("selectionchange", { detail: { rows } });
|
|
5877
6212
|
}
|
|
5878
|
-
/**
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
6213
|
+
/**
|
|
6214
|
+
* Makes `cell` the single tabbable cell (roving) and optionally focuses it.
|
|
6215
|
+
*
|
|
6216
|
+
* `cells` lets a caller that already walked the grid hand its collection over,
|
|
6217
|
+
* so one keystroke rebuilds the matrix once instead of twice. The write is
|
|
6218
|
+
* skipped where the attribute already holds the wanted value — comparing the
|
|
6219
|
+
* attribute rather than the IDL property, because a cell with no `tabindex` at
|
|
6220
|
+
* all reports `-1` and would then never receive the attribute it needs to be
|
|
6221
|
+
* focusable.
|
|
6222
|
+
*/
|
|
6223
|
+
#setActiveCell(cell, { focus }, cells) {
|
|
6224
|
+
for (const candidate of cells ?? this.#navigableCells()) {
|
|
6225
|
+
const wanted = candidate === cell ? "0" : "-1";
|
|
6226
|
+
if (candidate.getAttribute("tabindex") !== wanted) {
|
|
6227
|
+
candidate.setAttribute("tabindex", wanted);
|
|
6228
|
+
}
|
|
5883
6229
|
}
|
|
5884
6230
|
if (focus) cell.focus();
|
|
5885
6231
|
}
|
|
@@ -6612,10 +6958,6 @@ var DirectUploadController = class extends Controller {
|
|
|
6612
6958
|
this.#rows.delete(id);
|
|
6613
6959
|
}
|
|
6614
6960
|
}
|
|
6615
|
-
/**
|
|
6616
|
-
* Returns the widget to its pre-upload state just before Turbo caches the
|
|
6617
|
-
* page, so the snapshot never replays rows for uploads that cannot resume.
|
|
6618
|
-
*/
|
|
6619
6961
|
/**
|
|
6620
6962
|
* Rewinds for the snapshot and reports what that discarded. An upload in flight
|
|
6621
6963
|
* cannot survive the navigation, so a consumer mirroring the rows would keep a
|
|
@@ -6654,7 +6996,7 @@ var DirectUploadController = class extends Controller {
|
|
|
6654
6996
|
#detail(event) {
|
|
6655
6997
|
return event.detail ?? {};
|
|
6656
6998
|
}
|
|
6657
|
-
/** The file name
|
|
6999
|
+
/** The event's file name, or `""` when it carries none. */
|
|
6658
7000
|
#name(detail) {
|
|
6659
7001
|
return detail.file?.name ?? "";
|
|
6660
7002
|
}
|
|
@@ -7028,8 +7370,8 @@ var DrawerController = class extends Controller {
|
|
|
7028
7370
|
* rather than being re-derived from the declarative `open` Value (which would
|
|
7029
7371
|
* close a user-opened drawer). The `open` Value only seeds a genuinely fresh
|
|
7030
7372
|
* render. We normalize to a clean closed baseline first so {@link open} runs its
|
|
7031
|
-
* full reveal + trap activation — the {@link FocusTrap} is
|
|
7032
|
-
*
|
|
7373
|
+
* full reveal + trap activation — the {@link FocusTrap} is inactive after a
|
|
7374
|
+
* disconnect and must be re-activated.
|
|
7033
7375
|
*/
|
|
7034
7376
|
connect() {
|
|
7035
7377
|
this.#connected = true;
|
|
@@ -7271,33 +7613,64 @@ var EditableController = class extends Controller {
|
|
|
7271
7613
|
static values = {
|
|
7272
7614
|
submitOnBlur: { type: Boolean, default: true }
|
|
7273
7615
|
};
|
|
7274
|
-
static actions = ["
|
|
7616
|
+
static actions = ["cancel", "edit", "onDisplayKeydown", "onKeydown", "revert", "save"];
|
|
7275
7617
|
static events = ["cancel", "change"];
|
|
7276
7618
|
/** The value captured when edit mode began, used to detect real changes. */
|
|
7277
7619
|
#previousValue = "";
|
|
7620
|
+
/**
|
|
7621
|
+
* The value the last save replaced, or `null` when there is nothing to undo.
|
|
7622
|
+
* Cleared on connect: a value from before a page restore is unrecoverable, so
|
|
7623
|
+
* `revert()` must not resurrect one.
|
|
7624
|
+
*/
|
|
7625
|
+
#revertValue = null;
|
|
7278
7626
|
/**
|
|
7279
7627
|
* Owns IME lifecycle state for the edit surface, so a keydown that belongs to
|
|
7280
7628
|
* a composition (cancel or confirm) is never treated as an edit command.
|
|
7281
7629
|
*/
|
|
7282
7630
|
#composition = new CompositionTracker();
|
|
7631
|
+
/**
|
|
7632
|
+
* Watches focus leaving the editor from wherever it currently sits.
|
|
7633
|
+
*
|
|
7634
|
+
* `focusout` bubbles where `blur` does not, so one listener on the root sees
|
|
7635
|
+
* every departure — including one from a Save or Cancel button the consumer
|
|
7636
|
+
* placed beside the input. Binding the input alone would make the promise
|
|
7637
|
+
* "saves wherever focus moved" true only for focus that leaves the input
|
|
7638
|
+
* itself, and tabbing straight past an inner button would strand the editor
|
|
7639
|
+
* open.
|
|
7640
|
+
*/
|
|
7641
|
+
#onFocusOut = (event) => {
|
|
7642
|
+
const from = event.target;
|
|
7643
|
+
if (this.hasDisplayTarget && from instanceof Node && this.displayTarget.contains(from)) return;
|
|
7644
|
+
const next = event.relatedTarget;
|
|
7645
|
+
if (next instanceof Node && this.element.contains(next)) return;
|
|
7646
|
+
if (this.submitOnBlurValue) this.#commit(false);
|
|
7647
|
+
};
|
|
7283
7648
|
/** Establishes the initial display mode (display shown, input hidden). */
|
|
7284
7649
|
connect() {
|
|
7285
7650
|
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
7651
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
7652
|
+
this.#revertValue = null;
|
|
7286
7653
|
this.#setMode("display");
|
|
7287
7654
|
}
|
|
7288
|
-
/** Releases the composition listeners so nothing outlives the element. */
|
|
7655
|
+
/** Releases the composition and focus listeners so nothing outlives the element. */
|
|
7289
7656
|
disconnect() {
|
|
7290
7657
|
this.#composition.disconnect();
|
|
7658
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
7291
7659
|
}
|
|
7292
7660
|
/** Tracks an input added initially or after connect (e.g. a Turbo swap). */
|
|
7293
7661
|
inputTargetConnected(input) {
|
|
7294
7662
|
this.#composition.observe(input);
|
|
7663
|
+
this.#applyMode();
|
|
7295
7664
|
}
|
|
7296
7665
|
/** Removes composition listeners when the active input is replaced or removed. */
|
|
7297
7666
|
inputTargetDisconnected(input) {
|
|
7298
7667
|
this.#composition.unobserve(input);
|
|
7299
7668
|
}
|
|
7300
|
-
/**
|
|
7669
|
+
/** Re-hides or re-shows a display element that arrived after the mode was set. */
|
|
7670
|
+
displayTargetConnected() {
|
|
7671
|
+
this.#applyMode();
|
|
7672
|
+
}
|
|
7673
|
+
/** Enters edit mode: seeds the input from the declared value, focuses, selects. */
|
|
7301
7674
|
edit() {
|
|
7302
7675
|
if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
|
|
7303
7676
|
this.#previousValue = this.#currentValue;
|
|
@@ -7306,6 +7679,27 @@ var EditableController = class extends Controller {
|
|
|
7306
7679
|
this.inputTarget.focus();
|
|
7307
7680
|
this.inputTarget.select();
|
|
7308
7681
|
}
|
|
7682
|
+
/** Commits the edit and returns focus to the display element. */
|
|
7683
|
+
save() {
|
|
7684
|
+
this.#commit(true);
|
|
7685
|
+
}
|
|
7686
|
+
/** Discards edits, returns to display mode, and dispatches `cancel`. */
|
|
7687
|
+
cancel() {
|
|
7688
|
+
if (!this.#isEditing) return;
|
|
7689
|
+
this.#setMode("display");
|
|
7690
|
+
if (this.hasDisplayTarget) this.displayTarget.focus();
|
|
7691
|
+
this.dispatch("cancel", { detail: {} });
|
|
7692
|
+
}
|
|
7693
|
+
/**
|
|
7694
|
+
* Puts back the value the last save replaced — for a consumer whose server
|
|
7695
|
+
* rejected it. Silent by design: a `change` here would re-enter the same
|
|
7696
|
+
* handler that asked for the undo. One save, one undo.
|
|
7697
|
+
*/
|
|
7698
|
+
revert() {
|
|
7699
|
+
if (this.#revertValue === null || this.#isEditing) return;
|
|
7700
|
+
this.#writeValue(this.#revertValue);
|
|
7701
|
+
this.#revertValue = null;
|
|
7702
|
+
}
|
|
7309
7703
|
/** Adds `F2` as an editing entry point alongside the button's native activation. */
|
|
7310
7704
|
onDisplayKeydown(event) {
|
|
7311
7705
|
if (event.key === "F2") {
|
|
@@ -7319,56 +7713,57 @@ var EditableController = class extends Controller {
|
|
|
7319
7713
|
if (event.key === "Escape") {
|
|
7320
7714
|
if (event.defaultPrevented) return;
|
|
7321
7715
|
event.preventDefault();
|
|
7322
|
-
this
|
|
7716
|
+
this.cancel();
|
|
7323
7717
|
return;
|
|
7324
7718
|
}
|
|
7325
7719
|
if (event.key === "Enter") {
|
|
7326
7720
|
if (event.defaultPrevented) return;
|
|
7327
7721
|
if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
|
|
7328
7722
|
event.preventDefault();
|
|
7329
|
-
this.#
|
|
7723
|
+
this.#commit(true);
|
|
7330
7724
|
}
|
|
7331
7725
|
}
|
|
7332
|
-
/** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
|
|
7333
|
-
onBlur() {
|
|
7334
|
-
if (!this.#isEditing) return;
|
|
7335
|
-
if (this.submitOnBlurValue) this.#save(false);
|
|
7336
|
-
}
|
|
7337
7726
|
/**
|
|
7338
|
-
* Returns to display mode,
|
|
7339
|
-
*
|
|
7727
|
+
* Returns to display mode, storing the input's value and dispatching `change`
|
|
7728
|
+
* when it differs from where editing began.
|
|
7340
7729
|
*
|
|
7341
7730
|
* @param restoreFocus - Move focus back to the display element (explicit
|
|
7342
|
-
*
|
|
7731
|
+
* commit) rather than honoring the user's new focus target (blur).
|
|
7343
7732
|
*/
|
|
7344
|
-
#
|
|
7345
|
-
if (!this.#isEditing) return;
|
|
7346
|
-
const value = this.inputTarget.value;
|
|
7733
|
+
#commit(restoreFocus) {
|
|
7734
|
+
if (!this.#isEditing || !this.hasInputTarget) return;
|
|
7735
|
+
const value = this.inputTarget.value.trim();
|
|
7347
7736
|
const previous = this.#previousValue;
|
|
7348
|
-
this
|
|
7737
|
+
this.#writeValue(value);
|
|
7349
7738
|
this.#setMode("display");
|
|
7350
|
-
if (restoreFocus) this.displayTarget.focus();
|
|
7739
|
+
if (restoreFocus && this.hasDisplayTarget) this.displayTarget.focus();
|
|
7351
7740
|
if (value !== previous) {
|
|
7741
|
+
this.#revertValue = previous;
|
|
7352
7742
|
this.dispatch("change", { detail: { value, previous } });
|
|
7353
7743
|
}
|
|
7354
7744
|
}
|
|
7355
|
-
/**
|
|
7356
|
-
#
|
|
7357
|
-
if (!this
|
|
7358
|
-
this
|
|
7359
|
-
|
|
7360
|
-
|
|
7745
|
+
/** Stores `value` wherever the display element declares it. */
|
|
7746
|
+
#writeValue(value) {
|
|
7747
|
+
if (!this.hasDisplayTarget) return;
|
|
7748
|
+
const display = this.displayTarget;
|
|
7749
|
+
if (display.hasAttribute("data-value")) display.dataset.value = value;
|
|
7750
|
+
else display.textContent = value;
|
|
7361
7751
|
}
|
|
7362
|
-
/**
|
|
7363
|
-
#
|
|
7364
|
-
|
|
7365
|
-
const editing = mode === "editing";
|
|
7752
|
+
/** Derives both elements' visibility from the mode currently in the DOM. */
|
|
7753
|
+
#applyMode() {
|
|
7754
|
+
const editing = this.#isEditing;
|
|
7366
7755
|
if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
|
|
7367
7756
|
if (this.hasInputTarget) this.inputTarget.hidden = !editing;
|
|
7368
7757
|
}
|
|
7369
|
-
/**
|
|
7758
|
+
/** Records the mode, then brings both elements in line with it. */
|
|
7759
|
+
#setMode(mode) {
|
|
7760
|
+
this.element.dataset.mode = mode;
|
|
7761
|
+
this.#applyMode();
|
|
7762
|
+
}
|
|
7763
|
+
/** The value the display element declares, trimmed. */
|
|
7370
7764
|
get #currentValue() {
|
|
7371
|
-
|
|
7765
|
+
const display = this.displayTarget;
|
|
7766
|
+
return (display.dataset.value ?? display.textContent ?? "").trim();
|
|
7372
7767
|
}
|
|
7373
7768
|
/** Whether the editing control is a multi-line `<textarea>`. */
|
|
7374
7769
|
get #isMultiline() {
|
|
@@ -7980,15 +8375,46 @@ var FilterController = class extends Controller {
|
|
|
7980
8375
|
#onChange = () => {
|
|
7981
8376
|
this.apply();
|
|
7982
8377
|
};
|
|
8378
|
+
/**
|
|
8379
|
+
* Gates the declaration callback to the connected window.
|
|
8380
|
+
*
|
|
8381
|
+
* Stimulus delivers a Value callback ahead of `connect()` and again for every
|
|
8382
|
+
* runtime change; without the gate, merely connecting would emit an evaluation
|
|
8383
|
+
* — and its `change` — before `connect()` runs its own.
|
|
8384
|
+
*/
|
|
8385
|
+
#connected = false;
|
|
7983
8386
|
connect() {
|
|
7984
|
-
this
|
|
8387
|
+
this.#evaluate();
|
|
7985
8388
|
this.element.addEventListener("change", this.#onChange);
|
|
8389
|
+
this.#connected = true;
|
|
7986
8390
|
}
|
|
7987
8391
|
disconnect() {
|
|
8392
|
+
this.#connected = false;
|
|
7988
8393
|
this.element.removeEventListener("change", this.#onChange);
|
|
7989
8394
|
}
|
|
8395
|
+
/**
|
|
8396
|
+
* Re-evaluates when the match declaration changes at runtime.
|
|
8397
|
+
*
|
|
8398
|
+
* The declaration decides which items are shown, so an element kept across a
|
|
8399
|
+
* morph — where `connect()` does not run again — still has to follow it instead
|
|
8400
|
+
* of waiting for the next control interaction. The evaluation is the ordinary
|
|
8401
|
+
* one, `change` included: a declaration swap is an evaluation like any other.
|
|
8402
|
+
*/
|
|
8403
|
+
matchValueChanged() {
|
|
8404
|
+
if (!this.#connected) return;
|
|
8405
|
+
this.#evaluate();
|
|
8406
|
+
}
|
|
7990
8407
|
/** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
|
|
7991
8408
|
apply() {
|
|
8409
|
+
this.#evaluate();
|
|
8410
|
+
}
|
|
8411
|
+
/**
|
|
8412
|
+
* The evaluation itself: every item's visibility from the active tokens, then
|
|
8413
|
+
* the groups and the empty element, then the `change` event.
|
|
8414
|
+
*
|
|
8415
|
+
* @stimeoRenderRoot
|
|
8416
|
+
*/
|
|
8417
|
+
#evaluate() {
|
|
7992
8418
|
const active = this.#activeTokens();
|
|
7993
8419
|
let visibleCount = 0;
|
|
7994
8420
|
for (const item of this.itemTargets) {
|
|
@@ -8339,13 +8765,39 @@ var FocusController = class extends Controller {
|
|
|
8339
8765
|
initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
|
|
8340
8766
|
onEscape: () => this.deactivate()
|
|
8341
8767
|
});
|
|
8768
|
+
/**
|
|
8769
|
+
* Whether this scope is currently trapping.
|
|
8770
|
+
*
|
|
8771
|
+
* Held here rather than read back from the trap: the trap releases itself
|
|
8772
|
+
* before Turbo caches the page, so its own flag stops answering for the state
|
|
8773
|
+
* this controller publishes on the element and in its events.
|
|
8774
|
+
*/
|
|
8775
|
+
#active = false;
|
|
8776
|
+
/**
|
|
8777
|
+
* Returns the element to its untrapped form before Turbo copies the page. The
|
|
8778
|
+
* trap performs its own release on the same event, so what is left is the hook
|
|
8779
|
+
* this controller owns — carried into the snapshot it would describe a scope
|
|
8780
|
+
* that is no longer trapping.
|
|
8781
|
+
*
|
|
8782
|
+
* Silent, like `disconnect()`: the page is about to be frozen, and a release
|
|
8783
|
+
* nobody asked for is not a close to report.
|
|
8784
|
+
*/
|
|
8785
|
+
#beforeCache = new BeforeCacheReset(() => {
|
|
8786
|
+
this.#active = false;
|
|
8787
|
+
this.element.removeAttribute("data-focus-trapped");
|
|
8788
|
+
});
|
|
8342
8789
|
/** Stimulus drives activation from the `trap` value (also fires on connect). */
|
|
8343
8790
|
trapValueChanged() {
|
|
8344
8791
|
if (this.trapValue) this.#activate();
|
|
8345
8792
|
else this.#deactivate();
|
|
8346
8793
|
}
|
|
8794
|
+
connect() {
|
|
8795
|
+
this.#beforeCache.activate();
|
|
8796
|
+
}
|
|
8347
8797
|
disconnect() {
|
|
8348
8798
|
this.#trap.deactivate({ restoreFocus: false });
|
|
8799
|
+
this.#beforeCache.deactivate();
|
|
8800
|
+
this.#active = false;
|
|
8349
8801
|
this.element.removeAttribute("data-focus-trapped");
|
|
8350
8802
|
}
|
|
8351
8803
|
/** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
|
|
@@ -8359,13 +8811,15 @@ var FocusController = class extends Controller {
|
|
|
8359
8811
|
this.#deactivate();
|
|
8360
8812
|
}
|
|
8361
8813
|
#activate() {
|
|
8362
|
-
if (this.#
|
|
8814
|
+
if (this.#active) return;
|
|
8815
|
+
this.#active = true;
|
|
8363
8816
|
this.#trap.activate();
|
|
8364
8817
|
this.element.setAttribute("data-focus-trapped", "true");
|
|
8365
8818
|
this.dispatch("activate", { detail: {} });
|
|
8366
8819
|
}
|
|
8367
8820
|
#deactivate() {
|
|
8368
|
-
if (!this.#
|
|
8821
|
+
if (!this.#active) return;
|
|
8822
|
+
this.#active = false;
|
|
8369
8823
|
this.#trap.deactivate({ restoreFocus: this.restoreValue });
|
|
8370
8824
|
this.element.removeAttribute("data-focus-trapped");
|
|
8371
8825
|
this.dispatch("deactivate", { detail: {} });
|
|
@@ -8383,7 +8837,7 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
8383
8837
|
static #INVALID_ATTR = "data-stimeo--form-field-invalid";
|
|
8384
8838
|
/** Collapses one target/morph batch into one silent ARIA reconciliation. */
|
|
8385
8839
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
8386
|
-
/**
|
|
8840
|
+
/** Returns borrowed control ARIA before Turbo snapshots the page. */
|
|
8387
8841
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
8388
8842
|
#ariaDescribedBy = new AttributeLease("aria-describedby");
|
|
8389
8843
|
#ariaErrorMessage = new AttributeLease("aria-errormessage");
|
|
@@ -10500,14 +10954,37 @@ var LocalTimeController = class extends Controller {
|
|
|
10500
10954
|
}
|
|
10501
10955
|
};
|
|
10502
10956
|
var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
|
|
10957
|
+
var DEFAULT_MIN_COLUMN_WIDTH = 240;
|
|
10958
|
+
var DEFAULT_GAP = 16;
|
|
10959
|
+
function usableNumber(value, fallback) {
|
|
10960
|
+
return Number.isFinite(value) ? value : fallback;
|
|
10961
|
+
}
|
|
10503
10962
|
var MasonryController = class extends Controller {
|
|
10504
10963
|
static targets = ["item"];
|
|
10505
10964
|
static values = {
|
|
10506
|
-
minColumnWidth: { type: Number, default:
|
|
10507
|
-
gap: { type: Number, default:
|
|
10965
|
+
minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
|
|
10966
|
+
gap: { type: Number, default: DEFAULT_GAP }
|
|
10508
10967
|
};
|
|
10509
10968
|
static events = ["layout"];
|
|
10510
|
-
|
|
10969
|
+
/**
|
|
10970
|
+
* The declared numbers after validation, so the layout path never sees a value
|
|
10971
|
+
* it cannot compute with. Both are resolved once per declaration change rather
|
|
10972
|
+
* than on every pass.
|
|
10973
|
+
*/
|
|
10974
|
+
#minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;
|
|
10975
|
+
#gap = DEFAULT_GAP;
|
|
10976
|
+
/**
|
|
10977
|
+
* Collapses every re-layout trigger of one DOM mutation into a single pass, and
|
|
10978
|
+
* refuses to run before `connect()` or after `disconnect()`.
|
|
10979
|
+
*
|
|
10980
|
+
* The triggers arrive in bursts — a resize stream, a morph that syncs several
|
|
10981
|
+
* attributes, a batch of rows — and each pass measures every item, so folding
|
|
10982
|
+
* them keeps the work proportional to the batch rather than to the events in it.
|
|
10983
|
+
*/
|
|
10984
|
+
#reconcile = new MicrotaskCoalescer(() => this.#relayout());
|
|
10985
|
+
/** Items that left the target set and still carry the column hook. */
|
|
10986
|
+
#released = /* @__PURE__ */ new Set();
|
|
10987
|
+
#layout = new LayoutObserver(() => this.#reconcile.schedule());
|
|
10511
10988
|
#mutationObserver = null;
|
|
10512
10989
|
/** Last published column count, so `layout` fires only on real changes. */
|
|
10513
10990
|
#lastColumns = 0;
|
|
@@ -10517,20 +10994,49 @@ var MasonryController = class extends Controller {
|
|
|
10517
10994
|
* first pass ran before they settled; `load` does not bubble, so this is bound in
|
|
10518
10995
|
* the capture phase to catch every descendant.
|
|
10519
10996
|
*/
|
|
10520
|
-
#onLoad = () => this.#
|
|
10997
|
+
#onLoad = () => this.#reconcile.schedule();
|
|
10998
|
+
/** Resolves the declared column width once, falling back when it is unreadable. */
|
|
10999
|
+
minColumnWidthValueChanged() {
|
|
11000
|
+
this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
|
|
11001
|
+
this.#reconcile.schedule();
|
|
11002
|
+
}
|
|
11003
|
+
/** Resolves the declared gap once, falling back when it is unreadable. */
|
|
11004
|
+
gapValueChanged() {
|
|
11005
|
+
this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);
|
|
11006
|
+
this.#reconcile.schedule();
|
|
11007
|
+
}
|
|
11008
|
+
/** Packs an element that became an item without moving in the DOM. */
|
|
11009
|
+
itemTargetConnected() {
|
|
11010
|
+
this.#reconcile.schedule();
|
|
11011
|
+
}
|
|
11012
|
+
/**
|
|
11013
|
+
* Queues the column hook of an element that stopped being an item for removal.
|
|
11014
|
+
*
|
|
11015
|
+
* The removal is queued rather than immediate because teardown reports every
|
|
11016
|
+
* target as disconnected: doing it here would strip the whole grid just before
|
|
11017
|
+
* a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
|
|
11018
|
+
* with the pass, so only a genuine target change reaches it.
|
|
11019
|
+
*/
|
|
11020
|
+
itemTargetDisconnected(item) {
|
|
11021
|
+
this.#released.add(item);
|
|
11022
|
+
this.#reconcile.schedule();
|
|
11023
|
+
}
|
|
10521
11024
|
/** Observes size/content changes and performs the first layout pass. */
|
|
10522
11025
|
connect() {
|
|
10523
11026
|
this.#layout.observe(this.element);
|
|
10524
11027
|
this.#layout.observeViewport();
|
|
10525
11028
|
if (typeof MutationObserver !== "undefined") {
|
|
10526
|
-
this.#mutationObserver = new MutationObserver(() => this.#
|
|
11029
|
+
this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
|
|
10527
11030
|
this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
|
|
10528
11031
|
}
|
|
10529
11032
|
this.element.addEventListener("load", this.#onLoad, true);
|
|
10530
11033
|
this.#relayout();
|
|
11034
|
+
this.#reconcile.activate();
|
|
10531
11035
|
}
|
|
10532
11036
|
/** Releases both observers and the load listener so nothing fires after detach. */
|
|
10533
11037
|
disconnect() {
|
|
11038
|
+
this.#reconcile.cancel();
|
|
11039
|
+
this.#released.clear();
|
|
10534
11040
|
this.#layout.disconnect();
|
|
10535
11041
|
this.#mutationObserver?.disconnect();
|
|
10536
11042
|
this.#mutationObserver = null;
|
|
@@ -10539,26 +11045,53 @@ var MasonryController = class extends Controller {
|
|
|
10539
11045
|
}
|
|
10540
11046
|
/**
|
|
10541
11047
|
* Recomputes the column count and assigns every item to the shortest column.
|
|
10542
|
-
* Runs automatically on connect, on resize, on item add/remove,
|
|
10543
|
-
* descendant resource loads (private — there is no
|
|
10544
|
-
*
|
|
10545
|
-
*
|
|
10546
|
-
*
|
|
11048
|
+
* Runs automatically on connect, on resize, on item add/remove, when a declared
|
|
11049
|
+
* number changes, and when a descendant resource loads (private — there is no
|
|
11050
|
+
* public action; the observers, the target callbacks and the capture-phase
|
|
11051
|
+
* `load` listener drive it). Items are walked in DOM order; each lands in the
|
|
11052
|
+
* column with the least accumulated height, which keeps the packing balanced
|
|
11053
|
+
* without reordering the DOM.
|
|
11054
|
+
*
|
|
11055
|
+
* Every box is measured before anything is written. Interleaving the two would
|
|
11056
|
+
* make a consumer's `data-column` rule invalidate style once per item, and the
|
|
11057
|
+
* next measurement then has to settle layout again — once per item instead of
|
|
11058
|
+
* once per pass. The assignment is independent of the measurement because the
|
|
11059
|
+
* columns are uniform in width, so the order of the two passes does not change
|
|
11060
|
+
* the result.
|
|
11061
|
+
*
|
|
11062
|
+
* @stimeoRenderRoot
|
|
10547
11063
|
*/
|
|
10548
11064
|
#relayout() {
|
|
10549
11065
|
const items = this.itemTargets;
|
|
10550
11066
|
const columns = this.#columnCount();
|
|
11067
|
+
const boxes = items.map((item) => item.getBoundingClientRect().height);
|
|
11068
|
+
let changed = false;
|
|
11069
|
+
if (this.#released.size > 0) {
|
|
11070
|
+
const owned = new Set(items);
|
|
11071
|
+
for (const released of this.#released) {
|
|
11072
|
+
if (owned.has(released)) continue;
|
|
11073
|
+
if (released.hasAttribute("data-column")) {
|
|
11074
|
+
released.removeAttribute("data-column");
|
|
11075
|
+
changed = true;
|
|
11076
|
+
}
|
|
11077
|
+
}
|
|
11078
|
+
this.#released.clear();
|
|
11079
|
+
}
|
|
10551
11080
|
const heights = new Array(columns).fill(0);
|
|
10552
|
-
|
|
11081
|
+
items.forEach((item, index) => {
|
|
10553
11082
|
let shortest = 0;
|
|
10554
11083
|
for (let col = 1; col < columns; col++) {
|
|
10555
11084
|
if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
|
|
10556
11085
|
}
|
|
10557
|
-
|
|
10558
|
-
|
|
10559
|
-
|
|
11086
|
+
const assigned = String(shortest);
|
|
11087
|
+
if (item.getAttribute("data-column") !== assigned) {
|
|
11088
|
+
item.setAttribute("data-column", assigned);
|
|
11089
|
+
changed = true;
|
|
11090
|
+
}
|
|
11091
|
+
heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;
|
|
11092
|
+
});
|
|
10560
11093
|
this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
|
|
10561
|
-
if (columns !== this.#lastColumns) {
|
|
11094
|
+
if (columns !== this.#lastColumns || changed) {
|
|
10562
11095
|
this.#lastColumns = columns;
|
|
10563
11096
|
this.dispatch("layout", { detail: { columns } });
|
|
10564
11097
|
}
|
|
@@ -10571,9 +11104,9 @@ var MasonryController = class extends Controller {
|
|
|
10571
11104
|
*/
|
|
10572
11105
|
#columnCount() {
|
|
10573
11106
|
const width = this.element.getBoundingClientRect().width;
|
|
10574
|
-
const denominator = this
|
|
11107
|
+
const denominator = this.#minColumnWidth + this.#gap;
|
|
10575
11108
|
if (width <= 0 || denominator <= 0) return 1;
|
|
10576
|
-
return Math.max(1, Math.floor((width + this
|
|
11109
|
+
return Math.max(1, Math.floor((width + this.#gap) / denominator));
|
|
10577
11110
|
}
|
|
10578
11111
|
};
|
|
10579
11112
|
var MenuController = class extends Controller {
|
|
@@ -11191,8 +11724,8 @@ var MenubarController = class extends Controller {
|
|
|
11191
11724
|
* being hidden itself, a menu can stay visible after its owning top was removed,
|
|
11192
11725
|
* an open pair can appear from a morph with no layer registered for it, and the
|
|
11193
11726
|
* Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
|
|
11194
|
-
* all.
|
|
11195
|
-
* this layer is on
|
|
11727
|
+
* all. The only state carried between calls is the tracked focus record and which side
|
|
11728
|
+
* of the Escape stack this layer is on (which the stack itself does not expose), so the outcome is the
|
|
11196
11729
|
* same whichever mutation arrived and calling it more often than needed is free.
|
|
11197
11730
|
*/
|
|
11198
11731
|
#reconcile() {
|
|
@@ -12306,7 +12839,7 @@ var MultiSelectController = class extends Controller {
|
|
|
12306
12839
|
get #values() {
|
|
12307
12840
|
return this.#selectedOptions.map((option) => this.#optionValue(option));
|
|
12308
12841
|
}
|
|
12309
|
-
/** Normalized cardinality cap: zero
|
|
12842
|
+
/** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
|
|
12310
12843
|
get #selectionLimit() {
|
|
12311
12844
|
if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
|
|
12312
12845
|
return Math.max(1, Math.floor(this.maxValue));
|
|
@@ -13611,6 +14144,9 @@ function compilePattern(source) {
|
|
|
13611
14144
|
function hasModifier2(event) {
|
|
13612
14145
|
return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
13613
14146
|
}
|
|
14147
|
+
function statesDiffer(left, right) {
|
|
14148
|
+
return left.value !== right.value || left.state !== right.state;
|
|
14149
|
+
}
|
|
13614
14150
|
var OtpController = class extends Controller {
|
|
13615
14151
|
static targets = ["field", "value", "error"];
|
|
13616
14152
|
static values = {
|
|
@@ -13622,8 +14158,8 @@ var OtpController = class extends Controller {
|
|
|
13622
14158
|
#pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
|
|
13623
14159
|
/** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
|
|
13624
14160
|
#patternSource = DEFAULT_PATTERN;
|
|
13625
|
-
/**
|
|
13626
|
-
#
|
|
14161
|
+
/** Public state carried by the last dispatch; keeps a no-op sync silent. */
|
|
14162
|
+
#published = null;
|
|
13627
14163
|
/** Field whose confirming `input` after `compositionend` is already handled. */
|
|
13628
14164
|
#confirmedField = null;
|
|
13629
14165
|
/** True between connect and disconnect, so pre-connect Value changes stay silent. */
|
|
@@ -13659,7 +14195,7 @@ var OtpController = class extends Controller {
|
|
|
13659
14195
|
this.#beforeCache.activate();
|
|
13660
14196
|
this.#reconcile.activate();
|
|
13661
14197
|
this.#adopt();
|
|
13662
|
-
this.#
|
|
14198
|
+
this.#sync();
|
|
13663
14199
|
}
|
|
13664
14200
|
disconnect() {
|
|
13665
14201
|
this.#connected = false;
|
|
@@ -13851,19 +14387,23 @@ var OtpController = class extends Controller {
|
|
|
13851
14387
|
this.#markFilled(field, field.value);
|
|
13852
14388
|
}
|
|
13853
14389
|
/**
|
|
13854
|
-
* Absorbs a batch of field additions or removals as one
|
|
14390
|
+
* Absorbs a batch of field additions or removals as one state transition.
|
|
13855
14391
|
*
|
|
13856
|
-
* The page, not the user, moved the
|
|
14392
|
+
* The page, not the user, moved the state here, so it is reported as
|
|
13857
14393
|
* `reconcile`: automation listening for `change` must not read a re-render as
|
|
13858
14394
|
* an edit, and a passcode that happens to end up full must not fire the
|
|
13859
14395
|
* `complete` that submits it.
|
|
14396
|
+
*
|
|
14397
|
+
* Completeness moves on its own when the field count changes: dropping a
|
|
14398
|
+
* trailing empty field completes a passcode whose combined value never moved,
|
|
14399
|
+
* and adding one un-completes it. Comparing the whole derived state, not the
|
|
14400
|
+
* string it contains, is what makes those transitions reportable.
|
|
13860
14401
|
*/
|
|
13861
14402
|
#reconcileFields() {
|
|
13862
|
-
const previous = this.#
|
|
13863
|
-
const
|
|
13864
|
-
if (
|
|
13865
|
-
this
|
|
13866
|
-
this.dispatch("reconcile", { detail: { value: combined } });
|
|
14403
|
+
const previous = this.#published;
|
|
14404
|
+
const current = this.#sync();
|
|
14405
|
+
if (previous && !statesDiffer(previous, current)) return;
|
|
14406
|
+
this.dispatch("reconcile", { detail: { value: current.value } });
|
|
13867
14407
|
}
|
|
13868
14408
|
/**
|
|
13869
14409
|
* Validates the text an entry point received and distributes what it accepts.
|
|
@@ -13958,23 +14498,29 @@ var OtpController = class extends Controller {
|
|
|
13958
14498
|
const fields = this.fieldTargets;
|
|
13959
14499
|
return fields.length > 0 && fields.every((field) => field.value.length > 0);
|
|
13960
14500
|
}
|
|
13961
|
-
/**
|
|
14501
|
+
/**
|
|
14502
|
+
* Mirrors the combined value into the form and the root's readable state, and
|
|
14503
|
+
* records what was published so the next pass can compare against it.
|
|
14504
|
+
*/
|
|
13962
14505
|
#sync() {
|
|
13963
14506
|
const combined = this.#combinedValue();
|
|
13964
14507
|
if (this.hasValueTarget) {
|
|
13965
14508
|
this.valueTarget.value = combined;
|
|
13966
14509
|
}
|
|
13967
|
-
|
|
13968
|
-
|
|
14510
|
+
const state = this.#stateName(combined);
|
|
14511
|
+
this.#state.write(this.element, state);
|
|
14512
|
+
const published = { value: combined, state };
|
|
14513
|
+
this.#published = published;
|
|
14514
|
+
return published;
|
|
13969
14515
|
}
|
|
13970
14516
|
#stateName(combined) {
|
|
13971
14517
|
if (combined.length === 0) return "empty";
|
|
13972
14518
|
return this.#isComplete() ? "complete" : "partial";
|
|
13973
14519
|
}
|
|
13974
14520
|
#syncAndDispatch() {
|
|
13975
|
-
const
|
|
13976
|
-
|
|
13977
|
-
|
|
14521
|
+
const previous = this.#published;
|
|
14522
|
+
const { value: combined } = this.#sync();
|
|
14523
|
+
if (previous?.value === combined) return;
|
|
13978
14524
|
this.dispatch("change", { detail: { value: combined } });
|
|
13979
14525
|
if (this.#isComplete()) {
|
|
13980
14526
|
this.dispatch("complete", { detail: { value: combined } });
|
|
@@ -14448,8 +14994,8 @@ var OverflowMenuController = class extends Controller {
|
|
|
14448
14994
|
*
|
|
14449
14995
|
* With no known child at all (a fresh instance connecting to markup that already
|
|
14450
14996
|
* holds banked items), a fully-banked snapshot's inert boundary preserves whether an
|
|
14451
|
-
* unindexed run was prepended or appended before connect.
|
|
14452
|
-
*
|
|
14997
|
+
* unindexed run was prepended or appended before connect. Markup that holds banked items but no boundary —
|
|
14998
|
+
* server-rendered or hand-authored — uses the saved index as the
|
|
14453
14999
|
* offset, the inverse of the move that banked it.
|
|
14454
15000
|
*/
|
|
14455
15001
|
#merge(bar, banked, boundaryAt) {
|
|
@@ -14861,6 +15407,7 @@ var PaginationController = class _PaginationController extends Controller {
|
|
|
14861
15407
|
return button.hasAttribute(_PaginationController.#BOUNDARY_ATTR);
|
|
14862
15408
|
}
|
|
14863
15409
|
};
|
|
15410
|
+
var MAX_DELAY = 2 ** 31 - 1;
|
|
14864
15411
|
var PasswordRevealController = class extends Controller {
|
|
14865
15412
|
static targets = ["input", "toggle"];
|
|
14866
15413
|
static values = {
|
|
@@ -14870,11 +15417,54 @@ var PasswordRevealController = class extends Controller {
|
|
|
14870
15417
|
static events = ["toggle"];
|
|
14871
15418
|
/** Auto re-mask timer; torn down on disconnect. */
|
|
14872
15419
|
#timers = new SafeTimeout();
|
|
15420
|
+
/**
|
|
15421
|
+
* Whether this controller is between `connect()` and `disconnect()`.
|
|
15422
|
+
*
|
|
15423
|
+
* Target callbacks outlive the controller: Stimulus stops the target observer
|
|
15424
|
+
* after `disconnect()`, so a field leaving after teardown still reaches
|
|
15425
|
+
* {@link PasswordRevealController.inputTargetDisconnected}. Arming from there
|
|
15426
|
+
* would put a timer back that nothing will clear. The element staying in the
|
|
15427
|
+
* document does not answer this — unloading the controller leaves it there.
|
|
15428
|
+
*/
|
|
15429
|
+
#connected = false;
|
|
15430
|
+
/** Masks the field before Turbo copies the page into its snapshot. */
|
|
15431
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindToMasked());
|
|
14873
15432
|
connect() {
|
|
14874
|
-
this.#
|
|
15433
|
+
this.#connected = true;
|
|
15434
|
+
const visible = this.#isVisible;
|
|
15435
|
+
this.#reflect(visible);
|
|
15436
|
+
this.#beforeCache.activate();
|
|
15437
|
+
this.#arm(visible);
|
|
14875
15438
|
}
|
|
14876
15439
|
disconnect() {
|
|
15440
|
+
this.#connected = false;
|
|
14877
15441
|
this.#timers.clearAll();
|
|
15442
|
+
this.#beforeCache.deactivate();
|
|
15443
|
+
}
|
|
15444
|
+
/** Re-derives the hooks and the re-mask for a field swapped in after connect. */
|
|
15445
|
+
inputTargetConnected() {
|
|
15446
|
+
const visible = this.#connected && this.#isVisible;
|
|
15447
|
+
this.#reflect(visible);
|
|
15448
|
+
this.#arm(visible);
|
|
15449
|
+
}
|
|
15450
|
+
/**
|
|
15451
|
+
* Re-derives from whatever field is left rather than assuming none is. A swap
|
|
15452
|
+
* delivers this callback next to the arrival in either order, so a revealed
|
|
15453
|
+
* replacement that answered "masked" here would be described as hidden while
|
|
15454
|
+
* showing the password, and would carry no re-mask.
|
|
15455
|
+
*
|
|
15456
|
+
* Teardown is the one case with nothing to derive: target callbacks run after
|
|
15457
|
+
* `disconnect()`, so the field is still revealed and re-arming from it would
|
|
15458
|
+
* outlive the controller. A detached root is the signal to stand down.
|
|
15459
|
+
*/
|
|
15460
|
+
inputTargetDisconnected() {
|
|
15461
|
+
const visible = this.#connected && this.element.isConnected && this.#isVisible;
|
|
15462
|
+
this.#reflect(visible);
|
|
15463
|
+
this.#arm(visible);
|
|
15464
|
+
}
|
|
15465
|
+
/** Re-derives the pressed state for a button swapped in after connect. */
|
|
15466
|
+
toggleTargetConnected() {
|
|
15467
|
+
this.#reflect(this.#isVisible);
|
|
14878
15468
|
}
|
|
14879
15469
|
/** Toggles the input between masked and revealed. Bound via `data-action`. */
|
|
14880
15470
|
toggle() {
|
|
@@ -14903,11 +15493,37 @@ var PasswordRevealController = class extends Controller {
|
|
|
14903
15493
|
}
|
|
14904
15494
|
this.#reflect(visible);
|
|
14905
15495
|
this.dispatch("toggle", { detail: { visible } });
|
|
15496
|
+
this.#arm(visible);
|
|
15497
|
+
}
|
|
15498
|
+
/** Schedules the auto re-mask for a revealed field, replacing any pending one. */
|
|
15499
|
+
#arm(visible) {
|
|
14906
15500
|
this.#timers.clearAll();
|
|
14907
|
-
|
|
14908
|
-
|
|
15501
|
+
const delay = this.#autoHideDelay;
|
|
15502
|
+
if (visible && delay > 0) {
|
|
15503
|
+
this.#timers.set(() => this.#setVisible(false), delay);
|
|
14909
15504
|
}
|
|
14910
15505
|
}
|
|
15506
|
+
/**
|
|
15507
|
+
* The auto re-mask delay, held to what `setTimeout` can carry. Past that limit
|
|
15508
|
+
* a delay folds to zero, turning "keep it showing" into "hide it at once" —
|
|
15509
|
+
* the opposite of what the declaration asked for. A value that is no delay at
|
|
15510
|
+
* all stays out of the positive range {@link PasswordRevealController.#arm}
|
|
15511
|
+
* requires, so it schedules nothing.
|
|
15512
|
+
*/
|
|
15513
|
+
get #autoHideDelay() {
|
|
15514
|
+
return Math.min(this.autoHideValue, MAX_DELAY);
|
|
15515
|
+
}
|
|
15516
|
+
/**
|
|
15517
|
+
* Returns the field to masked before Turbo copies the page. Silent and
|
|
15518
|
+
* focus-free: the page is about to be frozen, so there is no one to tell and
|
|
15519
|
+
* nowhere for focus to go.
|
|
15520
|
+
*/
|
|
15521
|
+
#rewindToMasked() {
|
|
15522
|
+
this.#timers.clearAll();
|
|
15523
|
+
if (!this.hasInputTarget) return;
|
|
15524
|
+
this.inputTarget.type = "password";
|
|
15525
|
+
this.#reflect(false);
|
|
15526
|
+
}
|
|
14911
15527
|
/** Reflects the visible state onto `aria-pressed` and `data-state`. */
|
|
14912
15528
|
#reflect(visible) {
|
|
14913
15529
|
if (this.hasToggleTarget) {
|
|
@@ -14921,6 +15537,7 @@ var DEFAULT_LEVELS = ["weak", "fair", "good", "strong"];
|
|
|
14921
15537
|
var LENGTH_MILESTONES = [8, 12, 16];
|
|
14922
15538
|
var MAX_POINTS = LENGTH_MILESTONES.length + (CLASS_PATTERNS.length - 1);
|
|
14923
15539
|
var STRENGTH_BANDS = ["weak", "fair", "good", "strong"];
|
|
15540
|
+
var MIN_LEVELS = 2;
|
|
14924
15541
|
var PasswordStrengthController = class _PasswordStrengthController extends Controller {
|
|
14925
15542
|
static targets = ["input", "meter", "label"];
|
|
14926
15543
|
static values = {
|
|
@@ -14928,50 +15545,143 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
14928
15545
|
// A JSON list read through `parseStringList` rather than Stimulus's `Array`
|
|
14929
15546
|
// type: that reader throws out of the value observer before any callback
|
|
14930
15547
|
// runs, so one malformed attribute would stop the meter connecting.
|
|
14931
|
-
levels: { type: String, default: "" }
|
|
15548
|
+
levels: { type: String, default: "" },
|
|
15549
|
+
announceText: { type: String, default: "" }
|
|
14932
15550
|
};
|
|
14933
|
-
static actions = ["evaluate"];
|
|
14934
|
-
static events = ["change"];
|
|
14935
|
-
/** Delay (ms) before
|
|
15551
|
+
static actions = ["evaluate", "setScore"];
|
|
15552
|
+
static events = ["change", "reconcile"];
|
|
15553
|
+
/** Delay (ms) before one settled level is sent to the shared announcer. */
|
|
14936
15554
|
static #announceDelay = 200;
|
|
14937
15555
|
#timers = new SafeTimeout();
|
|
15556
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
15557
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcile());
|
|
15558
|
+
#levels = [...DEFAULT_LEVELS];
|
|
14938
15559
|
#announceId = null;
|
|
15560
|
+
#announcedLevel = null;
|
|
15561
|
+
#externalScore = null;
|
|
15562
|
+
#lastDetail = null;
|
|
15563
|
+
/** Reflects the current DOM state and opens the reconciliation window. */
|
|
14939
15564
|
connect() {
|
|
14940
|
-
this.#
|
|
15565
|
+
this.#repaint.activate();
|
|
15566
|
+
this.#beforeCache.activate();
|
|
15567
|
+
this.#lastDetail = this.#detail(this.#render());
|
|
14941
15568
|
}
|
|
15569
|
+
/** Releases the reconciliation window, the cache subscription and the debounce. */
|
|
14942
15570
|
disconnect() {
|
|
14943
|
-
this.#
|
|
14944
|
-
this.#
|
|
15571
|
+
this.#repaint.cancel();
|
|
15572
|
+
this.#beforeCache.deactivate();
|
|
15573
|
+
this.#cancelAnnouncement();
|
|
15574
|
+
this.#announcedLevel = null;
|
|
15575
|
+
this.#externalScore = null;
|
|
15576
|
+
this.#lastDetail = null;
|
|
14945
15577
|
}
|
|
14946
15578
|
/** Re-evaluates strength from the input. Bound via `data-action` (`input`). */
|
|
14947
15579
|
evaluate() {
|
|
14948
|
-
this.#
|
|
15580
|
+
this.#externalScore = null;
|
|
15581
|
+
this.#commit();
|
|
15582
|
+
}
|
|
15583
|
+
/**
|
|
15584
|
+
* Adopts a score computed outside the built-in heuristic, clamped into the
|
|
15585
|
+
* declared scale. An unreadable value leaves the current score standing, and
|
|
15586
|
+
* the next `evaluate` hands scoring back to the heuristic.
|
|
15587
|
+
*/
|
|
15588
|
+
setScore(event) {
|
|
15589
|
+
const next = toFiniteNumber(event.params?.score ?? event.detail?.score);
|
|
15590
|
+
if (next === null) return;
|
|
15591
|
+
this.#externalScore = next;
|
|
15592
|
+
this.#commit();
|
|
15593
|
+
}
|
|
15594
|
+
/** Re-reads the scale when application code or a Turbo morph changes `levels`. */
|
|
15595
|
+
levelsValueChanged() {
|
|
15596
|
+
this.#levels = this.#readLevels();
|
|
15597
|
+
this.#repaint.schedule();
|
|
15598
|
+
}
|
|
15599
|
+
/** Repaints when application code or a Turbo morph changes `minScore`. */
|
|
15600
|
+
minScoreValueChanged() {
|
|
15601
|
+
this.#repaint.schedule();
|
|
15602
|
+
}
|
|
15603
|
+
/** Repaints when application code or a Turbo morph changes `announceText`. */
|
|
15604
|
+
announceTextValueChanged() {
|
|
15605
|
+
this.#repaint.schedule();
|
|
15606
|
+
}
|
|
15607
|
+
/** Scores the field added or replaced at runtime. */
|
|
15608
|
+
inputTargetConnected() {
|
|
15609
|
+
this.#repaint.schedule();
|
|
15610
|
+
}
|
|
15611
|
+
/** Falls back to the pristine state after the scored field is removed. */
|
|
15612
|
+
inputTargetDisconnected() {
|
|
15613
|
+
this.#repaint.schedule();
|
|
15614
|
+
}
|
|
15615
|
+
/** Syncs a meter added or replaced at runtime, which carries no value yet. */
|
|
15616
|
+
meterTargetConnected() {
|
|
15617
|
+
this.#repaint.schedule();
|
|
15618
|
+
}
|
|
15619
|
+
/** Repaints the remaining output after a meter is removed. */
|
|
15620
|
+
meterTargetDisconnected() {
|
|
15621
|
+
this.#repaint.schedule();
|
|
15622
|
+
}
|
|
15623
|
+
/** Fills a readout added or replaced at runtime. */
|
|
15624
|
+
labelTargetConnected() {
|
|
15625
|
+
this.#repaint.schedule();
|
|
15626
|
+
}
|
|
15627
|
+
/** Repaints the remaining output after a readout is removed. */
|
|
15628
|
+
labelTargetDisconnected() {
|
|
15629
|
+
this.#repaint.schedule();
|
|
15630
|
+
}
|
|
15631
|
+
/** Reflects one confirmed scoring pass and offers its level to the reader. */
|
|
15632
|
+
#commit() {
|
|
15633
|
+
const reading = this.#render();
|
|
15634
|
+
this.#lastDetail = this.#detail(reading);
|
|
15635
|
+
this.dispatch("change", { detail: this.#lastDetail });
|
|
15636
|
+
this.#scheduleAnnouncement(reading);
|
|
15637
|
+
}
|
|
15638
|
+
/** Repaints one mutation batch and reports a changed controller-derived state. */
|
|
15639
|
+
#reconcile() {
|
|
15640
|
+
const previous = this.#lastDetail;
|
|
15641
|
+
const owed = this.#announceId !== null;
|
|
15642
|
+
this.#cancelAnnouncement();
|
|
15643
|
+
const reading = this.#render();
|
|
15644
|
+
const detail = this.#detail(reading);
|
|
15645
|
+
this.#lastDetail = detail;
|
|
15646
|
+
if (reading.score === 0) this.#announcedLevel = null;
|
|
15647
|
+
if (owed) this.#scheduleAnnouncement(reading);
|
|
15648
|
+
if (previous && this.#detailsDiffer(previous, detail)) {
|
|
15649
|
+
this.dispatch("reconcile", { detail });
|
|
15650
|
+
}
|
|
14949
15651
|
}
|
|
14950
15652
|
/**
|
|
14951
|
-
*
|
|
14952
|
-
*
|
|
14953
|
-
*
|
|
15653
|
+
* Synchronizes the meter ARIA, the root state hooks, the fill custom property
|
|
15654
|
+
* and the visible level readout.
|
|
15655
|
+
*
|
|
15656
|
+
* @stimeoRenderRoot
|
|
14954
15657
|
*/
|
|
14955
|
-
#
|
|
14956
|
-
const
|
|
14957
|
-
const
|
|
14958
|
-
const
|
|
14959
|
-
const
|
|
14960
|
-
const
|
|
15658
|
+
#render() {
|
|
15659
|
+
const max = this.#levels.length;
|
|
15660
|
+
const score = this.#currentScore(max);
|
|
15661
|
+
const level = this.#levels[score - 1] ?? "";
|
|
15662
|
+
const meetsMin = score > 0 && score >= this.#minScore;
|
|
15663
|
+
const band = this.#band(score, max);
|
|
14961
15664
|
this.#reflectMeter(score, max);
|
|
14962
|
-
this.#reflectRoot(score, max);
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
15665
|
+
this.#reflectRoot(score, max, band, meetsMin);
|
|
15666
|
+
this.#writeLabel(level);
|
|
15667
|
+
return { score, level, max, meetsMin, band };
|
|
15668
|
+
}
|
|
15669
|
+
/**
|
|
15670
|
+
* The declared minimum, or the Value's default (`0`, an inert gate) when the
|
|
15671
|
+
* declaration cannot be read as a number. Every comparison against an unread
|
|
15672
|
+
* number answers false, which would fail even the strongest password instead
|
|
15673
|
+
* of leaving the gate off, so the unreadable declaration is confined to its
|
|
15674
|
+
* own Value the way an unreadable scale is.
|
|
15675
|
+
*/
|
|
15676
|
+
get #minScore() {
|
|
15677
|
+
return Number.isFinite(this.minScoreValue) ? this.minScoreValue : 0;
|
|
15678
|
+
}
|
|
15679
|
+
/** The externally supplied score when one stands, else the heuristic's. */
|
|
15680
|
+
#currentScore(max) {
|
|
15681
|
+
if (this.#externalScore !== null) {
|
|
15682
|
+
return Math.min(max, Math.max(0, Math.round(this.#externalScore)));
|
|
14966
15683
|
}
|
|
14967
|
-
this.
|
|
14968
|
-
detail: { score, level: label, max, meetsMin: score > 0 && score >= this.minScoreValue }
|
|
14969
|
-
});
|
|
14970
|
-
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
14971
|
-
this.#announceId = this.#timers.set(() => {
|
|
14972
|
-
this.#writeLabel(label);
|
|
14973
|
-
this.#announceId = null;
|
|
14974
|
-
}, _PasswordStrengthController.#announceDelay);
|
|
15684
|
+
return this.#score(this.hasInputTarget ? this.inputTarget.value : "", max);
|
|
14975
15685
|
}
|
|
14976
15686
|
/** Syncs the meter target's ARIA value attributes (`0..levels.length`). */
|
|
14977
15687
|
#reflectMeter(score, max) {
|
|
@@ -14985,25 +15695,27 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
14985
15695
|
* empty), the `data-below-min` hook when the score is under `minScore`, and the
|
|
14986
15696
|
* `0–1` fill the consumer's CSS turns into the bar width.
|
|
14987
15697
|
*/
|
|
14988
|
-
#reflectRoot(score, max) {
|
|
14989
|
-
const band = this.#band(score, max);
|
|
15698
|
+
#reflectRoot(score, max, band, meetsMin) {
|
|
14990
15699
|
this.#toggle("data-strength", band, band.length > 0);
|
|
14991
|
-
this.#toggle("data-below-min", "true", score > 0 &&
|
|
14992
|
-
|
|
14993
|
-
this.element.style.setProperty("--stimeo--password-strength", String(ratio));
|
|
15700
|
+
this.#toggle("data-below-min", "true", score > 0 && !meetsMin);
|
|
15701
|
+
this.element.style.setProperty("--stimeo--password-strength", String(score / max));
|
|
14994
15702
|
}
|
|
14995
|
-
|
|
14996
|
-
|
|
15703
|
+
/** Writes the visible readout only when its text actually changed. */
|
|
15704
|
+
#writeLabel(text) {
|
|
15705
|
+
if (!this.hasLabelTarget || this.labelTarget.textContent === text) return;
|
|
15706
|
+
this.labelTarget.textContent = text;
|
|
14997
15707
|
}
|
|
14998
15708
|
/**
|
|
14999
15709
|
* Locale-independent styling band (one of {@link STRENGTH_BANDS}) for `score`
|
|
15000
|
-
* out of `max`. Empty input → `""`.
|
|
15001
|
-
*
|
|
15710
|
+
* out of `max`. Empty input → `""`. Maps the position within the declared scale
|
|
15711
|
+
* onto the position within the fixed bands, so both ends stay anchored on any
|
|
15712
|
+
* level count: the weakest score is always `weak` and the strongest `strong`,
|
|
15713
|
+
* and only the middle collapses when the scales differ in size.
|
|
15002
15714
|
*/
|
|
15003
15715
|
#band(score, max) {
|
|
15004
|
-
if (score <= 0
|
|
15005
|
-
const index = Math.
|
|
15006
|
-
return STRENGTH_BANDS[
|
|
15716
|
+
if (score <= 0) return "";
|
|
15717
|
+
const index = Math.round((score - 1) / (max - 1) * (STRENGTH_BANDS.length - 1));
|
|
15718
|
+
return STRENGTH_BANDS[index] ?? STRENGTH_BANDS[0];
|
|
15007
15719
|
}
|
|
15008
15720
|
/** Sets `name` to `value` when `on`, else removes it (value/presence data hook). */
|
|
15009
15721
|
#toggle(name, value, on) {
|
|
@@ -15013,6 +15725,11 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
15013
15725
|
this.element.removeAttribute(name);
|
|
15014
15726
|
}
|
|
15015
15727
|
}
|
|
15728
|
+
/** The declared level labels, or the defaults when the scale cannot order. */
|
|
15729
|
+
#readLevels() {
|
|
15730
|
+
const declared = parseStringList(this.levelsValue, DEFAULT_LEVELS);
|
|
15731
|
+
return declared.length >= MIN_LEVELS ? declared : [...DEFAULT_LEVELS];
|
|
15732
|
+
}
|
|
15016
15733
|
/**
|
|
15017
15734
|
* Lightweight zero-dependency strength heuristic returning an integer in
|
|
15018
15735
|
* `[0, max]` (`max` = number of levels). Empty input is `0` (no level); any
|
|
@@ -15022,7 +15739,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
15022
15739
|
* alone cannot mask trivial repetition.
|
|
15023
15740
|
*/
|
|
15024
15741
|
#score(password, max) {
|
|
15025
|
-
if (password.length === 0
|
|
15742
|
+
if (password.length === 0) return 0;
|
|
15026
15743
|
let points = 0;
|
|
15027
15744
|
for (const milestone of LENGTH_MILESTONES) {
|
|
15028
15745
|
if (password.length >= milestone) points += 1;
|
|
@@ -15032,6 +15749,64 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
15032
15749
|
const bucketed = Math.round(points / MAX_POINTS * max);
|
|
15033
15750
|
return Math.min(max, Math.max(1, bucketed));
|
|
15034
15751
|
}
|
|
15752
|
+
/** Debounces one i18n-neutral message for a level transition into the announcer. */
|
|
15753
|
+
#scheduleAnnouncement(reading) {
|
|
15754
|
+
this.#cancelAnnouncement();
|
|
15755
|
+
if (reading.score === 0) {
|
|
15756
|
+
this.#announcedLevel = null;
|
|
15757
|
+
return;
|
|
15758
|
+
}
|
|
15759
|
+
if (reading.level === this.#announcedLevel) return;
|
|
15760
|
+
const message = fillTemplate(this.announceTextValue, {
|
|
15761
|
+
level: reading.level,
|
|
15762
|
+
score: reading.score,
|
|
15763
|
+
max: reading.max,
|
|
15764
|
+
band: reading.band
|
|
15765
|
+
});
|
|
15766
|
+
if (message.trim().length === 0) return;
|
|
15767
|
+
this.#announceId = this.#timers.set(() => {
|
|
15768
|
+
this.#announcedLevel = reading.level;
|
|
15769
|
+
announce(message);
|
|
15770
|
+
this.#announceId = null;
|
|
15771
|
+
}, _PasswordStrengthController.#announceDelay);
|
|
15772
|
+
}
|
|
15773
|
+
/** Cancels the one outstanding message without touching visible output. */
|
|
15774
|
+
#cancelAnnouncement() {
|
|
15775
|
+
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
15776
|
+
this.#announceId = null;
|
|
15777
|
+
}
|
|
15778
|
+
/**
|
|
15779
|
+
* Returns every output derived from the field value to its pristine form.
|
|
15780
|
+
*
|
|
15781
|
+
* The value itself is not carried in a Turbo snapshot, so a band, a fill or a
|
|
15782
|
+
* readout left behind would describe a password the restored page no longer
|
|
15783
|
+
* holds. The pass is silent: `connect()` derives the state again from whatever
|
|
15784
|
+
* the restored field contains.
|
|
15785
|
+
*/
|
|
15786
|
+
#rewindForCache() {
|
|
15787
|
+
this.#cancelAnnouncement();
|
|
15788
|
+
this.#announcedLevel = null;
|
|
15789
|
+
this.#externalScore = null;
|
|
15790
|
+
this.#lastDetail = null;
|
|
15791
|
+
this.#reflectMeter(0, this.#levels.length);
|
|
15792
|
+
this.#toggle("data-strength", "", false);
|
|
15793
|
+
this.#toggle("data-below-min", "true", false);
|
|
15794
|
+
this.element.style.removeProperty("--stimeo--password-strength");
|
|
15795
|
+
this.#writeLabel("");
|
|
15796
|
+
}
|
|
15797
|
+
/** Selects the public event state from the richer internal reading. */
|
|
15798
|
+
#detail(reading) {
|
|
15799
|
+
return {
|
|
15800
|
+
score: reading.score,
|
|
15801
|
+
level: reading.level,
|
|
15802
|
+
max: reading.max,
|
|
15803
|
+
meetsMin: reading.meetsMin
|
|
15804
|
+
};
|
|
15805
|
+
}
|
|
15806
|
+
/** Compares exactly the state carried by `change` and `reconcile`. */
|
|
15807
|
+
#detailsDiffer(left, right) {
|
|
15808
|
+
return left.score !== right.score || left.level !== right.level || left.max !== right.max || left.meetsMin !== right.meetsMin;
|
|
15809
|
+
}
|
|
15035
15810
|
};
|
|
15036
15811
|
|
|
15037
15812
|
// src/utils/safe_storage.ts
|
|
@@ -16215,25 +16990,6 @@ var ProgressController = class extends Controller {
|
|
|
16215
16990
|
this.element.removeAttribute(OWNED_VALUE_TEXT2);
|
|
16216
16991
|
}
|
|
16217
16992
|
};
|
|
16218
|
-
|
|
16219
|
-
// src/utils/interactive_host.ts
|
|
16220
|
-
var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
|
|
16221
|
-
function isInteractiveHost(element) {
|
|
16222
|
-
if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
|
|
16223
|
-
let current = element;
|
|
16224
|
-
while (current) {
|
|
16225
|
-
const raw = current.getAttribute("contenteditable");
|
|
16226
|
-
if (raw !== null) {
|
|
16227
|
-
const value = raw.trim().toLowerCase();
|
|
16228
|
-
if (value === "false") return false;
|
|
16229
|
-
if (value === "" || value === "true" || value === "plaintext-only") return true;
|
|
16230
|
-
}
|
|
16231
|
-
current = current.parentElement;
|
|
16232
|
-
}
|
|
16233
|
-
return false;
|
|
16234
|
-
}
|
|
16235
|
-
|
|
16236
|
-
// src/controllers/radio_group_controller.ts
|
|
16237
16993
|
var OBSERVED_ATTRIBUTES4 = [
|
|
16238
16994
|
"aria-checked",
|
|
16239
16995
|
"aria-disabled",
|
|
@@ -16718,80 +17474,25 @@ var RadioGroupController = class extends Controller {
|
|
|
16718
17474
|
this.#markInternal(this.#internalCheckedValues, radio, value);
|
|
16719
17475
|
radio.setAttribute("aria-checked", value);
|
|
16720
17476
|
}
|
|
16721
|
-
/** Writes a roving value before the radio is visible to the shared primitive. */
|
|
16722
|
-
#writeTabindex(radio, value) {
|
|
16723
|
-
const serialized = String(value);
|
|
16724
|
-
this.#markInternal(this.#internalTabindexValues, radio, serialized);
|
|
16725
|
-
radio.tabIndex = value;
|
|
16726
|
-
}
|
|
16727
|
-
/** Mirrors the selected value or the empty state to the optional hidden field. */
|
|
16728
|
-
#reflectField(radio, { silent = false } = {}) {
|
|
16729
|
-
if (!this.hasFieldTarget) return;
|
|
16730
|
-
const value = radio ? this.#radioValue(radio) : "";
|
|
16731
|
-
if (this.fieldTarget.value === value) return;
|
|
16732
|
-
this.fieldTarget.value = value;
|
|
16733
|
-
if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
|
|
16734
|
-
}
|
|
16735
|
-
/** A radio's submitted value (`data-value`, defaulting to empty). */
|
|
16736
|
-
#radioValue(radio) {
|
|
16737
|
-
return radio.getAttribute("data-value") ?? "";
|
|
16738
|
-
}
|
|
16739
|
-
};
|
|
16740
|
-
|
|
16741
|
-
// src/utils/owned_pointer_session.ts
|
|
16742
|
-
var OwnedPointerSession = class {
|
|
16743
|
-
pointerId;
|
|
16744
|
-
#owner;
|
|
16745
|
-
#handlers;
|
|
16746
|
-
#abort = new AbortController();
|
|
16747
|
-
#active = true;
|
|
16748
|
-
constructor(start, owner, handlers) {
|
|
16749
|
-
this.pointerId = start.pointerId;
|
|
16750
|
-
this.#owner = owner;
|
|
16751
|
-
this.#handlers = handlers;
|
|
16752
|
-
const { signal } = this.#abort;
|
|
16753
|
-
owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
|
|
16754
|
-
owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
|
|
16755
|
-
owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
|
|
16756
|
-
owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
|
|
16757
|
-
try {
|
|
16758
|
-
owner.setPointerCapture?.(this.pointerId);
|
|
16759
|
-
} catch {
|
|
16760
|
-
}
|
|
16761
|
-
}
|
|
16762
|
-
/** Whether this session still owns its pointer and listeners. */
|
|
16763
|
-
get active() {
|
|
16764
|
-
return this.#active;
|
|
17477
|
+
/** Writes a roving value before the radio is visible to the shared primitive. */
|
|
17478
|
+
#writeTabindex(radio, value) {
|
|
17479
|
+
const serialized = String(value);
|
|
17480
|
+
this.#markInternal(this.#internalTabindexValues, radio, serialized);
|
|
17481
|
+
radio.tabIndex = value;
|
|
16765
17482
|
}
|
|
16766
|
-
/**
|
|
16767
|
-
|
|
16768
|
-
|
|
17483
|
+
/** Mirrors the selected value or the empty state to the optional hidden field. */
|
|
17484
|
+
#reflectField(radio, { silent = false } = {}) {
|
|
17485
|
+
if (!this.hasFieldTarget) return;
|
|
17486
|
+
const value = radio ? this.#radioValue(radio) : "";
|
|
17487
|
+
if (this.fieldTarget.value === value) return;
|
|
17488
|
+
this.fieldTarget.value = value;
|
|
17489
|
+
if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
|
|
16769
17490
|
}
|
|
16770
|
-
/**
|
|
16771
|
-
|
|
16772
|
-
|
|
16773
|
-
this.#active = false;
|
|
16774
|
-
this.#abort.abort();
|
|
16775
|
-
try {
|
|
16776
|
-
this.#owner.releasePointerCapture?.(this.pointerId);
|
|
16777
|
-
} catch {
|
|
16778
|
-
}
|
|
16779
|
-
this.#handlers.end?.();
|
|
17491
|
+
/** A radio's submitted value (`data-value`, defaulting to empty). */
|
|
17492
|
+
#radioValue(radio) {
|
|
17493
|
+
return radio.getAttribute("data-value") ?? "";
|
|
16780
17494
|
}
|
|
16781
|
-
#onMove = (event) => {
|
|
16782
|
-
if (this.owns(event)) this.#handlers.move(event);
|
|
16783
|
-
};
|
|
16784
|
-
#onEndEvent = (event) => {
|
|
16785
|
-
if (this.owns(event)) this.end();
|
|
16786
|
-
};
|
|
16787
|
-
#onLostCapture = (event) => {
|
|
16788
|
-
const pointerId = event.pointerId;
|
|
16789
|
-
if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
|
|
16790
|
-
this.end();
|
|
16791
|
-
};
|
|
16792
17495
|
};
|
|
16793
|
-
|
|
16794
|
-
// src/controllers/range_slider_controller.ts
|
|
16795
17496
|
var START_PROPERTY = "--stimeo--range-slider-start";
|
|
16796
17497
|
var END_PROPERTY = "--stimeo--range-slider-end";
|
|
16797
17498
|
var DEFAULT_MIN = 0;
|
|
@@ -17641,6 +18342,26 @@ var RelativeTimeController = class extends Controller {
|
|
|
17641
18342
|
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
17642
18343
|
}
|
|
17643
18344
|
};
|
|
18345
|
+
var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
|
|
18346
|
+
function restoreField(element) {
|
|
18347
|
+
if (element instanceof HTMLTextAreaElement) {
|
|
18348
|
+
element.value = element.defaultValue;
|
|
18349
|
+
return;
|
|
18350
|
+
}
|
|
18351
|
+
if (element instanceof HTMLSelectElement) {
|
|
18352
|
+
for (const option of element.options) option.selected = option.hasAttribute("selected");
|
|
18353
|
+
return;
|
|
18354
|
+
}
|
|
18355
|
+
if (element instanceof HTMLInputElement) {
|
|
18356
|
+
if (element.type === "checkbox" || element.type === "radio") {
|
|
18357
|
+
element.checked = element.defaultChecked;
|
|
18358
|
+
} else if (element.type === "file") {
|
|
18359
|
+
element.value = "";
|
|
18360
|
+
} else if (!STATELESS_INPUT_TYPES.has(element.type)) {
|
|
18361
|
+
element.value = element.defaultValue;
|
|
18362
|
+
}
|
|
18363
|
+
}
|
|
18364
|
+
}
|
|
17644
18365
|
var ResetBeforeCacheController = class extends Controller {
|
|
17645
18366
|
static values = {
|
|
17646
18367
|
scope: { type: String, default: "" },
|
|
@@ -17648,8 +18369,32 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
17648
18369
|
};
|
|
17649
18370
|
static actions = ["reset"];
|
|
17650
18371
|
static events = ["reset", "request"];
|
|
18372
|
+
/** The `scope` declaration after validation; empty when it cannot be parsed. */
|
|
18373
|
+
#scopeSelector = "";
|
|
17651
18374
|
/** Runs the reset just before Turbo caches the snapshot. */
|
|
17652
18375
|
#onBeforeCache = () => this.reset();
|
|
18376
|
+
/**
|
|
18377
|
+
* Validates the scope declaration once, keeping only a selector the engine can
|
|
18378
|
+
* read.
|
|
18379
|
+
*
|
|
18380
|
+
* A selector reads back as an ordinary string, so a malformed one survives
|
|
18381
|
+
* until it is handed to the DOM — and this part runs from a single listener
|
|
18382
|
+
* whose whole job is to keep a cached page from freezing. Falling back to the
|
|
18383
|
+
* default keeps that job running with a visible, findable result instead of
|
|
18384
|
+
* silently taking the sweep down.
|
|
18385
|
+
*/
|
|
18386
|
+
scopeValueChanged() {
|
|
18387
|
+
const selector = this.scopeValue;
|
|
18388
|
+
if (selector.length > 0) {
|
|
18389
|
+
try {
|
|
18390
|
+
this.element.matches(selector);
|
|
18391
|
+
this.#scopeSelector = selector;
|
|
18392
|
+
return;
|
|
18393
|
+
} catch {
|
|
18394
|
+
}
|
|
18395
|
+
}
|
|
18396
|
+
this.#scopeSelector = "";
|
|
18397
|
+
}
|
|
17653
18398
|
connect() {
|
|
17654
18399
|
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
17655
18400
|
}
|
|
@@ -17660,6 +18405,10 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
17660
18405
|
* Resets transient UI within scope to its initial state. Asks controllers to
|
|
17661
18406
|
* close (via `request`) first, then applies the declarative `data-reset-*` cleanup,
|
|
17662
18407
|
* and finally emits `reset`. Safe to call any number of times (idempotent).
|
|
18408
|
+
*
|
|
18409
|
+
* `dispatchReset` decides only whether the `request` ask goes out; the cleanup
|
|
18410
|
+
* and the closing `reset` run either way. Both the listener and this action
|
|
18411
|
+
* reach the same sweep, so `reset` is emitted for a manual call too.
|
|
17663
18412
|
*/
|
|
17664
18413
|
reset() {
|
|
17665
18414
|
const root = this.#scopeRoot();
|
|
@@ -17678,9 +18427,7 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
17678
18427
|
if (element instanceof HTMLFormElement) element.reset();
|
|
17679
18428
|
}
|
|
17680
18429
|
for (const element of root.querySelectorAll("[data-reset-value]")) {
|
|
17681
|
-
|
|
17682
|
-
element.value = "";
|
|
17683
|
-
}
|
|
18430
|
+
restoreField(element);
|
|
17684
18431
|
}
|
|
17685
18432
|
for (const element of root.querySelectorAll("[data-reset-hidden]")) {
|
|
17686
18433
|
element.hidden = true;
|
|
@@ -17692,30 +18439,38 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
17692
18439
|
}
|
|
17693
18440
|
/** The scan root: a `scope` descendant when set, else the controller element. */
|
|
17694
18441
|
#scopeRoot() {
|
|
17695
|
-
if (!this
|
|
17696
|
-
return this.element.querySelector(this
|
|
18442
|
+
if (!this.#scopeSelector) return this.element;
|
|
18443
|
+
return this.element.querySelector(this.#scopeSelector) ?? this.element;
|
|
17697
18444
|
}
|
|
17698
18445
|
};
|
|
18446
|
+
var DEFAULT_MIN2 = 0;
|
|
18447
|
+
var DEFAULT_MAX2 = 100;
|
|
18448
|
+
var DEFAULT_VALUE = 50;
|
|
18449
|
+
var DEFAULT_STEP = 1;
|
|
17699
18450
|
var ResizableController = class extends Controller {
|
|
17700
18451
|
static targets = ["primary", "secondary", "separator"];
|
|
17701
18452
|
static values = {
|
|
17702
|
-
min: { type: Number, default:
|
|
17703
|
-
max: { type: Number, default:
|
|
17704
|
-
step: { type: Number, default:
|
|
17705
|
-
value: { type: Number, default:
|
|
18453
|
+
min: { type: Number, default: DEFAULT_MIN2 },
|
|
18454
|
+
max: { type: Number, default: DEFAULT_MAX2 },
|
|
18455
|
+
step: { type: Number, default: DEFAULT_STEP },
|
|
18456
|
+
value: { type: Number, default: DEFAULT_VALUE }
|
|
17706
18457
|
};
|
|
17707
18458
|
static actions = ["onKeydown", "onPointerDown", "toggle"];
|
|
17708
18459
|
static events = ["change"];
|
|
17709
|
-
/**
|
|
17710
|
-
#valueBeforeCollapse =
|
|
18460
|
+
/** Where the divider sat before the current collapse; `null` when unknown. */
|
|
18461
|
+
#valueBeforeCollapse = null;
|
|
17711
18462
|
/** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
|
|
17712
18463
|
#dragAbort = null;
|
|
17713
18464
|
/** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
|
|
17714
18465
|
#paneTabindex = new TabindexLoan();
|
|
17715
18466
|
/** Releases the pane cycle listener bound in {@link connect}. */
|
|
17716
18467
|
#cycleAbort = null;
|
|
18468
|
+
/** Folds the range and position inputs of one morph batch into a single paint. */
|
|
18469
|
+
#repaint = new MicrotaskCoalescer(() => this.#render());
|
|
17717
18470
|
connect() {
|
|
17718
|
-
this
|
|
18471
|
+
this.element.removeAttribute("data-dragging");
|
|
18472
|
+
this.#repaint.activate();
|
|
18473
|
+
this.#render();
|
|
17719
18474
|
this.#cycleAbort = new AbortController();
|
|
17720
18475
|
this.element.addEventListener("keydown", this.#onCycleKeydown, {
|
|
17721
18476
|
signal: this.#cycleAbort.signal
|
|
@@ -17727,17 +18482,19 @@ var ResizableController = class extends Controller {
|
|
|
17727
18482
|
this.#dragAbort = null;
|
|
17728
18483
|
this.#cycleAbort?.abort();
|
|
17729
18484
|
this.#cycleAbort = null;
|
|
18485
|
+
this.#repaint.cancel();
|
|
17730
18486
|
this.#paneTabindex.returnAll();
|
|
18487
|
+
this.element.removeAttribute("data-dragging");
|
|
17731
18488
|
}
|
|
17732
18489
|
/** Moves focus to the next pane on `F6`, wrapping; entering at the first one. */
|
|
17733
18490
|
#onCycleKeydown = (event) => {
|
|
17734
18491
|
if (event.key !== "F6") return;
|
|
18492
|
+
if (event.isComposing) return;
|
|
17735
18493
|
if (event.defaultPrevented) return;
|
|
17736
18494
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
17737
18495
|
const panes = [];
|
|
17738
18496
|
if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
|
|
17739
18497
|
if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
|
|
17740
|
-
if (panes.length === 0) return;
|
|
17741
18498
|
const target = event.target;
|
|
17742
18499
|
const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
|
|
17743
18500
|
const next = panes[(current + 1) % panes.length];
|
|
@@ -17746,12 +18503,21 @@ var ResizableController = class extends Controller {
|
|
|
17746
18503
|
this.#paneTabindex.lend(next);
|
|
17747
18504
|
next.focus();
|
|
17748
18505
|
};
|
|
17749
|
-
/**
|
|
17750
|
-
* Stimulus lifecycle callback when the valueValue changes.
|
|
17751
|
-
* Keeps CSS fractions and ARIA status completely aligned.
|
|
17752
|
-
*/
|
|
18506
|
+
/** Re-renders when application code (or a Turbo morph) changes `value` at runtime. */
|
|
17753
18507
|
valueValueChanged() {
|
|
17754
|
-
this.#
|
|
18508
|
+
this.#repaint.schedule();
|
|
18509
|
+
}
|
|
18510
|
+
/** Re-renders when application code (or a Turbo morph) changes `min` at runtime. */
|
|
18511
|
+
minValueChanged() {
|
|
18512
|
+
this.#repaint.schedule();
|
|
18513
|
+
}
|
|
18514
|
+
/** Re-renders when application code (or a Turbo morph) changes `max` at runtime. */
|
|
18515
|
+
maxValueChanged() {
|
|
18516
|
+
this.#repaint.schedule();
|
|
18517
|
+
}
|
|
18518
|
+
/** Re-publishes range and position onto a separator swapped in after connect. */
|
|
18519
|
+
separatorTargetConnected() {
|
|
18520
|
+
this.#repaint.schedule();
|
|
17755
18521
|
}
|
|
17756
18522
|
/** Starts active pointer drag tracking and locks capture. */
|
|
17757
18523
|
onPointerDown(event) {
|
|
@@ -17772,44 +18538,44 @@ var ResizableController = class extends Controller {
|
|
|
17772
18538
|
onKeydown(event) {
|
|
17773
18539
|
if (isReservedArrowChord(event)) return;
|
|
17774
18540
|
if (!this.hasSeparatorTarget) return;
|
|
17775
|
-
const
|
|
17776
|
-
const isVertical =
|
|
18541
|
+
const { min, max } = this.#range;
|
|
18542
|
+
const isVertical = this.#isVertical;
|
|
17777
18543
|
let handled = true;
|
|
17778
|
-
let nextValue = this
|
|
18544
|
+
let nextValue = this.#position;
|
|
17779
18545
|
switch (event.key) {
|
|
17780
18546
|
case "ArrowLeft":
|
|
17781
18547
|
if (isVertical) {
|
|
17782
|
-
nextValue -= this
|
|
18548
|
+
nextValue -= this.#step;
|
|
17783
18549
|
} else {
|
|
17784
18550
|
handled = false;
|
|
17785
18551
|
}
|
|
17786
18552
|
break;
|
|
17787
18553
|
case "ArrowRight":
|
|
17788
18554
|
if (isVertical) {
|
|
17789
|
-
nextValue += this
|
|
18555
|
+
nextValue += this.#step;
|
|
17790
18556
|
} else {
|
|
17791
18557
|
handled = false;
|
|
17792
18558
|
}
|
|
17793
18559
|
break;
|
|
17794
18560
|
case "ArrowUp":
|
|
17795
18561
|
if (!isVertical) {
|
|
17796
|
-
nextValue -= this
|
|
18562
|
+
nextValue -= this.#step;
|
|
17797
18563
|
} else {
|
|
17798
18564
|
handled = false;
|
|
17799
18565
|
}
|
|
17800
18566
|
break;
|
|
17801
18567
|
case "ArrowDown":
|
|
17802
18568
|
if (!isVertical) {
|
|
17803
|
-
nextValue += this
|
|
18569
|
+
nextValue += this.#step;
|
|
17804
18570
|
} else {
|
|
17805
18571
|
handled = false;
|
|
17806
18572
|
}
|
|
17807
18573
|
break;
|
|
17808
18574
|
case "Home":
|
|
17809
|
-
nextValue =
|
|
18575
|
+
nextValue = min;
|
|
17810
18576
|
break;
|
|
17811
18577
|
case "End":
|
|
17812
|
-
nextValue =
|
|
18578
|
+
nextValue = max;
|
|
17813
18579
|
break;
|
|
17814
18580
|
case "Enter":
|
|
17815
18581
|
event.preventDefault();
|
|
@@ -17821,65 +18587,86 @@ var ResizableController = class extends Controller {
|
|
|
17821
18587
|
}
|
|
17822
18588
|
if (handled) {
|
|
17823
18589
|
event.preventDefault();
|
|
17824
|
-
this.valueValue = Math.max(
|
|
17825
|
-
this.#
|
|
18590
|
+
this.valueValue = Math.max(min, Math.min(nextValue, max));
|
|
18591
|
+
this.#render();
|
|
17826
18592
|
this.#dispatchChange();
|
|
17827
18593
|
}
|
|
17828
18594
|
}
|
|
17829
|
-
/**
|
|
18595
|
+
/** Collapses the primary pane to its minimum, or returns it to the last position. */
|
|
17830
18596
|
toggle() {
|
|
17831
|
-
const
|
|
17832
|
-
if (this
|
|
17833
|
-
this.#valueBeforeCollapse = this
|
|
17834
|
-
this.valueValue =
|
|
18597
|
+
const { min, max } = this.#range;
|
|
18598
|
+
if (this.#position > min) {
|
|
18599
|
+
this.#valueBeforeCollapse = this.#position;
|
|
18600
|
+
this.valueValue = min;
|
|
17835
18601
|
} else {
|
|
17836
|
-
this.valueValue = this.#valueBeforeCollapse
|
|
18602
|
+
this.valueValue = this.#valueBeforeCollapse ?? max;
|
|
18603
|
+
this.#valueBeforeCollapse = null;
|
|
17837
18604
|
}
|
|
17838
|
-
this.#
|
|
18605
|
+
this.#render();
|
|
17839
18606
|
this.#dispatchChange();
|
|
17840
18607
|
}
|
|
17841
18608
|
#onPointerMove = (event) => {
|
|
17842
18609
|
if (!this.hasSeparatorTarget) return;
|
|
17843
18610
|
const rect = this.element.getBoundingClientRect();
|
|
17844
|
-
const
|
|
17845
|
-
const
|
|
17846
|
-
|
|
17847
|
-
|
|
17848
|
-
|
|
17849
|
-
} else {
|
|
17850
|
-
fraction = (event.clientY - rect.top) / rect.height;
|
|
17851
|
-
}
|
|
17852
|
-
fraction = Math.max(0, Math.min(fraction, 1));
|
|
17853
|
-
const percent = Math.round(fraction * 100);
|
|
17854
|
-
this.valueValue = Math.max(this.minValue, Math.min(percent, this.maxValue));
|
|
17855
|
-
this.#clampAndSync();
|
|
18611
|
+
const raw = this.#isVertical ? (event.clientX - rect.left) / rect.width : (event.clientY - rect.top) / rect.height;
|
|
18612
|
+
const fraction = Number.isFinite(raw) ? Math.max(0, Math.min(raw, 1)) : 0;
|
|
18613
|
+
const { min, max } = this.#range;
|
|
18614
|
+
this.valueValue = Math.max(min, Math.min(Math.round(fraction * 100), max));
|
|
18615
|
+
this.#render();
|
|
17856
18616
|
};
|
|
17857
18617
|
#onPointerUp = (event) => {
|
|
17858
|
-
if (!this.hasSeparatorTarget) return;
|
|
17859
|
-
const separator = this.separatorTarget;
|
|
17860
|
-
separator.releasePointerCapture(event.pointerId);
|
|
17861
18618
|
this.element.removeAttribute("data-dragging");
|
|
17862
18619
|
this.#dragAbort?.abort();
|
|
17863
18620
|
this.#dragAbort = null;
|
|
18621
|
+
if (this.hasSeparatorTarget) {
|
|
18622
|
+
this.separatorTarget.releasePointerCapture(event.pointerId);
|
|
18623
|
+
}
|
|
17864
18624
|
this.#dispatchChange();
|
|
17865
18625
|
};
|
|
17866
|
-
|
|
17867
|
-
|
|
17868
|
-
|
|
17869
|
-
|
|
18626
|
+
/**
|
|
18627
|
+
* Publishes the position: the fraction consumer CSS multiplies a pane by, and
|
|
18628
|
+
* the range assistive tech reads off the separator. The clamped position is
|
|
18629
|
+
* written back to the Value so it, ARIA, CSS, and dispatched events agree.
|
|
18630
|
+
*
|
|
18631
|
+
* @stimeoRenderRoot
|
|
18632
|
+
*/
|
|
18633
|
+
#render() {
|
|
18634
|
+
const { min, max } = this.#range;
|
|
18635
|
+
const position = this.#position;
|
|
18636
|
+
if (this.valueValue !== position) {
|
|
18637
|
+
this.valueValue = position;
|
|
17870
18638
|
}
|
|
17871
|
-
|
|
17872
|
-
this.element.style.setProperty("--stimeo--resizable-fraction", String(fraction));
|
|
18639
|
+
this.element.style.setProperty("--stimeo--resizable-fraction", String(position / 100));
|
|
17873
18640
|
if (this.hasSeparatorTarget) {
|
|
17874
|
-
this.separatorTarget.setAttribute("aria-valuenow", String(
|
|
17875
|
-
this.separatorTarget.setAttribute("aria-valuemin", String(
|
|
17876
|
-
this.separatorTarget.setAttribute("aria-valuemax", String(
|
|
18641
|
+
this.separatorTarget.setAttribute("aria-valuenow", String(position));
|
|
18642
|
+
this.separatorTarget.setAttribute("aria-valuemin", String(min));
|
|
18643
|
+
this.separatorTarget.setAttribute("aria-valuemax", String(max));
|
|
17877
18644
|
}
|
|
17878
18645
|
}
|
|
17879
18646
|
#dispatchChange() {
|
|
17880
18647
|
const fraction = this.valueValue / 100;
|
|
17881
18648
|
this.dispatch("change", { detail: { value: this.valueValue, fraction } });
|
|
17882
18649
|
}
|
|
18650
|
+
/** The declared range after validation; an unreadable bound uses its default. */
|
|
18651
|
+
get #range() {
|
|
18652
|
+
const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN2;
|
|
18653
|
+
const max = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX2;
|
|
18654
|
+
return { min, max: Math.max(min, max) };
|
|
18655
|
+
}
|
|
18656
|
+
/** The declared position, clamped into the validated range. */
|
|
18657
|
+
get #position() {
|
|
18658
|
+
const { min, max } = this.#range;
|
|
18659
|
+
const value = Number.isFinite(this.valueValue) ? this.valueValue : DEFAULT_VALUE;
|
|
18660
|
+
return Math.max(min, Math.min(value, max));
|
|
18661
|
+
}
|
|
18662
|
+
/** The keyboard increment; a non-positive or unreadable one uses the default. */
|
|
18663
|
+
get #step() {
|
|
18664
|
+
return Number.isFinite(this.stepValue) && this.stepValue > 0 ? this.stepValue : DEFAULT_STEP;
|
|
18665
|
+
}
|
|
18666
|
+
/** Whether the divider runs vertically; a separator that does not say is horizontal. */
|
|
18667
|
+
get #isVertical() {
|
|
18668
|
+
return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
|
|
18669
|
+
}
|
|
17883
18670
|
};
|
|
17884
18671
|
var RovingController = class extends Controller {
|
|
17885
18672
|
static targets = ["item"];
|
|
@@ -18387,6 +19174,17 @@ var ScrollAreaController = class extends Controller {
|
|
|
18387
19174
|
this.#clearHostState();
|
|
18388
19175
|
}
|
|
18389
19176
|
};
|
|
19177
|
+
function parseStored(raw) {
|
|
19178
|
+
if (raw === null) return null;
|
|
19179
|
+
try {
|
|
19180
|
+
return JSON.parse(raw);
|
|
19181
|
+
} catch {
|
|
19182
|
+
return null;
|
|
19183
|
+
}
|
|
19184
|
+
}
|
|
19185
|
+
function storedOffset(value) {
|
|
19186
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
19187
|
+
}
|
|
18390
19188
|
var ScrollRestoreController = class extends Controller {
|
|
18391
19189
|
static values = {
|
|
18392
19190
|
key: { type: String, default: "" },
|
|
@@ -18399,6 +19197,26 @@ var ScrollRestoreController = class extends Controller {
|
|
|
18399
19197
|
/** Last offset captured while the element was live; persisted as-is on teardown. */
|
|
18400
19198
|
#lastTop = 0;
|
|
18401
19199
|
#lastLeft = 0;
|
|
19200
|
+
/**
|
|
19201
|
+
* The offsets this controller's own restore produced, awaiting the `scroll`
|
|
19202
|
+
* the engine fires for them. A restore the layout cannot reach yet lands short,
|
|
19203
|
+
* so treating that echo as the reader's position would cut the saved offset
|
|
19204
|
+
* down to whatever the unfinished layout allowed.
|
|
19205
|
+
*/
|
|
19206
|
+
#echoTop = null;
|
|
19207
|
+
#echoLeft = null;
|
|
19208
|
+
/** Offsets stored for an axis that is not tracked, carried through saves. */
|
|
19209
|
+
#carried = {};
|
|
19210
|
+
/**
|
|
19211
|
+
* Whether each axis holds an offset worth saving — one this namespace restored
|
|
19212
|
+
* or the reader moved. A save writes only the axes that are marked, so an
|
|
19213
|
+
* offset taken under a different key or axis is never re-published as if the
|
|
19214
|
+
* reader had left it there.
|
|
19215
|
+
*/
|
|
19216
|
+
#capturedTop = false;
|
|
19217
|
+
#capturedLeft = false;
|
|
19218
|
+
/** Whether the controller is connected; Value callbacks outside that window do not resync. */
|
|
19219
|
+
#connected = false;
|
|
18402
19220
|
#onScroll = () => {
|
|
18403
19221
|
this.#capture();
|
|
18404
19222
|
if (this.#rafId !== null) return;
|
|
@@ -18408,58 +19226,127 @@ var ScrollRestoreController = class extends Controller {
|
|
|
18408
19226
|
});
|
|
18409
19227
|
};
|
|
18410
19228
|
connect() {
|
|
19229
|
+
this.#connected = true;
|
|
18411
19230
|
this.#storageKey = this.#resolveKey();
|
|
18412
|
-
if (!this.#storageKey) return;
|
|
18413
|
-
this.#restore();
|
|
18414
19231
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
19232
|
+
if (this.#storageKey) this.#restore();
|
|
18415
19233
|
}
|
|
18416
19234
|
disconnect() {
|
|
18417
|
-
|
|
19235
|
+
this.#connected = false;
|
|
18418
19236
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
19237
|
+
this.#cancelFrame();
|
|
19238
|
+
this.#persist();
|
|
19239
|
+
}
|
|
19240
|
+
/** Re-derives the namespace when application code or a Turbo morph moves `key`. */
|
|
19241
|
+
keyValueChanged() {
|
|
19242
|
+
this.#resync();
|
|
19243
|
+
}
|
|
19244
|
+
/** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */
|
|
19245
|
+
axisValueChanged() {
|
|
19246
|
+
this.#resync();
|
|
19247
|
+
}
|
|
19248
|
+
/** Rebuilds the persistence state around the Values as they now read. */
|
|
19249
|
+
#resync() {
|
|
19250
|
+
if (!this.#connected) return;
|
|
19251
|
+
this.#cancelFrame();
|
|
19252
|
+
this.#storageKey = this.#resolveKey();
|
|
19253
|
+
this.#echoTop = null;
|
|
19254
|
+
this.#echoLeft = null;
|
|
19255
|
+
this.#carried = {};
|
|
19256
|
+
this.#capturedTop = false;
|
|
19257
|
+
this.#capturedLeft = false;
|
|
19258
|
+
if (this.#storageKey) this.#restore();
|
|
19259
|
+
}
|
|
19260
|
+
/** Drops the pending coalesced save, if one is queued. */
|
|
19261
|
+
#cancelFrame() {
|
|
18419
19262
|
if (this.#rafId !== null) {
|
|
18420
19263
|
cancelAnimationFrame(this.#rafId);
|
|
18421
19264
|
this.#rafId = null;
|
|
18422
19265
|
}
|
|
18423
|
-
this.#persist();
|
|
18424
19266
|
}
|
|
18425
|
-
/**
|
|
19267
|
+
/**
|
|
19268
|
+
* Records the live scroll offset for the configured axis.
|
|
19269
|
+
*
|
|
19270
|
+
* An axis holds its echo until that axis actually moves. A scroll event names
|
|
19271
|
+
* no axis, so consuming both on the first one to arrive would leave the other
|
|
19272
|
+
* unguarded: on `both`, moving one axis would take the clamped reading of the
|
|
19273
|
+
* untouched axis as the reader's own position and save it, cutting the stored
|
|
19274
|
+
* offset down to whatever the layout could reach at restore time.
|
|
19275
|
+
*/
|
|
18426
19276
|
#capture() {
|
|
18427
|
-
if (this.#tracksVertical)
|
|
18428
|
-
|
|
19277
|
+
if (this.#tracksVertical) {
|
|
19278
|
+
const top = this.element.scrollTop;
|
|
19279
|
+
if (top !== this.#echoTop) {
|
|
19280
|
+
this.#echoTop = null;
|
|
19281
|
+
this.#lastTop = top;
|
|
19282
|
+
this.#capturedTop = true;
|
|
19283
|
+
}
|
|
19284
|
+
}
|
|
19285
|
+
if (this.#tracksHorizontal) {
|
|
19286
|
+
const left = this.element.scrollLeft;
|
|
19287
|
+
if (left !== this.#echoLeft) {
|
|
19288
|
+
this.#echoLeft = null;
|
|
19289
|
+
this.#lastLeft = left;
|
|
19290
|
+
this.#capturedLeft = true;
|
|
19291
|
+
}
|
|
19292
|
+
}
|
|
18429
19293
|
}
|
|
18430
|
-
/**
|
|
19294
|
+
/**
|
|
19295
|
+
* Writes the marked axes, carrying through any offset held for an untracked one.
|
|
19296
|
+
* Without a key, or with neither axis marked, it writes nothing.
|
|
19297
|
+
*/
|
|
18431
19298
|
#persist() {
|
|
18432
|
-
|
|
18433
|
-
|
|
18434
|
-
if (this.#
|
|
19299
|
+
if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;
|
|
19300
|
+
const data = { ...this.#carried };
|
|
19301
|
+
if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;
|
|
19302
|
+
if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;
|
|
18435
19303
|
try {
|
|
18436
|
-
sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
19304
|
+
window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
18437
19305
|
} catch {
|
|
18438
19306
|
}
|
|
18439
19307
|
}
|
|
18440
19308
|
/** Applies the persisted scroll offset, if any, without moving focus. */
|
|
18441
19309
|
#restore() {
|
|
18442
|
-
let raw
|
|
18443
|
-
try {
|
|
18444
|
-
raw = sessionStorage.getItem(this.#storageKey);
|
|
18445
|
-
} catch {
|
|
18446
|
-
return;
|
|
18447
|
-
}
|
|
18448
|
-
if (raw === null) return;
|
|
18449
|
-
let data;
|
|
19310
|
+
let raw;
|
|
18450
19311
|
try {
|
|
18451
|
-
|
|
19312
|
+
raw = window.sessionStorage.getItem(this.#storageKey);
|
|
18452
19313
|
} catch {
|
|
18453
19314
|
return;
|
|
18454
19315
|
}
|
|
18455
|
-
|
|
18456
|
-
|
|
18457
|
-
|
|
18458
|
-
|
|
18459
|
-
|
|
18460
|
-
|
|
18461
|
-
|
|
19316
|
+
const data = parseStored(raw);
|
|
19317
|
+
if (data === null) return;
|
|
19318
|
+
this.#echoTop = null;
|
|
19319
|
+
this.#echoLeft = null;
|
|
19320
|
+
const top = storedOffset(data.top);
|
|
19321
|
+
const left = storedOffset(data.left);
|
|
19322
|
+
const options = { behavior: "instant" };
|
|
19323
|
+
let requested = false;
|
|
19324
|
+
if (this.#tracksVertical) {
|
|
19325
|
+
if (top !== null) {
|
|
19326
|
+
options.top = top;
|
|
19327
|
+
this.#lastTop = top;
|
|
19328
|
+
this.#capturedTop = true;
|
|
19329
|
+
requested = true;
|
|
19330
|
+
}
|
|
19331
|
+
} else if (data.top !== void 0) {
|
|
19332
|
+
this.#carried.top = data.top;
|
|
19333
|
+
}
|
|
19334
|
+
if (this.#tracksHorizontal) {
|
|
19335
|
+
if (left !== null) {
|
|
19336
|
+
options.left = left;
|
|
19337
|
+
this.#lastLeft = left;
|
|
19338
|
+
this.#capturedLeft = true;
|
|
19339
|
+
requested = true;
|
|
19340
|
+
}
|
|
19341
|
+
} else if (data.left !== void 0) {
|
|
19342
|
+
this.#carried.left = data.left;
|
|
18462
19343
|
}
|
|
19344
|
+
if (!requested) return;
|
|
19345
|
+
const beforeTop = this.element.scrollTop;
|
|
19346
|
+
const beforeLeft = this.element.scrollLeft;
|
|
19347
|
+
this.element.scrollTo(options);
|
|
19348
|
+
if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;
|
|
19349
|
+
if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;
|
|
18463
19350
|
}
|
|
18464
19351
|
/** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
|
|
18465
19352
|
#resolveKey() {
|
|
@@ -18473,10 +19360,11 @@ var ScrollRestoreController = class extends Controller {
|
|
|
18473
19360
|
return this.axisValue === "horizontal" || this.axisValue === "both";
|
|
18474
19361
|
}
|
|
18475
19362
|
};
|
|
19363
|
+
var DEFAULT_OFFSET = 400;
|
|
18476
19364
|
var ScrollVisibilityController = class extends Controller {
|
|
18477
19365
|
static targets = ["element"];
|
|
18478
19366
|
static values = {
|
|
18479
|
-
offset: { type: Number, default:
|
|
19367
|
+
offset: { type: Number, default: DEFAULT_OFFSET },
|
|
18480
19368
|
mode: { type: String, default: "offset" },
|
|
18481
19369
|
focusSelector: { type: String, default: "" },
|
|
18482
19370
|
root: { type: String, default: "" }
|
|
@@ -18494,8 +19382,32 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
18494
19382
|
* the window. Captured on connect so teardown detaches from the same source.
|
|
18495
19383
|
*/
|
|
18496
19384
|
#scrollSource = window;
|
|
19385
|
+
/**
|
|
19386
|
+
* Gates the declaration callbacks to the connected window.
|
|
19387
|
+
*
|
|
19388
|
+
* Stimulus delivers a Value callback ahead of `connect()` and again for every
|
|
19389
|
+
* runtime change; without the gate, merely connecting would evaluate — and
|
|
19390
|
+
* announce — before `connect()` runs its own first reflection.
|
|
19391
|
+
*/
|
|
19392
|
+
#connected = false;
|
|
19393
|
+
/** Validated threshold; a non-finite declaration reads as the default. */
|
|
19394
|
+
#offset = DEFAULT_OFFSET;
|
|
19395
|
+
/** Validated `root` selector; an unparsable declaration reads as absent. */
|
|
19396
|
+
#rootSelector = "";
|
|
19397
|
+
/** Validated `focusSelector`; an unparsable declaration reads as absent. */
|
|
19398
|
+
#focusSelector = "";
|
|
18497
19399
|
/** Focus targets this instance lent a `tabindex` to. */
|
|
18498
19400
|
#tabindex = new TabindexLoan();
|
|
19401
|
+
/**
|
|
19402
|
+
* The hide held back while the control itself owns focus.
|
|
19403
|
+
*
|
|
19404
|
+
* Completing the deferral re-runs the ordinary evaluation rather than applying
|
|
19405
|
+
* the stale decision: by the time focus leaves, the scroll position may have
|
|
19406
|
+
* moved back past the threshold.
|
|
19407
|
+
*/
|
|
19408
|
+
#pendingHide = new BlurDeferral(() => {
|
|
19409
|
+
if (this.#connected) this.#evaluate();
|
|
19410
|
+
});
|
|
18499
19411
|
#onScroll = () => {
|
|
18500
19412
|
if (this.#rafId !== null) return;
|
|
18501
19413
|
this.#rafId = requestAnimationFrame(() => {
|
|
@@ -18507,61 +19419,134 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
18507
19419
|
this.#scrollSource = this.#resolveScrollSource();
|
|
18508
19420
|
this.#lastScrollY = this.#scrollY();
|
|
18509
19421
|
this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
18510
|
-
this.#evaluate();
|
|
19422
|
+
this.#evaluate(false);
|
|
19423
|
+
this.#connected = true;
|
|
18511
19424
|
}
|
|
18512
19425
|
disconnect() {
|
|
19426
|
+
this.#connected = false;
|
|
18513
19427
|
this.#scrollSource.removeEventListener("scroll", this.#onScroll);
|
|
18514
19428
|
if (this.#rafId !== null) {
|
|
18515
19429
|
cancelAnimationFrame(this.#rafId);
|
|
18516
19430
|
this.#rafId = null;
|
|
18517
19431
|
}
|
|
19432
|
+
this.#pendingHide.releaseAll();
|
|
18518
19433
|
this.#tabindex.returnAll();
|
|
18519
19434
|
this.#visible = null;
|
|
18520
19435
|
}
|
|
19436
|
+
/** Writes the current visibility onto a control that arrives after connect. */
|
|
19437
|
+
elementTargetConnected(element) {
|
|
19438
|
+
if (this.#visible !== null) element.hidden = !this.#visible;
|
|
19439
|
+
}
|
|
19440
|
+
/** Drops a held-back hide together with the control it was waiting on. */
|
|
19441
|
+
elementTargetDisconnected() {
|
|
19442
|
+
this.#pendingHide.releaseAll();
|
|
19443
|
+
}
|
|
19444
|
+
/**
|
|
19445
|
+
* Validates `offset` once, then re-renders.
|
|
19446
|
+
*
|
|
19447
|
+
* Re-renders when application code (or a Turbo morph) changes `offset` at
|
|
19448
|
+
* runtime. A declaration that is not a finite number reads as the default, so
|
|
19449
|
+
* the comparison path never sees `NaN` — which would answer `false` to every
|
|
19450
|
+
* comparison and strand the element (in `direction` mode, even the guarantee
|
|
19451
|
+
* that the very top always reveals).
|
|
19452
|
+
*/
|
|
19453
|
+
offsetValueChanged() {
|
|
19454
|
+
this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
|
|
19455
|
+
if (this.#connected) this.#evaluate();
|
|
19456
|
+
}
|
|
19457
|
+
/** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */
|
|
19458
|
+
modeValueChanged() {
|
|
19459
|
+
if (this.#connected) this.#evaluate();
|
|
19460
|
+
}
|
|
19461
|
+
/** Validates `root` once so connect never parses a selector that throws. */
|
|
19462
|
+
rootValueChanged() {
|
|
19463
|
+
this.#rootSelector = this.#validSelector(this.rootValue);
|
|
19464
|
+
}
|
|
19465
|
+
/** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
|
|
19466
|
+
focusSelectorValueChanged() {
|
|
19467
|
+
this.#focusSelector = this.#validSelector(this.focusSelectorValue);
|
|
19468
|
+
}
|
|
18521
19469
|
/** Scrolls the source to the top and, optionally, moves focus to a safe target. */
|
|
18522
19470
|
toTop() {
|
|
18523
19471
|
const behavior = prefersReducedMotion() ? "instant" : "smooth";
|
|
18524
19472
|
this.#scrollSource.scrollTo({ top: 0, behavior });
|
|
18525
|
-
if (this
|
|
18526
|
-
const target = document.querySelector(this
|
|
19473
|
+
if (this.#focusSelector) {
|
|
19474
|
+
const target = document.querySelector(this.#focusSelector);
|
|
18527
19475
|
if (target) {
|
|
18528
19476
|
this.#tabindex.lend(target);
|
|
18529
|
-
target.focus();
|
|
19477
|
+
target.focus({ preventScroll: true });
|
|
18530
19478
|
}
|
|
18531
19479
|
}
|
|
18532
19480
|
}
|
|
18533
|
-
/**
|
|
18534
|
-
|
|
19481
|
+
/**
|
|
19482
|
+
* Decides the next visibility from the current scroll state and applies it.
|
|
19483
|
+
*
|
|
19484
|
+
* @param notify - whether a transition announces itself. The reflection
|
|
19485
|
+
* `connect()` performs is the current state, not a change.
|
|
19486
|
+
*
|
|
19487
|
+
* @stimeoRenderRoot
|
|
19488
|
+
*/
|
|
19489
|
+
#evaluate(notify = true) {
|
|
18535
19490
|
const y = this.#scrollY();
|
|
18536
19491
|
let nextVisible;
|
|
18537
19492
|
if (this.modeValue === "direction") {
|
|
18538
|
-
if (y <= this
|
|
19493
|
+
if (y <= this.#offset) {
|
|
18539
19494
|
nextVisible = true;
|
|
19495
|
+
} else if (y === this.#lastScrollY) {
|
|
19496
|
+
return;
|
|
18540
19497
|
} else {
|
|
18541
19498
|
nextVisible = y < this.#lastScrollY;
|
|
18542
19499
|
}
|
|
18543
19500
|
} else {
|
|
18544
|
-
nextVisible = y > this
|
|
19501
|
+
nextVisible = y > this.#offset;
|
|
18545
19502
|
}
|
|
18546
19503
|
this.#lastScrollY = y;
|
|
18547
|
-
this.#setVisible(nextVisible);
|
|
19504
|
+
this.#setVisible(nextVisible, notify);
|
|
18548
19505
|
}
|
|
18549
19506
|
/** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
|
|
18550
|
-
#setVisible(next) {
|
|
19507
|
+
#setVisible(next, notify) {
|
|
18551
19508
|
if (next === this.#visible) return;
|
|
19509
|
+
const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;
|
|
19510
|
+
if (focused) {
|
|
19511
|
+
this.#pendingHide.deferOnly(focused);
|
|
19512
|
+
return;
|
|
19513
|
+
}
|
|
18552
19514
|
this.#visible = next;
|
|
18553
19515
|
if (this.hasElementTarget) this.elementTarget.hidden = !next;
|
|
18554
19516
|
this.element.setAttribute("data-state", next ? "visible" : "hidden");
|
|
18555
|
-
this.dispatch("change", { detail: { visible: next } });
|
|
19517
|
+
if (notify) this.dispatch("change", { detail: { visible: next } });
|
|
18556
19518
|
}
|
|
18557
19519
|
/** Resolves the scroll source from `root` (falling back to the window). */
|
|
18558
19520
|
#resolveScrollSource() {
|
|
18559
|
-
if (this
|
|
18560
|
-
const root = document.querySelector(this
|
|
19521
|
+
if (this.#rootSelector) {
|
|
19522
|
+
const root = document.querySelector(this.#rootSelector);
|
|
18561
19523
|
if (root) return root;
|
|
18562
19524
|
}
|
|
18563
19525
|
return window;
|
|
18564
19526
|
}
|
|
19527
|
+
/**
|
|
19528
|
+
* The focus owner inside the target, or `null` when focus is elsewhere.
|
|
19529
|
+
*
|
|
19530
|
+
* `blur` does not bubble, so the deferral has to ride the focused element
|
|
19531
|
+
* itself: waiting on a container that never receives the event would hold the
|
|
19532
|
+
* hide forever.
|
|
19533
|
+
*/
|
|
19534
|
+
#focusedWithin() {
|
|
19535
|
+
const focused = document.activeElement;
|
|
19536
|
+
if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
|
|
19537
|
+
return null;
|
|
19538
|
+
}
|
|
19539
|
+
/** Returns `declared` when it parses as a selector, and `""` when it does not. */
|
|
19540
|
+
#validSelector(declared) {
|
|
19541
|
+
if (declared.length > 0) {
|
|
19542
|
+
try {
|
|
19543
|
+
this.element.matches(declared);
|
|
19544
|
+
return declared;
|
|
19545
|
+
} catch {
|
|
19546
|
+
}
|
|
19547
|
+
}
|
|
19548
|
+
return "";
|
|
19549
|
+
}
|
|
18565
19550
|
#scrollY() {
|
|
18566
19551
|
if (this.#scrollSource === window) {
|
|
18567
19552
|
return window.scrollY ?? window.pageYOffset ?? 0;
|
|
@@ -18973,16 +19958,16 @@ var ScrollspyController = class extends Controller {
|
|
|
18973
19958
|
return value.substring(1) || null;
|
|
18974
19959
|
}
|
|
18975
19960
|
};
|
|
18976
|
-
var
|
|
18977
|
-
var
|
|
19961
|
+
var DEFAULT_MIN3 = 0;
|
|
19962
|
+
var DEFAULT_MAX3 = 100;
|
|
18978
19963
|
var SeparatorController = class extends Controller {
|
|
18979
19964
|
static values = {
|
|
18980
19965
|
orientation: { type: String, default: "horizontal" },
|
|
18981
19966
|
focusable: { type: Boolean, default: false },
|
|
18982
|
-
min: { type: Number, default:
|
|
18983
|
-
max: { type: Number, default:
|
|
19967
|
+
min: { type: Number, default: DEFAULT_MIN3 },
|
|
19968
|
+
max: { type: Number, default: DEFAULT_MAX3 },
|
|
18984
19969
|
step: { type: Number, default: 1 },
|
|
18985
|
-
value: { type: Number, default:
|
|
19970
|
+
value: { type: Number, default: DEFAULT_MIN3 }
|
|
18986
19971
|
};
|
|
18987
19972
|
static actions = ["onKeydown"];
|
|
18988
19973
|
static events = ["change"];
|
|
@@ -19103,8 +20088,8 @@ var SeparatorController = class extends Controller {
|
|
|
19103
20088
|
}
|
|
19104
20089
|
/** Finite, ordered range shared by rendering and keyboard stepping. */
|
|
19105
20090
|
get #effectiveRange() {
|
|
19106
|
-
const min = Number.isFinite(this.minValue) ? this.minValue :
|
|
19107
|
-
const candidateMax = Number.isFinite(this.maxValue) ? this.maxValue :
|
|
20091
|
+
const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN3;
|
|
20092
|
+
const candidateMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX3;
|
|
19108
20093
|
return {
|
|
19109
20094
|
min,
|
|
19110
20095
|
max: Math.max(min, candidateMax),
|
|
@@ -20129,11 +21114,6 @@ var StepIndicatorController = class extends Controller {
|
|
|
20129
21114
|
};
|
|
20130
21115
|
static actions = ["setCurrent"];
|
|
20131
21116
|
static events = ["change"];
|
|
20132
|
-
/**
|
|
20133
|
-
* Whether the target callbacks may render. Stimulus reports the authored steps
|
|
20134
|
-
* as connected before `connect()` and the remaining ones as disconnected after
|
|
20135
|
-
* `disconnect()`, so this keeps a connect at one render pass, not one per step.
|
|
20136
|
-
*/
|
|
20137
21117
|
/**
|
|
20138
21118
|
* Collapses a batch of step callbacks — and a morph that swaps `current` with
|
|
20139
21119
|
* them — into one repaint. Replacing a list of N steps delivers N callbacks, and
|
|
@@ -21423,35 +22403,48 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
21423
22403
|
};
|
|
21424
22404
|
var MODES = ["light", "dark", "system"];
|
|
21425
22405
|
var isMode = (value) => typeof value === "string" && MODES.includes(value);
|
|
22406
|
+
var DEFAULT_MODE = "system";
|
|
22407
|
+
var DEFAULT_TARGET = "html";
|
|
21426
22408
|
var ThemeController = class extends Controller {
|
|
21427
22409
|
static targets = ["option"];
|
|
21428
22410
|
static values = {
|
|
21429
|
-
mode: { type: String, default:
|
|
22411
|
+
mode: { type: String, default: DEFAULT_MODE },
|
|
21430
22412
|
storageKey: { type: String, default: "stimeo-theme" },
|
|
21431
|
-
target: { type: String, default:
|
|
22413
|
+
target: { type: String, default: DEFAULT_TARGET }
|
|
21432
22414
|
};
|
|
21433
22415
|
static actions = ["set", "toggle"];
|
|
21434
22416
|
static events = ["change"];
|
|
21435
22417
|
/** The OS dark-mode query, watched so `system` tracks live changes. */
|
|
21436
22418
|
#media = null;
|
|
22419
|
+
/** Gate for the target callbacks, which Stimulus runs before `connect()`. */
|
|
22420
|
+
#connected = false;
|
|
22421
|
+
/** The `target` declaration after validation; the default when unparsable. */
|
|
22422
|
+
#targetSelector = DEFAULT_TARGET;
|
|
22423
|
+
/** Owns the single Tab stop across the option set (APG radiogroup). */
|
|
22424
|
+
#roving = new RovingTabindex(() => this.optionTargets);
|
|
22425
|
+
/**
|
|
22426
|
+
* The pair last reported, so a move can be told from a repeat. Neither side is
|
|
22427
|
+
* readable after the fact — assigning the Value updates the mode before any
|
|
22428
|
+
* comparison, and the OS query has already flipped by the time it notifies —
|
|
22429
|
+
* so what was reported has to be kept rather than recomputed.
|
|
22430
|
+
*/
|
|
22431
|
+
#published = {
|
|
22432
|
+
mode: DEFAULT_MODE,
|
|
22433
|
+
resolved: "light"
|
|
22434
|
+
};
|
|
21437
22435
|
/** Re-resolves while in `system` mode when the OS preference flips. */
|
|
21438
22436
|
#onMediaChange = () => {
|
|
21439
|
-
if (this
|
|
21440
|
-
|
|
21441
|
-
this.#syncControls();
|
|
21442
|
-
this.#dispatchChange();
|
|
21443
|
-
}
|
|
22437
|
+
if (this.#mode !== "system") return;
|
|
22438
|
+
this.#commit();
|
|
21444
22439
|
};
|
|
21445
22440
|
/** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
|
|
21446
22441
|
#onKeydown = (event) => {
|
|
21447
22442
|
if (event.defaultPrevented) return;
|
|
21448
22443
|
if (isReservedArrowChord(event)) return;
|
|
21449
22444
|
const options = this.optionTargets;
|
|
21450
|
-
if (options.length === 0) return;
|
|
21451
22445
|
const target = event.target;
|
|
21452
22446
|
const current = options.indexOf(target);
|
|
21453
22447
|
if (current === -1) return;
|
|
21454
|
-
const last = options.length - 1;
|
|
21455
22448
|
let next = current;
|
|
21456
22449
|
const step = logicalArrowStep(event.key, this.element);
|
|
21457
22450
|
switch (event.key) {
|
|
@@ -21459,13 +22452,12 @@ var ThemeController = class extends Controller {
|
|
|
21459
22452
|
case "ArrowRight":
|
|
21460
22453
|
case "ArrowUp":
|
|
21461
22454
|
case "ArrowLeft":
|
|
21462
|
-
next =
|
|
22455
|
+
next = rovingMove(current, options.length, step, "wrap");
|
|
21463
22456
|
break;
|
|
21464
22457
|
case "Home":
|
|
21465
|
-
next = 0;
|
|
21466
|
-
break;
|
|
21467
22458
|
case "End":
|
|
21468
|
-
|
|
22459
|
+
if (hasModifierChord(event)) return;
|
|
22460
|
+
next = event.key === "Home" ? 0 : options.length - 1;
|
|
21469
22461
|
break;
|
|
21470
22462
|
default:
|
|
21471
22463
|
return;
|
|
@@ -21474,25 +22466,58 @@ var ThemeController = class extends Controller {
|
|
|
21474
22466
|
const option = options[next];
|
|
21475
22467
|
if (!option) return;
|
|
21476
22468
|
option.focus();
|
|
21477
|
-
this.#
|
|
22469
|
+
const mode = this.#optionMode(option);
|
|
22470
|
+
if (mode) this.#setMode(mode);
|
|
21478
22471
|
};
|
|
21479
22472
|
connect() {
|
|
21480
22473
|
const stored = this.#readStored();
|
|
21481
22474
|
if (stored) this.modeValue = stored;
|
|
21482
22475
|
this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
|
21483
22476
|
this.#media?.addEventListener("change", this.#onMediaChange);
|
|
21484
|
-
|
|
22477
|
+
this.element.addEventListener("keydown", this.#onKeydown);
|
|
22478
|
+
this.#connected = true;
|
|
21485
22479
|
this.#applyTheme();
|
|
21486
22480
|
this.#syncControls();
|
|
22481
|
+
this.#published = this.#current;
|
|
21487
22482
|
}
|
|
21488
22483
|
disconnect() {
|
|
22484
|
+
this.#connected = false;
|
|
21489
22485
|
this.#media?.removeEventListener("change", this.#onMediaChange);
|
|
21490
22486
|
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
21491
22487
|
}
|
|
21492
|
-
/**
|
|
22488
|
+
/** Validates the `target` declaration once, so the render path never parses. */
|
|
22489
|
+
targetValueChanged() {
|
|
22490
|
+
const selector = this.targetValue;
|
|
22491
|
+
if (selector.length > 0) {
|
|
22492
|
+
try {
|
|
22493
|
+
this.element.matches(selector);
|
|
22494
|
+
this.#targetSelector = selector;
|
|
22495
|
+
return;
|
|
22496
|
+
} catch {
|
|
22497
|
+
}
|
|
22498
|
+
}
|
|
22499
|
+
this.#targetSelector = DEFAULT_TARGET;
|
|
22500
|
+
}
|
|
22501
|
+
/** Re-derives the single Tab stop and ARIA for an option set that changed. */
|
|
22502
|
+
optionTargetConnected() {
|
|
22503
|
+
if (this.#connected) this.#syncControls();
|
|
22504
|
+
}
|
|
22505
|
+
/** Re-derives them again when an option leaves, so a Tab stop always remains. */
|
|
22506
|
+
optionTargetDisconnected() {
|
|
22507
|
+
if (this.#connected) this.#syncControls();
|
|
22508
|
+
}
|
|
22509
|
+
/**
|
|
22510
|
+
* Selects the mode the activated option declares.
|
|
22511
|
+
*
|
|
22512
|
+
* Read through {@link ThemeController.#optionMode}, the same lane that decides
|
|
22513
|
+
* which option is checked, so the two can never disagree about what an option
|
|
22514
|
+
* declares.
|
|
22515
|
+
*/
|
|
21493
22516
|
set(event) {
|
|
21494
|
-
const
|
|
21495
|
-
if (
|
|
22517
|
+
const option = event.currentTarget;
|
|
22518
|
+
if (!(option instanceof HTMLElement)) return;
|
|
22519
|
+
const mode = this.#optionMode(option);
|
|
22520
|
+
if (mode) this.#setMode(mode);
|
|
21496
22521
|
}
|
|
21497
22522
|
/** Toggles light↔dark for the 2-value single-button contract. */
|
|
21498
22523
|
toggle() {
|
|
@@ -21502,9 +22527,26 @@ var ThemeController = class extends Controller {
|
|
|
21502
22527
|
#setMode(mode) {
|
|
21503
22528
|
this.modeValue = mode;
|
|
21504
22529
|
this.#writeStored(mode);
|
|
22530
|
+
this.#commit();
|
|
22531
|
+
}
|
|
22532
|
+
/**
|
|
22533
|
+
* Applies the current mode and reports it, but reports only a real move: the
|
|
22534
|
+
* event means "the selection or the effective theme moved", so re-choosing the
|
|
22535
|
+
* option already chosen is not one.
|
|
22536
|
+
*/
|
|
22537
|
+
#commit() {
|
|
21505
22538
|
this.#applyTheme();
|
|
21506
22539
|
this.#syncControls();
|
|
21507
|
-
this.#
|
|
22540
|
+
const next = this.#current;
|
|
22541
|
+
const last = this.#published;
|
|
22542
|
+
this.#published = next;
|
|
22543
|
+
if (last.mode !== next.mode || last.resolved !== next.resolved) {
|
|
22544
|
+
this.dispatch("change", { detail: { ...next } });
|
|
22545
|
+
}
|
|
22546
|
+
}
|
|
22547
|
+
/** The pair the `change` detail carries, read from current state. */
|
|
22548
|
+
get #current() {
|
|
22549
|
+
return { mode: this.#mode, resolved: this.#resolved() };
|
|
21508
22550
|
}
|
|
21509
22551
|
/** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
|
|
21510
22552
|
#applyTheme() {
|
|
@@ -21518,39 +22560,45 @@ var ThemeController = class extends Controller {
|
|
|
21518
22560
|
#syncControls() {
|
|
21519
22561
|
const options = this.optionTargets;
|
|
21520
22562
|
if (options.length > 0) {
|
|
21521
|
-
|
|
21522
|
-
|
|
21523
|
-
|
|
21524
|
-
|
|
21525
|
-
option.
|
|
21526
|
-
|
|
21527
|
-
}
|
|
21528
|
-
|
|
21529
|
-
if (!hasTabbable && first) first.tabIndex = 0;
|
|
22563
|
+
const mode = this.#mode;
|
|
22564
|
+
let selected = -1;
|
|
22565
|
+
options.forEach((option, index) => {
|
|
22566
|
+
const isSelected = this.#optionMode(option) === mode;
|
|
22567
|
+
option.setAttribute("aria-checked", String(isSelected));
|
|
22568
|
+
if (isSelected && selected === -1) selected = index;
|
|
22569
|
+
});
|
|
22570
|
+
this.#roving.setActive(selected === -1 ? 0 : selected, { items: options });
|
|
21530
22571
|
return;
|
|
21531
22572
|
}
|
|
21532
|
-
|
|
22573
|
+
if (this.#isToggleButton) {
|
|
22574
|
+
this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
|
|
22575
|
+
}
|
|
21533
22576
|
}
|
|
21534
|
-
/**
|
|
21535
|
-
#
|
|
21536
|
-
this.
|
|
22577
|
+
/** Whether the controller element is the button of the 2-value contract. */
|
|
22578
|
+
get #isToggleButton() {
|
|
22579
|
+
return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
|
|
22580
|
+
}
|
|
22581
|
+
/** The selected mode after validation; an unreadable declaration is the default. */
|
|
22582
|
+
get #mode() {
|
|
22583
|
+
return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
|
|
21537
22584
|
}
|
|
21538
22585
|
/** The effective theme: the OS preference when `system`, else the mode itself. */
|
|
21539
22586
|
#resolved() {
|
|
21540
|
-
|
|
21541
|
-
if (
|
|
22587
|
+
const mode = this.#mode;
|
|
22588
|
+
if (mode === "dark") return "dark";
|
|
22589
|
+
if (mode === "light") return "light";
|
|
21542
22590
|
return this.#media?.matches ? "dark" : "light";
|
|
21543
22591
|
}
|
|
21544
|
-
/**
|
|
22592
|
+
/** An option's mode from its `data-value`, or `null` when that is not one of the three. */
|
|
21545
22593
|
#optionMode(option) {
|
|
21546
|
-
const mode = option.getAttribute("data-
|
|
21547
|
-
return isMode(mode) ? mode :
|
|
22594
|
+
const mode = option.getAttribute("data-value");
|
|
22595
|
+
return isMode(mode) ? mode : null;
|
|
21548
22596
|
}
|
|
21549
22597
|
/** Resolves the state-hook target (`<html>` by default). */
|
|
21550
22598
|
#targetElement() {
|
|
21551
|
-
|
|
21552
|
-
|
|
21553
|
-
return document.querySelector(
|
|
22599
|
+
const selector = this.#targetSelector;
|
|
22600
|
+
if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
|
|
22601
|
+
return document.querySelector(selector);
|
|
21554
22602
|
}
|
|
21555
22603
|
/** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
|
|
21556
22604
|
#readStored() {
|
|
@@ -22007,8 +23055,8 @@ var ToastController = class extends Controller {
|
|
|
22007
23055
|
}
|
|
22008
23056
|
/**
|
|
22009
23057
|
* Stimulus lifecycle callback triggered automatically when a new item target
|
|
22010
|
-
* enters the DOM
|
|
22011
|
-
*
|
|
23058
|
+
* enters the DOM, from a client-side injection or a Turbo Stream append alike. An
|
|
23059
|
+
* item already leaving, or parented outside `list`, is skipped.
|
|
22012
23060
|
*/
|
|
22013
23061
|
itemTargetConnected(element) {
|
|
22014
23062
|
this.enforceMaxLimit();
|
|
@@ -22974,12 +24022,21 @@ var TransitionController = class extends Controller {
|
|
|
22974
24022
|
static events = ["entered", "left"];
|
|
22975
24023
|
/** Owns the cancellable completion wait (terminal events + bounded fallback). */
|
|
22976
24024
|
#transition = new TransitionCompletion();
|
|
24025
|
+
/** Rewinds a half-applied stage before Turbo copies the page into its snapshot. */
|
|
24026
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
22977
24027
|
#rafId = null;
|
|
24028
|
+
/**
|
|
24029
|
+
* Stage classes this controller put on the element. Removing by declaration
|
|
24030
|
+
* instead would take a token the consumer also authored, and would strand the
|
|
24031
|
+
* token that was applied when a Value changes mid-transition.
|
|
24032
|
+
*/
|
|
24033
|
+
#staged = /* @__PURE__ */ new Set();
|
|
22978
24034
|
connect() {
|
|
22979
|
-
this.#
|
|
22980
|
-
this
|
|
24035
|
+
this.#beforeCache.activate();
|
|
24036
|
+
this.#settleState();
|
|
22981
24037
|
}
|
|
22982
24038
|
disconnect() {
|
|
24039
|
+
this.#beforeCache.deactivate();
|
|
22983
24040
|
this.#cancel();
|
|
22984
24041
|
}
|
|
22985
24042
|
/** Shows the element with the enter transition. */
|
|
@@ -23009,6 +24066,7 @@ var TransitionController = class extends Controller {
|
|
|
23009
24066
|
const from = isEnter ? this.enterFromValue : this.leaveFromValue;
|
|
23010
24067
|
const to = isEnter ? this.enterToValue : this.leaveToValue;
|
|
23011
24068
|
this.#add(base, from);
|
|
24069
|
+
void this.element.offsetWidth;
|
|
23012
24070
|
this.#rafId = this.#raf(() => {
|
|
23013
24071
|
this.#rafId = null;
|
|
23014
24072
|
this.#remove(from);
|
|
@@ -23030,6 +24088,21 @@ var TransitionController = class extends Controller {
|
|
|
23030
24088
|
this.dispatch("left", { detail: {} });
|
|
23031
24089
|
}
|
|
23032
24090
|
}
|
|
24091
|
+
/** Writes the state hook the element's visibility implies. */
|
|
24092
|
+
#settleState() {
|
|
24093
|
+
this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
|
|
24094
|
+
}
|
|
24095
|
+
/**
|
|
24096
|
+
* Returns the element to a settled state before Turbo copies the page.
|
|
24097
|
+
*
|
|
24098
|
+
* The snapshot is taken while the controller is still connected, so stripping on
|
|
24099
|
+
* the next `connect()` would only repair the page after it has been painted from
|
|
24100
|
+
* the cache. The pass is silent: `connect()` derives the state again on restore.
|
|
24101
|
+
*/
|
|
24102
|
+
#rewindForCache() {
|
|
24103
|
+
this.#cancel();
|
|
24104
|
+
this.#settleState();
|
|
24105
|
+
}
|
|
23033
24106
|
/** Cancels any in-flight transition (interruption / teardown). */
|
|
23034
24107
|
#cancel() {
|
|
23035
24108
|
if (this.#rafId !== null) {
|
|
@@ -23039,24 +24112,34 @@ var TransitionController = class extends Controller {
|
|
|
23039
24112
|
this.#transition.cancel();
|
|
23040
24113
|
this.#strip();
|
|
23041
24114
|
}
|
|
24115
|
+
/**
|
|
24116
|
+
* Applies the stage tokens this controller does not already find on the
|
|
24117
|
+
* element, and claims exactly those.
|
|
24118
|
+
*
|
|
24119
|
+
* A token already on the element is left unclaimed: it is either the consumer's
|
|
24120
|
+
* standing class or one an earlier stage of this transition already claimed, and
|
|
24121
|
+
* in neither case may this call take ownership of it. That is what keeps a
|
|
24122
|
+
* standing class the consumer also named as a stage Value; the cost is that such
|
|
24123
|
+
* a token cannot be staged, so the property it drives resolves from their CSS.
|
|
24124
|
+
*/
|
|
23042
24125
|
#add(...lists) {
|
|
23043
|
-
const
|
|
23044
|
-
|
|
24126
|
+
for (const token of lists.flatMap(tokensOf)) {
|
|
24127
|
+
if (this.element.classList.contains(token)) continue;
|
|
24128
|
+
this.element.classList.add(token);
|
|
24129
|
+
this.#staged.add(token);
|
|
24130
|
+
}
|
|
23045
24131
|
}
|
|
24132
|
+
/** Drops the named tokens that are this controller's to drop. */
|
|
23046
24133
|
#remove(...lists) {
|
|
23047
|
-
const
|
|
23048
|
-
|
|
24134
|
+
for (const token of lists.flatMap(tokensOf)) {
|
|
24135
|
+
if (!this.#staged.delete(token)) continue;
|
|
24136
|
+
this.element.classList.remove(token);
|
|
24137
|
+
}
|
|
23049
24138
|
}
|
|
23050
|
-
/** Removes every stage class
|
|
24139
|
+
/** Removes every stage class this controller applied, so none lingers. */
|
|
23051
24140
|
#strip() {
|
|
23052
|
-
this
|
|
23053
|
-
|
|
23054
|
-
this.enterFromValue,
|
|
23055
|
-
this.enterToValue,
|
|
23056
|
-
this.leaveValue,
|
|
23057
|
-
this.leaveFromValue,
|
|
23058
|
-
this.leaveToValue
|
|
23059
|
-
);
|
|
24141
|
+
this.element.classList.remove(...this.#staged);
|
|
24142
|
+
this.#staged.clear();
|
|
23060
24143
|
}
|
|
23061
24144
|
#raf(callback) {
|
|
23062
24145
|
if (typeof window.requestAnimationFrame === "function") {
|