stimeo-ui 0.3.0 → 0.4.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 +56 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/color_picker_controller.js +46 -0
- data/dist/controllers/command_palette_controller.js +32 -5
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/date_range_picker_controller.js +50 -0
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/local_time_controller.js +100 -6
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/overflow_menu_controller.js +33 -6
- data/dist/controllers/progress_controller.js +116 -9
- data/dist/controllers/range_slider_controller.js +68 -3
- data/dist/controllers/rating_controller.js +53 -0
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +60 -10
- data/dist/index.js +1056 -281
- 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: d5c989b2c56e2578e6c05c5e01c29e31cdba141e9a49110701ba4a40dc91198e
|
|
4
|
+
data.tar.gz: 6b123f7cad0a54ef52b13c525bb53cf2eb43f0cc09748ab0d8cbf7d738e1fca9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b95db793fc2a866c4c099f98ca1a689a36b5f15419d6b89b1c2f6423f694e03421c68d1bf930c0920c6d8feeb0c554a430e03523367fdabfa24578769a922c9b
|
|
7
|
+
data.tar.gz: 06ee52f5e9a6a56c9fea57f9e2f291e58336228e17a5bc5685238be3d98e00718a81837eb4196ddd08a230006492cd5e23d950537285f1e088c6bb2767b4fc12
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ 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.4.0] - 2026-08-11
|
|
11
|
+
|
|
12
|
+
The feedback and status components announce through the shared
|
|
13
|
+
`stimeo--announcer` and survive Turbo caching and morphs. The Inspector reads the
|
|
14
|
+
`data:` hashes Rails helpers render. Two public API entries were removed.
|
|
15
|
+
|
|
16
|
+
### Removed
|
|
17
|
+
|
|
18
|
+
- **Breaking** — sidebar: the `beforeCache` action. Delete
|
|
19
|
+
`data-action="turbo:before-cache@document->stimeo--sidebar#beforeCache"`; the
|
|
20
|
+
behavior is built in, and leaving it makes `stimeo check` report an unknown
|
|
21
|
+
action.
|
|
22
|
+
- **Breaking** — empty-state: the `announce` boolean, and the `role="status"` /
|
|
23
|
+
`aria-live="polite"` it applied to the `empty` target. Use `announceText` /
|
|
24
|
+
`announceFilledText` instead.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- countdown, empty-state, frame-loading, meter, network-status, progress,
|
|
29
|
+
skeleton, spinner: `announceText`, plus `announceFilledText` /
|
|
30
|
+
`announceOnlineText` / `announceReadyText` where a component has two
|
|
31
|
+
transitions to report. Wording is yours and defaults to empty, and the page
|
|
32
|
+
needs a `stimeo--announcer` for any of it to be read.
|
|
33
|
+
- spinner: `timeout`, with a `stimeo--spinner:timeout` event. Off by default.
|
|
34
|
+
- stick-to-bottom: `pinOnConnect`. Off by default.
|
|
35
|
+
- Inspector: Stimulus wiring in a Rails helper's literal `data:` hash is checked
|
|
36
|
+
like static attributes. Re-run `stimeo check` — it can report new diagnostics.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- meter, progress: an `aria-valuetext` you authored is preserved across renders.
|
|
41
|
+
- local-time, relative-time: the locale falls back to the nearest ancestor
|
|
42
|
+
carrying `lang`, not just the element's own and then the document's.
|
|
43
|
+
- countdown: `data-state` is the source of truth for the run state; `autostart`
|
|
44
|
+
governs only markup that states none.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Turbo caching and morphs across announcer, color-picker, countdown,
|
|
49
|
+
date-range-picker, frame-loading, local-time, meter, overflow-menu, progress,
|
|
50
|
+
range-slider, rating, relative-time, sidebar, spinner, step-indicator: the
|
|
51
|
+
page is rewound before the snapshot and repaints when values are swapped,
|
|
52
|
+
instead of restoring stuck state or leaving a stale reading.
|
|
53
|
+
- network-status: transitions are actually announced.
|
|
54
|
+
- countdown: resume steps by one unit, and a settled timer no longer re-emits
|
|
55
|
+
`complete` on reconnect.
|
|
56
|
+
- step-indicator: runtime step changes re-derive the set, and a non-numeric
|
|
57
|
+
`current` falls back to the first step.
|
|
58
|
+
- local-time: a `datetime` with a space separator (`2026-06-08 12:30`) parses.
|
|
59
|
+
- relative-time: a malformed `locale` no longer throws.
|
|
60
|
+
- skeleton, frame-loading: a repeated signal no longer restarts or stacks the
|
|
61
|
+
minimum-duration wait.
|
|
62
|
+
- Empty, inverted, and non-numeric ranges floor to `0` across meter, progress,
|
|
63
|
+
slider, and range-slider.
|
|
64
|
+
|
|
10
65
|
## [0.3.0] - 2026-08-09
|
|
11
66
|
|
|
12
67
|
Minor release focused on consistent keyboard and selection behavior, resilient
|
|
@@ -336,6 +391,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
|
|
|
336
391
|
by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
|
|
337
392
|
RubyGems.
|
|
338
393
|
|
|
394
|
+
[0.4.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.4.0
|
|
339
395
|
[0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
|
|
340
396
|
[0.2.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.1
|
|
341
397
|
[0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/alert_dialog_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/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -2,6 +2,68 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/announcer_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
|
+
|
|
34
|
+
// src/utils/microtask_coalescer.ts
|
|
35
|
+
var MicrotaskCoalescer = class {
|
|
36
|
+
#run;
|
|
37
|
+
#queued = false;
|
|
38
|
+
#active = false;
|
|
39
|
+
#generation = 0;
|
|
40
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
41
|
+
constructor(run) {
|
|
42
|
+
this.#run = run;
|
|
43
|
+
}
|
|
44
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
45
|
+
activate() {
|
|
46
|
+
this.#active = true;
|
|
47
|
+
}
|
|
48
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
49
|
+
cancel() {
|
|
50
|
+
this.#active = false;
|
|
51
|
+
this.#queued = false;
|
|
52
|
+
this.#generation += 1;
|
|
53
|
+
}
|
|
54
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
55
|
+
schedule() {
|
|
56
|
+
if (!this.#active || this.#queued) return;
|
|
57
|
+
this.#queued = true;
|
|
58
|
+
const generation = this.#generation;
|
|
59
|
+
queueMicrotask(() => {
|
|
60
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
61
|
+
this.#queued = false;
|
|
62
|
+
this.#run();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
5
67
|
// src/utils/safe_timeout.ts
|
|
6
68
|
var TimerRegistry = class {
|
|
7
69
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -56,6 +118,7 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
56
118
|
};
|
|
57
119
|
|
|
58
120
|
// src/controllers/announcer_controller.ts
|
|
121
|
+
var LEVELS = ["polite", "assertive"];
|
|
59
122
|
var AnnouncerController = class extends Controller {
|
|
60
123
|
static targets = ["polite", "assertive"];
|
|
61
124
|
static values = {
|
|
@@ -67,6 +130,30 @@ var AnnouncerController = class extends Controller {
|
|
|
67
130
|
#timers = new SafeTimeout();
|
|
68
131
|
/** Live regions generated to stand in for absent targets, for teardown. */
|
|
69
132
|
#generated = /* @__PURE__ */ new Map();
|
|
133
|
+
/** Messages waiting to be written, oldest first, one queue per politeness. */
|
|
134
|
+
#queues = /* @__PURE__ */ new Map();
|
|
135
|
+
/** Politeness levels whose next drain is already armed. */
|
|
136
|
+
#draining = /* @__PURE__ */ new Set();
|
|
137
|
+
/** Collapses a batch of target callbacks (and morph removals) into one pass. */
|
|
138
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileRegions());
|
|
139
|
+
/**
|
|
140
|
+
* Watches the host's own children for a generated region disappearing. A morph
|
|
141
|
+
* drops it — the server's HTML never had it — and no target callback reports
|
|
142
|
+
* that, because a generated region carries no target attribute. `subtree` stays
|
|
143
|
+
* off so writing a message inside a region does not re-enter this pass.
|
|
144
|
+
*/
|
|
145
|
+
#hostWatch = new MutationObserver(() => {
|
|
146
|
+
this.#reconcile.schedule();
|
|
147
|
+
});
|
|
148
|
+
/** Rewinds to an announceable initial state for the snapshot; see the remarks. */
|
|
149
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
150
|
+
/**
|
|
151
|
+
* The one timer a region may have outstanding — its dedupe re-set, then its
|
|
152
|
+
* auto-clear. Held weakly so a swapped-out target is not retained; a leftover
|
|
153
|
+
* id is harmless because {@link SafeTimeout.clear} no-ops on an id it does not
|
|
154
|
+
* own.
|
|
155
|
+
*/
|
|
156
|
+
#pending = /* @__PURE__ */ new WeakMap();
|
|
70
157
|
/**
|
|
71
158
|
* Guards against handling the same CustomEvent twice. An event dispatched on
|
|
72
159
|
* the controller element with `bubbles: true` reaches both the element and the
|
|
@@ -83,13 +170,86 @@ var AnnouncerController = class extends Controller {
|
|
|
83
170
|
this.#announce(message, this.#assertiveFromDetail(detail));
|
|
84
171
|
};
|
|
85
172
|
connect() {
|
|
173
|
+
this.#reconcile.activate();
|
|
174
|
+
this.#beforeCache.activate();
|
|
175
|
+
this.#reconcileRegions();
|
|
176
|
+
this.#hostWatch.observe(this.element, { childList: true });
|
|
86
177
|
this.element.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
|
|
87
178
|
window.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
|
|
88
179
|
}
|
|
89
180
|
disconnect() {
|
|
181
|
+
this.#reconcile.cancel();
|
|
182
|
+
this.#hostWatch.disconnect();
|
|
183
|
+
this.#beforeCache.deactivate();
|
|
90
184
|
this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
|
|
91
185
|
window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
|
|
92
186
|
this.#timers.clearAll();
|
|
187
|
+
this.#queues.clear();
|
|
188
|
+
this.#draining.clear();
|
|
189
|
+
this.#removeGenerated();
|
|
190
|
+
}
|
|
191
|
+
/** Retires the stand-in once the consumer supplies a polite region. */
|
|
192
|
+
politeTargetConnected() {
|
|
193
|
+
this.#reconcile.schedule();
|
|
194
|
+
}
|
|
195
|
+
/** Materialises a stand-in once the consumer's polite region goes away. */
|
|
196
|
+
politeTargetDisconnected() {
|
|
197
|
+
this.#reconcile.schedule();
|
|
198
|
+
}
|
|
199
|
+
/** Retires the stand-in once the consumer supplies an assertive region. */
|
|
200
|
+
assertiveTargetConnected() {
|
|
201
|
+
this.#reconcile.schedule();
|
|
202
|
+
}
|
|
203
|
+
/** Materialises a stand-in once the consumer's assertive region goes away. */
|
|
204
|
+
assertiveTargetDisconnected() {
|
|
205
|
+
this.#reconcile.schedule();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Brings the region set back to exactly one region per politeness and reports
|
|
209
|
+
* whether anything had to be created.
|
|
210
|
+
*
|
|
211
|
+
* A region created here is not written to in the same task: assistive tech
|
|
212
|
+
* reports changes to regions it already knows about, so {@link drain} waits a
|
|
213
|
+
* task whenever this says a region is new.
|
|
214
|
+
*/
|
|
215
|
+
#reconcileRegions() {
|
|
216
|
+
let created = false;
|
|
217
|
+
for (const level of LEVELS) {
|
|
218
|
+
if (this.#hasTargetFor(level)) {
|
|
219
|
+
const generated = this.#generated.get(level);
|
|
220
|
+
if (generated) {
|
|
221
|
+
generated.remove();
|
|
222
|
+
this.#generated.delete(level);
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const existing = this.#generated.get(level);
|
|
227
|
+
if (existing?.isConnected) continue;
|
|
228
|
+
this.#generated.set(level, this.#createRegion(level));
|
|
229
|
+
created = true;
|
|
230
|
+
}
|
|
231
|
+
return created;
|
|
232
|
+
}
|
|
233
|
+
/** Whether the consumer supplied a target for `level`. */
|
|
234
|
+
#hasTargetFor(level) {
|
|
235
|
+
return level === "assertive" ? this.hasAssertiveTarget : this.hasPoliteTarget;
|
|
236
|
+
}
|
|
237
|
+
/** Builds a visually hidden live region for `level` and attaches it. */
|
|
238
|
+
#createRegion(level) {
|
|
239
|
+
const region = document.createElement("div");
|
|
240
|
+
region.setAttribute("aria-live", level);
|
|
241
|
+
region.setAttribute("aria-atomic", "true");
|
|
242
|
+
visuallyHide(region);
|
|
243
|
+
this.element.appendChild(region);
|
|
244
|
+
return region;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Removes and forgets every region this controller generated. Authored targets
|
|
248
|
+
* belong to the consumer and are left untouched. Forgetting them is what keeps
|
|
249
|
+
* the live page working after a snapshot rewind: the next announcement finds an
|
|
250
|
+
* empty map and materialises a fresh region.
|
|
251
|
+
*/
|
|
252
|
+
#removeGenerated() {
|
|
93
253
|
for (const region of this.#generated.values()) {
|
|
94
254
|
region.remove();
|
|
95
255
|
}
|
|
@@ -111,47 +271,122 @@ var AnnouncerController = class extends Controller {
|
|
|
111
271
|
this.#announce(message, assertive);
|
|
112
272
|
}
|
|
113
273
|
/**
|
|
114
|
-
*
|
|
274
|
+
* Queues `message` for its politeness and arms the drain.
|
|
115
275
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* observed and announced again.
|
|
276
|
+
* Queuing is what makes a burst audible: assistive tech announces the changes it
|
|
277
|
+
* observes, so several messages written into one region within a single task are
|
|
278
|
+
* one change and only the last is read.
|
|
120
279
|
*/
|
|
121
280
|
#announce(message, assertive) {
|
|
122
|
-
const
|
|
281
|
+
const level = assertive ? "assertive" : "polite";
|
|
282
|
+
const queue = this.#queues.get(level);
|
|
283
|
+
if (queue) {
|
|
284
|
+
queue.push(message);
|
|
285
|
+
} else {
|
|
286
|
+
this.#queues.set(level, [message]);
|
|
287
|
+
}
|
|
288
|
+
this.#scheduleDrain(level);
|
|
289
|
+
}
|
|
290
|
+
/** Arms one drain pass for `level`; further messages ride the pass already armed. */
|
|
291
|
+
#scheduleDrain(level) {
|
|
292
|
+
if (this.#draining.has(level)) return;
|
|
293
|
+
this.#draining.add(level);
|
|
294
|
+
this.#timers.set(() => {
|
|
295
|
+
this.#draining.delete(level);
|
|
296
|
+
this.#drain(level);
|
|
297
|
+
}, 0);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Writes one queued message, then arms the next pass while the queue holds more.
|
|
301
|
+
*
|
|
302
|
+
* Two steps take a whole pass without consuming the message: materialising a
|
|
303
|
+
* region (it has to be in the accessibility tree before the text arrives) and
|
|
304
|
+
* emptying a region that already holds this exact text (an unchanged node is not
|
|
305
|
+
* re-read, so `dedupeReannounce` clears first and writes on the following pass).
|
|
306
|
+
*/
|
|
307
|
+
#drain(level) {
|
|
308
|
+
const queue = this.#queues.get(level);
|
|
309
|
+
const message = queue?.[0];
|
|
310
|
+
if (queue === void 0 || message === void 0) return;
|
|
311
|
+
if (this.#reconcileRegions()) {
|
|
312
|
+
this.#scheduleDrain(level);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const region = this.#regionFor(level);
|
|
123
316
|
if (this.dedupeReannounceValue && region.textContent === message) {
|
|
317
|
+
this.#cancelPending(region);
|
|
124
318
|
region.textContent = "";
|
|
125
|
-
this.#
|
|
126
|
-
region.textContent = message;
|
|
127
|
-
this.#scheduleClear(region, message);
|
|
128
|
-
}, 0);
|
|
319
|
+
this.#scheduleDrain(level);
|
|
129
320
|
return;
|
|
130
321
|
}
|
|
322
|
+
queue.shift();
|
|
323
|
+
this.#cancelPending(region);
|
|
131
324
|
region.textContent = message;
|
|
132
325
|
this.#scheduleClear(region, message);
|
|
326
|
+
if (queue.length > 0) this.#scheduleDrain(level);
|
|
133
327
|
}
|
|
134
328
|
/** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
|
|
135
329
|
#scheduleClear(region, message) {
|
|
136
330
|
if (this.clearAfterValue <= 0) return;
|
|
137
|
-
this.#
|
|
138
|
-
|
|
139
|
-
|
|
331
|
+
this.#schedule(
|
|
332
|
+
region,
|
|
333
|
+
() => {
|
|
334
|
+
if (region.textContent === message) region.textContent = "";
|
|
335
|
+
},
|
|
336
|
+
this.clearAfterValue
|
|
337
|
+
);
|
|
140
338
|
}
|
|
141
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* Arms `region`'s single pending timer. Callers reach here with the slot
|
|
341
|
+
* already free — `#announce` releases it, and a fired timer clears its own
|
|
342
|
+
* entry below — so this does not cancel again.
|
|
343
|
+
*/
|
|
344
|
+
#schedule(region, callback, delay) {
|
|
345
|
+
const id = this.#timers.set(() => {
|
|
346
|
+
this.#pending.delete(region);
|
|
347
|
+
callback();
|
|
348
|
+
}, delay);
|
|
349
|
+
this.#pending.set(region, id);
|
|
350
|
+
}
|
|
351
|
+
/** Releases `region`'s pending timer, if it has one. */
|
|
352
|
+
#cancelPending(region) {
|
|
353
|
+
this.#timers.clear(this.#pending.get(region) ?? -1);
|
|
354
|
+
this.#pending.delete(region);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Resolves the live region for a politeness level.
|
|
358
|
+
*
|
|
359
|
+
* The remembered stand-in is used only while it is still in the document: a morph
|
|
360
|
+
* can drop it, and writing into the detached node would announce nothing at all.
|
|
361
|
+
*/
|
|
142
362
|
#regionFor(level) {
|
|
143
363
|
if (level === "assertive" && this.hasAssertiveTarget) return this.assertiveTarget;
|
|
144
364
|
if (level === "polite" && this.hasPoliteTarget) return this.politeTarget;
|
|
145
365
|
const existing = this.#generated.get(level);
|
|
146
|
-
if (existing) return existing;
|
|
147
|
-
const region =
|
|
148
|
-
region.setAttribute("aria-live", level);
|
|
149
|
-
region.setAttribute("aria-atomic", "true");
|
|
150
|
-
visuallyHide(region);
|
|
151
|
-
this.element.appendChild(region);
|
|
366
|
+
if (existing?.isConnected) return existing;
|
|
367
|
+
const region = this.#createRegion(level);
|
|
152
368
|
this.#generated.set(level, region);
|
|
153
369
|
return region;
|
|
154
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Restores the announceable initial state for the snapshot Turbo is about to
|
|
373
|
+
* take: queued and displayed messages go, generated regions go, and the live
|
|
374
|
+
* page — which keeps running when a visit is aborted — gets its regions back on
|
|
375
|
+
* the next task, after the clone.
|
|
376
|
+
*/
|
|
377
|
+
#rewindForCache() {
|
|
378
|
+
this.#queues.clear();
|
|
379
|
+
this.#draining.clear();
|
|
380
|
+
this.#timers.clearAll();
|
|
381
|
+
for (const level of LEVELS) {
|
|
382
|
+
if (this.#hasTargetFor(level)) {
|
|
383
|
+
const target = level === "assertive" ? this.assertiveTarget : this.politeTarget;
|
|
384
|
+
target.textContent = "";
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
this.#removeGenerated();
|
|
388
|
+
this.#timers.set(() => this.#reconcileRegions(), 0);
|
|
389
|
+
}
|
|
155
390
|
/** Extracts a non-empty string `message` from a CustomEvent detail, else null. */
|
|
156
391
|
#messageFromDetail(detail) {
|
|
157
392
|
if (detail && typeof detail === "object" && "message" in detail) {
|
|
@@ -25,6 +25,39 @@ function toFiniteNumber(raw) {
|
|
|
25
25
|
return Number.isFinite(value) ? value : null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// src/utils/microtask_coalescer.ts
|
|
29
|
+
var MicrotaskCoalescer = class {
|
|
30
|
+
#run;
|
|
31
|
+
#queued = false;
|
|
32
|
+
#active = false;
|
|
33
|
+
#generation = 0;
|
|
34
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
35
|
+
constructor(run) {
|
|
36
|
+
this.#run = run;
|
|
37
|
+
}
|
|
38
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
39
|
+
activate() {
|
|
40
|
+
this.#active = true;
|
|
41
|
+
}
|
|
42
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
43
|
+
cancel() {
|
|
44
|
+
this.#active = false;
|
|
45
|
+
this.#queued = false;
|
|
46
|
+
this.#generation += 1;
|
|
47
|
+
}
|
|
48
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
49
|
+
schedule() {
|
|
50
|
+
if (!this.#active || this.#queued) return;
|
|
51
|
+
this.#queued = true;
|
|
52
|
+
const generation = this.#generation;
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
55
|
+
this.#queued = false;
|
|
56
|
+
this.#run();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
28
61
|
// src/controllers/color_picker_controller.ts
|
|
29
62
|
var COLOR_PROPERTY = "--stimeo-color";
|
|
30
63
|
var CHANNEL_RANGE = {
|
|
@@ -51,16 +84,29 @@ var ColorPickerController = class extends Controller {
|
|
|
51
84
|
/** Aborts in-progress pointer-drag listeners on drag end / teardown. */
|
|
52
85
|
#dragAbort = null;
|
|
53
86
|
/** Seeds the model from the initial hex value and renders every surface. */
|
|
87
|
+
/**
|
|
88
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
89
|
+
* pass Stimulus delivers before `connect()`.
|
|
90
|
+
*/
|
|
91
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
92
|
+
this.#render();
|
|
93
|
+
});
|
|
54
94
|
connect() {
|
|
95
|
+
this.#repaint.activate();
|
|
55
96
|
const parsed = hexToHsla(this.valueValue);
|
|
56
97
|
if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
57
98
|
this.#render();
|
|
58
99
|
}
|
|
59
100
|
/** Cancels any active pointer drag so document listeners never leak. */
|
|
60
101
|
disconnect() {
|
|
102
|
+
this.#repaint.cancel();
|
|
61
103
|
this.#dragAbort?.abort();
|
|
62
104
|
this.#dragAbort = null;
|
|
63
105
|
}
|
|
106
|
+
/** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
|
|
107
|
+
alphaValueChanged() {
|
|
108
|
+
this.#repaint.schedule();
|
|
109
|
+
}
|
|
64
110
|
/** Keyboard stepping on the focused channel slider (APG Slider model). */
|
|
65
111
|
onKeydown(event) {
|
|
66
112
|
if (isReservedArrowChord(event)) return;
|
|
@@ -67,6 +67,35 @@ var CompositionTracker = class {
|
|
|
67
67
|
};
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
// src/utils/before_cache_reset.ts
|
|
71
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
72
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
73
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
74
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
75
|
+
static #onBeforeCache = () => {
|
|
76
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
77
|
+
};
|
|
78
|
+
#rewind;
|
|
79
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
80
|
+
constructor(rewind) {
|
|
81
|
+
this.#rewind = rewind;
|
|
82
|
+
}
|
|
83
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
84
|
+
activate() {
|
|
85
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
86
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
87
|
+
if (first) {
|
|
88
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
92
|
+
deactivate() {
|
|
93
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
94
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
95
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
70
99
|
// src/utils/escape_layer.ts
|
|
71
100
|
var EscapeLayer = class _EscapeLayer {
|
|
72
101
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -200,7 +229,7 @@ var FocusTrap = class {
|
|
|
200
229
|
}
|
|
201
230
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
202
231
|
document.addEventListener("keydown", this.#onKeydown);
|
|
203
|
-
|
|
232
|
+
this.#beforeCache.activate();
|
|
204
233
|
const onEscape = this.#options.onEscape;
|
|
205
234
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
206
235
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -217,7 +246,7 @@ var FocusTrap = class {
|
|
|
217
246
|
this.#activeState = false;
|
|
218
247
|
this.#escapeLayer.deactivate();
|
|
219
248
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
220
|
-
|
|
249
|
+
this.#beforeCache.deactivate();
|
|
221
250
|
if (this.#scrollLocked) {
|
|
222
251
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
223
252
|
this.#scrollLocked = false;
|
|
@@ -241,9 +270,7 @@ var FocusTrap = class {
|
|
|
241
270
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
242
271
|
* is left alone mid-navigation. The listener lives only while active.
|
|
243
272
|
*/
|
|
244
|
-
#
|
|
245
|
-
this.deactivate({ restoreFocus: false });
|
|
246
|
-
};
|
|
273
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
247
274
|
/**
|
|
248
275
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
249
276
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/confirm_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/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|