stimeo-ui 0.9.0 → 0.11.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 +149 -0
- data/dist/cable/index.js +226 -43
- data/dist/controllers/alert_dialog_controller.js +17 -7
- data/dist/controllers/announcer_controller.js +1 -1
- data/dist/controllers/bulk_select_controller.js +2 -2
- data/dist/controllers/collapsible_controller.js +1 -1
- 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 +1 -1
- 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/focus_controller.js +45 -7
- data/dist/controllers/form_field_controller.js +1 -1
- data/dist/controllers/menubar_controller.js +2 -2
- data/dist/controllers/multi_select_controller.js +1 -1
- 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/pointer_drag_controller.js +64 -18
- data/dist/controllers/portal_controller.js +32 -11
- data/dist/controllers/preview_guard_controller.js +180 -20
- data/dist/controllers/resizable_controller.js +1 -1
- data/dist/controllers/roving_controller.js +127 -14
- 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/stick_to_bottom_controller.js +149 -28
- 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 +1188 -318
- 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) {
|
|
@@ -1693,8 +1706,8 @@ var BulkSelectController = class extends Controller {
|
|
|
1693
1706
|
static events = ["change", "reconcile"];
|
|
1694
1707
|
/** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
|
|
1695
1708
|
* `connect()` over markup that already carries the attribute rehydrates the
|
|
1696
|
-
* mode — a morph, a Turbo Stream,
|
|
1697
|
-
* visit
|
|
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. */
|
|
1698
1711
|
#allPagesMode = false;
|
|
1699
1712
|
/** Last emitted figures, so a recompute reports only on a real change. */
|
|
1700
1713
|
#lastCount = -1;
|
|
@@ -3497,7 +3510,7 @@ var CollapsibleController = class extends Controller {
|
|
|
3497
3510
|
* `open` Value disagrees. An already-open region remains open without a
|
|
3498
3511
|
* close/reopen cycle. The Value only seeds a genuinely fresh render where no
|
|
3499
3512
|
* state attribute is present yet; any opening animation in that case belongs to
|
|
3500
|
-
* the consumer's CSS.
|
|
3513
|
+
* the consumer's CSS.
|
|
3501
3514
|
*/
|
|
3502
3515
|
connect() {
|
|
3503
3516
|
this.#connected = true;
|
|
@@ -5421,7 +5434,7 @@ var CountdownController = class extends Controller {
|
|
|
5421
5434
|
#pausedAmount = 0;
|
|
5422
5435
|
/**
|
|
5423
5436
|
* The amount the slots are currently showing, floored to the second they render.
|
|
5424
|
-
* 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 —
|
|
5425
5438
|
* is what a pause has to preserve: storing the fraction behind the display instead
|
|
5426
5439
|
* makes the first tick after a resume step by two units.
|
|
5427
5440
|
*/
|
|
@@ -6177,7 +6190,7 @@ var DataGridController = class extends Controller {
|
|
|
6177
6190
|
const row = cell.closest("[role='row']");
|
|
6178
6191
|
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
6179
6192
|
}
|
|
6180
|
-
/**
|
|
6193
|
+
/** Cycles a header's sort on keyboard activation and emits `sort`. */
|
|
6181
6194
|
#cycleSort(header) {
|
|
6182
6195
|
const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
|
|
6183
6196
|
for (const other of this.columnHeaderTargets) {
|
|
@@ -6945,10 +6958,6 @@ var DirectUploadController = class extends Controller {
|
|
|
6945
6958
|
this.#rows.delete(id);
|
|
6946
6959
|
}
|
|
6947
6960
|
}
|
|
6948
|
-
/**
|
|
6949
|
-
* Returns the widget to its pre-upload state just before Turbo caches the
|
|
6950
|
-
* page, so the snapshot never replays rows for uploads that cannot resume.
|
|
6951
|
-
*/
|
|
6952
6961
|
/**
|
|
6953
6962
|
* Rewinds for the snapshot and reports what that discarded. An upload in flight
|
|
6954
6963
|
* cannot survive the navigation, so a consumer mirroring the rows would keep a
|
|
@@ -6987,7 +6996,7 @@ var DirectUploadController = class extends Controller {
|
|
|
6987
6996
|
#detail(event) {
|
|
6988
6997
|
return event.detail ?? {};
|
|
6989
6998
|
}
|
|
6990
|
-
/** The file name
|
|
6999
|
+
/** The event's file name, or `""` when it carries none. */
|
|
6991
7000
|
#name(detail) {
|
|
6992
7001
|
return detail.file?.name ?? "";
|
|
6993
7002
|
}
|
|
@@ -7361,8 +7370,8 @@ var DrawerController = class extends Controller {
|
|
|
7361
7370
|
* rather than being re-derived from the declarative `open` Value (which would
|
|
7362
7371
|
* close a user-opened drawer). The `open` Value only seeds a genuinely fresh
|
|
7363
7372
|
* render. We normalize to a clean closed baseline first so {@link open} runs its
|
|
7364
|
-
* full reveal + trap activation — the {@link FocusTrap} is
|
|
7365
|
-
*
|
|
7373
|
+
* full reveal + trap activation — the {@link FocusTrap} is inactive after a
|
|
7374
|
+
* disconnect and must be re-activated.
|
|
7366
7375
|
*/
|
|
7367
7376
|
connect() {
|
|
7368
7377
|
this.#connected = true;
|
|
@@ -8756,13 +8765,39 @@ var FocusController = class extends Controller {
|
|
|
8756
8765
|
initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
|
|
8757
8766
|
onEscape: () => this.deactivate()
|
|
8758
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
|
+
});
|
|
8759
8789
|
/** Stimulus drives activation from the `trap` value (also fires on connect). */
|
|
8760
8790
|
trapValueChanged() {
|
|
8761
8791
|
if (this.trapValue) this.#activate();
|
|
8762
8792
|
else this.#deactivate();
|
|
8763
8793
|
}
|
|
8794
|
+
connect() {
|
|
8795
|
+
this.#beforeCache.activate();
|
|
8796
|
+
}
|
|
8764
8797
|
disconnect() {
|
|
8765
8798
|
this.#trap.deactivate({ restoreFocus: false });
|
|
8799
|
+
this.#beforeCache.deactivate();
|
|
8800
|
+
this.#active = false;
|
|
8766
8801
|
this.element.removeAttribute("data-focus-trapped");
|
|
8767
8802
|
}
|
|
8768
8803
|
/** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
|
|
@@ -8776,13 +8811,15 @@ var FocusController = class extends Controller {
|
|
|
8776
8811
|
this.#deactivate();
|
|
8777
8812
|
}
|
|
8778
8813
|
#activate() {
|
|
8779
|
-
if (this.#
|
|
8814
|
+
if (this.#active) return;
|
|
8815
|
+
this.#active = true;
|
|
8780
8816
|
this.#trap.activate();
|
|
8781
8817
|
this.element.setAttribute("data-focus-trapped", "true");
|
|
8782
8818
|
this.dispatch("activate", { detail: {} });
|
|
8783
8819
|
}
|
|
8784
8820
|
#deactivate() {
|
|
8785
|
-
if (!this.#
|
|
8821
|
+
if (!this.#active) return;
|
|
8822
|
+
this.#active = false;
|
|
8786
8823
|
this.#trap.deactivate({ restoreFocus: this.restoreValue });
|
|
8787
8824
|
this.element.removeAttribute("data-focus-trapped");
|
|
8788
8825
|
this.dispatch("deactivate", { detail: {} });
|
|
@@ -8800,7 +8837,7 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
8800
8837
|
static #INVALID_ATTR = "data-stimeo--form-field-invalid";
|
|
8801
8838
|
/** Collapses one target/morph batch into one silent ARIA reconciliation. */
|
|
8802
8839
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
8803
|
-
/**
|
|
8840
|
+
/** Returns borrowed control ARIA before Turbo snapshots the page. */
|
|
8804
8841
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
8805
8842
|
#ariaDescribedBy = new AttributeLease("aria-describedby");
|
|
8806
8843
|
#ariaErrorMessage = new AttributeLease("aria-errormessage");
|
|
@@ -11687,8 +11724,8 @@ var MenubarController = class extends Controller {
|
|
|
11687
11724
|
* being hidden itself, a menu can stay visible after its owning top was removed,
|
|
11688
11725
|
* an open pair can appear from a morph with no layer registered for it, and the
|
|
11689
11726
|
* Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
|
|
11690
|
-
* all.
|
|
11691
|
-
* 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
|
|
11692
11729
|
* same whichever mutation arrived and calling it more often than needed is free.
|
|
11693
11730
|
*/
|
|
11694
11731
|
#reconcile() {
|
|
@@ -12802,7 +12839,7 @@ var MultiSelectController = class extends Controller {
|
|
|
12802
12839
|
get #values() {
|
|
12803
12840
|
return this.#selectedOptions.map((option) => this.#optionValue(option));
|
|
12804
12841
|
}
|
|
12805
|
-
/** Normalized cardinality cap: zero
|
|
12842
|
+
/** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
|
|
12806
12843
|
get #selectionLimit() {
|
|
12807
12844
|
if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
|
|
12808
12845
|
return Math.max(1, Math.floor(this.maxValue));
|
|
@@ -14957,8 +14994,8 @@ var OverflowMenuController = class extends Controller {
|
|
|
14957
14994
|
*
|
|
14958
14995
|
* With no known child at all (a fresh instance connecting to markup that already
|
|
14959
14996
|
* holds banked items), a fully-banked snapshot's inert boundary preserves whether an
|
|
14960
|
-
* unindexed run was prepended or appended before connect.
|
|
14961
|
-
*
|
|
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
|
|
14962
14999
|
* offset, the inverse of the move that banked it.
|
|
14963
15000
|
*/
|
|
14964
15001
|
#merge(bar, banked, boundaryAt) {
|
|
@@ -15370,6 +15407,7 @@ var PaginationController = class _PaginationController extends Controller {
|
|
|
15370
15407
|
return button.hasAttribute(_PaginationController.#BOUNDARY_ATTR);
|
|
15371
15408
|
}
|
|
15372
15409
|
};
|
|
15410
|
+
var MAX_DELAY = 2 ** 31 - 1;
|
|
15373
15411
|
var PasswordRevealController = class extends Controller {
|
|
15374
15412
|
static targets = ["input", "toggle"];
|
|
15375
15413
|
static values = {
|
|
@@ -15379,11 +15417,54 @@ var PasswordRevealController = class extends Controller {
|
|
|
15379
15417
|
static events = ["toggle"];
|
|
15380
15418
|
/** Auto re-mask timer; torn down on disconnect. */
|
|
15381
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());
|
|
15382
15432
|
connect() {
|
|
15383
|
-
this.#
|
|
15433
|
+
this.#connected = true;
|
|
15434
|
+
const visible = this.#isVisible;
|
|
15435
|
+
this.#reflect(visible);
|
|
15436
|
+
this.#beforeCache.activate();
|
|
15437
|
+
this.#arm(visible);
|
|
15384
15438
|
}
|
|
15385
15439
|
disconnect() {
|
|
15440
|
+
this.#connected = false;
|
|
15386
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);
|
|
15387
15468
|
}
|
|
15388
15469
|
/** Toggles the input between masked and revealed. Bound via `data-action`. */
|
|
15389
15470
|
toggle() {
|
|
@@ -15412,11 +15493,37 @@ var PasswordRevealController = class extends Controller {
|
|
|
15412
15493
|
}
|
|
15413
15494
|
this.#reflect(visible);
|
|
15414
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) {
|
|
15415
15500
|
this.#timers.clearAll();
|
|
15416
|
-
|
|
15417
|
-
|
|
15501
|
+
const delay = this.#autoHideDelay;
|
|
15502
|
+
if (visible && delay > 0) {
|
|
15503
|
+
this.#timers.set(() => this.#setVisible(false), delay);
|
|
15418
15504
|
}
|
|
15419
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
|
+
}
|
|
15420
15527
|
/** Reflects the visible state onto `aria-pressed` and `data-state`. */
|
|
15421
15528
|
#reflect(visible) {
|
|
15422
15529
|
if (this.hasToggleTarget) {
|
|
@@ -15430,6 +15537,7 @@ var DEFAULT_LEVELS = ["weak", "fair", "good", "strong"];
|
|
|
15430
15537
|
var LENGTH_MILESTONES = [8, 12, 16];
|
|
15431
15538
|
var MAX_POINTS = LENGTH_MILESTONES.length + (CLASS_PATTERNS.length - 1);
|
|
15432
15539
|
var STRENGTH_BANDS = ["weak", "fair", "good", "strong"];
|
|
15540
|
+
var MIN_LEVELS = 2;
|
|
15433
15541
|
var PasswordStrengthController = class _PasswordStrengthController extends Controller {
|
|
15434
15542
|
static targets = ["input", "meter", "label"];
|
|
15435
15543
|
static values = {
|
|
@@ -15437,50 +15545,143 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
15437
15545
|
// A JSON list read through `parseStringList` rather than Stimulus's `Array`
|
|
15438
15546
|
// type: that reader throws out of the value observer before any callback
|
|
15439
15547
|
// runs, so one malformed attribute would stop the meter connecting.
|
|
15440
|
-
levels: { type: String, default: "" }
|
|
15548
|
+
levels: { type: String, default: "" },
|
|
15549
|
+
announceText: { type: String, default: "" }
|
|
15441
15550
|
};
|
|
15442
|
-
static actions = ["evaluate"];
|
|
15443
|
-
static events = ["change"];
|
|
15444
|
-
/** 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. */
|
|
15445
15554
|
static #announceDelay = 200;
|
|
15446
15555
|
#timers = new SafeTimeout();
|
|
15556
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
15557
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcile());
|
|
15558
|
+
#levels = [...DEFAULT_LEVELS];
|
|
15447
15559
|
#announceId = null;
|
|
15560
|
+
#announcedLevel = null;
|
|
15561
|
+
#externalScore = null;
|
|
15562
|
+
#lastDetail = null;
|
|
15563
|
+
/** Reflects the current DOM state and opens the reconciliation window. */
|
|
15448
15564
|
connect() {
|
|
15449
|
-
this.#
|
|
15565
|
+
this.#repaint.activate();
|
|
15566
|
+
this.#beforeCache.activate();
|
|
15567
|
+
this.#lastDetail = this.#detail(this.#render());
|
|
15450
15568
|
}
|
|
15569
|
+
/** Releases the reconciliation window, the cache subscription and the debounce. */
|
|
15451
15570
|
disconnect() {
|
|
15452
|
-
this.#
|
|
15453
|
-
this.#
|
|
15571
|
+
this.#repaint.cancel();
|
|
15572
|
+
this.#beforeCache.deactivate();
|
|
15573
|
+
this.#cancelAnnouncement();
|
|
15574
|
+
this.#announcedLevel = null;
|
|
15575
|
+
this.#externalScore = null;
|
|
15576
|
+
this.#lastDetail = null;
|
|
15454
15577
|
}
|
|
15455
15578
|
/** Re-evaluates strength from the input. Bound via `data-action` (`input`). */
|
|
15456
15579
|
evaluate() {
|
|
15457
|
-
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
|
+
}
|
|
15458
15651
|
}
|
|
15459
15652
|
/**
|
|
15460
|
-
*
|
|
15461
|
-
*
|
|
15462
|
-
*
|
|
15653
|
+
* Synchronizes the meter ARIA, the root state hooks, the fill custom property
|
|
15654
|
+
* and the visible level readout.
|
|
15655
|
+
*
|
|
15656
|
+
* @stimeoRenderRoot
|
|
15463
15657
|
*/
|
|
15464
|
-
#
|
|
15465
|
-
const
|
|
15466
|
-
const
|
|
15467
|
-
const
|
|
15468
|
-
const
|
|
15469
|
-
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);
|
|
15470
15664
|
this.#reflectMeter(score, max);
|
|
15471
|
-
this.#reflectRoot(score, max);
|
|
15472
|
-
|
|
15473
|
-
|
|
15474
|
-
|
|
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)));
|
|
15475
15683
|
}
|
|
15476
|
-
this.
|
|
15477
|
-
detail: { score, level: label, max, meetsMin: score > 0 && score >= this.minScoreValue }
|
|
15478
|
-
});
|
|
15479
|
-
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
15480
|
-
this.#announceId = this.#timers.set(() => {
|
|
15481
|
-
this.#writeLabel(label);
|
|
15482
|
-
this.#announceId = null;
|
|
15483
|
-
}, _PasswordStrengthController.#announceDelay);
|
|
15684
|
+
return this.#score(this.hasInputTarget ? this.inputTarget.value : "", max);
|
|
15484
15685
|
}
|
|
15485
15686
|
/** Syncs the meter target's ARIA value attributes (`0..levels.length`). */
|
|
15486
15687
|
#reflectMeter(score, max) {
|
|
@@ -15494,25 +15695,27 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
15494
15695
|
* empty), the `data-below-min` hook when the score is under `minScore`, and the
|
|
15495
15696
|
* `0–1` fill the consumer's CSS turns into the bar width.
|
|
15496
15697
|
*/
|
|
15497
|
-
#reflectRoot(score, max) {
|
|
15498
|
-
const band = this.#band(score, max);
|
|
15698
|
+
#reflectRoot(score, max, band, meetsMin) {
|
|
15499
15699
|
this.#toggle("data-strength", band, band.length > 0);
|
|
15500
|
-
this.#toggle("data-below-min", "true", score > 0 &&
|
|
15501
|
-
|
|
15502
|
-
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));
|
|
15503
15702
|
}
|
|
15504
|
-
|
|
15505
|
-
|
|
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;
|
|
15506
15707
|
}
|
|
15507
15708
|
/**
|
|
15508
15709
|
* Locale-independent styling band (one of {@link STRENGTH_BANDS}) for `score`
|
|
15509
|
-
* out of `max`. Empty input → `""`.
|
|
15510
|
-
*
|
|
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.
|
|
15511
15714
|
*/
|
|
15512
15715
|
#band(score, max) {
|
|
15513
|
-
if (score <= 0
|
|
15514
|
-
const index = Math.
|
|
15515
|
-
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];
|
|
15516
15719
|
}
|
|
15517
15720
|
/** Sets `name` to `value` when `on`, else removes it (value/presence data hook). */
|
|
15518
15721
|
#toggle(name, value, on) {
|
|
@@ -15522,6 +15725,11 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
15522
15725
|
this.element.removeAttribute(name);
|
|
15523
15726
|
}
|
|
15524
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
|
+
}
|
|
15525
15733
|
/**
|
|
15526
15734
|
* Lightweight zero-dependency strength heuristic returning an integer in
|
|
15527
15735
|
* `[0, max]` (`max` = number of levels). Empty input is `0` (no level); any
|
|
@@ -15531,7 +15739,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
15531
15739
|
* alone cannot mask trivial repetition.
|
|
15532
15740
|
*/
|
|
15533
15741
|
#score(password, max) {
|
|
15534
|
-
if (password.length === 0
|
|
15742
|
+
if (password.length === 0) return 0;
|
|
15535
15743
|
let points = 0;
|
|
15536
15744
|
for (const milestone of LENGTH_MILESTONES) {
|
|
15537
15745
|
if (password.length >= milestone) points += 1;
|
|
@@ -15541,6 +15749,64 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
15541
15749
|
const bucketed = Math.round(points / MAX_POINTS * max);
|
|
15542
15750
|
return Math.min(max, Math.max(1, bucketed));
|
|
15543
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
|
+
}
|
|
15544
15810
|
};
|
|
15545
15811
|
|
|
15546
15812
|
// src/utils/safe_storage.ts
|
|
@@ -16023,6 +16289,7 @@ var PersistController = class extends Controller {
|
|
|
16023
16289
|
this.dispatch("error", { detail: { key, operation, reason } });
|
|
16024
16290
|
}
|
|
16025
16291
|
};
|
|
16292
|
+
var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
|
|
16026
16293
|
var PointerDragController = class _PointerDragController extends Controller {
|
|
16027
16294
|
static targets = ["handle"];
|
|
16028
16295
|
static values = {
|
|
@@ -16032,6 +16299,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16032
16299
|
disabled: { type: Boolean, default: false },
|
|
16033
16300
|
follow: { type: Boolean, default: false }
|
|
16034
16301
|
};
|
|
16302
|
+
static actions = ["reset"];
|
|
16035
16303
|
static events = ["start", "move", "end", "cancel"];
|
|
16036
16304
|
/** Marker attribute recording that this controller set the touch-action. */
|
|
16037
16305
|
static #TOUCH_ACTION_MARKER = "data-pointer-drag-touch-action";
|
|
@@ -16065,23 +16333,49 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16065
16333
|
}
|
|
16066
16334
|
this.#teardown();
|
|
16067
16335
|
}
|
|
16068
|
-
/**
|
|
16336
|
+
/**
|
|
16337
|
+
* Returns the element to its origin: drops the committed follow offset and the
|
|
16338
|
+
* inline `translate` that carries it.
|
|
16339
|
+
*
|
|
16340
|
+
* In follow mode the inline `translate` belongs to this controller, and the
|
|
16341
|
+
* committed offset lives in a field the DOM cannot reach — so a consumer that
|
|
16342
|
+
* wants the element back at the start, or that has written a position of its
|
|
16343
|
+
* own, needs this to make the two agree again. An in-flight drag is cancelled
|
|
16344
|
+
* first, or its deltas would land on top of the offset just cleared.
|
|
16345
|
+
*/
|
|
16346
|
+
reset() {
|
|
16347
|
+
const live = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
|
|
16348
|
+
if (this.#pointer || this.#keyboard) this.#teardownSessions();
|
|
16349
|
+
if (live) this.#dispatchCancel(live);
|
|
16350
|
+
this.#followBase = { x: 0, y: 0 };
|
|
16351
|
+
this.#applyFollow(0, 0);
|
|
16352
|
+
}
|
|
16069
16353
|
handleTargetConnected(handle) {
|
|
16354
|
+
if (handle !== this.element) this.#restoreHandle(this.element);
|
|
16070
16355
|
this.#prepareHandle(handle);
|
|
16071
16356
|
}
|
|
16072
16357
|
handleTargetDisconnected(handle) {
|
|
16073
|
-
if (!this.element.contains(handle)) {
|
|
16074
|
-
if (this.#pointer?.handle === handle) this.#endPointerSession();
|
|
16075
|
-
if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
|
|
16076
|
-
}
|
|
16077
16358
|
this.#restoreHandle(handle);
|
|
16359
|
+
if (this.element.contains(handle)) return;
|
|
16360
|
+
const interrupted = this.#pointer?.handle === handle && this.#pointer.started ? this.#pointer.pointerType : this.#keyboard?.handle === handle ? "keyboard" : null;
|
|
16361
|
+
if (this.#pointer?.handle === handle) this.#endPointerSession();
|
|
16362
|
+
if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
|
|
16363
|
+
if (!this.hasHandleTarget) this.#prepareHandle(this.element);
|
|
16364
|
+
if (interrupted) this.#dispatchCancel(interrupted);
|
|
16078
16365
|
}
|
|
16079
|
-
/**
|
|
16080
|
-
|
|
16366
|
+
/**
|
|
16367
|
+
* Re-derives the handles' touch-action when the axis changes.
|
|
16368
|
+
*
|
|
16369
|
+
* Two layers, like the `tabindex` loan: the marker says the value was once
|
|
16370
|
+
* ours, and the current inline value says it still is. A consumer that wrote
|
|
16371
|
+
* its own `touch-action` after connect keeps it.
|
|
16372
|
+
*/
|
|
16373
|
+
axisValueChanged(_value, previousValue) {
|
|
16374
|
+
const lent = this.#touchActionFor(previousValue ?? this.axisValue);
|
|
16081
16375
|
for (const handle of this.#handles()) {
|
|
16082
|
-
if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER))
|
|
16083
|
-
|
|
16084
|
-
|
|
16376
|
+
if (!handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) continue;
|
|
16377
|
+
if (handle.style.touchAction !== lent) continue;
|
|
16378
|
+
handle.style.touchAction = this.#touchActionForAxis();
|
|
16085
16379
|
}
|
|
16086
16380
|
}
|
|
16087
16381
|
/** Cancels any in-flight session when the controller is disabled mid-drag. */
|
|
@@ -16162,9 +16456,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16162
16456
|
const handle = this.#handleFor(event.target);
|
|
16163
16457
|
if (!handle) return;
|
|
16164
16458
|
if (event.defaultPrevented) return;
|
|
16459
|
+
if (event.isComposing) return;
|
|
16460
|
+
if (this.#ownsNativeKeys(event.target, handle)) return;
|
|
16165
16461
|
if (isReservedArrowChord(event)) return;
|
|
16166
16462
|
if (event.key === "Escape") {
|
|
16167
|
-
if (event.isComposing) return;
|
|
16168
16463
|
if (this.#pointer?.started) {
|
|
16169
16464
|
const { pointerType } = this.#pointer;
|
|
16170
16465
|
this.#teardownSessions();
|
|
@@ -16273,6 +16568,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16273
16568
|
const interrupted = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
|
|
16274
16569
|
this.#teardownSessions();
|
|
16275
16570
|
for (const handle of this.#handles()) this.#restoreHandle(handle);
|
|
16571
|
+
this.#followReset();
|
|
16276
16572
|
if (interrupted && this.element.isConnected) this.#dispatchCancel(interrupted);
|
|
16277
16573
|
}
|
|
16278
16574
|
/**
|
|
@@ -16342,8 +16638,12 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16342
16638
|
}
|
|
16343
16639
|
/** `touch-action` that lets the page keep panning on the locked axis only. */
|
|
16344
16640
|
#touchActionForAxis() {
|
|
16345
|
-
|
|
16346
|
-
|
|
16641
|
+
return this.#touchActionFor(this.axisValue);
|
|
16642
|
+
}
|
|
16643
|
+
/** The `touch-action` an `axis` declaration lends, for any axis value. */
|
|
16644
|
+
#touchActionFor(axis) {
|
|
16645
|
+
if (axis === "x") return "pan-y";
|
|
16646
|
+
if (axis === "y") return "pan-x";
|
|
16347
16647
|
return "none";
|
|
16348
16648
|
}
|
|
16349
16649
|
/**
|
|
@@ -16354,8 +16654,10 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16354
16654
|
* are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
|
|
16355
16655
|
*/
|
|
16356
16656
|
#prepareHandle(handle) {
|
|
16357
|
-
|
|
16358
|
-
|
|
16657
|
+
const lent = this.#touchActionForAxis();
|
|
16658
|
+
const marked = handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
|
|
16659
|
+
if (handle.style.touchAction === "" || marked && handle.style.touchAction === lent) {
|
|
16660
|
+
handle.style.touchAction = lent;
|
|
16359
16661
|
handle.setAttribute(_PointerDragController.#TOUCH_ACTION_MARKER, "true");
|
|
16360
16662
|
}
|
|
16361
16663
|
if (handle.tabIndex < 0 && !handle.hasAttribute("tabindex")) {
|
|
@@ -16363,17 +16665,27 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
16363
16665
|
handle.setAttribute(_PointerDragController.#TABINDEX_MARKER, "true");
|
|
16364
16666
|
}
|
|
16365
16667
|
}
|
|
16668
|
+
/** Whether the key belongs to a native control or editing surface in the handle. */
|
|
16669
|
+
#ownsNativeKeys(target, handle) {
|
|
16670
|
+
const owner = target?.closest(NATIVE_KEY_OWNERS) ?? null;
|
|
16671
|
+
return owner !== null && owner !== handle && handle.contains(owner);
|
|
16672
|
+
}
|
|
16366
16673
|
/** Reverts the marker-owned touch-action + tabindex (authored values untouched). */
|
|
16367
16674
|
#restoreHandle(handle) {
|
|
16368
16675
|
if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
|
|
16369
|
-
handle.style.touchAction
|
|
16676
|
+
if (handle.style.touchAction === this.#touchActionForAxis()) {
|
|
16677
|
+
handle.style.touchAction = "";
|
|
16678
|
+
}
|
|
16370
16679
|
handle.removeAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
|
|
16371
16680
|
}
|
|
16372
16681
|
if (handle.hasAttribute(_PointerDragController.#TABINDEX_MARKER)) {
|
|
16373
|
-
handle.
|
|
16374
|
-
if (
|
|
16682
|
+
const ours = handle.getAttribute("tabindex") === "0";
|
|
16683
|
+
if (ours && document.activeElement !== handle) {
|
|
16375
16684
|
handle.removeAttribute("tabindex");
|
|
16685
|
+
handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
|
|
16686
|
+
return;
|
|
16376
16687
|
}
|
|
16688
|
+
if (!ours) handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
|
|
16377
16689
|
}
|
|
16378
16690
|
}
|
|
16379
16691
|
};
|
|
@@ -16486,15 +16798,42 @@ var PortalController = class extends Controller {
|
|
|
16486
16798
|
static events = ["mount", "unmount"];
|
|
16487
16799
|
/** Decides whether a `disconnect()` is an in-page move or a real detach. */
|
|
16488
16800
|
#gate = new DetachGate();
|
|
16801
|
+
/** The `to` declaration after validation; the default when it cannot be parsed. */
|
|
16802
|
+
#toSelector = "body";
|
|
16803
|
+
/**
|
|
16804
|
+
* Validates the destination declaration once, keeping only a selector the engine can
|
|
16805
|
+
* read.
|
|
16806
|
+
*
|
|
16807
|
+
* A selector reads back as an ordinary string, so a malformed one survives until it is
|
|
16808
|
+
* handed to the DOM — where it would take the whole teleport down and leave the part
|
|
16809
|
+
* silently doing nothing. Falling back to the default puts the node somewhere visible
|
|
16810
|
+
* instead, which reads as a mistake and can be traced back to the declaration.
|
|
16811
|
+
*/
|
|
16812
|
+
toValueChanged() {
|
|
16813
|
+
const selector = this.toValue;
|
|
16814
|
+
if (selector.length > 0) {
|
|
16815
|
+
try {
|
|
16816
|
+
this.element.matches(selector);
|
|
16817
|
+
this.#toSelector = selector;
|
|
16818
|
+
return;
|
|
16819
|
+
} catch {
|
|
16820
|
+
}
|
|
16821
|
+
}
|
|
16822
|
+
this.#toSelector = "body";
|
|
16823
|
+
}
|
|
16489
16824
|
connect() {
|
|
16490
16825
|
this.#gate.cancel();
|
|
16491
|
-
|
|
16826
|
+
const existing = portalState.get(this.element);
|
|
16827
|
+
if (existing) {
|
|
16828
|
+
existing.owner = this;
|
|
16829
|
+
return;
|
|
16830
|
+
}
|
|
16492
16831
|
const node = this.hasContentTarget ? this.contentTarget : this.element;
|
|
16493
16832
|
const destination = this.#destination();
|
|
16494
16833
|
if (!destination || destination === node || node.contains(destination)) return;
|
|
16495
16834
|
const placeholder = document.createComment("stimeo--portal");
|
|
16496
16835
|
node.parentNode?.insertBefore(placeholder, node);
|
|
16497
|
-
portalState.set(this.element, { node, placeholder });
|
|
16836
|
+
portalState.set(this.element, { node, placeholder, owner: this });
|
|
16498
16837
|
if (this.positionValue === "prepend") {
|
|
16499
16838
|
destination.prepend(node);
|
|
16500
16839
|
} else {
|
|
@@ -16513,7 +16852,7 @@ var PortalController = class extends Controller {
|
|
|
16513
16852
|
}
|
|
16514
16853
|
this.#gate.disconnected(this, () => {
|
|
16515
16854
|
const current = portalState.get(this.element);
|
|
16516
|
-
if (current) this.#restore(current);
|
|
16855
|
+
if (current?.owner === this) this.#restore(current);
|
|
16517
16856
|
});
|
|
16518
16857
|
}
|
|
16519
16858
|
/** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
|
|
@@ -16529,30 +16868,84 @@ var PortalController = class extends Controller {
|
|
|
16529
16868
|
placeholder.remove();
|
|
16530
16869
|
this.dispatch("unmount", { detail: {} });
|
|
16531
16870
|
}
|
|
16532
|
-
/** Resolves the destination
|
|
16871
|
+
/** Resolves the destination from the validated `to` selector. */
|
|
16533
16872
|
#destination() {
|
|
16534
|
-
|
|
16535
|
-
|
|
16536
|
-
|
|
16537
|
-
|
|
16538
|
-
|
|
16539
|
-
|
|
16873
|
+
return document.querySelector(this.#toSelector);
|
|
16874
|
+
}
|
|
16875
|
+
};
|
|
16876
|
+
|
|
16877
|
+
// src/utils/style_property_lease.ts
|
|
16878
|
+
var StylePropertyLease = class {
|
|
16879
|
+
#property;
|
|
16880
|
+
#records = /* @__PURE__ */ new Map();
|
|
16881
|
+
/** @param property - The CSS property whose temporary values this lease owns. */
|
|
16882
|
+
constructor(property) {
|
|
16883
|
+
this.#property = property;
|
|
16884
|
+
}
|
|
16885
|
+
/** Writes or removes the leased declaration while preserving its authored value. */
|
|
16886
|
+
write(element, value, priority = "") {
|
|
16887
|
+
const existing = this.#records.get(element);
|
|
16888
|
+
if (existing) {
|
|
16889
|
+
existing.writtenValue = value;
|
|
16890
|
+
existing.writtenPriority = value === null ? "" : priority;
|
|
16891
|
+
} else {
|
|
16892
|
+
this.#records.set(element, {
|
|
16893
|
+
originalValue: element.style.getPropertyValue(this.#property),
|
|
16894
|
+
originalPriority: element.style.getPropertyPriority(this.#property),
|
|
16895
|
+
writtenValue: value,
|
|
16896
|
+
writtenPriority: value === null ? "" : priority
|
|
16897
|
+
});
|
|
16898
|
+
}
|
|
16899
|
+
this.#reflect(element, value, priority);
|
|
16900
|
+
}
|
|
16901
|
+
/** Returns one lease without overwriting a later consumer declaration. */
|
|
16902
|
+
return(element) {
|
|
16903
|
+
const record = this.#records.get(element);
|
|
16904
|
+
if (!record) return;
|
|
16905
|
+
this.#records.delete(element);
|
|
16906
|
+
const style = element.style;
|
|
16907
|
+
const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
|
|
16908
|
+
if (stillOwned) {
|
|
16909
|
+
this.#reflect(element, record.originalValue, record.originalPriority);
|
|
16910
|
+
}
|
|
16911
|
+
}
|
|
16912
|
+
/** Returns every outstanding declaration lease. */
|
|
16913
|
+
returnAll() {
|
|
16914
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
16915
|
+
}
|
|
16916
|
+
/** Reflects only a real declaration transition. */
|
|
16917
|
+
#reflect(element, value, priority) {
|
|
16918
|
+
const style = element.style;
|
|
16919
|
+
const nextValue = value ?? "";
|
|
16920
|
+
const nextPriority = value === null ? "" : priority;
|
|
16921
|
+
if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
|
|
16922
|
+
return;
|
|
16540
16923
|
}
|
|
16924
|
+
if (value === null) style.removeProperty(this.#property);
|
|
16925
|
+
else style.setProperty(this.#property, value, priority);
|
|
16541
16926
|
}
|
|
16542
16927
|
};
|
|
16928
|
+
|
|
16929
|
+
// src/controllers/preview_guard_controller.ts
|
|
16543
16930
|
var PreviewGuardController = class extends Controller {
|
|
16544
16931
|
static values = {
|
|
16545
|
-
placeholder: { type: String, default: "" }
|
|
16546
|
-
mode: { type: String, default: "hide" }
|
|
16932
|
+
placeholder: { type: String, default: "" }
|
|
16547
16933
|
};
|
|
16548
16934
|
static events = ["hide", "show"];
|
|
16935
|
+
#visibility = new StylePropertyLease("visibility");
|
|
16936
|
+
#beforeCache = new BeforeCacheReset(() => this.#restore());
|
|
16549
16937
|
#observer = null;
|
|
16938
|
+
#connected = false;
|
|
16550
16939
|
#hidden = false;
|
|
16551
|
-
/**
|
|
16552
|
-
#
|
|
16553
|
-
/**
|
|
16554
|
-
#
|
|
16940
|
+
/** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */
|
|
16941
|
+
#savedNodes = null;
|
|
16942
|
+
/** The descendant that held focus when the guard went up, so show can hand it back. */
|
|
16943
|
+
#focused = null;
|
|
16555
16944
|
connect() {
|
|
16945
|
+
this.#connected = true;
|
|
16946
|
+
if (this.#hidden) this.#reguard();
|
|
16947
|
+
else this.element.removeAttribute("data-preview-hidden");
|
|
16948
|
+
this.#beforeCache.activate();
|
|
16556
16949
|
if (typeof MutationObserver !== "undefined") {
|
|
16557
16950
|
this.#observer = new MutationObserver(() => this.#sync());
|
|
16558
16951
|
this.#observer.observe(document.documentElement, {
|
|
@@ -16563,44 +16956,115 @@ var PreviewGuardController = class extends Controller {
|
|
|
16563
16956
|
this.#sync();
|
|
16564
16957
|
}
|
|
16565
16958
|
disconnect() {
|
|
16959
|
+
this.#connected = false;
|
|
16960
|
+
this.#beforeCache.deactivate();
|
|
16566
16961
|
this.#observer?.disconnect();
|
|
16567
16962
|
this.#observer = null;
|
|
16568
|
-
this.#restore();
|
|
16569
16963
|
}
|
|
16570
|
-
/**
|
|
16964
|
+
/**
|
|
16965
|
+
* Re-guards to match a `placeholder` changed at runtime — the value is the content on
|
|
16966
|
+
* display while the guard is up, so a morph that swaps it must not leave the old one.
|
|
16967
|
+
*/
|
|
16968
|
+
placeholderValueChanged() {
|
|
16969
|
+
if (!this.#connected || !this.#hidden) return;
|
|
16970
|
+
this.#reguard();
|
|
16971
|
+
}
|
|
16972
|
+
/**
|
|
16973
|
+
* Reflects the current `data-turbo-preview` state onto the element.
|
|
16974
|
+
*
|
|
16975
|
+
* @stimeoRenderRoot
|
|
16976
|
+
*/
|
|
16571
16977
|
#sync() {
|
|
16572
16978
|
const previewing = document.documentElement.hasAttribute("data-turbo-preview");
|
|
16573
16979
|
if (previewing && !this.#hidden) this.#hide();
|
|
16574
16980
|
else if (!previewing && this.#hidden) this.#show();
|
|
16575
16981
|
}
|
|
16576
16982
|
#hide() {
|
|
16983
|
+
this.#focused = this.#focusedInside();
|
|
16984
|
+
this.#applyGuard();
|
|
16985
|
+
this.dispatch("hide", { detail: {} });
|
|
16986
|
+
}
|
|
16987
|
+
/** Puts the guard up in the form the current `placeholder` calls for. */
|
|
16988
|
+
#applyGuard() {
|
|
16577
16989
|
this.#hidden = true;
|
|
16578
|
-
if (this.
|
|
16579
|
-
this.#
|
|
16580
|
-
this.element.textContent = this.placeholderValue;
|
|
16990
|
+
if (this.placeholderValue === "") {
|
|
16991
|
+
this.#visibility.write(this.element, "hidden");
|
|
16581
16992
|
} else {
|
|
16582
|
-
this.#
|
|
16583
|
-
this.element.
|
|
16993
|
+
this.#savedNodes = document.createDocumentFragment();
|
|
16994
|
+
this.#savedNodes.append(...this.element.childNodes);
|
|
16995
|
+
this.element.textContent = this.placeholderValue;
|
|
16584
16996
|
}
|
|
16585
16997
|
this.element.setAttribute("data-preview-hidden", "true");
|
|
16586
|
-
|
|
16998
|
+
}
|
|
16999
|
+
/**
|
|
17000
|
+
* Re-forms a guard that is already up so it matches the current `placeholder`.
|
|
17001
|
+
*
|
|
17002
|
+
* No event: `hide` reports that the guard went up, and it has not come down. Swapping
|
|
17003
|
+
* one stand-in text for another writes only that text — putting the held content back
|
|
17004
|
+
* first would reconnect the whole subtree for an instant. Only a change of *form* —
|
|
17005
|
+
* to or from the empty placeholder — has to revert, and the focus the guard is holding
|
|
17006
|
+
* carries across it rather than being handed back and taken again.
|
|
17007
|
+
*/
|
|
17008
|
+
#reguard() {
|
|
17009
|
+
if (this.#savedNodes && this.placeholderValue !== "") {
|
|
17010
|
+
this.element.textContent = this.placeholderValue;
|
|
17011
|
+
return;
|
|
17012
|
+
}
|
|
17013
|
+
const held = this.#focused;
|
|
17014
|
+
this.#revert();
|
|
17015
|
+
this.#applyGuard();
|
|
17016
|
+
this.#focused = held;
|
|
16587
17017
|
}
|
|
16588
17018
|
#show() {
|
|
16589
17019
|
this.#restore();
|
|
16590
17020
|
this.dispatch("show", { detail: {} });
|
|
16591
17021
|
}
|
|
16592
|
-
/** Reverts the guard
|
|
17022
|
+
/** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */
|
|
16593
17023
|
#restore() {
|
|
16594
|
-
|
|
17024
|
+
this.#revert();
|
|
17025
|
+
this.#refocus();
|
|
17026
|
+
}
|
|
17027
|
+
/**
|
|
17028
|
+
* Puts the element back the way the guard found it.
|
|
17029
|
+
*
|
|
17030
|
+
* Every step is a no-op on an element this controller never guarded: the lease returns
|
|
17031
|
+
* only declarations it recorded, and the hook is removed whether or not it is there.
|
|
17032
|
+
*/
|
|
17033
|
+
#revert() {
|
|
16595
17034
|
this.#hidden = false;
|
|
16596
|
-
if (this.#
|
|
16597
|
-
this.element.textContent =
|
|
16598
|
-
this.#
|
|
17035
|
+
if (this.#savedNodes) {
|
|
17036
|
+
this.element.textContent = "";
|
|
17037
|
+
this.element.append(this.#savedNodes);
|
|
17038
|
+
this.#savedNodes = null;
|
|
16599
17039
|
} else {
|
|
16600
|
-
this.element
|
|
17040
|
+
this.#visibility.return(this.element);
|
|
16601
17041
|
}
|
|
16602
17042
|
this.element.removeAttribute("data-preview-hidden");
|
|
16603
17043
|
}
|
|
17044
|
+
/**
|
|
17045
|
+
* The focused element the guard is about to make unfocusable, if any.
|
|
17046
|
+
*
|
|
17047
|
+
* The element itself counts as well as its descendants: guarding takes focus either way
|
|
17048
|
+
* — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes
|
|
17049
|
+
* outright — so the browser drops focus to `<body>` the moment the guard goes up.
|
|
17050
|
+
*/
|
|
17051
|
+
#focusedInside() {
|
|
17052
|
+
const active = document.activeElement;
|
|
17053
|
+
return active instanceof HTMLElement && this.element.contains(active) ? active : null;
|
|
17054
|
+
}
|
|
17055
|
+
/**
|
|
17056
|
+
* Hands focus back to the element the guard took it from.
|
|
17057
|
+
*
|
|
17058
|
+
* Only when that element is still in the document and focus has not moved on since —
|
|
17059
|
+
* anything else is the user's or another controller's, and putting it back would be
|
|
17060
|
+
* taking it.
|
|
17061
|
+
*/
|
|
17062
|
+
#refocus() {
|
|
17063
|
+
const target = this.#focused;
|
|
17064
|
+
this.#focused = null;
|
|
17065
|
+
if (!target?.isConnected || document.activeElement !== document.body) return;
|
|
17066
|
+
target.focus();
|
|
17067
|
+
}
|
|
16604
17068
|
};
|
|
16605
17069
|
var OWNED_VALUE_TEXT2 = "data-stimeo--progress-owns-valuetext";
|
|
16606
17070
|
var ProgressController = class extends Controller {
|
|
@@ -18326,7 +18790,7 @@ var ResizableController = class extends Controller {
|
|
|
18326
18790
|
this.#dispatchChange();
|
|
18327
18791
|
}
|
|
18328
18792
|
}
|
|
18329
|
-
/**
|
|
18793
|
+
/** Collapses the primary pane to its minimum, or returns it to the last position. */
|
|
18330
18794
|
toggle() {
|
|
18331
18795
|
const { min, max } = this.#range;
|
|
18332
18796
|
if (this.#position > min) {
|
|
@@ -18402,6 +18866,7 @@ var ResizableController = class extends Controller {
|
|
|
18402
18866
|
return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
|
|
18403
18867
|
}
|
|
18404
18868
|
};
|
|
18869
|
+
var STATE_ATTRIBUTES2 = ["disabled", "hidden"];
|
|
18405
18870
|
var RovingController = class extends Controller {
|
|
18406
18871
|
static targets = ["item"];
|
|
18407
18872
|
static values = {
|
|
@@ -18411,12 +18876,14 @@ var RovingController = class extends Controller {
|
|
|
18411
18876
|
};
|
|
18412
18877
|
static events = ["change"];
|
|
18413
18878
|
#roving = new RovingTabindex(() => this.itemTargets);
|
|
18414
|
-
#reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
|
|
18879
|
+
#reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop(true));
|
|
18415
18880
|
#connected = false;
|
|
18881
|
+
#observer = null;
|
|
18416
18882
|
connect() {
|
|
18417
|
-
this.#ensureTabStop();
|
|
18883
|
+
this.#ensureTabStop(false);
|
|
18418
18884
|
this.element.addEventListener("keydown", this.#onKeydown);
|
|
18419
18885
|
this.element.addEventListener("focusin", this.#onFocusin);
|
|
18886
|
+
this.#watchState();
|
|
18420
18887
|
this.#connected = true;
|
|
18421
18888
|
this.#reconcile.activate();
|
|
18422
18889
|
}
|
|
@@ -18425,6 +18892,8 @@ var RovingController = class extends Controller {
|
|
|
18425
18892
|
this.#reconcile.cancel();
|
|
18426
18893
|
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
18427
18894
|
this.element.removeEventListener("focusin", this.#onFocusin);
|
|
18895
|
+
this.#observer?.disconnect();
|
|
18896
|
+
this.#observer = null;
|
|
18428
18897
|
}
|
|
18429
18898
|
/** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
|
|
18430
18899
|
itemTargetConnected(item) {
|
|
@@ -18440,10 +18909,9 @@ var RovingController = class extends Controller {
|
|
|
18440
18909
|
#onKeydown = (event) => {
|
|
18441
18910
|
if (event.defaultPrevented) return;
|
|
18442
18911
|
if (isReservedArrowChord(event)) return;
|
|
18443
|
-
|
|
18912
|
+
if (event.isComposing) return;
|
|
18444
18913
|
const current = this.#indexOf(event.target);
|
|
18445
18914
|
if (current === -1) return;
|
|
18446
|
-
const length = items.length;
|
|
18447
18915
|
const wrap = this.wrapValue ? "wrap" : "clamp";
|
|
18448
18916
|
const orientation = this.orientationValue;
|
|
18449
18917
|
const horizontal = orientation === "horizontal" || orientation === "both";
|
|
@@ -18453,16 +18921,16 @@ var RovingController = class extends Controller {
|
|
|
18453
18921
|
const backwardKey = rtl ? "ArrowRight" : "ArrowLeft";
|
|
18454
18922
|
let next;
|
|
18455
18923
|
if (horizontal && event.key === forwardKey || vertical && event.key === "ArrowDown") {
|
|
18456
|
-
next =
|
|
18924
|
+
next = this.#step(current, 1, wrap);
|
|
18457
18925
|
} else if (horizontal && event.key === backwardKey || vertical && event.key === "ArrowUp") {
|
|
18458
|
-
next =
|
|
18459
|
-
} else if (this.homeEndValue && event.key === "Home") {
|
|
18460
|
-
|
|
18461
|
-
|
|
18462
|
-
next = length - 1;
|
|
18926
|
+
next = this.#step(current, -1, wrap);
|
|
18927
|
+
} else if (this.homeEndValue && (event.key === "Home" || event.key === "End")) {
|
|
18928
|
+
if (hasModifierChord(event)) return;
|
|
18929
|
+
next = event.key === "Home" ? this.#firstReachable() : this.#lastReachable();
|
|
18463
18930
|
} else {
|
|
18464
18931
|
return;
|
|
18465
18932
|
}
|
|
18933
|
+
if (next === -1) return;
|
|
18466
18934
|
event.preventDefault();
|
|
18467
18935
|
this.#activate(next, true);
|
|
18468
18936
|
};
|
|
@@ -18473,7 +18941,8 @@ var RovingController = class extends Controller {
|
|
|
18473
18941
|
*/
|
|
18474
18942
|
#onFocusin = (event) => {
|
|
18475
18943
|
const index = this.#indexOf(event.target);
|
|
18476
|
-
if (index
|
|
18944
|
+
if (index === -1 || !this.#reachable(index)) return;
|
|
18945
|
+
this.#activate(index, false);
|
|
18477
18946
|
};
|
|
18478
18947
|
/** Resolves the item index owning an event target (the item or a descendant). */
|
|
18479
18948
|
#indexOf(target) {
|
|
@@ -18489,66 +18958,106 @@ var RovingController = class extends Controller {
|
|
|
18489
18958
|
this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
|
|
18490
18959
|
}
|
|
18491
18960
|
}
|
|
18492
|
-
/**
|
|
18493
|
-
|
|
18961
|
+
/**
|
|
18962
|
+
* Keeps the Tab stop on a reachable item.
|
|
18963
|
+
*
|
|
18964
|
+
* `followFocus` is on for re-establishment only: an item added and focused in
|
|
18965
|
+
* the same task has already claimed the stop through `focusin`, and the batch
|
|
18966
|
+
* that follows must not hand it back to the first item. `connect()` passes it
|
|
18967
|
+
* off so the authored DOM decides the initial stop. With nothing reachable the
|
|
18968
|
+
* DOM is left as it is — a group inside a collapsed region gets its stop back
|
|
18969
|
+
* when the region opens, instead of losing it for good.
|
|
18970
|
+
*/
|
|
18971
|
+
#ensureTabStop(followFocus) {
|
|
18972
|
+
const items = this.itemTargets;
|
|
18973
|
+
const focused = followFocus ? items.findIndex((item, i) => item === document.activeElement && this.#reachable(i)) : -1;
|
|
18494
18974
|
const active = this.#roving.activeIndex;
|
|
18495
|
-
|
|
18496
|
-
|
|
18497
|
-
|
|
18498
|
-
|
|
18499
|
-
// src/utils/style_property_lease.ts
|
|
18500
|
-
var StylePropertyLease = class {
|
|
18501
|
-
#property;
|
|
18502
|
-
#records = /* @__PURE__ */ new Map();
|
|
18503
|
-
/** @param property - The CSS property whose temporary values this lease owns. */
|
|
18504
|
-
constructor(property) {
|
|
18505
|
-
this.#property = property;
|
|
18975
|
+
const kept = active !== -1 && this.#reachable(active) ? active : -1;
|
|
18976
|
+
const index = focused !== -1 ? focused : kept !== -1 ? kept : this.#firstReachable();
|
|
18977
|
+
if (index === -1) return;
|
|
18978
|
+
this.#roving.setActive(index);
|
|
18506
18979
|
}
|
|
18507
|
-
/**
|
|
18508
|
-
|
|
18509
|
-
|
|
18510
|
-
|
|
18511
|
-
|
|
18512
|
-
|
|
18513
|
-
|
|
18514
|
-
|
|
18515
|
-
|
|
18516
|
-
|
|
18517
|
-
|
|
18518
|
-
|
|
18519
|
-
|
|
18980
|
+
/**
|
|
18981
|
+
* Resolves the next reachable index in `delta`'s direction, honouring `wrap`.
|
|
18982
|
+
*
|
|
18983
|
+
* An arrow on the widget's own axis is the widget's to consume even when the
|
|
18984
|
+
* position does not change, so a clamped end resolves to the current item
|
|
18985
|
+
* rather than to nothing. An unreachable origin escapes to the first reachable
|
|
18986
|
+
* item instead of sitting in a dead end, and `-1` is left for the one case that
|
|
18987
|
+
* really is not ours: no reachable item anywhere.
|
|
18988
|
+
*/
|
|
18989
|
+
#step(current, delta, wrap) {
|
|
18990
|
+
const length = this.itemTargets.length;
|
|
18991
|
+
let index = current;
|
|
18992
|
+
for (let taken = 0; taken < length; taken += 1) {
|
|
18993
|
+
const candidate = rovingMove(index, length, delta, wrap);
|
|
18994
|
+
if (candidate === index) break;
|
|
18995
|
+
index = candidate;
|
|
18996
|
+
if (this.#reachable(index)) return index;
|
|
18997
|
+
}
|
|
18998
|
+
return this.#reachable(current) ? current : this.#firstReachable();
|
|
18999
|
+
}
|
|
19000
|
+
/** Index of the first reachable item, or `-1`. */
|
|
19001
|
+
#firstReachable() {
|
|
19002
|
+
return this.itemTargets.findIndex((_item, i) => this.#reachable(i));
|
|
19003
|
+
}
|
|
19004
|
+
/** Index of the last reachable item, or `-1`. */
|
|
19005
|
+
#lastReachable() {
|
|
19006
|
+
for (let i = this.itemTargets.length - 1; i >= 0; i -= 1) {
|
|
19007
|
+
if (this.#reachable(i)) return i;
|
|
18520
19008
|
}
|
|
18521
|
-
|
|
19009
|
+
return -1;
|
|
18522
19010
|
}
|
|
18523
|
-
/**
|
|
18524
|
-
|
|
18525
|
-
|
|
18526
|
-
|
|
18527
|
-
|
|
18528
|
-
|
|
18529
|
-
|
|
18530
|
-
|
|
18531
|
-
|
|
18532
|
-
|
|
19011
|
+
/**
|
|
19012
|
+
* Whether the item at `index` can hold the Tab stop and take focus.
|
|
19013
|
+
*
|
|
19014
|
+
* `aria-disabled` is deliberately not consulted: it keeps an item reachable and
|
|
19015
|
+
* only suppresses activation, which this part does not own.
|
|
19016
|
+
*/
|
|
19017
|
+
#reachable(index) {
|
|
19018
|
+
const item = this.itemTargets[index];
|
|
19019
|
+
if (!item) return false;
|
|
19020
|
+
if (this.#hidden(item)) return false;
|
|
19021
|
+
if (!("disabled" in item)) return true;
|
|
19022
|
+
if (item.disabled) return false;
|
|
19023
|
+
return !inheritsFieldsetDisabled(item);
|
|
18533
19024
|
}
|
|
18534
|
-
/**
|
|
18535
|
-
|
|
18536
|
-
|
|
19025
|
+
/**
|
|
19026
|
+
* Whether `item`, or anything between it and the container, is `hidden`.
|
|
19027
|
+
*
|
|
19028
|
+
* The walk stops at the container on purpose: a group inside a hidden region is
|
|
19029
|
+
* already out of the page's Tab order, and calling every item unreachable there
|
|
19030
|
+
* would drop the stop with nothing left to restore it — the ancestor lies
|
|
19031
|
+
* outside the subtree whose state attributes are watched.
|
|
19032
|
+
*/
|
|
19033
|
+
#hidden(item) {
|
|
19034
|
+
let node = item;
|
|
19035
|
+
while (node && node !== this.element) {
|
|
19036
|
+
if (node.hasAttribute("hidden")) return true;
|
|
19037
|
+
node = node.parentElement;
|
|
19038
|
+
}
|
|
19039
|
+
return false;
|
|
18537
19040
|
}
|
|
18538
|
-
/**
|
|
18539
|
-
|
|
18540
|
-
|
|
18541
|
-
|
|
18542
|
-
|
|
18543
|
-
|
|
18544
|
-
|
|
19041
|
+
/**
|
|
19042
|
+
* Watches the attributes that decide reachability, so disabling the item that
|
|
19043
|
+
* holds the Tab stop hands it to another one instead of taking the whole set
|
|
19044
|
+
* out of the Tab sequence. An enclosing `fieldset` disables items from outside
|
|
19045
|
+
* the observed subtree, so each one's own `disabled` is watched too.
|
|
19046
|
+
*/
|
|
19047
|
+
#watchState() {
|
|
19048
|
+
if (typeof MutationObserver === "undefined") return;
|
|
19049
|
+
const observer = new MutationObserver(() => this.#reconcile.schedule());
|
|
19050
|
+
observer.observe(this.element, {
|
|
19051
|
+
subtree: true,
|
|
19052
|
+
attributes: true,
|
|
19053
|
+
attributeFilter: STATE_ATTRIBUTES2
|
|
19054
|
+
});
|
|
19055
|
+
for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
|
|
19056
|
+
observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
|
|
18545
19057
|
}
|
|
18546
|
-
|
|
18547
|
-
else style.setProperty(this.#property, value, priority);
|
|
19058
|
+
this.#observer = observer;
|
|
18548
19059
|
}
|
|
18549
19060
|
};
|
|
18550
|
-
|
|
18551
|
-
// src/controllers/scroll_area_controller.ts
|
|
18552
19061
|
var EDGE_EPSILON = 1;
|
|
18553
19062
|
var ScrollAreaController = class extends Controller {
|
|
18554
19063
|
static targets = ["viewport"];
|
|
@@ -18908,6 +19417,17 @@ var ScrollAreaController = class extends Controller {
|
|
|
18908
19417
|
this.#clearHostState();
|
|
18909
19418
|
}
|
|
18910
19419
|
};
|
|
19420
|
+
function parseStored(raw) {
|
|
19421
|
+
if (raw === null) return null;
|
|
19422
|
+
try {
|
|
19423
|
+
return JSON.parse(raw);
|
|
19424
|
+
} catch {
|
|
19425
|
+
return null;
|
|
19426
|
+
}
|
|
19427
|
+
}
|
|
19428
|
+
function storedOffset(value) {
|
|
19429
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
19430
|
+
}
|
|
18911
19431
|
var ScrollRestoreController = class extends Controller {
|
|
18912
19432
|
static values = {
|
|
18913
19433
|
key: { type: String, default: "" },
|
|
@@ -18920,6 +19440,26 @@ var ScrollRestoreController = class extends Controller {
|
|
|
18920
19440
|
/** Last offset captured while the element was live; persisted as-is on teardown. */
|
|
18921
19441
|
#lastTop = 0;
|
|
18922
19442
|
#lastLeft = 0;
|
|
19443
|
+
/**
|
|
19444
|
+
* The offsets this controller's own restore produced, awaiting the `scroll`
|
|
19445
|
+
* the engine fires for them. A restore the layout cannot reach yet lands short,
|
|
19446
|
+
* so treating that echo as the reader's position would cut the saved offset
|
|
19447
|
+
* down to whatever the unfinished layout allowed.
|
|
19448
|
+
*/
|
|
19449
|
+
#echoTop = null;
|
|
19450
|
+
#echoLeft = null;
|
|
19451
|
+
/** Offsets stored for an axis that is not tracked, carried through saves. */
|
|
19452
|
+
#carried = {};
|
|
19453
|
+
/**
|
|
19454
|
+
* Whether each axis holds an offset worth saving — one this namespace restored
|
|
19455
|
+
* or the reader moved. A save writes only the axes that are marked, so an
|
|
19456
|
+
* offset taken under a different key or axis is never re-published as if the
|
|
19457
|
+
* reader had left it there.
|
|
19458
|
+
*/
|
|
19459
|
+
#capturedTop = false;
|
|
19460
|
+
#capturedLeft = false;
|
|
19461
|
+
/** Whether the controller is connected; Value callbacks outside that window do not resync. */
|
|
19462
|
+
#connected = false;
|
|
18923
19463
|
#onScroll = () => {
|
|
18924
19464
|
this.#capture();
|
|
18925
19465
|
if (this.#rafId !== null) return;
|
|
@@ -18929,58 +19469,127 @@ var ScrollRestoreController = class extends Controller {
|
|
|
18929
19469
|
});
|
|
18930
19470
|
};
|
|
18931
19471
|
connect() {
|
|
19472
|
+
this.#connected = true;
|
|
18932
19473
|
this.#storageKey = this.#resolveKey();
|
|
18933
|
-
if (!this.#storageKey) return;
|
|
18934
|
-
this.#restore();
|
|
18935
19474
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
19475
|
+
if (this.#storageKey) this.#restore();
|
|
18936
19476
|
}
|
|
18937
19477
|
disconnect() {
|
|
18938
|
-
|
|
19478
|
+
this.#connected = false;
|
|
18939
19479
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
19480
|
+
this.#cancelFrame();
|
|
19481
|
+
this.#persist();
|
|
19482
|
+
}
|
|
19483
|
+
/** Re-derives the namespace when application code or a Turbo morph moves `key`. */
|
|
19484
|
+
keyValueChanged() {
|
|
19485
|
+
this.#resync();
|
|
19486
|
+
}
|
|
19487
|
+
/** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */
|
|
19488
|
+
axisValueChanged() {
|
|
19489
|
+
this.#resync();
|
|
19490
|
+
}
|
|
19491
|
+
/** Rebuilds the persistence state around the Values as they now read. */
|
|
19492
|
+
#resync() {
|
|
19493
|
+
if (!this.#connected) return;
|
|
19494
|
+
this.#cancelFrame();
|
|
19495
|
+
this.#storageKey = this.#resolveKey();
|
|
19496
|
+
this.#echoTop = null;
|
|
19497
|
+
this.#echoLeft = null;
|
|
19498
|
+
this.#carried = {};
|
|
19499
|
+
this.#capturedTop = false;
|
|
19500
|
+
this.#capturedLeft = false;
|
|
19501
|
+
if (this.#storageKey) this.#restore();
|
|
19502
|
+
}
|
|
19503
|
+
/** Drops the pending coalesced save, if one is queued. */
|
|
19504
|
+
#cancelFrame() {
|
|
18940
19505
|
if (this.#rafId !== null) {
|
|
18941
19506
|
cancelAnimationFrame(this.#rafId);
|
|
18942
19507
|
this.#rafId = null;
|
|
18943
19508
|
}
|
|
18944
|
-
this.#persist();
|
|
18945
19509
|
}
|
|
18946
|
-
/**
|
|
19510
|
+
/**
|
|
19511
|
+
* Records the live scroll offset for the configured axis.
|
|
19512
|
+
*
|
|
19513
|
+
* An axis holds its echo until that axis actually moves. A scroll event names
|
|
19514
|
+
* no axis, so consuming both on the first one to arrive would leave the other
|
|
19515
|
+
* unguarded: on `both`, moving one axis would take the clamped reading of the
|
|
19516
|
+
* untouched axis as the reader's own position and save it, cutting the stored
|
|
19517
|
+
* offset down to whatever the layout could reach at restore time.
|
|
19518
|
+
*/
|
|
18947
19519
|
#capture() {
|
|
18948
|
-
if (this.#tracksVertical)
|
|
18949
|
-
|
|
19520
|
+
if (this.#tracksVertical) {
|
|
19521
|
+
const top = this.element.scrollTop;
|
|
19522
|
+
if (top !== this.#echoTop) {
|
|
19523
|
+
this.#echoTop = null;
|
|
19524
|
+
this.#lastTop = top;
|
|
19525
|
+
this.#capturedTop = true;
|
|
19526
|
+
}
|
|
19527
|
+
}
|
|
19528
|
+
if (this.#tracksHorizontal) {
|
|
19529
|
+
const left = this.element.scrollLeft;
|
|
19530
|
+
if (left !== this.#echoLeft) {
|
|
19531
|
+
this.#echoLeft = null;
|
|
19532
|
+
this.#lastLeft = left;
|
|
19533
|
+
this.#capturedLeft = true;
|
|
19534
|
+
}
|
|
19535
|
+
}
|
|
18950
19536
|
}
|
|
18951
|
-
/**
|
|
19537
|
+
/**
|
|
19538
|
+
* Writes the marked axes, carrying through any offset held for an untracked one.
|
|
19539
|
+
* Without a key, or with neither axis marked, it writes nothing.
|
|
19540
|
+
*/
|
|
18952
19541
|
#persist() {
|
|
18953
|
-
|
|
18954
|
-
|
|
18955
|
-
if (this.#
|
|
19542
|
+
if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;
|
|
19543
|
+
const data = { ...this.#carried };
|
|
19544
|
+
if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;
|
|
19545
|
+
if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;
|
|
18956
19546
|
try {
|
|
18957
|
-
sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
19547
|
+
window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
|
|
18958
19548
|
} catch {
|
|
18959
19549
|
}
|
|
18960
19550
|
}
|
|
18961
19551
|
/** Applies the persisted scroll offset, if any, without moving focus. */
|
|
18962
19552
|
#restore() {
|
|
18963
|
-
let raw
|
|
18964
|
-
try {
|
|
18965
|
-
raw = sessionStorage.getItem(this.#storageKey);
|
|
18966
|
-
} catch {
|
|
18967
|
-
return;
|
|
18968
|
-
}
|
|
18969
|
-
if (raw === null) return;
|
|
18970
|
-
let data;
|
|
19553
|
+
let raw;
|
|
18971
19554
|
try {
|
|
18972
|
-
|
|
19555
|
+
raw = window.sessionStorage.getItem(this.#storageKey);
|
|
18973
19556
|
} catch {
|
|
18974
19557
|
return;
|
|
18975
19558
|
}
|
|
18976
|
-
|
|
18977
|
-
|
|
18978
|
-
|
|
18979
|
-
|
|
18980
|
-
|
|
18981
|
-
|
|
18982
|
-
|
|
19559
|
+
const data = parseStored(raw);
|
|
19560
|
+
if (data === null) return;
|
|
19561
|
+
this.#echoTop = null;
|
|
19562
|
+
this.#echoLeft = null;
|
|
19563
|
+
const top = storedOffset(data.top);
|
|
19564
|
+
const left = storedOffset(data.left);
|
|
19565
|
+
const options = { behavior: "instant" };
|
|
19566
|
+
let requested = false;
|
|
19567
|
+
if (this.#tracksVertical) {
|
|
19568
|
+
if (top !== null) {
|
|
19569
|
+
options.top = top;
|
|
19570
|
+
this.#lastTop = top;
|
|
19571
|
+
this.#capturedTop = true;
|
|
19572
|
+
requested = true;
|
|
19573
|
+
}
|
|
19574
|
+
} else if (data.top !== void 0) {
|
|
19575
|
+
this.#carried.top = data.top;
|
|
19576
|
+
}
|
|
19577
|
+
if (this.#tracksHorizontal) {
|
|
19578
|
+
if (left !== null) {
|
|
19579
|
+
options.left = left;
|
|
19580
|
+
this.#lastLeft = left;
|
|
19581
|
+
this.#capturedLeft = true;
|
|
19582
|
+
requested = true;
|
|
19583
|
+
}
|
|
19584
|
+
} else if (data.left !== void 0) {
|
|
19585
|
+
this.#carried.left = data.left;
|
|
18983
19586
|
}
|
|
19587
|
+
if (!requested) return;
|
|
19588
|
+
const beforeTop = this.element.scrollTop;
|
|
19589
|
+
const beforeLeft = this.element.scrollLeft;
|
|
19590
|
+
this.element.scrollTo(options);
|
|
19591
|
+
if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;
|
|
19592
|
+
if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;
|
|
18984
19593
|
}
|
|
18985
19594
|
/** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
|
|
18986
19595
|
#resolveKey() {
|
|
@@ -18994,10 +19603,11 @@ var ScrollRestoreController = class extends Controller {
|
|
|
18994
19603
|
return this.axisValue === "horizontal" || this.axisValue === "both";
|
|
18995
19604
|
}
|
|
18996
19605
|
};
|
|
19606
|
+
var DEFAULT_OFFSET = 400;
|
|
18997
19607
|
var ScrollVisibilityController = class extends Controller {
|
|
18998
19608
|
static targets = ["element"];
|
|
18999
19609
|
static values = {
|
|
19000
|
-
offset: { type: Number, default:
|
|
19610
|
+
offset: { type: Number, default: DEFAULT_OFFSET },
|
|
19001
19611
|
mode: { type: String, default: "offset" },
|
|
19002
19612
|
focusSelector: { type: String, default: "" },
|
|
19003
19613
|
root: { type: String, default: "" }
|
|
@@ -19015,8 +19625,32 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
19015
19625
|
* the window. Captured on connect so teardown detaches from the same source.
|
|
19016
19626
|
*/
|
|
19017
19627
|
#scrollSource = window;
|
|
19628
|
+
/**
|
|
19629
|
+
* Gates the declaration callbacks to the connected window.
|
|
19630
|
+
*
|
|
19631
|
+
* Stimulus delivers a Value callback ahead of `connect()` and again for every
|
|
19632
|
+
* runtime change; without the gate, merely connecting would evaluate — and
|
|
19633
|
+
* announce — before `connect()` runs its own first reflection.
|
|
19634
|
+
*/
|
|
19635
|
+
#connected = false;
|
|
19636
|
+
/** Validated threshold; a non-finite declaration reads as the default. */
|
|
19637
|
+
#offset = DEFAULT_OFFSET;
|
|
19638
|
+
/** Validated `root` selector; an unparsable declaration reads as absent. */
|
|
19639
|
+
#rootSelector = "";
|
|
19640
|
+
/** Validated `focusSelector`; an unparsable declaration reads as absent. */
|
|
19641
|
+
#focusSelector = "";
|
|
19018
19642
|
/** Focus targets this instance lent a `tabindex` to. */
|
|
19019
19643
|
#tabindex = new TabindexLoan();
|
|
19644
|
+
/**
|
|
19645
|
+
* The hide held back while the control itself owns focus.
|
|
19646
|
+
*
|
|
19647
|
+
* Completing the deferral re-runs the ordinary evaluation rather than applying
|
|
19648
|
+
* the stale decision: by the time focus leaves, the scroll position may have
|
|
19649
|
+
* moved back past the threshold.
|
|
19650
|
+
*/
|
|
19651
|
+
#pendingHide = new BlurDeferral(() => {
|
|
19652
|
+
if (this.#connected) this.#evaluate();
|
|
19653
|
+
});
|
|
19020
19654
|
#onScroll = () => {
|
|
19021
19655
|
if (this.#rafId !== null) return;
|
|
19022
19656
|
this.#rafId = requestAnimationFrame(() => {
|
|
@@ -19028,61 +19662,134 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
19028
19662
|
this.#scrollSource = this.#resolveScrollSource();
|
|
19029
19663
|
this.#lastScrollY = this.#scrollY();
|
|
19030
19664
|
this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
19031
|
-
this.#evaluate();
|
|
19665
|
+
this.#evaluate(false);
|
|
19666
|
+
this.#connected = true;
|
|
19032
19667
|
}
|
|
19033
19668
|
disconnect() {
|
|
19669
|
+
this.#connected = false;
|
|
19034
19670
|
this.#scrollSource.removeEventListener("scroll", this.#onScroll);
|
|
19035
19671
|
if (this.#rafId !== null) {
|
|
19036
19672
|
cancelAnimationFrame(this.#rafId);
|
|
19037
19673
|
this.#rafId = null;
|
|
19038
19674
|
}
|
|
19675
|
+
this.#pendingHide.releaseAll();
|
|
19039
19676
|
this.#tabindex.returnAll();
|
|
19040
19677
|
this.#visible = null;
|
|
19041
19678
|
}
|
|
19679
|
+
/** Writes the current visibility onto a control that arrives after connect. */
|
|
19680
|
+
elementTargetConnected(element) {
|
|
19681
|
+
if (this.#visible !== null) element.hidden = !this.#visible;
|
|
19682
|
+
}
|
|
19683
|
+
/** Drops a held-back hide together with the control it was waiting on. */
|
|
19684
|
+
elementTargetDisconnected() {
|
|
19685
|
+
this.#pendingHide.releaseAll();
|
|
19686
|
+
}
|
|
19687
|
+
/**
|
|
19688
|
+
* Validates `offset` once, then re-renders.
|
|
19689
|
+
*
|
|
19690
|
+
* Re-renders when application code (or a Turbo morph) changes `offset` at
|
|
19691
|
+
* runtime. A declaration that is not a finite number reads as the default, so
|
|
19692
|
+
* the comparison path never sees `NaN` — which would answer `false` to every
|
|
19693
|
+
* comparison and strand the element (in `direction` mode, even the guarantee
|
|
19694
|
+
* that the very top always reveals).
|
|
19695
|
+
*/
|
|
19696
|
+
offsetValueChanged() {
|
|
19697
|
+
this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
|
|
19698
|
+
if (this.#connected) this.#evaluate();
|
|
19699
|
+
}
|
|
19700
|
+
/** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */
|
|
19701
|
+
modeValueChanged() {
|
|
19702
|
+
if (this.#connected) this.#evaluate();
|
|
19703
|
+
}
|
|
19704
|
+
/** Validates `root` once so connect never parses a selector that throws. */
|
|
19705
|
+
rootValueChanged() {
|
|
19706
|
+
this.#rootSelector = this.#validSelector(this.rootValue);
|
|
19707
|
+
}
|
|
19708
|
+
/** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
|
|
19709
|
+
focusSelectorValueChanged() {
|
|
19710
|
+
this.#focusSelector = this.#validSelector(this.focusSelectorValue);
|
|
19711
|
+
}
|
|
19042
19712
|
/** Scrolls the source to the top and, optionally, moves focus to a safe target. */
|
|
19043
19713
|
toTop() {
|
|
19044
19714
|
const behavior = prefersReducedMotion() ? "instant" : "smooth";
|
|
19045
19715
|
this.#scrollSource.scrollTo({ top: 0, behavior });
|
|
19046
|
-
if (this
|
|
19047
|
-
const target = document.querySelector(this
|
|
19716
|
+
if (this.#focusSelector) {
|
|
19717
|
+
const target = document.querySelector(this.#focusSelector);
|
|
19048
19718
|
if (target) {
|
|
19049
19719
|
this.#tabindex.lend(target);
|
|
19050
|
-
target.focus();
|
|
19720
|
+
target.focus({ preventScroll: true });
|
|
19051
19721
|
}
|
|
19052
19722
|
}
|
|
19053
19723
|
}
|
|
19054
|
-
/**
|
|
19055
|
-
|
|
19724
|
+
/**
|
|
19725
|
+
* Decides the next visibility from the current scroll state and applies it.
|
|
19726
|
+
*
|
|
19727
|
+
* @param notify - whether a transition announces itself. The reflection
|
|
19728
|
+
* `connect()` performs is the current state, not a change.
|
|
19729
|
+
*
|
|
19730
|
+
* @stimeoRenderRoot
|
|
19731
|
+
*/
|
|
19732
|
+
#evaluate(notify = true) {
|
|
19056
19733
|
const y = this.#scrollY();
|
|
19057
19734
|
let nextVisible;
|
|
19058
19735
|
if (this.modeValue === "direction") {
|
|
19059
|
-
if (y <= this
|
|
19736
|
+
if (y <= this.#offset) {
|
|
19060
19737
|
nextVisible = true;
|
|
19738
|
+
} else if (y === this.#lastScrollY) {
|
|
19739
|
+
return;
|
|
19061
19740
|
} else {
|
|
19062
19741
|
nextVisible = y < this.#lastScrollY;
|
|
19063
19742
|
}
|
|
19064
19743
|
} else {
|
|
19065
|
-
nextVisible = y > this
|
|
19744
|
+
nextVisible = y > this.#offset;
|
|
19066
19745
|
}
|
|
19067
19746
|
this.#lastScrollY = y;
|
|
19068
|
-
this.#setVisible(nextVisible);
|
|
19747
|
+
this.#setVisible(nextVisible, notify);
|
|
19069
19748
|
}
|
|
19070
19749
|
/** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
|
|
19071
|
-
#setVisible(next) {
|
|
19750
|
+
#setVisible(next, notify) {
|
|
19072
19751
|
if (next === this.#visible) return;
|
|
19752
|
+
const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;
|
|
19753
|
+
if (focused) {
|
|
19754
|
+
this.#pendingHide.deferOnly(focused);
|
|
19755
|
+
return;
|
|
19756
|
+
}
|
|
19073
19757
|
this.#visible = next;
|
|
19074
19758
|
if (this.hasElementTarget) this.elementTarget.hidden = !next;
|
|
19075
19759
|
this.element.setAttribute("data-state", next ? "visible" : "hidden");
|
|
19076
|
-
this.dispatch("change", { detail: { visible: next } });
|
|
19760
|
+
if (notify) this.dispatch("change", { detail: { visible: next } });
|
|
19077
19761
|
}
|
|
19078
19762
|
/** Resolves the scroll source from `root` (falling back to the window). */
|
|
19079
19763
|
#resolveScrollSource() {
|
|
19080
|
-
if (this
|
|
19081
|
-
const root = document.querySelector(this
|
|
19764
|
+
if (this.#rootSelector) {
|
|
19765
|
+
const root = document.querySelector(this.#rootSelector);
|
|
19082
19766
|
if (root) return root;
|
|
19083
19767
|
}
|
|
19084
19768
|
return window;
|
|
19085
19769
|
}
|
|
19770
|
+
/**
|
|
19771
|
+
* The focus owner inside the target, or `null` when focus is elsewhere.
|
|
19772
|
+
*
|
|
19773
|
+
* `blur` does not bubble, so the deferral has to ride the focused element
|
|
19774
|
+
* itself: waiting on a container that never receives the event would hold the
|
|
19775
|
+
* hide forever.
|
|
19776
|
+
*/
|
|
19777
|
+
#focusedWithin() {
|
|
19778
|
+
const focused = document.activeElement;
|
|
19779
|
+
if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
|
|
19780
|
+
return null;
|
|
19781
|
+
}
|
|
19782
|
+
/** Returns `declared` when it parses as a selector, and `""` when it does not. */
|
|
19783
|
+
#validSelector(declared) {
|
|
19784
|
+
if (declared.length > 0) {
|
|
19785
|
+
try {
|
|
19786
|
+
this.element.matches(declared);
|
|
19787
|
+
return declared;
|
|
19788
|
+
} catch {
|
|
19789
|
+
}
|
|
19790
|
+
}
|
|
19791
|
+
return "";
|
|
19792
|
+
}
|
|
19086
19793
|
#scrollY() {
|
|
19087
19794
|
if (this.#scrollSource === window) {
|
|
19088
19795
|
return window.scrollY ?? window.pageYOffset ?? 0;
|
|
@@ -20650,11 +21357,6 @@ var StepIndicatorController = class extends Controller {
|
|
|
20650
21357
|
};
|
|
20651
21358
|
static actions = ["setCurrent"];
|
|
20652
21359
|
static events = ["change"];
|
|
20653
|
-
/**
|
|
20654
|
-
* Whether the target callbacks may render. Stimulus reports the authored steps
|
|
20655
|
-
* as connected before `connect()` and the remaining ones as disconnected after
|
|
20656
|
-
* `disconnect()`, so this keeps a connect at one render pass, not one per step.
|
|
20657
|
-
*/
|
|
20658
21360
|
/**
|
|
20659
21361
|
* Collapses a batch of step callbacks — and a morph that swaps `current` with
|
|
20660
21362
|
* them — into one repaint. Replacing a list of N steps delivers N callbacks, and
|
|
@@ -20824,6 +21526,7 @@ var StepperController = class extends Controller {
|
|
|
20824
21526
|
return Math.min(last, Math.max(0, Math.trunc(index)));
|
|
20825
21527
|
}
|
|
20826
21528
|
};
|
|
21529
|
+
var DEFAULT_THRESHOLD = 80;
|
|
20827
21530
|
var countElements = (nodes) => {
|
|
20828
21531
|
let n = 0;
|
|
20829
21532
|
for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
|
|
@@ -20832,34 +21535,53 @@ var countElements = (nodes) => {
|
|
|
20832
21535
|
var StickToBottomController = class extends Controller {
|
|
20833
21536
|
static targets = ["content"];
|
|
20834
21537
|
static values = {
|
|
20835
|
-
threshold: { type: Number, default:
|
|
21538
|
+
threshold: { type: Number, default: DEFAULT_THRESHOLD },
|
|
20836
21539
|
behavior: { type: String, default: "auto" },
|
|
20837
21540
|
pinOnConnect: { type: Boolean, default: false }
|
|
20838
21541
|
};
|
|
20839
21542
|
static actions = ["scrollToBottom"];
|
|
20840
21543
|
static events = ["pin", "new"];
|
|
20841
21544
|
#observer = null;
|
|
20842
|
-
/**
|
|
20843
|
-
#
|
|
21545
|
+
/** The element the append observer currently holds, so a swap can be detected. */
|
|
21546
|
+
#watched = null;
|
|
21547
|
+
/** Watches for the box a container connected without one is still waiting on. */
|
|
21548
|
+
#layout = new LayoutObserver(() => this.#onLaidOut());
|
|
21549
|
+
#awaitingLayout = false;
|
|
21550
|
+
#connected = false;
|
|
20844
21551
|
#pinned = false;
|
|
20845
21552
|
#onScroll = () => this.#updatePinned();
|
|
20846
21553
|
connect() {
|
|
21554
|
+
this.#connected = true;
|
|
20847
21555
|
if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
|
|
21556
|
+
this.element.removeAttribute("data-has-new");
|
|
20848
21557
|
this.#pinned = this.#isPinned();
|
|
20849
21558
|
this.#reflectPinned();
|
|
20850
21559
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
20851
|
-
|
|
20852
|
-
|
|
20853
|
-
this.#observer.observe(this.#watched(), { childList: true });
|
|
20854
|
-
}
|
|
20855
|
-
if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
|
|
21560
|
+
this.#syncWatched();
|
|
21561
|
+
if (!this.#measurable()) this.#waitForLayout();
|
|
20856
21562
|
}
|
|
20857
21563
|
disconnect() {
|
|
21564
|
+
this.#connected = false;
|
|
20858
21565
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
20859
|
-
this.#
|
|
20860
|
-
this.#observer = null;
|
|
21566
|
+
this.#stopWatching();
|
|
20861
21567
|
this.#stopWaitingForLayout();
|
|
20862
21568
|
}
|
|
21569
|
+
/** Moves the append watch onto a `content` target that arrived at runtime. */
|
|
21570
|
+
contentTargetConnected() {
|
|
21571
|
+
this.#syncWatched();
|
|
21572
|
+
}
|
|
21573
|
+
/** Moves the append watch off a `content` target that left, back onto the container. */
|
|
21574
|
+
contentTargetDisconnected() {
|
|
21575
|
+
this.#syncWatched();
|
|
21576
|
+
}
|
|
21577
|
+
/**
|
|
21578
|
+
* Re-derives pinned when the distance that counts as the bottom is changed at runtime
|
|
21579
|
+
* (a morph that swaps the attribute on a retained element).
|
|
21580
|
+
*/
|
|
21581
|
+
thresholdValueChanged() {
|
|
21582
|
+
if (!this.#connected) return;
|
|
21583
|
+
this.#updatePinned();
|
|
21584
|
+
}
|
|
20863
21585
|
/**
|
|
20864
21586
|
* Jumps to the bottom and re-pins (wired to a "new messages" button).
|
|
20865
21587
|
*
|
|
@@ -20888,7 +21610,11 @@ var StickToBottomController = class extends Controller {
|
|
|
20888
21610
|
this.dispatch("new", { detail: { count: added } });
|
|
20889
21611
|
}
|
|
20890
21612
|
}
|
|
20891
|
-
/**
|
|
21613
|
+
/**
|
|
21614
|
+
* Recomputes pinned from the scroll position and reflects it on a transition.
|
|
21615
|
+
*
|
|
21616
|
+
* @stimeoRenderRoot
|
|
21617
|
+
*/
|
|
20892
21618
|
#updatePinned() {
|
|
20893
21619
|
const pinned = this.#isPinned();
|
|
20894
21620
|
if (pinned === this.#pinned) return;
|
|
@@ -20905,9 +21631,25 @@ var StickToBottomController = class extends Controller {
|
|
|
20905
21631
|
this.element.removeAttribute("data-pinned");
|
|
20906
21632
|
}
|
|
20907
21633
|
}
|
|
21634
|
+
/** Whether the container currently sits within `threshold` of its bottom. */
|
|
20908
21635
|
#isPinned() {
|
|
21636
|
+
if (!this.#measurable()) return false;
|
|
20909
21637
|
const el = this.element;
|
|
20910
|
-
return el.scrollHeight - el.clientHeight - el.scrollTop <= this
|
|
21638
|
+
return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
|
|
21639
|
+
}
|
|
21640
|
+
/**
|
|
21641
|
+
* The distance from the bottom that counts as pinned: a finite, non-negative number of
|
|
21642
|
+
* pixels. Anything else names no distance the container can be at, and settles the
|
|
21643
|
+
* comparison the same way at every scroll position, so it falls back to the default.
|
|
21644
|
+
* `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
|
|
21645
|
+
* distance sits below the closest the container ever gets; `Infinity` is never
|
|
21646
|
+
* exceeded. The first two stop following and flag every append as new, and the last
|
|
21647
|
+
* never stops following — it takes the reading position the flag exists to protect.
|
|
21648
|
+
* Zero is a real declaration: it pins at the exact bottom only.
|
|
21649
|
+
*/
|
|
21650
|
+
get #threshold() {
|
|
21651
|
+
const declared = this.thresholdValue;
|
|
21652
|
+
return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THRESHOLD;
|
|
20911
21653
|
}
|
|
20912
21654
|
/**
|
|
20913
21655
|
* Whether the container has a box to scroll and to measure. One that is not rendered
|
|
@@ -20918,23 +21660,25 @@ var StickToBottomController = class extends Controller {
|
|
|
20918
21660
|
return this.element.clientHeight > 0;
|
|
20919
21661
|
}
|
|
20920
21662
|
/**
|
|
20921
|
-
* Holds the
|
|
20922
|
-
*
|
|
21663
|
+
* Holds the pinned decision until the container is laid out — otherwise the panel opens
|
|
21664
|
+
* at the top while the state claims the bottom, and the appends that arrived meanwhile
|
|
21665
|
+
* were followed into a box that could not move rather than flagged.
|
|
20923
21666
|
*/
|
|
20924
|
-
#
|
|
20925
|
-
|
|
20926
|
-
this.#layout = new ResizeObserver(() => {
|
|
20927
|
-
if (!this.#measurable()) return;
|
|
20928
|
-
this.#stopWaitingForLayout();
|
|
20929
|
-
this.#scrollToBottom("instant");
|
|
20930
|
-
this.#updatePinned();
|
|
20931
|
-
});
|
|
21667
|
+
#waitForLayout() {
|
|
21668
|
+
this.#awaitingLayout = true;
|
|
20932
21669
|
this.#layout.observe(this.element);
|
|
20933
21670
|
}
|
|
20934
|
-
/**
|
|
21671
|
+
/** Runs the held decision once the container has the box it was waiting for. */
|
|
21672
|
+
#onLaidOut() {
|
|
21673
|
+
if (!this.#awaitingLayout || !this.#measurable()) return;
|
|
21674
|
+
this.#stopWaitingForLayout();
|
|
21675
|
+
if (this.pinOnConnectValue) this.#scrollToBottom("instant");
|
|
21676
|
+
this.#updatePinned();
|
|
21677
|
+
}
|
|
21678
|
+
/** Releases the layout watch, whether or not the held decision ever ran. */
|
|
20935
21679
|
#stopWaitingForLayout() {
|
|
20936
|
-
this.#
|
|
20937
|
-
this.#layout
|
|
21680
|
+
this.#awaitingLayout = false;
|
|
21681
|
+
this.#layout.disconnect();
|
|
20938
21682
|
}
|
|
20939
21683
|
/**
|
|
20940
21684
|
* Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
|
|
@@ -20950,9 +21694,32 @@ var StickToBottomController = class extends Controller {
|
|
|
20950
21694
|
this.element.scrollTop = top;
|
|
20951
21695
|
}
|
|
20952
21696
|
}
|
|
20953
|
-
/**
|
|
20954
|
-
|
|
20955
|
-
|
|
21697
|
+
/**
|
|
21698
|
+
* Points the append watch at the current `content` target, or at the container when
|
|
21699
|
+
* there is none. Re-resolved whenever that target changes, so a swap does not leave the
|
|
21700
|
+
* observer holding a detached node whose appends nobody sees.
|
|
21701
|
+
*
|
|
21702
|
+
* Stimulus runs the target callbacks outside the connected window too — before
|
|
21703
|
+
* `connect()` for a target already in the DOM, and after `disconnect()` while the
|
|
21704
|
+
* element is torn down — where this would arm an observer nothing releases. Re-syncing
|
|
21705
|
+
* to the target already held is left alone, so an arrival still in flight is not
|
|
21706
|
+
* dropped with the observer that was about to deliver it.
|
|
21707
|
+
*/
|
|
21708
|
+
#syncWatched() {
|
|
21709
|
+
if (!this.#connected) return;
|
|
21710
|
+
const next = this.hasContentTarget ? this.contentTarget : this.element;
|
|
21711
|
+
if (next === this.#watched) return;
|
|
21712
|
+
this.#stopWatching();
|
|
21713
|
+
this.#watched = next;
|
|
21714
|
+
if (typeof MutationObserver === "undefined") return;
|
|
21715
|
+
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
21716
|
+
this.#observer.observe(next, { childList: true });
|
|
21717
|
+
}
|
|
21718
|
+
/** Releases the append watch and the element it held. */
|
|
21719
|
+
#stopWatching() {
|
|
21720
|
+
this.#observer?.disconnect();
|
|
21721
|
+
this.#observer = null;
|
|
21722
|
+
this.#watched = null;
|
|
20956
21723
|
}
|
|
20957
21724
|
/**
|
|
20958
21725
|
* The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
|
|
@@ -21944,35 +22711,48 @@ var TextareaAutosizeController = class extends Controller {
|
|
|
21944
22711
|
};
|
|
21945
22712
|
var MODES = ["light", "dark", "system"];
|
|
21946
22713
|
var isMode = (value) => typeof value === "string" && MODES.includes(value);
|
|
22714
|
+
var DEFAULT_MODE = "system";
|
|
22715
|
+
var DEFAULT_TARGET = "html";
|
|
21947
22716
|
var ThemeController = class extends Controller {
|
|
21948
22717
|
static targets = ["option"];
|
|
21949
22718
|
static values = {
|
|
21950
|
-
mode: { type: String, default:
|
|
22719
|
+
mode: { type: String, default: DEFAULT_MODE },
|
|
21951
22720
|
storageKey: { type: String, default: "stimeo-theme" },
|
|
21952
|
-
target: { type: String, default:
|
|
22721
|
+
target: { type: String, default: DEFAULT_TARGET }
|
|
21953
22722
|
};
|
|
21954
22723
|
static actions = ["set", "toggle"];
|
|
21955
22724
|
static events = ["change"];
|
|
21956
22725
|
/** The OS dark-mode query, watched so `system` tracks live changes. */
|
|
21957
22726
|
#media = null;
|
|
22727
|
+
/** Gate for the target callbacks, which Stimulus runs before `connect()`. */
|
|
22728
|
+
#connected = false;
|
|
22729
|
+
/** The `target` declaration after validation; the default when unparsable. */
|
|
22730
|
+
#targetSelector = DEFAULT_TARGET;
|
|
22731
|
+
/** Owns the single Tab stop across the option set (APG radiogroup). */
|
|
22732
|
+
#roving = new RovingTabindex(() => this.optionTargets);
|
|
22733
|
+
/**
|
|
22734
|
+
* The pair last reported, so a move can be told from a repeat. Neither side is
|
|
22735
|
+
* readable after the fact — assigning the Value updates the mode before any
|
|
22736
|
+
* comparison, and the OS query has already flipped by the time it notifies —
|
|
22737
|
+
* so what was reported has to be kept rather than recomputed.
|
|
22738
|
+
*/
|
|
22739
|
+
#published = {
|
|
22740
|
+
mode: DEFAULT_MODE,
|
|
22741
|
+
resolved: "light"
|
|
22742
|
+
};
|
|
21958
22743
|
/** Re-resolves while in `system` mode when the OS preference flips. */
|
|
21959
22744
|
#onMediaChange = () => {
|
|
21960
|
-
if (this
|
|
21961
|
-
|
|
21962
|
-
this.#syncControls();
|
|
21963
|
-
this.#dispatchChange();
|
|
21964
|
-
}
|
|
22745
|
+
if (this.#mode !== "system") return;
|
|
22746
|
+
this.#commit();
|
|
21965
22747
|
};
|
|
21966
22748
|
/** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
|
|
21967
22749
|
#onKeydown = (event) => {
|
|
21968
22750
|
if (event.defaultPrevented) return;
|
|
21969
22751
|
if (isReservedArrowChord(event)) return;
|
|
21970
22752
|
const options = this.optionTargets;
|
|
21971
|
-
if (options.length === 0) return;
|
|
21972
22753
|
const target = event.target;
|
|
21973
22754
|
const current = options.indexOf(target);
|
|
21974
22755
|
if (current === -1) return;
|
|
21975
|
-
const last = options.length - 1;
|
|
21976
22756
|
let next = current;
|
|
21977
22757
|
const step = logicalArrowStep(event.key, this.element);
|
|
21978
22758
|
switch (event.key) {
|
|
@@ -21980,13 +22760,12 @@ var ThemeController = class extends Controller {
|
|
|
21980
22760
|
case "ArrowRight":
|
|
21981
22761
|
case "ArrowUp":
|
|
21982
22762
|
case "ArrowLeft":
|
|
21983
|
-
next =
|
|
22763
|
+
next = rovingMove(current, options.length, step, "wrap");
|
|
21984
22764
|
break;
|
|
21985
22765
|
case "Home":
|
|
21986
|
-
next = 0;
|
|
21987
|
-
break;
|
|
21988
22766
|
case "End":
|
|
21989
|
-
|
|
22767
|
+
if (hasModifierChord(event)) return;
|
|
22768
|
+
next = event.key === "Home" ? 0 : options.length - 1;
|
|
21990
22769
|
break;
|
|
21991
22770
|
default:
|
|
21992
22771
|
return;
|
|
@@ -21995,25 +22774,58 @@ var ThemeController = class extends Controller {
|
|
|
21995
22774
|
const option = options[next];
|
|
21996
22775
|
if (!option) return;
|
|
21997
22776
|
option.focus();
|
|
21998
|
-
this.#
|
|
22777
|
+
const mode = this.#optionMode(option);
|
|
22778
|
+
if (mode) this.#setMode(mode);
|
|
21999
22779
|
};
|
|
22000
22780
|
connect() {
|
|
22001
22781
|
const stored = this.#readStored();
|
|
22002
22782
|
if (stored) this.modeValue = stored;
|
|
22003
22783
|
this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
|
22004
22784
|
this.#media?.addEventListener("change", this.#onMediaChange);
|
|
22005
|
-
|
|
22785
|
+
this.element.addEventListener("keydown", this.#onKeydown);
|
|
22786
|
+
this.#connected = true;
|
|
22006
22787
|
this.#applyTheme();
|
|
22007
22788
|
this.#syncControls();
|
|
22789
|
+
this.#published = this.#current;
|
|
22008
22790
|
}
|
|
22009
22791
|
disconnect() {
|
|
22792
|
+
this.#connected = false;
|
|
22010
22793
|
this.#media?.removeEventListener("change", this.#onMediaChange);
|
|
22011
22794
|
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
22012
22795
|
}
|
|
22013
|
-
/**
|
|
22796
|
+
/** Validates the `target` declaration once, so the render path never parses. */
|
|
22797
|
+
targetValueChanged() {
|
|
22798
|
+
const selector = this.targetValue;
|
|
22799
|
+
if (selector.length > 0) {
|
|
22800
|
+
try {
|
|
22801
|
+
this.element.matches(selector);
|
|
22802
|
+
this.#targetSelector = selector;
|
|
22803
|
+
return;
|
|
22804
|
+
} catch {
|
|
22805
|
+
}
|
|
22806
|
+
}
|
|
22807
|
+
this.#targetSelector = DEFAULT_TARGET;
|
|
22808
|
+
}
|
|
22809
|
+
/** Re-derives the single Tab stop and ARIA for an option set that changed. */
|
|
22810
|
+
optionTargetConnected() {
|
|
22811
|
+
if (this.#connected) this.#syncControls();
|
|
22812
|
+
}
|
|
22813
|
+
/** Re-derives them again when an option leaves, so a Tab stop always remains. */
|
|
22814
|
+
optionTargetDisconnected() {
|
|
22815
|
+
if (this.#connected) this.#syncControls();
|
|
22816
|
+
}
|
|
22817
|
+
/**
|
|
22818
|
+
* Selects the mode the activated option declares.
|
|
22819
|
+
*
|
|
22820
|
+
* Read through {@link ThemeController.#optionMode}, the same lane that decides
|
|
22821
|
+
* which option is checked, so the two can never disagree about what an option
|
|
22822
|
+
* declares.
|
|
22823
|
+
*/
|
|
22014
22824
|
set(event) {
|
|
22015
|
-
const
|
|
22016
|
-
if (
|
|
22825
|
+
const option = event.currentTarget;
|
|
22826
|
+
if (!(option instanceof HTMLElement)) return;
|
|
22827
|
+
const mode = this.#optionMode(option);
|
|
22828
|
+
if (mode) this.#setMode(mode);
|
|
22017
22829
|
}
|
|
22018
22830
|
/** Toggles light↔dark for the 2-value single-button contract. */
|
|
22019
22831
|
toggle() {
|
|
@@ -22023,9 +22835,26 @@ var ThemeController = class extends Controller {
|
|
|
22023
22835
|
#setMode(mode) {
|
|
22024
22836
|
this.modeValue = mode;
|
|
22025
22837
|
this.#writeStored(mode);
|
|
22838
|
+
this.#commit();
|
|
22839
|
+
}
|
|
22840
|
+
/**
|
|
22841
|
+
* Applies the current mode and reports it, but reports only a real move: the
|
|
22842
|
+
* event means "the selection or the effective theme moved", so re-choosing the
|
|
22843
|
+
* option already chosen is not one.
|
|
22844
|
+
*/
|
|
22845
|
+
#commit() {
|
|
22026
22846
|
this.#applyTheme();
|
|
22027
22847
|
this.#syncControls();
|
|
22028
|
-
this.#
|
|
22848
|
+
const next = this.#current;
|
|
22849
|
+
const last = this.#published;
|
|
22850
|
+
this.#published = next;
|
|
22851
|
+
if (last.mode !== next.mode || last.resolved !== next.resolved) {
|
|
22852
|
+
this.dispatch("change", { detail: { ...next } });
|
|
22853
|
+
}
|
|
22854
|
+
}
|
|
22855
|
+
/** The pair the `change` detail carries, read from current state. */
|
|
22856
|
+
get #current() {
|
|
22857
|
+
return { mode: this.#mode, resolved: this.#resolved() };
|
|
22029
22858
|
}
|
|
22030
22859
|
/** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
|
|
22031
22860
|
#applyTheme() {
|
|
@@ -22039,39 +22868,45 @@ var ThemeController = class extends Controller {
|
|
|
22039
22868
|
#syncControls() {
|
|
22040
22869
|
const options = this.optionTargets;
|
|
22041
22870
|
if (options.length > 0) {
|
|
22042
|
-
|
|
22043
|
-
|
|
22044
|
-
|
|
22045
|
-
|
|
22046
|
-
option.
|
|
22047
|
-
|
|
22048
|
-
}
|
|
22049
|
-
|
|
22050
|
-
if (!hasTabbable && first) first.tabIndex = 0;
|
|
22871
|
+
const mode = this.#mode;
|
|
22872
|
+
let selected = -1;
|
|
22873
|
+
options.forEach((option, index) => {
|
|
22874
|
+
const isSelected = this.#optionMode(option) === mode;
|
|
22875
|
+
option.setAttribute("aria-checked", String(isSelected));
|
|
22876
|
+
if (isSelected && selected === -1) selected = index;
|
|
22877
|
+
});
|
|
22878
|
+
this.#roving.setActive(selected === -1 ? 0 : selected, { items: options });
|
|
22051
22879
|
return;
|
|
22052
22880
|
}
|
|
22053
|
-
|
|
22881
|
+
if (this.#isToggleButton) {
|
|
22882
|
+
this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
|
|
22883
|
+
}
|
|
22054
22884
|
}
|
|
22055
|
-
/**
|
|
22056
|
-
#
|
|
22057
|
-
this.
|
|
22885
|
+
/** Whether the controller element is the button of the 2-value contract. */
|
|
22886
|
+
get #isToggleButton() {
|
|
22887
|
+
return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
|
|
22888
|
+
}
|
|
22889
|
+
/** The selected mode after validation; an unreadable declaration is the default. */
|
|
22890
|
+
get #mode() {
|
|
22891
|
+
return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
|
|
22058
22892
|
}
|
|
22059
22893
|
/** The effective theme: the OS preference when `system`, else the mode itself. */
|
|
22060
22894
|
#resolved() {
|
|
22061
|
-
|
|
22062
|
-
if (
|
|
22895
|
+
const mode = this.#mode;
|
|
22896
|
+
if (mode === "dark") return "dark";
|
|
22897
|
+
if (mode === "light") return "light";
|
|
22063
22898
|
return this.#media?.matches ? "dark" : "light";
|
|
22064
22899
|
}
|
|
22065
|
-
/**
|
|
22900
|
+
/** An option's mode from its `data-value`, or `null` when that is not one of the three. */
|
|
22066
22901
|
#optionMode(option) {
|
|
22067
|
-
const mode = option.getAttribute("data-
|
|
22068
|
-
return isMode(mode) ? mode :
|
|
22902
|
+
const mode = option.getAttribute("data-value");
|
|
22903
|
+
return isMode(mode) ? mode : null;
|
|
22069
22904
|
}
|
|
22070
22905
|
/** Resolves the state-hook target (`<html>` by default). */
|
|
22071
22906
|
#targetElement() {
|
|
22072
|
-
|
|
22073
|
-
|
|
22074
|
-
return document.querySelector(
|
|
22907
|
+
const selector = this.#targetSelector;
|
|
22908
|
+
if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
|
|
22909
|
+
return document.querySelector(selector);
|
|
22075
22910
|
}
|
|
22076
22911
|
/** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
|
|
22077
22912
|
#readStored() {
|
|
@@ -22528,8 +23363,8 @@ var ToastController = class extends Controller {
|
|
|
22528
23363
|
}
|
|
22529
23364
|
/**
|
|
22530
23365
|
* Stimulus lifecycle callback triggered automatically when a new item target
|
|
22531
|
-
* enters the DOM
|
|
22532
|
-
*
|
|
23366
|
+
* enters the DOM, from a client-side injection or a Turbo Stream append alike. An
|
|
23367
|
+
* item already leaving, or parented outside `list`, is skipped.
|
|
22533
23368
|
*/
|
|
22534
23369
|
itemTargetConnected(element) {
|
|
22535
23370
|
this.enforceMaxLimit();
|
|
@@ -23146,7 +23981,7 @@ var ToggleGroupController = class extends Controller {
|
|
|
23146
23981
|
return item.getAttribute("data-value") ?? "";
|
|
23147
23982
|
}
|
|
23148
23983
|
};
|
|
23149
|
-
var
|
|
23984
|
+
var STATE_ATTRIBUTES3 = ["disabled", "hidden"];
|
|
23150
23985
|
var ToolbarController = class extends Controller {
|
|
23151
23986
|
static targets = ["control"];
|
|
23152
23987
|
static values = {
|
|
@@ -23175,7 +24010,7 @@ var ToolbarController = class extends Controller {
|
|
|
23175
24010
|
subtree: true,
|
|
23176
24011
|
childList: true,
|
|
23177
24012
|
attributes: true,
|
|
23178
|
-
attributeFilter:
|
|
24013
|
+
attributeFilter: STATE_ATTRIBUTES3
|
|
23179
24014
|
});
|
|
23180
24015
|
for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
|
|
23181
24016
|
observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
|
|
@@ -23495,12 +24330,21 @@ var TransitionController = class extends Controller {
|
|
|
23495
24330
|
static events = ["entered", "left"];
|
|
23496
24331
|
/** Owns the cancellable completion wait (terminal events + bounded fallback). */
|
|
23497
24332
|
#transition = new TransitionCompletion();
|
|
24333
|
+
/** Rewinds a half-applied stage before Turbo copies the page into its snapshot. */
|
|
24334
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
23498
24335
|
#rafId = null;
|
|
24336
|
+
/**
|
|
24337
|
+
* Stage classes this controller put on the element. Removing by declaration
|
|
24338
|
+
* instead would take a token the consumer also authored, and would strand the
|
|
24339
|
+
* token that was applied when a Value changes mid-transition.
|
|
24340
|
+
*/
|
|
24341
|
+
#staged = /* @__PURE__ */ new Set();
|
|
23499
24342
|
connect() {
|
|
23500
|
-
this.#
|
|
23501
|
-
this
|
|
24343
|
+
this.#beforeCache.activate();
|
|
24344
|
+
this.#settleState();
|
|
23502
24345
|
}
|
|
23503
24346
|
disconnect() {
|
|
24347
|
+
this.#beforeCache.deactivate();
|
|
23504
24348
|
this.#cancel();
|
|
23505
24349
|
}
|
|
23506
24350
|
/** Shows the element with the enter transition. */
|
|
@@ -23530,6 +24374,7 @@ var TransitionController = class extends Controller {
|
|
|
23530
24374
|
const from = isEnter ? this.enterFromValue : this.leaveFromValue;
|
|
23531
24375
|
const to = isEnter ? this.enterToValue : this.leaveToValue;
|
|
23532
24376
|
this.#add(base, from);
|
|
24377
|
+
void this.element.offsetWidth;
|
|
23533
24378
|
this.#rafId = this.#raf(() => {
|
|
23534
24379
|
this.#rafId = null;
|
|
23535
24380
|
this.#remove(from);
|
|
@@ -23551,6 +24396,21 @@ var TransitionController = class extends Controller {
|
|
|
23551
24396
|
this.dispatch("left", { detail: {} });
|
|
23552
24397
|
}
|
|
23553
24398
|
}
|
|
24399
|
+
/** Writes the state hook the element's visibility implies. */
|
|
24400
|
+
#settleState() {
|
|
24401
|
+
this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
|
|
24402
|
+
}
|
|
24403
|
+
/**
|
|
24404
|
+
* Returns the element to a settled state before Turbo copies the page.
|
|
24405
|
+
*
|
|
24406
|
+
* The snapshot is taken while the controller is still connected, so stripping on
|
|
24407
|
+
* the next `connect()` would only repair the page after it has been painted from
|
|
24408
|
+
* the cache. The pass is silent: `connect()` derives the state again on restore.
|
|
24409
|
+
*/
|
|
24410
|
+
#rewindForCache() {
|
|
24411
|
+
this.#cancel();
|
|
24412
|
+
this.#settleState();
|
|
24413
|
+
}
|
|
23554
24414
|
/** Cancels any in-flight transition (interruption / teardown). */
|
|
23555
24415
|
#cancel() {
|
|
23556
24416
|
if (this.#rafId !== null) {
|
|
@@ -23560,24 +24420,34 @@ var TransitionController = class extends Controller {
|
|
|
23560
24420
|
this.#transition.cancel();
|
|
23561
24421
|
this.#strip();
|
|
23562
24422
|
}
|
|
24423
|
+
/**
|
|
24424
|
+
* Applies the stage tokens this controller does not already find on the
|
|
24425
|
+
* element, and claims exactly those.
|
|
24426
|
+
*
|
|
24427
|
+
* A token already on the element is left unclaimed: it is either the consumer's
|
|
24428
|
+
* standing class or one an earlier stage of this transition already claimed, and
|
|
24429
|
+
* in neither case may this call take ownership of it. That is what keeps a
|
|
24430
|
+
* standing class the consumer also named as a stage Value; the cost is that such
|
|
24431
|
+
* a token cannot be staged, so the property it drives resolves from their CSS.
|
|
24432
|
+
*/
|
|
23563
24433
|
#add(...lists) {
|
|
23564
|
-
const
|
|
23565
|
-
|
|
24434
|
+
for (const token of lists.flatMap(tokensOf)) {
|
|
24435
|
+
if (this.element.classList.contains(token)) continue;
|
|
24436
|
+
this.element.classList.add(token);
|
|
24437
|
+
this.#staged.add(token);
|
|
24438
|
+
}
|
|
23566
24439
|
}
|
|
24440
|
+
/** Drops the named tokens that are this controller's to drop. */
|
|
23567
24441
|
#remove(...lists) {
|
|
23568
|
-
const
|
|
23569
|
-
|
|
24442
|
+
for (const token of lists.flatMap(tokensOf)) {
|
|
24443
|
+
if (!this.#staged.delete(token)) continue;
|
|
24444
|
+
this.element.classList.remove(token);
|
|
24445
|
+
}
|
|
23570
24446
|
}
|
|
23571
|
-
/** Removes every stage class
|
|
24447
|
+
/** Removes every stage class this controller applied, so none lingers. */
|
|
23572
24448
|
#strip() {
|
|
23573
|
-
this
|
|
23574
|
-
|
|
23575
|
-
this.enterFromValue,
|
|
23576
|
-
this.enterToValue,
|
|
23577
|
-
this.leaveValue,
|
|
23578
|
-
this.leaveFromValue,
|
|
23579
|
-
this.leaveToValue
|
|
23580
|
-
);
|
|
24449
|
+
this.element.classList.remove(...this.#staged);
|
|
24450
|
+
this.#staged.clear();
|
|
23581
24451
|
}
|
|
23582
24452
|
#raf(callback) {
|
|
23583
24453
|
if (typeof window.requestAnimationFrame === "function") {
|