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,23 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/network_status_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/safe_timeout.ts
6
23
  var TimerRegistry = class {
7
24
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -59,20 +76,17 @@ var SafeTimeout = class extends TimerRegistry {
59
76
  var NetworkStatusController = class extends Controller {
60
77
  static targets = ["offline", "online"];
61
78
  static values = {
79
+ announceText: { type: String, default: "" },
80
+ announceOnlineText: { type: String, default: "" },
62
81
  onlineAutoHide: { type: Number, default: 0 }
63
82
  };
64
83
  static events = ["change"];
65
84
  #timers = new SafeTimeout();
66
85
  /** Last known connectivity; guards against duplicate-state re-announcements. */
67
86
  #online = true;
68
- /** Banner text captured from the markup so transitions can re-write it. */
69
- #offlineMessage = "";
70
- #onlineMessage = "";
71
87
  #handleOnline = () => this.#update(true);
72
88
  #handleOffline = () => this.#update(false);
73
89
  connect() {
74
- this.#offlineMessage = this.hasOfflineTarget ? (this.offlineTarget.textContent ?? "").trim() : "";
75
- this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? "").trim() : "";
76
90
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
77
91
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
78
92
  this.#online = navigator.onLine;
@@ -86,7 +100,12 @@ var NetworkStatusController = class extends Controller {
86
100
  window.removeEventListener("offline", this.#handleOffline);
87
101
  this.#timers.clearAll();
88
102
  }
89
- /** Applies a connectivity transition, guarded against duplicate states. */
103
+ /**
104
+ * Applies a connectivity transition, guarded against duplicate states.
105
+ *
106
+ * The event goes out last, so a listener reading `data-state` or a banner's
107
+ * visibility sees the state the transition landed on rather than the previous one.
108
+ */
90
109
  #update(online) {
91
110
  if (online === this.#online) return;
92
111
  this.#online = online;
@@ -96,6 +115,9 @@ var NetworkStatusController = class extends Controller {
96
115
  } else {
97
116
  this.#showOffline();
98
117
  }
118
+ announce(fillTemplate(online ? this.announceOnlineTextValue : this.announceTextValue, {}), {
119
+ assertive: !online
120
+ });
99
121
  this.dispatch("change", { detail: { online } });
100
122
  }
101
123
  /** Shows the offline banner and hides the recovery banner. */
@@ -103,7 +125,6 @@ var NetworkStatusController = class extends Controller {
103
125
  this.#timers.clearAll();
104
126
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
105
127
  if (this.hasOfflineTarget) {
106
- this.offlineTarget.textContent = this.#offlineMessage;
107
128
  this.offlineTarget.hidden = false;
108
129
  }
109
130
  }
@@ -111,7 +132,6 @@ var NetworkStatusController = class extends Controller {
111
132
  #showOnline() {
112
133
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
113
134
  if (!this.hasOnlineTarget) return;
114
- this.onlineTarget.textContent = this.#onlineMessage;
115
135
  this.onlineTarget.hidden = false;
116
136
  if (this.onlineAutoHideValue > 0) {
117
137
  this.#timers.set(() => {
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/overflow_menu_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/focus_candidate.ts
6
35
  function inheritsFieldsetDisabled(control) {
7
36
  let fieldset = control.closest("fieldset[disabled]");
@@ -183,17 +212,15 @@ var OverflowMenuController = class extends Controller {
183
212
  #lastHidden = null;
184
213
  /** The `tabindex` this instance lends the root for the focus fallback. */
185
214
  #tabindex = new TabindexLoan();
186
- /** Hands Turbo a pristine snapshot: the cache is written after this event. */
187
- #onBeforeCache = () => {
188
- this.#restoreAll();
189
- };
215
+ /** Hands Turbo a pristine snapshot of the bar, with every item back in place. */
216
+ #beforeCache = new BeforeCacheReset(() => this.#restoreAll());
190
217
  connect() {
191
218
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
192
219
  const trigger = this.#trigger();
193
220
  if (trigger !== null && this.#isBareTrigger(trigger)) {
194
221
  trigger.textContent = this.moreLabelValue;
195
222
  }
196
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
223
+ this.#beforeCache.activate();
197
224
  this.#layout.observe(this.element);
198
225
  this.#layout.observeViewport();
199
226
  this.update();
@@ -201,7 +228,7 @@ var OverflowMenuController = class extends Controller {
201
228
  disconnect() {
202
229
  this.#layout.disconnect();
203
230
  this.#timers.clearAll();
204
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
231
+ this.#beforeCache.deactivate();
205
232
  this.#restoreAll();
206
233
  this.#lastHidden = null;
207
234
  }
@@ -2,6 +2,23 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/progress_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,7 +26,57 @@ 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/progress_controller.ts
79
+ var OWNED_VALUE_TEXT = "data-stimeo--progress-owns-valuetext";
13
80
  var ProgressController = class extends Controller {
14
81
  static targets = ["bar"];
15
82
  static values = {
@@ -17,13 +84,27 @@ var ProgressController = class extends Controller {
17
84
  min: { type: Number, default: 0 },
18
85
  max: { type: Number, default: 100 },
19
86
  indeterminate: { type: Boolean, default: false },
20
- valueText: { type: String, default: "" }
87
+ valueText: { type: String, default: "" },
88
+ announceText: { type: String, default: "" }
21
89
  };
22
90
  static actions = ["setValue"];
23
91
  static events = ["change", "complete"];
92
+ /**
93
+ * Collapses a morph that swaps several render inputs at once into one repaint.
94
+ * A single update usually rewrites the whole set, and each Value would otherwise
95
+ * repaint on its own.
96
+ */
97
+ #repaint = new MicrotaskCoalescer(() => {
98
+ this.#render();
99
+ });
24
100
  connect() {
101
+ this.#repaint.activate();
25
102
  this.#render();
26
103
  }
104
+ /** Closes the window in which a queued repaint may still run. */
105
+ disconnect() {
106
+ this.#repaint.cancel();
107
+ }
27
108
  /**
28
109
  * Updates the progress value from an action param (`amount`) or a
29
110
  * `detail.value` CustomEvent, normalizes it into range, syncs ARIA, and
@@ -39,11 +120,30 @@ var ProgressController = class extends Controller {
39
120
  this.dispatch("change", { detail: { value, ratio: this.#ratio } });
40
121
  if (value >= this.maxValue) {
41
122
  this.dispatch("complete", { detail: { value } });
123
+ announce(
124
+ fillTemplate(this.announceTextValue, { value, percent: Math.round(this.#ratio * 100) })
125
+ );
42
126
  }
43
127
  }
44
- /** Re-render when the indeterminate flag is toggled via its data attribute. */
128
+ /** Repaints when application code (or a Turbo morph) changes `indeterminate` at runtime. */
45
129
  indeterminateValueChanged() {
46
- this.#render();
130
+ this.#repaint.schedule();
131
+ }
132
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
133
+ valueValueChanged() {
134
+ this.#repaint.schedule();
135
+ }
136
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
137
+ minValueChanged() {
138
+ this.#repaint.schedule();
139
+ }
140
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
141
+ maxValueChanged() {
142
+ this.#repaint.schedule();
143
+ }
144
+ /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
145
+ valueTextValueChanged() {
146
+ this.#repaint.schedule();
47
147
  }
48
148
  /** Clamps `raw` into the configured `[min, max]` range. */
49
149
  #clamp(raw) {
@@ -51,9 +151,7 @@ var ProgressController = class extends Controller {
51
151
  }
52
152
  /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */
53
153
  get #ratio() {
54
- const span = this.maxValue - this.minValue;
55
- if (span <= 0) return 0;
56
- return (this.#clamp(this.valueValue) - this.minValue) / span;
154
+ return rangeFraction(this.valueValue, this.minValue, this.maxValue);
57
155
  }
58
156
  /** Reflects value/range/indeterminate onto ARIA, `data-state`, and the ratio. */
59
157
  #render() {
@@ -61,7 +159,7 @@ var ProgressController = class extends Controller {
61
159
  this.element.setAttribute("aria-valuemax", String(this.maxValue));
62
160
  if (this.indeterminateValue) {
63
161
  this.element.removeAttribute("aria-valuenow");
64
- this.element.removeAttribute("aria-valuetext");
162
+ this.#clearOwnValueText();
65
163
  this.element.style.removeProperty("--stimeo-progress-ratio");
66
164
  this.element.setAttribute("data-state", "indeterminate");
67
165
  return;
@@ -75,16 +173,25 @@ var ProgressController = class extends Controller {
75
173
  /**
76
174
  * Sets `aria-valuetext` from the consumer-provided template, substituting
77
175
  * `{value}` and `{percent}`. Left to the consumer so the human-readable text
78
- * stays i18n-neutral in the library; cleared when no template is given.
176
+ * stays i18n-neutral in the library. With no template the attribute belongs to
177
+ * the consumer, so only a text this controller wrote is taken back
178
+ * ({@link OWNED_VALUE_TEXT}).
79
179
  */
80
180
  #applyValueText(value) {
81
181
  if (this.valueTextValue.length === 0) {
82
- this.element.removeAttribute("aria-valuetext");
182
+ this.#clearOwnValueText();
83
183
  return;
84
184
  }
85
185
  const percent = Math.round(this.#ratio * 100);
86
186
  const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent));
87
187
  this.element.setAttribute("aria-valuetext", text);
188
+ this.element.setAttribute(OWNED_VALUE_TEXT, "");
189
+ }
190
+ /** Removes `aria-valuetext` only when this controller is the one that wrote it. */
191
+ #clearOwnValueText() {
192
+ if (!this.element.hasAttribute(OWNED_VALUE_TEXT)) return;
193
+ this.element.removeAttribute("aria-valuetext");
194
+ this.element.removeAttribute(OWNED_VALUE_TEXT);
88
195
  }
89
196
  };
90
197
 
@@ -18,6 +18,55 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
20
 
21
+ // src/utils/microtask_coalescer.ts
22
+ var MicrotaskCoalescer = class {
23
+ #run;
24
+ #queued = false;
25
+ #active = false;
26
+ #generation = 0;
27
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
28
+ constructor(run) {
29
+ this.#run = run;
30
+ }
31
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
32
+ activate() {
33
+ this.#active = true;
34
+ }
35
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
36
+ cancel() {
37
+ this.#active = false;
38
+ this.#queued = false;
39
+ this.#generation += 1;
40
+ }
41
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
42
+ schedule() {
43
+ if (!this.#active || this.#queued) return;
44
+ this.#queued = true;
45
+ const generation = this.#generation;
46
+ queueMicrotask(() => {
47
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
48
+ this.#queued = false;
49
+ this.#run();
50
+ });
51
+ }
52
+ };
53
+
54
+ // src/utils/range.ts
55
+ function rangeFraction(value, min, max) {
56
+ const span = max - min;
57
+ if (!(span > 0)) return 0;
58
+ const clamped = Math.min(max, Math.max(min, value));
59
+ let fraction;
60
+ if (Number.isFinite(span)) {
61
+ fraction = (clamped - min) / span;
62
+ } else {
63
+ const scale = Math.max(Math.abs(min), Math.abs(max));
64
+ fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
65
+ }
66
+ if (!Number.isFinite(fraction)) return 0;
67
+ return Math.min(1, Math.max(0, fraction));
68
+ }
69
+
21
70
  // src/controllers/range_slider_controller.ts
22
71
  var START_PROPERTY = "--stimeo-range-start";
23
72
  var END_PROPERTY = "--stimeo-range-end";
@@ -40,16 +89,33 @@ var RangeSliderController = class extends Controller {
40
89
  return this.logicalTrackValue && isRtl(this.element);
41
90
  }
42
91
  /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
92
+ /**
93
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
94
+ * pass Stimulus delivers before `connect()`.
95
+ */
96
+ #repaint = new MicrotaskCoalescer(() => {
97
+ this.#render(this.startValue, this.endValue);
98
+ });
43
99
  connect() {
100
+ this.#repaint.activate();
44
101
  const lo = Math.min(this.startValue, this.endValue);
45
102
  const hi = Math.max(this.startValue, this.endValue);
46
103
  this.#commit(lo, hi, false);
47
104
  }
48
105
  /** Cancels any active pointer drag so document listeners never leak. */
49
106
  disconnect() {
107
+ this.#repaint.cancel();
50
108
  this.#dragAbort?.abort();
51
109
  this.#dragAbort = null;
52
110
  }
111
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
112
+ minValueChanged() {
113
+ this.#repaint.schedule();
114
+ }
115
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
116
+ maxValueChanged() {
117
+ this.#repaint.schedule();
118
+ }
53
119
  /** Keyboard stepping for whichever thumb is focused (the action's element). */
54
120
  onKeydown(event) {
55
121
  if (isReservedArrowChord(event)) return;
@@ -165,14 +231,13 @@ var RangeSliderController = class extends Controller {
165
231
  this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
166
232
  this.endThumbTarget.setAttribute("aria-valuenow", String(end));
167
233
  }
168
- const span = this.maxValue - this.minValue;
169
234
  this.element.style.setProperty(
170
235
  START_PROPERTY,
171
- String(span > 0 ? (start - this.minValue) / span : 0)
236
+ String(rangeFraction(start, this.minValue, this.maxValue))
172
237
  );
173
238
  this.element.style.setProperty(
174
239
  END_PROPERTY,
175
- String(span > 0 ? (end - this.minValue) / span : 0)
240
+ String(rangeFraction(end, this.minValue, this.maxValue))
176
241
  );
177
242
  }
178
243
  /** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
@@ -13,6 +13,39 @@ function isReservedArrowChord(event, allow = []) {
13
13
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
14
  }
15
15
 
16
+ // src/utils/microtask_coalescer.ts
17
+ var MicrotaskCoalescer = class {
18
+ #run;
19
+ #queued = false;
20
+ #active = false;
21
+ #generation = 0;
22
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
23
+ constructor(run) {
24
+ this.#run = run;
25
+ }
26
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
27
+ activate() {
28
+ this.#active = true;
29
+ }
30
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
31
+ cancel() {
32
+ this.#active = false;
33
+ this.#queued = false;
34
+ this.#generation += 1;
35
+ }
36
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
37
+ schedule() {
38
+ if (!this.#active || this.#queued) return;
39
+ this.#queued = true;
40
+ const generation = this.#generation;
41
+ queueMicrotask(() => {
42
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
43
+ this.#queued = false;
44
+ this.#run();
45
+ });
46
+ }
47
+ };
48
+
16
49
  // src/utils/roving_tabindex.ts
17
50
  var RovingTabindex = class {
18
51
  /** Returns the current ordered item elements; called on every operation. */
@@ -59,13 +92,33 @@ var RatingController = class extends Controller {
59
92
  static events = ["change"];
60
93
  #roving = new RovingTabindex(() => this.symbolTargets);
61
94
  /** Reflects the initial value, or switches to the non-interactive readonly view. */
95
+ /**
96
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
97
+ * pass Stimulus delivers before `connect()`.
98
+ */
99
+ #repaint = new MicrotaskCoalescer(() => {
100
+ if (this.readonlyValue) {
101
+ this.#applyReadonly();
102
+ return;
103
+ }
104
+ this.#apply(this.#clamp(this.valueValue), { focus: false });
105
+ });
62
106
  connect() {
107
+ this.#repaint.activate();
63
108
  if (this.readonlyValue) {
64
109
  this.#applyReadonly();
65
110
  return;
66
111
  }
67
112
  this.#apply(this.#clamp(this.valueValue), { focus: false });
68
113
  }
114
+ /** Closes the window in which a queued repaint may still run. */
115
+ disconnect() {
116
+ this.#repaint.cancel();
117
+ }
118
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
119
+ valueValueChanged() {
120
+ this.#repaint.schedule();
121
+ }
69
122
  /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
70
123
  select(event) {
71
124
  if (this.readonlyValue) return;