stimeo-ui 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -0
- data/dist/controllers/alert_dialog_controller.js +17 -7
- data/dist/controllers/announcer_controller.js +1 -1
- data/dist/controllers/bulk_select_controller.js +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/resizable_controller.js +1 -1
- data/dist/controllers/scroll_restore_controller.js +127 -27
- data/dist/controllers/scroll_visibility_controller.js +167 -14
- data/dist/controllers/sidebar_controller.js +15 -5
- data/dist/controllers/step_indicator_controller.js +0 -5
- data/dist/controllers/theme_controller.js +151 -40
- data/dist/controllers/toast_controller.js +2 -2
- data/dist/controllers/transition_controller.js +79 -15
- data/dist/index.js +740 -178
- data/dist/positioning/index.js +84 -25
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9426006a2ca0153a2f91db44ca72beff51851141b42ab82bf64f55a68f853651
|
|
4
|
+
data.tar.gz: fdbd9afa0e2c79f74a823c69b04f55295626fec0b8315e3fec7ec1f88f0406d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac9a8e4008547f68a709b27964c767bae3adf601770a353b6834e58866f6d84ce68f257cb0d5a8e80aa685cbc2a12e4068414f8351952e2d9afeaf1582764d7f
|
|
7
|
+
data.tar.gz: ac6559ced3a65c78f00b836d8a5f6a6c7aed7dfe50b091b13f537418a32f9e76d5bf00c487faa23d13851670069dd2d7a1bfdb50c67cd17a29fd073c16be86f1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
|
|
8
8
|
change between releases.
|
|
9
9
|
|
|
10
|
+
## [0.10.0] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
Minor release with no new components. Eight existing ones are reworked — anchored,
|
|
13
|
+
focus, password-reveal, password-strength, scroll-restore, scroll-visibility,
|
|
14
|
+
theme, and transition — and a shared change to the focus trap reaches the six
|
|
15
|
+
modal overlays too. Most of them changed a contract, so read Removed and Changed
|
|
16
|
+
before upgrading. The Inspector manifest stays on schema v12.
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
|
|
20
|
+
- theme: the `mode` action param. Options declare their mode with
|
|
21
|
+
`data-value="light|dark|system"` — migrate every option or it can never become
|
|
22
|
+
the selection.
|
|
23
|
+
- password-strength: the live region on the `label` target. Drop its `aria-live`,
|
|
24
|
+
seat a `stimeo--announcer` on the page, and set `announceText`.
|
|
25
|
+
- focus: `[autofocus]` initial focus and the optional scroll lock, neither of
|
|
26
|
+
which was ever implemented. Initial focus is the `initial` target, else the
|
|
27
|
+
first focusable descendant, else the container.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- password-strength: `announceText` (`{level}` / `{score}` / `{max}` / `{band}`),
|
|
32
|
+
a `setScore` action for a score computed outside the built-in heuristic, and a
|
|
33
|
+
`reconcile` event.
|
|
34
|
+
- `stimeo check` warns when a page uses password-strength with no
|
|
35
|
+
`stimeo--announcer`.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- alert-dialog, command-palette, confirm, dialog, drawer, sidebar, and focus:
|
|
40
|
+
background isolation walks from the element up to `<body>` and inerts each
|
|
41
|
+
ancestor's other children, so one nested below `<body>` isolates its own branch.
|
|
42
|
+
- transition: declare a token as a stage Value **or** author it as a standing
|
|
43
|
+
class, not both — a token already on the element is the consumer's and is never
|
|
44
|
+
applied or removed. `connect()` no longer strips half-applied stage classes.
|
|
45
|
+
- A half-applied state is rewound before Turbo caches the page, silently:
|
|
46
|
+
transition's stage classes, focus's `data-focus-trapped`, password-reveal's
|
|
47
|
+
revealed field, and everything password-strength derives from the field value.
|
|
48
|
+
- focus reads its declarations at fixed moments: only `trap` is watched, `auto`
|
|
49
|
+
and `inert` are read when the trap turns on, and `restore` when it turns off.
|
|
50
|
+
- A declaration that cannot be read falls back to its default instead of poisoning
|
|
51
|
+
the widget — anchored's `placement` / `offset` / `padding`, theme's `mode` /
|
|
52
|
+
`target`, scroll-visibility's `offset` / `root` / `focusSelector`,
|
|
53
|
+
password-strength's `minScore` / `levels`.
|
|
54
|
+
- Targets and declarations swapped in at runtime are followed: anchored's `anchor`
|
|
55
|
+
/ `floating`, password-reveal's `input` / `toggle`, password-strength's targets,
|
|
56
|
+
theme's options, scroll-visibility's `element` / `offset` / `mode`, and
|
|
57
|
+
scroll-restore's `key` / `axis`.
|
|
58
|
+
- Events report transitions only — theme's `change` when `mode` or `resolved`
|
|
59
|
+
actually moved, scroll-visibility's `change` on a real visibility change
|
|
60
|
+
(connecting is silent), password-strength's announcement when the level changes.
|
|
61
|
+
A scroll with no vertical movement leaves scroll-visibility alone.
|
|
62
|
+
- Smaller contract shifts: password-strength's `data-strength` bands anchor both
|
|
63
|
+
ends of the declared scale (a level count other than four moves the boundaries)
|
|
64
|
+
and its `meter` target needs an accessible name; scroll-restore always restores
|
|
65
|
+
instantly (so `scroll-behavior: smooth` no longer animates it) and writes
|
|
66
|
+
nothing for an element it never restored and the reader never scrolled; theme writes
|
|
67
|
+
`aria-pressed` only on a button and passes modified `Home` / `End` through.
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- transition: `enter()` from `hidden` runs the CSS transition instead of popping
|
|
72
|
+
in, so `entered` arrives on the real terminal event rather than the safety
|
|
73
|
+
timeout.
|
|
74
|
+
- A control that owns focus is no longer hidden or stranded: scroll-visibility
|
|
75
|
+
holds the hide until it blurs and `toTop` focuses without scrolling, and focus's
|
|
76
|
+
state hook and events follow the controller's own record of trapping.
|
|
77
|
+
- Stored and measured state holds: scroll-restore keeps a saved offset that a
|
|
78
|
+
clamped restore would have overwritten and never republishes it under a new
|
|
79
|
+
`key`; password-reveal arms the `autoHide` it skipped on an already-revealed
|
|
80
|
+
field; anchored measures a target swapped in at runtime and writes no stale
|
|
81
|
+
placement after teardown.
|
|
82
|
+
|
|
10
83
|
## [0.9.0] - 2026-08-28
|
|
11
84
|
|
|
12
85
|
Minor release with no new components. Ten existing ones are reworked —
|
|
@@ -783,6 +856,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
783
856
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
784
857
|
RubyGems.
|
|
785
858
|
|
|
859
|
+
[0.10.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.10.0
|
|
786
860
|
[0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
|
|
787
861
|
[0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
|
|
788
862
|
[0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
|
|
@@ -315,15 +315,25 @@ var FocusTrap = class {
|
|
|
315
315
|
* content cannot be focused or reached by assistive technology, honoring the
|
|
316
316
|
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
317
317
|
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
318
|
+
*
|
|
319
|
+
* The walk climbs from the container to `body` and inerts each ancestor's other
|
|
320
|
+
* children. Scanning only `body`'s children would skip the branch the container
|
|
321
|
+
* sits in — everything beside it inside that branch is background too, and a
|
|
322
|
+
* nested container is the ordinary case.
|
|
318
323
|
*/
|
|
319
324
|
#isolateBackground() {
|
|
320
325
|
const container = this.#getContainer();
|
|
321
326
|
this.#inertedSiblings = [];
|
|
322
|
-
for (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
sibling.
|
|
326
|
-
|
|
327
|
+
for (let node = container; node !== document.body; ) {
|
|
328
|
+
const parent = node.parentElement;
|
|
329
|
+
if (!parent) break;
|
|
330
|
+
for (const sibling of Array.from(parent.children)) {
|
|
331
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
332
|
+
if (sibling === node || sibling.inert) continue;
|
|
333
|
+
sibling.inert = true;
|
|
334
|
+
this.#inertedSiblings.push(sibling);
|
|
335
|
+
}
|
|
336
|
+
node = parent;
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
339
|
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
@@ -361,8 +371,8 @@ var AlertDialogController = class extends Controller {
|
|
|
361
371
|
static actions = ["cancel", "confirm", "open"];
|
|
362
372
|
static events = ["cancel", "confirm"];
|
|
363
373
|
/**
|
|
364
|
-
* Owns the modal side effects. Escape
|
|
365
|
-
* emits the same event
|
|
374
|
+
* Owns the modal side effects. Escape takes the same cancel path as
|
|
375
|
+
* {@link cancel} and emits the same event, tagged `"escape"` instead of `"user"`; focus falls
|
|
366
376
|
* back to the trigger when nothing was focused before opening.
|
|
367
377
|
*/
|
|
368
378
|
#trap = new FocusTrap(() => this.dialogTarget, {
|
|
@@ -338,7 +338,7 @@ var AnnouncerController = class extends Controller {
|
|
|
338
338
|
}
|
|
339
339
|
/**
|
|
340
340
|
* Arms `region`'s single pending timer. Callers reach here with the slot
|
|
341
|
-
* already free — `#
|
|
341
|
+
* already free — `#drain` releases it before writing, and a fired timer clears its own
|
|
342
342
|
* entry below — so this does not cancel again.
|
|
343
343
|
*/
|
|
344
344
|
#schedule(region, callback, delay) {
|
|
@@ -63,8 +63,8 @@ var BulkSelectController = class extends Controller {
|
|
|
63
63
|
static events = ["change", "reconcile"];
|
|
64
64
|
/** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
|
|
65
65
|
* `connect()` over markup that already carries the attribute rehydrates the
|
|
66
|
-
* mode — a morph, a Turbo Stream,
|
|
67
|
-
* visit
|
|
66
|
+
* mode — a morph, a Turbo Stream, a server that renders it back, or a restore
|
|
67
|
+
* visit, whose cached snapshot carries the attribute too. */
|
|
68
68
|
#allPagesMode = false;
|
|
69
69
|
/** Last emitted figures, so a recompute reports only on a real change. */
|
|
70
70
|
#lastCount = -1;
|
|
@@ -217,7 +217,7 @@ var CollapsibleController = class extends Controller {
|
|
|
217
217
|
* `open` Value disagrees. An already-open region remains open without a
|
|
218
218
|
* close/reopen cycle. The Value only seeds a genuinely fresh render where no
|
|
219
219
|
* state attribute is present yet; any opening animation in that case belongs to
|
|
220
|
-
* the consumer's CSS.
|
|
220
|
+
* the consumer's CSS.
|
|
221
221
|
*/
|
|
222
222
|
connect() {
|
|
223
223
|
this.#connected = true;
|
|
@@ -380,15 +380,25 @@ var FocusTrap = class {
|
|
|
380
380
|
* content cannot be focused or reached by assistive technology, honoring the
|
|
381
381
|
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
382
382
|
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
383
|
+
*
|
|
384
|
+
* The walk climbs from the container to `body` and inerts each ancestor's other
|
|
385
|
+
* children. Scanning only `body`'s children would skip the branch the container
|
|
386
|
+
* sits in — everything beside it inside that branch is background too, and a
|
|
387
|
+
* nested container is the ordinary case.
|
|
383
388
|
*/
|
|
384
389
|
#isolateBackground() {
|
|
385
390
|
const container = this.#getContainer();
|
|
386
391
|
this.#inertedSiblings = [];
|
|
387
|
-
for (
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
sibling.
|
|
391
|
-
|
|
392
|
+
for (let node = container; node !== document.body; ) {
|
|
393
|
+
const parent = node.parentElement;
|
|
394
|
+
if (!parent) break;
|
|
395
|
+
for (const sibling of Array.from(parent.children)) {
|
|
396
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
397
|
+
if (sibling === node || sibling.inert) continue;
|
|
398
|
+
sibling.inert = true;
|
|
399
|
+
this.#inertedSiblings.push(sibling);
|
|
400
|
+
}
|
|
401
|
+
node = parent;
|
|
392
402
|
}
|
|
393
403
|
}
|
|
394
404
|
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
@@ -315,15 +315,25 @@ var FocusTrap = class {
|
|
|
315
315
|
* content cannot be focused or reached by assistive technology, honoring the
|
|
316
316
|
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
317
317
|
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
318
|
+
*
|
|
319
|
+
* The walk climbs from the container to `body` and inerts each ancestor's other
|
|
320
|
+
* children. Scanning only `body`'s children would skip the branch the container
|
|
321
|
+
* sits in — everything beside it inside that branch is background too, and a
|
|
322
|
+
* nested container is the ordinary case.
|
|
318
323
|
*/
|
|
319
324
|
#isolateBackground() {
|
|
320
325
|
const container = this.#getContainer();
|
|
321
326
|
this.#inertedSiblings = [];
|
|
322
|
-
for (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
sibling.
|
|
326
|
-
|
|
327
|
+
for (let node = container; node !== document.body; ) {
|
|
328
|
+
const parent = node.parentElement;
|
|
329
|
+
if (!parent) break;
|
|
330
|
+
for (const sibling of Array.from(parent.children)) {
|
|
331
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
332
|
+
if (sibling === node || sibling.inert) continue;
|
|
333
|
+
sibling.inert = true;
|
|
334
|
+
this.#inertedSiblings.push(sibling);
|
|
335
|
+
}
|
|
336
|
+
node = parent;
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
339
|
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
@@ -121,7 +121,7 @@ var CountdownController = class extends Controller {
|
|
|
121
121
|
#pausedAmount = 0;
|
|
122
122
|
/**
|
|
123
123
|
* The amount the slots are currently showing, floored to the second they render.
|
|
124
|
-
* It lags {@link currentAmount} by up to one tick, and it — not the live reading —
|
|
124
|
+
* It lags {@link #currentAmount} by up to one tick, and it — not the live reading —
|
|
125
125
|
* is what a pause has to preserve: storing the fraction behind the display instead
|
|
126
126
|
* makes the first tick after a resume step by two units.
|
|
127
127
|
*/
|
|
@@ -298,7 +298,7 @@ var DataGridController = class extends Controller {
|
|
|
298
298
|
const row = cell.closest("[role='row']");
|
|
299
299
|
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
300
300
|
}
|
|
301
|
-
/**
|
|
301
|
+
/** Cycles a header's sort on keyboard activation and emits `sort`. */
|
|
302
302
|
#cycleSort(header) {
|
|
303
303
|
const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
|
|
304
304
|
for (const other of this.columnHeaderTargets) {
|
|
@@ -315,15 +315,25 @@ var FocusTrap = class {
|
|
|
315
315
|
* content cannot be focused or reached by assistive technology, honoring the
|
|
316
316
|
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
317
317
|
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
318
|
+
*
|
|
319
|
+
* The walk climbs from the container to `body` and inerts each ancestor's other
|
|
320
|
+
* children. Scanning only `body`'s children would skip the branch the container
|
|
321
|
+
* sits in — everything beside it inside that branch is background too, and a
|
|
322
|
+
* nested container is the ordinary case.
|
|
318
323
|
*/
|
|
319
324
|
#isolateBackground() {
|
|
320
325
|
const container = this.#getContainer();
|
|
321
326
|
this.#inertedSiblings = [];
|
|
322
|
-
for (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
sibling.
|
|
326
|
-
|
|
327
|
+
for (let node = container; node !== document.body; ) {
|
|
328
|
+
const parent = node.parentElement;
|
|
329
|
+
if (!parent) break;
|
|
330
|
+
for (const sibling of Array.from(parent.children)) {
|
|
331
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
332
|
+
if (sibling === node || sibling.inert) continue;
|
|
333
|
+
sibling.inert = true;
|
|
334
|
+
this.#inertedSiblings.push(sibling);
|
|
335
|
+
}
|
|
336
|
+
node = parent;
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
339
|
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
@@ -310,10 +310,6 @@ var DirectUploadController = class extends Controller {
|
|
|
310
310
|
this.#rows.delete(id);
|
|
311
311
|
}
|
|
312
312
|
}
|
|
313
|
-
/**
|
|
314
|
-
* Returns the widget to its pre-upload state just before Turbo caches the
|
|
315
|
-
* page, so the snapshot never replays rows for uploads that cannot resume.
|
|
316
|
-
*/
|
|
317
313
|
/**
|
|
318
314
|
* Rewinds for the snapshot and reports what that discarded. An upload in flight
|
|
319
315
|
* cannot survive the navigation, so a consumer mirroring the rows would keep a
|
|
@@ -352,7 +348,7 @@ var DirectUploadController = class extends Controller {
|
|
|
352
348
|
#detail(event) {
|
|
353
349
|
return event.detail ?? {};
|
|
354
350
|
}
|
|
355
|
-
/** The file name
|
|
351
|
+
/** The event's file name, or `""` when it carries none. */
|
|
356
352
|
#name(detail) {
|
|
357
353
|
return detail.file?.name ?? "";
|
|
358
354
|
}
|
|
@@ -315,15 +315,25 @@ var FocusTrap = class {
|
|
|
315
315
|
* content cannot be focused or reached by assistive technology, honoring the
|
|
316
316
|
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
317
317
|
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
318
|
+
*
|
|
319
|
+
* The walk climbs from the container to `body` and inerts each ancestor's other
|
|
320
|
+
* children. Scanning only `body`'s children would skip the branch the container
|
|
321
|
+
* sits in — everything beside it inside that branch is background too, and a
|
|
322
|
+
* nested container is the ordinary case.
|
|
318
323
|
*/
|
|
319
324
|
#isolateBackground() {
|
|
320
325
|
const container = this.#getContainer();
|
|
321
326
|
this.#inertedSiblings = [];
|
|
322
|
-
for (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
sibling.
|
|
326
|
-
|
|
327
|
+
for (let node = container; node !== document.body; ) {
|
|
328
|
+
const parent = node.parentElement;
|
|
329
|
+
if (!parent) break;
|
|
330
|
+
for (const sibling of Array.from(parent.children)) {
|
|
331
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
332
|
+
if (sibling === node || sibling.inert) continue;
|
|
333
|
+
sibling.inert = true;
|
|
334
|
+
this.#inertedSiblings.push(sibling);
|
|
335
|
+
}
|
|
336
|
+
node = parent;
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
339
|
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
@@ -576,8 +586,8 @@ var DrawerController = class extends Controller {
|
|
|
576
586
|
* rather than being re-derived from the declarative `open` Value (which would
|
|
577
587
|
* close a user-opened drawer). The `open` Value only seeds a genuinely fresh
|
|
578
588
|
* render. We normalize to a clean closed baseline first so {@link open} runs its
|
|
579
|
-
* full reveal + trap activation — the {@link FocusTrap} is
|
|
580
|
-
*
|
|
589
|
+
* full reveal + trap activation — the {@link FocusTrap} is inactive after a
|
|
590
|
+
* disconnect and must be re-activated.
|
|
581
591
|
*/
|
|
582
592
|
connect() {
|
|
583
593
|
this.#connected = true;
|
|
@@ -315,15 +315,25 @@ var FocusTrap = class {
|
|
|
315
315
|
* content cannot be focused or reached by assistive technology, honoring the
|
|
316
316
|
* `aria-modal="true"` contract. An element that was *already* `inert` is left
|
|
317
317
|
* untracked so `#releaseBackground` does not wrongly clear it.
|
|
318
|
+
*
|
|
319
|
+
* The walk climbs from the container to `body` and inerts each ancestor's other
|
|
320
|
+
* children. Scanning only `body`'s children would skip the branch the container
|
|
321
|
+
* sits in — everything beside it inside that branch is background too, and a
|
|
322
|
+
* nested container is the ordinary case.
|
|
318
323
|
*/
|
|
319
324
|
#isolateBackground() {
|
|
320
325
|
const container = this.#getContainer();
|
|
321
326
|
this.#inertedSiblings = [];
|
|
322
|
-
for (
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
sibling.
|
|
326
|
-
|
|
327
|
+
for (let node = container; node !== document.body; ) {
|
|
328
|
+
const parent = node.parentElement;
|
|
329
|
+
if (!parent) break;
|
|
330
|
+
for (const sibling of Array.from(parent.children)) {
|
|
331
|
+
if (!(sibling instanceof HTMLElement)) continue;
|
|
332
|
+
if (sibling === node || sibling.inert) continue;
|
|
333
|
+
sibling.inert = true;
|
|
334
|
+
this.#inertedSiblings.push(sibling);
|
|
335
|
+
}
|
|
336
|
+
node = parent;
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
339
|
/** Reverts the `inert` flags applied by `#isolateBackground`. */
|
|
@@ -375,13 +385,39 @@ var FocusController = class extends Controller {
|
|
|
375
385
|
initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
|
|
376
386
|
onEscape: () => this.deactivate()
|
|
377
387
|
});
|
|
388
|
+
/**
|
|
389
|
+
* Whether this scope is currently trapping.
|
|
390
|
+
*
|
|
391
|
+
* Held here rather than read back from the trap: the trap releases itself
|
|
392
|
+
* before Turbo caches the page, so its own flag stops answering for the state
|
|
393
|
+
* this controller publishes on the element and in its events.
|
|
394
|
+
*/
|
|
395
|
+
#active = false;
|
|
396
|
+
/**
|
|
397
|
+
* Returns the element to its untrapped form before Turbo copies the page. The
|
|
398
|
+
* trap performs its own release on the same event, so what is left is the hook
|
|
399
|
+
* this controller owns — carried into the snapshot it would describe a scope
|
|
400
|
+
* that is no longer trapping.
|
|
401
|
+
*
|
|
402
|
+
* Silent, like `disconnect()`: the page is about to be frozen, and a release
|
|
403
|
+
* nobody asked for is not a close to report.
|
|
404
|
+
*/
|
|
405
|
+
#beforeCache = new BeforeCacheReset(() => {
|
|
406
|
+
this.#active = false;
|
|
407
|
+
this.element.removeAttribute("data-focus-trapped");
|
|
408
|
+
});
|
|
378
409
|
/** Stimulus drives activation from the `trap` value (also fires on connect). */
|
|
379
410
|
trapValueChanged() {
|
|
380
411
|
if (this.trapValue) this.#activate();
|
|
381
412
|
else this.#deactivate();
|
|
382
413
|
}
|
|
414
|
+
connect() {
|
|
415
|
+
this.#beforeCache.activate();
|
|
416
|
+
}
|
|
383
417
|
disconnect() {
|
|
384
418
|
this.#trap.deactivate({ restoreFocus: false });
|
|
419
|
+
this.#beforeCache.deactivate();
|
|
420
|
+
this.#active = false;
|
|
385
421
|
this.element.removeAttribute("data-focus-trapped");
|
|
386
422
|
}
|
|
387
423
|
/** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
|
|
@@ -395,13 +431,15 @@ var FocusController = class extends Controller {
|
|
|
395
431
|
this.#deactivate();
|
|
396
432
|
}
|
|
397
433
|
#activate() {
|
|
398
|
-
if (this.#
|
|
434
|
+
if (this.#active) return;
|
|
435
|
+
this.#active = true;
|
|
399
436
|
this.#trap.activate();
|
|
400
437
|
this.element.setAttribute("data-focus-trapped", "true");
|
|
401
438
|
this.dispatch("activate", { detail: {} });
|
|
402
439
|
}
|
|
403
440
|
#deactivate() {
|
|
404
|
-
if (!this.#
|
|
441
|
+
if (!this.#active) return;
|
|
442
|
+
this.#active = false;
|
|
405
443
|
this.#trap.deactivate({ restoreFocus: this.restoreValue });
|
|
406
444
|
this.element.removeAttribute("data-focus-trapped");
|
|
407
445
|
this.dispatch("deactivate", { detail: {} });
|
|
@@ -146,7 +146,7 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
146
146
|
static #INVALID_ATTR = "data-stimeo--form-field-invalid";
|
|
147
147
|
/** Collapses one target/morph batch into one silent ARIA reconciliation. */
|
|
148
148
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
149
|
-
/**
|
|
149
|
+
/** Returns borrowed control ARIA before Turbo snapshots the page. */
|
|
150
150
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
151
151
|
#ariaDescribedBy = new AttributeLease("aria-describedby");
|
|
152
152
|
#ariaErrorMessage = new AttributeLease("aria-errormessage");
|
|
@@ -629,8 +629,8 @@ var MenubarController = class extends Controller {
|
|
|
629
629
|
* being hidden itself, a menu can stay visible after its owning top was removed,
|
|
630
630
|
* an open pair can appear from a morph with no layer registered for it, and the
|
|
631
631
|
* Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
|
|
632
|
-
* all.
|
|
633
|
-
* this layer is on
|
|
632
|
+
* all. The only state carried between calls is the tracked focus record and which side
|
|
633
|
+
* of the Escape stack this layer is on (which the stack itself does not expose), so the outcome is the
|
|
634
634
|
* same whichever mutation arrived and calling it more often than needed is free.
|
|
635
635
|
*/
|
|
636
636
|
#reconcile() {
|
|
@@ -1048,7 +1048,7 @@ var MultiSelectController = class extends Controller {
|
|
|
1048
1048
|
get #values() {
|
|
1049
1049
|
return this.#selectedOptions.map((option) => this.#optionValue(option));
|
|
1050
1050
|
}
|
|
1051
|
-
/** Normalized cardinality cap: zero
|
|
1051
|
+
/** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
|
|
1052
1052
|
get #selectionLimit() {
|
|
1053
1053
|
if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
|
|
1054
1054
|
return Math.max(1, Math.floor(this.maxValue));
|
|
@@ -382,8 +382,8 @@ var OverflowMenuController = class extends Controller {
|
|
|
382
382
|
*
|
|
383
383
|
* With no known child at all (a fresh instance connecting to markup that already
|
|
384
384
|
* holds banked items), a fully-banked snapshot's inert boundary preserves whether an
|
|
385
|
-
* unindexed run was prepended or appended before connect.
|
|
386
|
-
*
|
|
385
|
+
* unindexed run was prepended or appended before connect. Markup that holds banked items but no boundary —
|
|
386
|
+
* server-rendered or hand-authored — uses the saved index as the
|
|
387
387
|
* offset, the inverse of the move that banked it.
|
|
388
388
|
*/
|
|
389
389
|
#merge(bar, banked, boundaryAt) {
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/password_reveal_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/safe_timeout.ts
|
|
6
35
|
var TimerRegistry = class {
|
|
7
36
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -56,6 +85,7 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
56
85
|
};
|
|
57
86
|
|
|
58
87
|
// src/controllers/password_reveal_controller.ts
|
|
88
|
+
var MAX_DELAY = 2 ** 31 - 1;
|
|
59
89
|
var PasswordRevealController = class extends Controller {
|
|
60
90
|
static targets = ["input", "toggle"];
|
|
61
91
|
static values = {
|
|
@@ -65,11 +95,54 @@ var PasswordRevealController = class extends Controller {
|
|
|
65
95
|
static events = ["toggle"];
|
|
66
96
|
/** Auto re-mask timer; torn down on disconnect. */
|
|
67
97
|
#timers = new SafeTimeout();
|
|
98
|
+
/**
|
|
99
|
+
* Whether this controller is between `connect()` and `disconnect()`.
|
|
100
|
+
*
|
|
101
|
+
* Target callbacks outlive the controller: Stimulus stops the target observer
|
|
102
|
+
* after `disconnect()`, so a field leaving after teardown still reaches
|
|
103
|
+
* {@link PasswordRevealController.inputTargetDisconnected}. Arming from there
|
|
104
|
+
* would put a timer back that nothing will clear. The element staying in the
|
|
105
|
+
* document does not answer this — unloading the controller leaves it there.
|
|
106
|
+
*/
|
|
107
|
+
#connected = false;
|
|
108
|
+
/** Masks the field before Turbo copies the page into its snapshot. */
|
|
109
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindToMasked());
|
|
68
110
|
connect() {
|
|
69
|
-
this.#
|
|
111
|
+
this.#connected = true;
|
|
112
|
+
const visible = this.#isVisible;
|
|
113
|
+
this.#reflect(visible);
|
|
114
|
+
this.#beforeCache.activate();
|
|
115
|
+
this.#arm(visible);
|
|
70
116
|
}
|
|
71
117
|
disconnect() {
|
|
118
|
+
this.#connected = false;
|
|
72
119
|
this.#timers.clearAll();
|
|
120
|
+
this.#beforeCache.deactivate();
|
|
121
|
+
}
|
|
122
|
+
/** Re-derives the hooks and the re-mask for a field swapped in after connect. */
|
|
123
|
+
inputTargetConnected() {
|
|
124
|
+
const visible = this.#connected && this.#isVisible;
|
|
125
|
+
this.#reflect(visible);
|
|
126
|
+
this.#arm(visible);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Re-derives from whatever field is left rather than assuming none is. A swap
|
|
130
|
+
* delivers this callback next to the arrival in either order, so a revealed
|
|
131
|
+
* replacement that answered "masked" here would be described as hidden while
|
|
132
|
+
* showing the password, and would carry no re-mask.
|
|
133
|
+
*
|
|
134
|
+
* Teardown is the one case with nothing to derive: target callbacks run after
|
|
135
|
+
* `disconnect()`, so the field is still revealed and re-arming from it would
|
|
136
|
+
* outlive the controller. A detached root is the signal to stand down.
|
|
137
|
+
*/
|
|
138
|
+
inputTargetDisconnected() {
|
|
139
|
+
const visible = this.#connected && this.element.isConnected && this.#isVisible;
|
|
140
|
+
this.#reflect(visible);
|
|
141
|
+
this.#arm(visible);
|
|
142
|
+
}
|
|
143
|
+
/** Re-derives the pressed state for a button swapped in after connect. */
|
|
144
|
+
toggleTargetConnected() {
|
|
145
|
+
this.#reflect(this.#isVisible);
|
|
73
146
|
}
|
|
74
147
|
/** Toggles the input between masked and revealed. Bound via `data-action`. */
|
|
75
148
|
toggle() {
|
|
@@ -98,11 +171,37 @@ var PasswordRevealController = class extends Controller {
|
|
|
98
171
|
}
|
|
99
172
|
this.#reflect(visible);
|
|
100
173
|
this.dispatch("toggle", { detail: { visible } });
|
|
174
|
+
this.#arm(visible);
|
|
175
|
+
}
|
|
176
|
+
/** Schedules the auto re-mask for a revealed field, replacing any pending one. */
|
|
177
|
+
#arm(visible) {
|
|
101
178
|
this.#timers.clearAll();
|
|
102
|
-
|
|
103
|
-
|
|
179
|
+
const delay = this.#autoHideDelay;
|
|
180
|
+
if (visible && delay > 0) {
|
|
181
|
+
this.#timers.set(() => this.#setVisible(false), delay);
|
|
104
182
|
}
|
|
105
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* The auto re-mask delay, held to what `setTimeout` can carry. Past that limit
|
|
186
|
+
* a delay folds to zero, turning "keep it showing" into "hide it at once" —
|
|
187
|
+
* the opposite of what the declaration asked for. A value that is no delay at
|
|
188
|
+
* all stays out of the positive range {@link PasswordRevealController.#arm}
|
|
189
|
+
* requires, so it schedules nothing.
|
|
190
|
+
*/
|
|
191
|
+
get #autoHideDelay() {
|
|
192
|
+
return Math.min(this.autoHideValue, MAX_DELAY);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Returns the field to masked before Turbo copies the page. Silent and
|
|
196
|
+
* focus-free: the page is about to be frozen, so there is no one to tell and
|
|
197
|
+
* nowhere for focus to go.
|
|
198
|
+
*/
|
|
199
|
+
#rewindToMasked() {
|
|
200
|
+
this.#timers.clearAll();
|
|
201
|
+
if (!this.hasInputTarget) return;
|
|
202
|
+
this.inputTarget.type = "password";
|
|
203
|
+
this.#reflect(false);
|
|
204
|
+
}
|
|
106
205
|
/** Reflects the visible state onto `aria-pressed` and `data-state`. */
|
|
107
206
|
#reflect(visible) {
|
|
108
207
|
if (this.hasToggleTarget) {
|