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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/dist/controllers/alert_dialog_controller.js +32 -5
  4. data/dist/controllers/announcer_controller.js +255 -20
  5. data/dist/controllers/color_picker_controller.js +46 -0
  6. data/dist/controllers/command_palette_controller.js +32 -5
  7. data/dist/controllers/confirm_controller.js +32 -5
  8. data/dist/controllers/countdown_controller.js +112 -12
  9. data/dist/controllers/date_range_picker_controller.js +50 -0
  10. data/dist/controllers/dialog_controller.js +32 -5
  11. data/dist/controllers/drawer_controller.js +32 -5
  12. data/dist/controllers/empty_state_controller.js +24 -10
  13. data/dist/controllers/focus_controller.js +32 -5
  14. data/dist/controllers/frame_loading_controller.js +177 -15
  15. data/dist/controllers/local_time_controller.js +100 -6
  16. data/dist/controllers/meter_controller.js +145 -26
  17. data/dist/controllers/network_status_controller.js +28 -8
  18. data/dist/controllers/overflow_menu_controller.js +33 -6
  19. data/dist/controllers/progress_controller.js +116 -9
  20. data/dist/controllers/range_slider_controller.js +68 -3
  21. data/dist/controllers/rating_controller.js +53 -0
  22. data/dist/controllers/relative_time_controller.js +133 -12
  23. data/dist/controllers/sidebar_controller.js +37 -8
  24. data/dist/controllers/skeleton_controller.js +73 -20
  25. data/dist/controllers/slider_controller.js +17 -2
  26. data/dist/controllers/spinner_controller.js +228 -27
  27. data/dist/controllers/step_indicator_controller.js +82 -5
  28. data/dist/controllers/stick_to_bottom_controller.js +60 -10
  29. data/dist/index.js +1056 -281
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. 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.#timeouts.clearAll();
217
+ this.#floor.cancel();
75
218
  if (!this.#loading) this.#begin();
76
219
  };
77
220
  #onEnd = () => {
78
221
  if (!this.#loading) return;
79
- const remaining = this.minDurationValue - (Date.now() - this.#startedAt);
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
- if (this.#loading) {
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.#startedAt = Date.now();
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
- /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */
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 hasTime = /T\d{2}:\d{2}/.test(value);
43
- const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(value);
44
- return hasTime && !hasZone ? `${value}Z` : value;
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 element's `lang`, then the document's. */
167
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
74
168
  get #locale() {
75
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
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
- detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state }
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 the value into a `low`/`medium`/`high` segment. Values at or
59
- * below `low` are `low`; at or above `high` are `high`; otherwise `medium`.
60
- * With neither threshold present, everything is `medium`.
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
- get #state() {
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
- /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */
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(this.#ratio));
75
- this.element.setAttribute("data-state", this.#state);
76
- this.#applyValueText(value);
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
- * cleared when no template is given.
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.removeAttribute("aria-valuetext");
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(this.#ratio * 100);
89
- const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", this.#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