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
|
@@ -2,6 +2,145 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/frame_loading_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
|
+
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/before_cache_reset.ts
|
|
23
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
24
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
25
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
26
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
27
|
+
static #onBeforeCache = () => {
|
|
28
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
29
|
+
};
|
|
30
|
+
#rewind;
|
|
31
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
32
|
+
constructor(rewind) {
|
|
33
|
+
this.#rewind = rewind;
|
|
34
|
+
}
|
|
35
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
36
|
+
activate() {
|
|
37
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
38
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
39
|
+
if (first) {
|
|
40
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
44
|
+
deactivate() {
|
|
45
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
46
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
47
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/utils/detach_gate.ts
|
|
52
|
+
var DetachGate = class _DetachGate {
|
|
53
|
+
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
54
|
+
#pending = false;
|
|
55
|
+
/**
|
|
56
|
+
* True when the disconnect is definitely a real detach — the element left
|
|
57
|
+
* the document, or `data-controller` no longer lists the identifier. False
|
|
58
|
+
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
59
|
+
*/
|
|
60
|
+
static isDetached(host) {
|
|
61
|
+
if (!host.element.isConnected) return true;
|
|
62
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
63
|
+
return !tokens.includes(host.identifier);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
67
|
+
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
68
|
+
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
69
|
+
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
70
|
+
* element within the same mutation batch, before the checkpoint drains.
|
|
71
|
+
*/
|
|
72
|
+
disconnected(host, teardown) {
|
|
73
|
+
if (_DetachGate.isDetached(host)) {
|
|
74
|
+
this.#pending = false;
|
|
75
|
+
teardown();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.#pending = true;
|
|
79
|
+
queueMicrotask(() => {
|
|
80
|
+
if (!this.#pending) return;
|
|
81
|
+
this.#pending = false;
|
|
82
|
+
teardown();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
87
|
+
* an in-page move) and from the head of any teardown path not routed through
|
|
88
|
+
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
89
|
+
* never run the teardown a second time.
|
|
90
|
+
*/
|
|
91
|
+
cancel() {
|
|
92
|
+
this.#pending = false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/utils/min_duration_floor.ts
|
|
97
|
+
var MinDurationFloor = class {
|
|
98
|
+
#timers;
|
|
99
|
+
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
100
|
+
#timerId = null;
|
|
101
|
+
/** Epoch ms the floor is measured from. */
|
|
102
|
+
#since = 0;
|
|
103
|
+
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
104
|
+
constructor(timers) {
|
|
105
|
+
this.#timers = timers;
|
|
106
|
+
}
|
|
107
|
+
/** Starts the floor: call when the state being held becomes visible. */
|
|
108
|
+
begin() {
|
|
109
|
+
this.#since = Date.now();
|
|
110
|
+
}
|
|
111
|
+
/** True while a finish is held back waiting for the floor to elapse. */
|
|
112
|
+
get pending() {
|
|
113
|
+
return this.#timerId !== null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
117
|
+
*
|
|
118
|
+
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
119
|
+
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
120
|
+
* end a state that has since restarted. Controllers that want the first signal
|
|
121
|
+
* to win guard on {@link pending} before calling.
|
|
122
|
+
*/
|
|
123
|
+
schedule(minDuration, finish) {
|
|
124
|
+
this.cancel();
|
|
125
|
+
const remaining = minDuration - (Date.now() - this.#since);
|
|
126
|
+
if (remaining > 0) {
|
|
127
|
+
this.#timerId = this.#timers.set(() => {
|
|
128
|
+
this.#timerId = null;
|
|
129
|
+
finish();
|
|
130
|
+
}, remaining);
|
|
131
|
+
} else {
|
|
132
|
+
finish();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
136
|
+
cancel() {
|
|
137
|
+
if (this.#timerId !== null) {
|
|
138
|
+
this.#timers.clear(this.#timerId);
|
|
139
|
+
this.#timerId = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
5
144
|
// src/utils/safe_timeout.ts
|
|
6
145
|
var TimerRegistry = class {
|
|
7
146
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,32 +198,32 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
198
|
var FrameLoadingController = class extends Controller {
|
|
60
199
|
static targets = ["content", "skeleton", "overlay"];
|
|
61
200
|
static values = {
|
|
201
|
+
announceText: { type: String, default: "" },
|
|
202
|
+
announceReadyText: { type: String, default: "" },
|
|
62
203
|
minDuration: { type: Number, default: 0 },
|
|
63
204
|
restoreFocus: { type: Boolean, default: true }
|
|
64
205
|
};
|
|
65
206
|
static events = ["start", "end"];
|
|
66
207
|
#timeouts = new SafeTimeout();
|
|
208
|
+
#floor = new MinDurationFloor(this.#timeouts);
|
|
209
|
+
#gate = new DetachGate();
|
|
210
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
67
211
|
#loading = false;
|
|
68
|
-
#startedAt = 0;
|
|
69
212
|
#inertApplied = false;
|
|
70
213
|
#previousFocus = null;
|
|
71
214
|
/** The id of the retreated element, used to re-find it if the load replaced it. */
|
|
72
215
|
#previousFocusId = "";
|
|
73
216
|
#onStart = () => {
|
|
74
|
-
this.#
|
|
217
|
+
this.#floor.cancel();
|
|
75
218
|
if (!this.#loading) this.#begin();
|
|
76
219
|
};
|
|
77
220
|
#onEnd = () => {
|
|
78
221
|
if (!this.#loading) return;
|
|
79
|
-
|
|
80
|
-
if (remaining > 0) {
|
|
81
|
-
this.#timeouts.clearAll();
|
|
82
|
-
this.#timeouts.set(() => this.#finish(), remaining);
|
|
83
|
-
} else {
|
|
84
|
-
this.#finish();
|
|
85
|
-
}
|
|
222
|
+
this.#floor.schedule(this.minDurationValue, () => this.#finish());
|
|
86
223
|
};
|
|
87
224
|
connect() {
|
|
225
|
+
this.#gate.cancel();
|
|
226
|
+
this.#beforeCache.activate();
|
|
88
227
|
this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
|
|
89
228
|
this.element.addEventListener("turbo:frame-load", this.#onEnd);
|
|
90
229
|
this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
@@ -93,19 +232,40 @@ var FrameLoadingController = class extends Controller {
|
|
|
93
232
|
this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
|
|
94
233
|
this.element.removeEventListener("turbo:frame-load", this.#onEnd);
|
|
95
234
|
this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
235
|
+
this.#beforeCache.deactivate();
|
|
236
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Drops the held finish and the loading bookkeeping on a real detach. The markup
|
|
240
|
+
* keeps whatever it last held: the page being cached is rewound at
|
|
241
|
+
* `turbo:before-cache` instead, where the frame is still whole.
|
|
242
|
+
*/
|
|
243
|
+
#teardown() {
|
|
244
|
+
this.#gate.cancel();
|
|
96
245
|
this.#timeouts.clearAll();
|
|
97
|
-
|
|
98
|
-
this.element.removeAttribute("aria-busy");
|
|
99
|
-
this.element.removeAttribute("data-frame-loading");
|
|
100
|
-
this.#clearInert();
|
|
101
|
-
}
|
|
246
|
+
this.#floor.cancel();
|
|
102
247
|
this.#loading = false;
|
|
103
248
|
this.#previousFocus = null;
|
|
104
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Returns the frame to its resting hooks for the snapshot Turbo is about to
|
|
252
|
+
* take, so a page reached with the Back button does not restore a frame that is
|
|
253
|
+
* busy and inert with nothing left to finish it. State only — no `end` event and
|
|
254
|
+
* no focus move, because the load did not actually complete. The live page keeps
|
|
255
|
+
* its held finish, so a navigation that never completes still ends properly.
|
|
256
|
+
*/
|
|
257
|
+
#rewindForCache() {
|
|
258
|
+
if (!this.#loading) return;
|
|
259
|
+
this.element.removeAttribute("aria-busy");
|
|
260
|
+
this.element.removeAttribute("data-frame-loading");
|
|
261
|
+
if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
|
|
262
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
|
|
263
|
+
this.#clearInert();
|
|
264
|
+
}
|
|
105
265
|
/** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
|
|
106
266
|
#begin() {
|
|
107
267
|
this.#loading = true;
|
|
108
|
-
this.#
|
|
268
|
+
this.#floor.begin();
|
|
109
269
|
this.element.setAttribute("aria-busy", "true");
|
|
110
270
|
this.element.setAttribute("data-frame-loading", "true");
|
|
111
271
|
if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
|
|
@@ -113,6 +273,7 @@ var FrameLoadingController = class extends Controller {
|
|
|
113
273
|
this.#applyInert();
|
|
114
274
|
this.#retreatFocus();
|
|
115
275
|
this.dispatch("start", { detail: {} });
|
|
276
|
+
announce(fillTemplate(this.announceTextValue, {}));
|
|
116
277
|
}
|
|
117
278
|
/** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
|
|
118
279
|
#finish() {
|
|
@@ -124,6 +285,7 @@ var FrameLoadingController = class extends Controller {
|
|
|
124
285
|
this.#clearInert();
|
|
125
286
|
this.#restoreFocus();
|
|
126
287
|
this.dispatch("end", { detail: {} });
|
|
288
|
+
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
127
289
|
}
|
|
128
290
|
/** Marks the content inert to block double-submits while stale (if we own it). */
|
|
129
291
|
#applyInert() {
|
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/local_time_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/microtask_coalescer.ts
|
|
6
|
+
var MicrotaskCoalescer = class {
|
|
7
|
+
#run;
|
|
8
|
+
#queued = false;
|
|
9
|
+
#active = false;
|
|
10
|
+
#generation = 0;
|
|
11
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
12
|
+
constructor(run) {
|
|
13
|
+
this.#run = run;
|
|
14
|
+
}
|
|
15
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
16
|
+
activate() {
|
|
17
|
+
this.#active = true;
|
|
18
|
+
}
|
|
19
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
20
|
+
cancel() {
|
|
21
|
+
this.#active = false;
|
|
22
|
+
this.#queued = false;
|
|
23
|
+
this.#generation += 1;
|
|
24
|
+
}
|
|
25
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
26
|
+
schedule() {
|
|
27
|
+
if (!this.#active || this.#queued) return;
|
|
28
|
+
this.#queued = true;
|
|
29
|
+
const generation = this.#generation;
|
|
30
|
+
queueMicrotask(() => {
|
|
31
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
32
|
+
this.#queued = false;
|
|
33
|
+
this.#run();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
3
38
|
// src/controllers/local_time_controller.ts
|
|
4
39
|
var STYLES = /* @__PURE__ */ new Set(["full", "long", "medium", "short"]);
|
|
5
40
|
function toStyle(value) {
|
|
@@ -14,7 +49,53 @@ var LocalTimeController = class extends Controller {
|
|
|
14
49
|
titleFormat: { type: String, default: "" }
|
|
15
50
|
};
|
|
16
51
|
static events = ["format"];
|
|
52
|
+
/** Collapses a morph that swaps several render inputs at once into one repaint. */
|
|
53
|
+
#resync = new MicrotaskCoalescer(() => this.#render());
|
|
54
|
+
/**
|
|
55
|
+
* Watches the one render input that is not a Value. Only `datetime` is filtered
|
|
56
|
+
* in, so the text and `title` this controller writes cannot re-enter the pass.
|
|
57
|
+
*/
|
|
58
|
+
#datetimeWatch = new MutationObserver(() => {
|
|
59
|
+
this.#resync.schedule();
|
|
60
|
+
});
|
|
17
61
|
connect() {
|
|
62
|
+
this.#resync.activate();
|
|
63
|
+
this.#datetimeWatch.observe(this.element, { attributeFilter: ["datetime"] });
|
|
64
|
+
this.#render();
|
|
65
|
+
}
|
|
66
|
+
disconnect() {
|
|
67
|
+
this.#resync.cancel();
|
|
68
|
+
this.#datetimeWatch.disconnect();
|
|
69
|
+
}
|
|
70
|
+
/** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
|
|
71
|
+
localeValueChanged() {
|
|
72
|
+
this.#resync.schedule();
|
|
73
|
+
}
|
|
74
|
+
/** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
|
|
75
|
+
timeZoneValueChanged() {
|
|
76
|
+
this.#resync.schedule();
|
|
77
|
+
}
|
|
78
|
+
/** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
|
|
79
|
+
dateStyleValueChanged() {
|
|
80
|
+
this.#resync.schedule();
|
|
81
|
+
}
|
|
82
|
+
/** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
|
|
83
|
+
timeStyleValueChanged() {
|
|
84
|
+
this.#resync.schedule();
|
|
85
|
+
}
|
|
86
|
+
/** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
|
|
87
|
+
titleFormatValueChanged() {
|
|
88
|
+
this.#resync.schedule();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Formats the instant in `datetime` against the current Values and writes it out.
|
|
92
|
+
*
|
|
93
|
+
* The `format` event rides with every pass, including a repaint a morph triggers:
|
|
94
|
+
* its condition is that formatting was applied, and a repaint applies it with a
|
|
95
|
+
* new result. A pass that cannot format writes nothing and emits nothing, so the
|
|
96
|
+
* authored absolute text stays as the fallback.
|
|
97
|
+
*/
|
|
98
|
+
#render() {
|
|
18
99
|
const date = this.#parse();
|
|
19
100
|
if (date === null) return;
|
|
20
101
|
const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
|
|
@@ -24,7 +105,10 @@ var LocalTimeController = class extends Controller {
|
|
|
24
105
|
if (title !== null) this.element.setAttribute("title", title);
|
|
25
106
|
this.dispatch("format", { detail: { formatted } });
|
|
26
107
|
}
|
|
27
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace
|
|
110
|
+
* around the attribute value is tolerated.
|
|
111
|
+
*/
|
|
28
112
|
#parse() {
|
|
29
113
|
const raw = this.element.getAttribute("datetime");
|
|
30
114
|
if (!raw) return null;
|
|
@@ -37,11 +121,21 @@ var LocalTimeController = class extends Controller {
|
|
|
37
121
|
* the *runtime's* local zone, contradicting "the server emits UTC". Values that
|
|
38
122
|
* already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
|
|
39
123
|
* parsed as UTC) are returned unchanged.
|
|
124
|
+
*
|
|
125
|
+
* HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is
|
|
126
|
+
* left to each engine, so a whole value shaped that way is normalized to the `T`
|
|
127
|
+
* separator first. The pattern is anchored: a value trailing anything else — a
|
|
128
|
+
* zone word such as `"2026-06-08 12:30:00 UTC"` — is handed to `Date.parse` as
|
|
129
|
+
* authored instead of being turned into a string nothing can parse.
|
|
40
130
|
*/
|
|
41
131
|
#asUtc(value) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
132
|
+
const isoLike = value.replace(
|
|
133
|
+
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$/,
|
|
134
|
+
"$1T$2"
|
|
135
|
+
);
|
|
136
|
+
const hasTime = /T\d{2}:\d{2}/.test(isoLike);
|
|
137
|
+
const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(isoLike);
|
|
138
|
+
return hasTime && !hasZone ? `${isoLike}Z` : isoLike;
|
|
45
139
|
}
|
|
46
140
|
/**
|
|
47
141
|
* Builds the optional detailed `title`. `titleFormat` is an `Intl` style
|
|
@@ -70,9 +164,9 @@ var LocalTimeController = class extends Controller {
|
|
|
70
164
|
return null;
|
|
71
165
|
}
|
|
72
166
|
}
|
|
73
|
-
/** Locale precedence: the value, then the
|
|
167
|
+
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
74
168
|
get #locale() {
|
|
75
|
-
return this.localeValue || this.element.lang
|
|
169
|
+
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
76
170
|
}
|
|
77
171
|
};
|
|
78
172
|
|
|
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/meter_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
|
+
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
|
+
|
|
5
22
|
// src/utils/coerce.ts
|
|
6
23
|
function toFiniteNumber(raw) {
|
|
7
24
|
if (raw === null || raw === void 0 || raw === "") return null;
|
|
@@ -9,10 +26,61 @@ function toFiniteNumber(raw) {
|
|
|
9
26
|
return Number.isFinite(value) ? value : null;
|
|
10
27
|
}
|
|
11
28
|
|
|
29
|
+
// src/utils/microtask_coalescer.ts
|
|
30
|
+
var MicrotaskCoalescer = class {
|
|
31
|
+
#run;
|
|
32
|
+
#queued = false;
|
|
33
|
+
#active = false;
|
|
34
|
+
#generation = 0;
|
|
35
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
36
|
+
constructor(run) {
|
|
37
|
+
this.#run = run;
|
|
38
|
+
}
|
|
39
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
40
|
+
activate() {
|
|
41
|
+
this.#active = true;
|
|
42
|
+
}
|
|
43
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
44
|
+
cancel() {
|
|
45
|
+
this.#active = false;
|
|
46
|
+
this.#queued = false;
|
|
47
|
+
this.#generation += 1;
|
|
48
|
+
}
|
|
49
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
50
|
+
schedule() {
|
|
51
|
+
if (!this.#active || this.#queued) return;
|
|
52
|
+
this.#queued = true;
|
|
53
|
+
const generation = this.#generation;
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
56
|
+
this.#queued = false;
|
|
57
|
+
this.#run();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// src/utils/range.ts
|
|
63
|
+
function rangeFraction(value, min, max) {
|
|
64
|
+
const span = max - min;
|
|
65
|
+
if (!(span > 0)) return 0;
|
|
66
|
+
const clamped = Math.min(max, Math.max(min, value));
|
|
67
|
+
let fraction;
|
|
68
|
+
if (Number.isFinite(span)) {
|
|
69
|
+
fraction = (clamped - min) / span;
|
|
70
|
+
} else {
|
|
71
|
+
const scale = Math.max(Math.abs(min), Math.abs(max));
|
|
72
|
+
fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
|
|
73
|
+
}
|
|
74
|
+
if (!Number.isFinite(fraction)) return 0;
|
|
75
|
+
return Math.min(1, Math.max(0, fraction));
|
|
76
|
+
}
|
|
77
|
+
|
|
12
78
|
// src/controllers/meter_controller.ts
|
|
79
|
+
var OWNED_VALUE_TEXT = "data-stimeo--meter-owns-valuetext";
|
|
13
80
|
var MeterController = class extends Controller {
|
|
14
81
|
static targets = ["bar"];
|
|
15
82
|
static values = {
|
|
83
|
+
announceText: { type: String, default: "" },
|
|
16
84
|
value: { type: Number, default: 0 },
|
|
17
85
|
min: { type: Number, default: 0 },
|
|
18
86
|
max: { type: Number, default: 100 },
|
|
@@ -23,9 +91,24 @@ var MeterController = class extends Controller {
|
|
|
23
91
|
};
|
|
24
92
|
static actions = ["setValue"];
|
|
25
93
|
static events = ["change"];
|
|
94
|
+
/**
|
|
95
|
+
* Collapses a morph that swaps several render inputs at once into one repaint.
|
|
96
|
+
* A single update usually rewrites the whole set, and each Value would otherwise
|
|
97
|
+
* repaint on its own.
|
|
98
|
+
*/
|
|
99
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
100
|
+
this.#render();
|
|
101
|
+
});
|
|
102
|
+
/** The segment last announced, so only a change is read out. */
|
|
103
|
+
#announcedState = null;
|
|
26
104
|
connect() {
|
|
105
|
+
this.#repaint.activate();
|
|
27
106
|
this.#render();
|
|
28
107
|
}
|
|
108
|
+
/** Closes the window in which a queued repaint may still run. */
|
|
109
|
+
disconnect() {
|
|
110
|
+
this.#repaint.cancel();
|
|
111
|
+
}
|
|
29
112
|
/**
|
|
30
113
|
* Updates the measured value from an action param (`amount`) or a
|
|
31
114
|
* `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
|
|
@@ -35,59 +118,95 @@ var MeterController = class extends Controller {
|
|
|
35
118
|
const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);
|
|
36
119
|
if (next === null) return;
|
|
37
120
|
this.valueValue = this.#clamp(next);
|
|
38
|
-
this.#render();
|
|
39
|
-
this.dispatch("change", {
|
|
40
|
-
|
|
41
|
-
|
|
121
|
+
const reading = this.#render();
|
|
122
|
+
this.dispatch("change", { detail: reading });
|
|
123
|
+
if (reading.state !== this.#announcedState) {
|
|
124
|
+
this.#announcedState = reading.state;
|
|
125
|
+
announce(
|
|
126
|
+
fillTemplate(this.announceTextValue, { state: reading.state, value: reading.value })
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
|
|
131
|
+
valueValueChanged() {
|
|
132
|
+
this.#repaint.schedule();
|
|
133
|
+
}
|
|
134
|
+
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
135
|
+
minValueChanged() {
|
|
136
|
+
this.#repaint.schedule();
|
|
137
|
+
}
|
|
138
|
+
/** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
|
|
139
|
+
maxValueChanged() {
|
|
140
|
+
this.#repaint.schedule();
|
|
141
|
+
}
|
|
142
|
+
/** Repaints when application code (or a Turbo morph) changes `low` at runtime. */
|
|
143
|
+
lowValueChanged() {
|
|
144
|
+
this.#repaint.schedule();
|
|
145
|
+
}
|
|
146
|
+
/** Repaints when application code (or a Turbo morph) changes `high` at runtime. */
|
|
147
|
+
highValueChanged() {
|
|
148
|
+
this.#repaint.schedule();
|
|
149
|
+
}
|
|
150
|
+
/** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
|
|
151
|
+
valueTextValueChanged() {
|
|
152
|
+
this.#repaint.schedule();
|
|
42
153
|
}
|
|
43
154
|
/** Clamps `raw` into the configured `[min, max]` range. */
|
|
44
155
|
#clamp(raw) {
|
|
45
156
|
return Math.min(this.maxValue, Math.max(this.minValue, raw));
|
|
46
157
|
}
|
|
47
|
-
/** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */
|
|
48
|
-
get #ratio() {
|
|
49
|
-
const span = this.maxValue - this.minValue;
|
|
50
|
-
if (span <= 0) return 0;
|
|
51
|
-
return (this.#clamp(this.valueValue) - this.minValue) / span;
|
|
52
|
-
}
|
|
53
158
|
/** Whether a threshold attribute is present (absent = no threshold). */
|
|
54
159
|
#hasThreshold(name) {
|
|
55
160
|
return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);
|
|
56
161
|
}
|
|
57
162
|
/**
|
|
58
|
-
* Classifies
|
|
59
|
-
*
|
|
60
|
-
*
|
|
163
|
+
* Classifies `value` into a `low`/`medium`/`high` segment. Values at or below
|
|
164
|
+
* `low` are `low`; at or above `high` are `high`; otherwise `medium`. With
|
|
165
|
+
* neither threshold present, everything is `medium`.
|
|
61
166
|
*/
|
|
62
|
-
|
|
63
|
-
const value = this.#clamp(this.valueValue);
|
|
167
|
+
#stateOf(value) {
|
|
64
168
|
if (this.#hasThreshold("low") && value <= this.lowValue) return "low";
|
|
65
169
|
if (this.#hasThreshold("high") && value >= this.highValue) return "high";
|
|
66
170
|
return "medium";
|
|
67
171
|
}
|
|
68
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.
|
|
174
|
+
* The reading is derived once and returned, so the `change` detail reports the
|
|
175
|
+
* same numbers the DOM just received.
|
|
176
|
+
*/
|
|
69
177
|
#render() {
|
|
70
178
|
const value = this.#clamp(this.valueValue);
|
|
179
|
+
const reading = {
|
|
180
|
+
value,
|
|
181
|
+
ratio: rangeFraction(value, this.minValue, this.maxValue),
|
|
182
|
+
state: this.#stateOf(value)
|
|
183
|
+
};
|
|
71
184
|
this.element.setAttribute("aria-valuemin", String(this.minValue));
|
|
72
185
|
this.element.setAttribute("aria-valuemax", String(this.maxValue));
|
|
73
|
-
this.element.setAttribute("aria-valuenow", String(value));
|
|
74
|
-
this.element.style.setProperty("--stimeo-meter-ratio", String(
|
|
75
|
-
this.element.setAttribute("data-state",
|
|
76
|
-
this.#applyValueText(
|
|
186
|
+
this.element.setAttribute("aria-valuenow", String(reading.value));
|
|
187
|
+
this.element.style.setProperty("--stimeo-meter-ratio", String(reading.ratio));
|
|
188
|
+
this.element.setAttribute("data-state", reading.state);
|
|
189
|
+
this.#applyValueText(reading);
|
|
190
|
+
return reading;
|
|
77
191
|
}
|
|
78
192
|
/**
|
|
79
193
|
* Sets `aria-valuetext` from the consumer-provided template, substituting
|
|
80
|
-
* `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library
|
|
81
|
-
*
|
|
194
|
+
* `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library.
|
|
195
|
+
* With no template the attribute belongs to the consumer, so only a text this
|
|
196
|
+
* controller wrote is taken back ({@link OWNED_VALUE_TEXT}).
|
|
82
197
|
*/
|
|
83
|
-
#applyValueText(value) {
|
|
198
|
+
#applyValueText({ value, ratio, state }) {
|
|
84
199
|
if (this.valueTextValue.length === 0) {
|
|
85
|
-
this.element.
|
|
200
|
+
if (this.element.hasAttribute(OWNED_VALUE_TEXT)) {
|
|
201
|
+
this.element.removeAttribute("aria-valuetext");
|
|
202
|
+
this.element.removeAttribute(OWNED_VALUE_TEXT);
|
|
203
|
+
}
|
|
86
204
|
return;
|
|
87
205
|
}
|
|
88
|
-
const percent = Math.round(
|
|
89
|
-
const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}",
|
|
206
|
+
const percent = Math.round(ratio * 100);
|
|
207
|
+
const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", state);
|
|
90
208
|
this.element.setAttribute("aria-valuetext", text);
|
|
209
|
+
this.element.setAttribute(OWNED_VALUE_TEXT, "");
|
|
91
210
|
}
|
|
92
211
|
};
|
|
93
212
|
|