stimeo-ui 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +163 -0
- data/dist/controllers/alert_dialog_controller.js +17 -7
- data/dist/controllers/announcer_controller.js +1 -1
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/collapsible_controller.js +1 -1
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/command_palette_controller.js +15 -5
- data/dist/controllers/confirm_controller.js +15 -5
- data/dist/controllers/countdown_controller.js +1 -1
- data/dist/controllers/data_grid_controller.js +151 -25
- data/dist/controllers/dialog_controller.js +15 -5
- data/dist/controllers/direct_upload_controller.js +1 -5
- data/dist/controllers/drawer_controller.js +17 -7
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/focus_controller.js +45 -7
- data/dist/controllers/form_field_controller.js +1 -1
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/menubar_controller.js +2 -2
- data/dist/controllers/multi_select_controller.js +1 -1
- data/dist/controllers/otp_controller.js +29 -16
- data/dist/controllers/overflow_menu_controller.js +2 -2
- data/dist/controllers/password_reveal_controller.js +102 -3
- data/dist/controllers/password_strength_controller.js +287 -42
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/scroll_restore_controller.js +127 -27
- data/dist/controllers/scroll_visibility_controller.js +167 -14
- data/dist/controllers/sidebar_controller.js +15 -5
- data/dist/controllers/step_indicator_controller.js +0 -5
- data/dist/controllers/theme_controller.js +151 -40
- data/dist/controllers/toast_controller.js +2 -2
- data/dist/controllers/transition_controller.js +79 -15
- data/dist/index.js +1612 -529
- data/dist/positioning/index.js +84 -25
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
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,167 @@ 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
|
+
|
|
83
|
+
## [0.9.0] - 2026-08-28
|
|
84
|
+
|
|
85
|
+
Minor release with no new components. Ten existing ones are reworked —
|
|
86
|
+
bulk-select, clipboard, color-picker, data-grid, editable, filter, masonry, otp,
|
|
87
|
+
reset-before-cache, and resizable — and of those, bulk-select, clipboard,
|
|
88
|
+
data-grid, and editable also changed their markup contracts, so read Removed and
|
|
89
|
+
Changed before upgrading. The Inspector manifest stays on schema v12.
|
|
90
|
+
|
|
91
|
+
### Removed
|
|
92
|
+
|
|
93
|
+
- bulk-select: `announce`, and the `aria-live` on the `bar` target that went with
|
|
94
|
+
it — the count goes through the page's `stimeo--announcer` now.
|
|
95
|
+
- clipboard: the live-region requirement on the `feedback` target. Drop the
|
|
96
|
+
`role="status"` and `aria-live`; the slot is a plain visible label.
|
|
97
|
+
- editable: the `onBlur` action. Drop `blur->stimeo--editable#onBlur` — the
|
|
98
|
+
departure is watched on the controller element.
|
|
99
|
+
|
|
100
|
+
### Added
|
|
101
|
+
|
|
102
|
+
- bulk-select: `announceText`, carrying `{count}`, and a `reconcile` event.
|
|
103
|
+
- clipboard: `announceCopiedText` and `announceErrorText`. Like `announceText`
|
|
104
|
+
they default to empty, so seat a `stimeo--announcer` on the page and write the
|
|
105
|
+
wording you want read.
|
|
106
|
+
- editable: `save` / `cancel` / `revert` actions, and `data-value` on the
|
|
107
|
+
`display` target for a display that renders the value rather than being it.
|
|
108
|
+
- color-picker: `data-value-text`, an `aria-valuetext` template whose `{value}`
|
|
109
|
+
carries the channel value, for pages that are not in English.
|
|
110
|
+
- Declarations read once are followed when they change — filter's `match`,
|
|
111
|
+
masonry's `minColumnWidth` / `gap`, resizable's `min` / `max` — as are targets
|
|
112
|
+
swapped in later by color-picker, editable, masonry, and resizable, and masonry
|
|
113
|
+
also relays out when a descendant resource finishes loading.
|
|
114
|
+
|
|
115
|
+
### Changed
|
|
116
|
+
|
|
117
|
+
- bulk-select: `selectAllPages` checks the rows on the page too, so a row that
|
|
118
|
+
arrives while the mode is on lands checked. Hiding the bar hands focus to the
|
|
119
|
+
select-all box, and a `bar` carrying `role="toolbar"` wants `stimeo--toolbar`
|
|
120
|
+
alongside it for the arrow keys and the single tab stop.
|
|
121
|
+
- clipboard: `copied` and `error` are transient `data-state` values — a
|
|
122
|
+
`connect()` that finds either returns to `idle`, and the state is rewound
|
|
123
|
+
before Turbo caches the page. Any other authored `data-state` is left alone.
|
|
124
|
+
- color-picker: `aria-valuenow` / `aria-valuetext` / `aria-valuemin` /
|
|
125
|
+
`aria-valuemax` are controller-owned, so the markup supplies `role="slider"`
|
|
126
|
+
and a name; an omitted range resolves to the channel default (hue `0`–`360`,
|
|
127
|
+
the rest `0`–`100`). Only a primary-button press drags, and an `alpha` slider
|
|
128
|
+
is inert unless `alpha` is on.
|
|
129
|
+
- data-grid: a keystroke or click that reached a control inside a cell, and one
|
|
130
|
+
an IME is composing, belongs to that control — no move, no sort, no selection.
|
|
131
|
+
The exception is a click on a sortable header's own `<button>`, which needs
|
|
132
|
+
`tabindex="-1"`; host `gridcell` / `columnheader` on `td` / `th`.
|
|
133
|
+
- editable: the mode is read back from the DOM and the controller writes no ARIA.
|
|
134
|
+
Focus landing inside the component has not left the edit, so a Save or Cancel
|
|
135
|
+
button beside the input keeps it open, and the value is trimmed once at the save.
|
|
136
|
+
- reset-before-cache: `data-reset-value` returns a field to what the markup
|
|
137
|
+
declares — `defaultValue`, `defaultChecked`, the `selected` option — instead of
|
|
138
|
+
emptying it; a file input empties and `hidden` / `submit` / `reset` / `button` /
|
|
139
|
+
`image` are left alone.
|
|
140
|
+
- resizable: a separator with no `aria-orientation` reads as `horizontal`, and
|
|
141
|
+
`toggle` reopens at the position the pane collapsed from. Pointer capture and
|
|
142
|
+
the drag listeners sit on the `separator` target, not on `event.target` and
|
|
143
|
+
`document`.
|
|
144
|
+
- masonry: `layout` fires when the result moves, not only on a column count
|
|
145
|
+
change, and every relayout after the first is deferred to a microtask. otp:
|
|
146
|
+
`reconcile` reports a move in the pair (joined value, `data-state`).
|
|
147
|
+
- A declaration that cannot be read falls back to its default instead of reaching
|
|
148
|
+
ARIA or CSS as `NaN` — masonry's `minColumnWidth` / `gap`, resizable's `min` /
|
|
149
|
+
`max` / `value` / `step`, bulk-select's `totalCount`, and reset-before-cache's
|
|
150
|
+
`scope`, which no longer aborts the whole sweep.
|
|
151
|
+
- Inspector: clipboard's `feedback` is no longer required, a color-picker
|
|
152
|
+
`slider` is checked for `role` and a name only, and bulk-select and clipboard
|
|
153
|
+
join the components that need a `stimeo--announcer`.
|
|
154
|
+
|
|
155
|
+
### Fixed
|
|
156
|
+
|
|
157
|
+
- data-grid: `Enter` and `Space` on a `<button>` nested in a cell reach it, so a
|
|
158
|
+
row-action button is keyboard-reachable, and rows coming and going at runtime
|
|
159
|
+
re-establish exactly one tab stop. editable: tabbing past a Save or Cancel
|
|
160
|
+
button no longer strands the editor open, and a display removed mid-edit no
|
|
161
|
+
longer throws. bulk-select: a nested bulk-select's rows stay out of the outer
|
|
162
|
+
container's count.
|
|
163
|
+
- clipboard: a copy that resolved after disconnect wrote to the detached element
|
|
164
|
+
and armed a timer past the teardown, and a repeat inside the feedback window is
|
|
165
|
+
read out again. In color-picker a drag belongs to the pointer that started it,
|
|
166
|
+
in masonry an item removed and re-appended in one batch keeps its layout hooks,
|
|
167
|
+
and in resizable `data-dragging` no longer rides a Turbo snapshot.
|
|
168
|
+
- reset-before-cache: `data-reset-value` wrote an empty string into a checkbox's
|
|
169
|
+
`value` attribute, and could leave a select with nothing selected at all.
|
|
170
|
+
|
|
10
171
|
## [0.8.0] - 2026-08-22
|
|
11
172
|
|
|
12
173
|
Minor release with no new components. Eight existing ones are reworked —
|
|
@@ -695,6 +856,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
695
856
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
696
857
|
RubyGems.
|
|
697
858
|
|
|
859
|
+
[0.10.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.10.0
|
|
860
|
+
[0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
|
|
698
861
|
[0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
|
|
699
862
|
[0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
|
|
700
863
|
[0.6.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.6.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) {
|
|
@@ -1,72 +1,189 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/bulk_select_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/microtask_coalescer.ts
|
|
23
|
+
var MicrotaskCoalescer = class {
|
|
24
|
+
#run;
|
|
25
|
+
#queued = false;
|
|
26
|
+
#active = false;
|
|
27
|
+
#generation = 0;
|
|
28
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
29
|
+
constructor(run) {
|
|
30
|
+
this.#run = run;
|
|
31
|
+
}
|
|
32
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
33
|
+
activate() {
|
|
34
|
+
this.#active = true;
|
|
35
|
+
}
|
|
36
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
37
|
+
cancel() {
|
|
38
|
+
this.#active = false;
|
|
39
|
+
this.#queued = false;
|
|
40
|
+
this.#generation += 1;
|
|
41
|
+
}
|
|
42
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
43
|
+
schedule() {
|
|
44
|
+
if (!this.#active || this.#queued) return;
|
|
45
|
+
this.#queued = true;
|
|
46
|
+
const generation = this.#generation;
|
|
47
|
+
queueMicrotask(() => {
|
|
48
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
49
|
+
this.#queued = false;
|
|
50
|
+
this.#run();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
3
55
|
// src/controllers/bulk_select_controller.ts
|
|
4
56
|
var BulkSelectController = class extends Controller {
|
|
5
57
|
static targets = ["all", "item", "bar", "count", "selectAllPages"];
|
|
6
58
|
static values = {
|
|
7
59
|
totalCount: { type: Number, default: 0 },
|
|
8
|
-
|
|
60
|
+
announceText: { type: String, default: "" }
|
|
9
61
|
};
|
|
10
62
|
static actions = ["clear", "selectAllPages"];
|
|
11
|
-
static events = ["change"];
|
|
12
|
-
/** All-pages mode is a transient UI state
|
|
13
|
-
*
|
|
63
|
+
static events = ["change", "reconcile"];
|
|
64
|
+
/** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
|
|
65
|
+
* `connect()` over markup that already carries the attribute rehydrates the
|
|
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. */
|
|
14
68
|
#allPagesMode = false;
|
|
15
|
-
/** Last emitted figures, so a recompute
|
|
69
|
+
/** Last emitted figures, so a recompute reports only on a real change. */
|
|
16
70
|
#lastCount = -1;
|
|
17
71
|
#lastAllPages = false;
|
|
72
|
+
/** Collapses every signal from one DOM or Value update into one repair. */
|
|
73
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
|
|
18
74
|
/** Delegated `change` handler covering the select-all box and every row. */
|
|
19
75
|
#onChange = (event) => {
|
|
20
76
|
const target = event.target;
|
|
21
77
|
if (!target) return;
|
|
22
78
|
if (this.hasAllTarget && target === this.allTarget) {
|
|
23
79
|
this.#applyAll();
|
|
24
|
-
} else if (
|
|
80
|
+
} else if (this.itemTargets.some((item) => item === target)) {
|
|
25
81
|
this.#exitAllPages();
|
|
26
|
-
this.#recompute(
|
|
82
|
+
this.#reportChange(this.#recompute());
|
|
27
83
|
}
|
|
28
84
|
};
|
|
29
85
|
connect() {
|
|
30
86
|
this.#allPagesMode = this.element.dataset.allPages === "true";
|
|
87
|
+
this.#reconcile.activate();
|
|
31
88
|
this.element.addEventListener("change", this.#onChange);
|
|
32
|
-
this.#recompute(
|
|
89
|
+
this.#recompute();
|
|
33
90
|
}
|
|
34
91
|
disconnect() {
|
|
35
92
|
this.element.removeEventListener("change", this.#onChange);
|
|
93
|
+
this.#reconcile.cancel();
|
|
94
|
+
}
|
|
95
|
+
/** Repairs the figures for a row that arrived at runtime. */
|
|
96
|
+
itemTargetConnected() {
|
|
97
|
+
this.#reconcile.schedule();
|
|
98
|
+
}
|
|
99
|
+
/** Repairs the figures after a row leaves, so a removed selection stops counting. */
|
|
100
|
+
itemTargetDisconnected() {
|
|
101
|
+
this.#reconcile.schedule();
|
|
102
|
+
}
|
|
103
|
+
/** Reflects the current selection onto a select-all box added at runtime. */
|
|
104
|
+
allTargetConnected() {
|
|
105
|
+
this.#reconcile.schedule();
|
|
106
|
+
}
|
|
107
|
+
/** Repairs the figures after the select-all box leaves. */
|
|
108
|
+
allTargetDisconnected() {
|
|
109
|
+
this.#reconcile.schedule();
|
|
110
|
+
}
|
|
111
|
+
/** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
|
|
112
|
+
totalCountValueChanged() {
|
|
113
|
+
if (!Number.isFinite(this.totalCountValue)) {
|
|
114
|
+
this.totalCountValue = 0;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.#reconcile.schedule();
|
|
118
|
+
}
|
|
119
|
+
/** Repaints so wording changed at runtime is used by the next announcement. */
|
|
120
|
+
announceTextValueChanged() {
|
|
121
|
+
this.#reconcile.schedule();
|
|
36
122
|
}
|
|
37
123
|
/** Clears every selection (rows + select-all) and exits all-pages mode. */
|
|
38
124
|
clear() {
|
|
39
|
-
for (const item of this
|
|
125
|
+
for (const item of this.itemTargets) item.checked = false;
|
|
40
126
|
if (this.hasAllTarget) {
|
|
41
127
|
this.allTarget.checked = false;
|
|
42
128
|
this.allTarget.indeterminate = false;
|
|
43
129
|
}
|
|
44
130
|
this.#exitAllPages();
|
|
45
|
-
this.#recompute(
|
|
131
|
+
this.#reportChange(this.#recompute());
|
|
46
132
|
}
|
|
47
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Enters "select all across pages" mode: the count shows `totalCount`, and every
|
|
135
|
+
* row on this page is checked.
|
|
136
|
+
*
|
|
137
|
+
* The mode's claim is that the whole set is selected, so leaving a visible row
|
|
138
|
+
* unchecked would put the page and the count in open disagreement.
|
|
139
|
+
*/
|
|
48
140
|
selectAllPages() {
|
|
49
141
|
this.#allPagesMode = true;
|
|
50
|
-
this.#
|
|
142
|
+
this.#checkEveryRow();
|
|
143
|
+
this.#reportChange(this.#recompute());
|
|
144
|
+
}
|
|
145
|
+
/** Marks every row on this page selected. */
|
|
146
|
+
#checkEveryRow() {
|
|
147
|
+
for (const item of this.itemTargets) item.checked = true;
|
|
51
148
|
}
|
|
52
149
|
/** Mirrors the select-all box to every row, then recomputes. */
|
|
53
150
|
#applyAll() {
|
|
54
151
|
if (!this.hasAllTarget) return;
|
|
55
152
|
const { checked } = this.allTarget;
|
|
56
|
-
for (const item of this
|
|
153
|
+
for (const item of this.itemTargets) item.checked = checked;
|
|
57
154
|
this.#exitAllPages();
|
|
58
|
-
this.#recompute(
|
|
155
|
+
this.#reportChange(this.#recompute());
|
|
59
156
|
}
|
|
60
157
|
#exitAllPages() {
|
|
61
158
|
this.#allPagesMode = false;
|
|
62
159
|
}
|
|
160
|
+
/** Repairs the derived state after the page moved rows or a render input. */
|
|
161
|
+
#reconcileNow() {
|
|
162
|
+
if (this.#allPagesMode) this.#checkEveryRow();
|
|
163
|
+
const detail = this.#recompute();
|
|
164
|
+
if (!detail) return;
|
|
165
|
+
this.dispatch("reconcile", { detail });
|
|
166
|
+
this.#announce(detail);
|
|
167
|
+
}
|
|
168
|
+
/** Reports a selection the user moved. */
|
|
169
|
+
#reportChange(detail) {
|
|
170
|
+
if (!detail) return;
|
|
171
|
+
this.dispatch("change", { detail });
|
|
172
|
+
this.#announce(detail);
|
|
173
|
+
}
|
|
174
|
+
/** Hands the count to the shared announcer, worded by the consumer. */
|
|
175
|
+
#announce(detail) {
|
|
176
|
+
announce(fillTemplate(this.announceTextValue, { count: detail.count }));
|
|
177
|
+
}
|
|
63
178
|
/**
|
|
64
179
|
* Recomputes the count, the select-all checked/indeterminate state, and the bar
|
|
65
|
-
* visibility from the current DOM.
|
|
66
|
-
*
|
|
180
|
+
* visibility from the current DOM. Returns the figures when the emitted count or
|
|
181
|
+
* all-pages flag actually moved, and `null` when they did not.
|
|
182
|
+
*
|
|
183
|
+
* @stimeoRenderRoot
|
|
67
184
|
*/
|
|
68
|
-
#recompute(
|
|
69
|
-
const items = this
|
|
185
|
+
#recompute() {
|
|
186
|
+
const items = this.itemTargets;
|
|
70
187
|
const total = items.length;
|
|
71
188
|
const checked = items.filter((item) => item.checked).length;
|
|
72
189
|
const allPages = this.#allPagesMode;
|
|
@@ -77,8 +194,10 @@ var BulkSelectController = class extends Controller {
|
|
|
77
194
|
const count = allPages ? this.totalCountValue : checked;
|
|
78
195
|
const show = allPages || checked > 0;
|
|
79
196
|
if (this.hasBarTarget) {
|
|
197
|
+
if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {
|
|
198
|
+
this.allTarget.focus();
|
|
199
|
+
}
|
|
80
200
|
this.barTarget.hidden = !show;
|
|
81
|
-
this.barTarget.setAttribute("aria-live", this.announceValue ? "polite" : "off");
|
|
82
201
|
}
|
|
83
202
|
if (this.hasCountTarget) this.countTarget.textContent = String(count);
|
|
84
203
|
this.element.setAttribute("data-selected-count", String(checked));
|
|
@@ -87,15 +206,7 @@ var BulkSelectController = class extends Controller {
|
|
|
87
206
|
const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;
|
|
88
207
|
this.#lastCount = count;
|
|
89
208
|
this.#lastAllPages = allPages;
|
|
90
|
-
|
|
91
|
-
this.dispatch("change", { detail: { count, allPages } });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
/** Live list of row checkboxes, queried from the DOM so dynamic rows count. */
|
|
95
|
-
get #items() {
|
|
96
|
-
return Array.from(
|
|
97
|
-
this.element.querySelectorAll('[data-stimeo--bulk-select-target="item"]')
|
|
98
|
-
);
|
|
209
|
+
return changed ? { count, allPages } : null;
|
|
99
210
|
}
|
|
100
211
|
};
|
|
101
212
|
|
|
@@ -2,6 +2,46 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/clipboard_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/utils/before_cache_reset.ts
|
|
17
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
18
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
19
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
20
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
21
|
+
static #onBeforeCache = () => {
|
|
22
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
23
|
+
};
|
|
24
|
+
#rewind;
|
|
25
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
26
|
+
constructor(rewind) {
|
|
27
|
+
this.#rewind = rewind;
|
|
28
|
+
}
|
|
29
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
30
|
+
activate() {
|
|
31
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
32
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
33
|
+
if (first) {
|
|
34
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
38
|
+
deactivate() {
|
|
39
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
40
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
41
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
5
45
|
// src/utils/default_attribute.ts
|
|
6
46
|
function setDefaultAttribute(element, name, value) {
|
|
7
47
|
if (element.hasAttribute(name)) return false;
|
|
@@ -63,45 +103,59 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
63
103
|
};
|
|
64
104
|
|
|
65
105
|
// src/controllers/clipboard_controller.ts
|
|
106
|
+
var TRANSIENT_STATES = /* @__PURE__ */ new Set(["copied", "error"]);
|
|
66
107
|
var ClipboardController = class extends Controller {
|
|
67
108
|
static targets = ["source", "button", "feedback"];
|
|
68
109
|
static values = {
|
|
69
110
|
text: { type: String, default: "" },
|
|
70
111
|
feedbackDuration: { type: Number, default: 2e3 },
|
|
71
112
|
copiedLabel: { type: String, default: "Copied" },
|
|
72
|
-
errorLabel: { type: String, default: "Copy failed" }
|
|
113
|
+
errorLabel: { type: String, default: "Copy failed" },
|
|
114
|
+
announceCopiedText: { type: String, default: "" },
|
|
115
|
+
announceErrorText: { type: String, default: "" }
|
|
73
116
|
};
|
|
74
117
|
static actions = ["copy"];
|
|
75
118
|
static events = ["copy"];
|
|
76
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* The pending return to idle — the only timer this controller schedules, so
|
|
121
|
+
* `clearAll()` is exactly "drop the auto-clear" and needs no id of its own.
|
|
122
|
+
*/
|
|
77
123
|
#timers = new SafeTimeout();
|
|
124
|
+
/** Returns the completion state to idle for the snapshot Turbo takes. */
|
|
125
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewind());
|
|
78
126
|
/**
|
|
79
|
-
*
|
|
80
|
-
* a
|
|
81
|
-
*
|
|
127
|
+
* Whether this connection is still live. `copy()` suspends on the Clipboard API,
|
|
128
|
+
* and a teardown that lands while it is suspended must win: the continuation
|
|
129
|
+
* would otherwise write to an element nobody owns and arm a timer past the
|
|
130
|
+
* `clearAll()` that was supposed to be the last word.
|
|
82
131
|
*/
|
|
83
|
-
#
|
|
132
|
+
#connected = false;
|
|
84
133
|
connect() {
|
|
85
|
-
|
|
134
|
+
this.#connected = true;
|
|
135
|
+
this.#adopt();
|
|
136
|
+
this.#beforeCache.activate();
|
|
86
137
|
}
|
|
87
138
|
disconnect() {
|
|
139
|
+
this.#connected = false;
|
|
140
|
+
this.#beforeCache.deactivate();
|
|
88
141
|
this.#timers.clearAll();
|
|
89
142
|
}
|
|
90
143
|
/**
|
|
91
144
|
* Copies the resolved text and reports the outcome. Bound via `data-action`
|
|
92
|
-
* (click).
|
|
93
|
-
* — including on failure — so consumers can react either way.
|
|
145
|
+
* (click). Dispatches `stimeo--clipboard:copy` with `{ success, text }` once per
|
|
146
|
+
* completed attempt — including on failure — so consumers can react either way.
|
|
147
|
+
* An attempt whose connection ended while it was in flight reports nothing.
|
|
94
148
|
*/
|
|
95
149
|
async copy() {
|
|
96
150
|
const text = this.#resolveText();
|
|
97
151
|
let success = false;
|
|
98
152
|
try {
|
|
99
|
-
if (!navigator.clipboard?.writeText) throw new Error("Clipboard API unavailable");
|
|
100
153
|
await navigator.clipboard.writeText(text);
|
|
101
154
|
success = true;
|
|
102
155
|
} catch {
|
|
103
156
|
success = false;
|
|
104
157
|
}
|
|
158
|
+
if (!this.#connected) return;
|
|
105
159
|
this.#reportResult(success);
|
|
106
160
|
this.dispatch("copy", { detail: { success, text } });
|
|
107
161
|
}
|
|
@@ -118,24 +172,52 @@ var ClipboardController = class extends Controller {
|
|
|
118
172
|
}
|
|
119
173
|
return source.textContent ?? "";
|
|
120
174
|
}
|
|
121
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Reads the current state back from the DOM.
|
|
177
|
+
*
|
|
178
|
+
* A `copied` or `error` found at connect time is this controller's own output
|
|
179
|
+
* from a connection that is gone, and so is the timer that would have cleared it
|
|
180
|
+
* — nothing else would ever return the element to `idle`. Any other authored
|
|
181
|
+
* value belongs to the consumer and only a missing attribute takes the default.
|
|
182
|
+
*/
|
|
183
|
+
#adopt() {
|
|
184
|
+
if (this.#inTransientState()) {
|
|
185
|
+
this.#reset();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
setDefaultAttribute(this.element, "data-state", "idle");
|
|
189
|
+
}
|
|
190
|
+
/** Whether `data-state` currently holds one of the values this controller writes. */
|
|
191
|
+
#inTransientState() {
|
|
192
|
+
const state = this.element.getAttribute("data-state");
|
|
193
|
+
return state !== null && TRANSIENT_STATES.has(state);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Returns the completion state to idle for the snapshot Turbo is about to take,
|
|
197
|
+
* so a page reached with the Back button does not report a copy that happened
|
|
198
|
+
* before the navigation. Only a state this controller wrote is rewound — an
|
|
199
|
+
* authored one is the consumer's and has to survive into the snapshot, exactly as
|
|
200
|
+
* `connect()` leaves it alone. State only — no `copy` is dispatched, which would
|
|
201
|
+
* claim a fresh copy ran.
|
|
202
|
+
*/
|
|
203
|
+
#rewind() {
|
|
204
|
+
if (!this.#inTransientState()) return;
|
|
205
|
+
this.#timers.clearAll();
|
|
206
|
+
this.#reset();
|
|
207
|
+
}
|
|
208
|
+
/** Reflects the result, announces it, and schedules the return to idle. */
|
|
122
209
|
#reportResult(success) {
|
|
123
210
|
this.element.setAttribute("data-state", success ? "copied" : "error");
|
|
124
211
|
if (this.hasFeedbackTarget) {
|
|
125
212
|
this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
|
|
126
213
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.#resetTimerId = null;
|
|
130
|
-
}
|
|
214
|
+
announce(success ? this.announceCopiedTextValue : this.announceErrorTextValue);
|
|
215
|
+
this.#timers.clearAll();
|
|
131
216
|
if (this.feedbackDurationValue > 0) {
|
|
132
|
-
this.#
|
|
133
|
-
this.#resetTimerId = null;
|
|
134
|
-
this.#reset();
|
|
135
|
-
}, this.feedbackDurationValue);
|
|
217
|
+
this.#timers.set(() => this.#reset(), this.feedbackDurationValue);
|
|
136
218
|
}
|
|
137
219
|
}
|
|
138
|
-
/** Returns to the idle state and
|
|
220
|
+
/** Returns to the idle state and empties the completion slot. */
|
|
139
221
|
#reset() {
|
|
140
222
|
this.element.setAttribute("data-state", "idle");
|
|
141
223
|
if (this.hasFeedbackTarget) {
|
|
@@ -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;
|