stimeo-ui 0.4.0 → 0.6.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/breadcrumb_controller.js +5 -1
  6. data/dist/controllers/carousel_controller.js +90 -10
  7. data/dist/controllers/checkbox_controller.js +136 -25
  8. data/dist/controllers/clipboard_controller.js +8 -3
  9. data/dist/controllers/collapsible_controller.js +4 -1
  10. data/dist/controllers/color_picker_controller.js +41 -11
  11. data/dist/controllers/context_menu_controller.js +2 -2
  12. data/dist/controllers/countdown_controller.js +5 -1
  13. data/dist/controllers/date_range_picker_controller.js +162 -31
  14. data/dist/controllers/direct_upload_controller.js +3 -3
  15. data/dist/controllers/empty_state_controller.js +107 -16
  16. data/dist/controllers/file_dropzone_controller.js +26 -3
  17. data/dist/controllers/flash_controller.js +161 -21
  18. data/dist/controllers/form_validation_controller.js +8 -2
  19. data/dist/controllers/frame_loading_controller.js +94 -22
  20. data/dist/controllers/highlight_controller.js +38 -1
  21. data/dist/controllers/idle_controller.js +39 -6
  22. data/dist/controllers/local_time_controller.js +2 -0
  23. data/dist/controllers/masonry_controller.js +1 -1
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/meter_controller.js +3 -1
  26. data/dist/controllers/multi_select_controller.js +460 -151
  27. data/dist/controllers/network_status_controller.js +1 -3
  28. data/dist/controllers/number_input_controller.js +455 -64
  29. data/dist/controllers/overflow_menu_controller.js +5 -1
  30. data/dist/controllers/pagination_controller.js +38 -1
  31. data/dist/controllers/password_strength_controller.js +21 -3
  32. data/dist/controllers/persist_controller.js +24 -5
  33. data/dist/controllers/pointer_drag_controller.js +10 -0
  34. data/dist/controllers/portal_controller.js +10 -0
  35. data/dist/controllers/progress_controller.js +7 -3
  36. data/dist/controllers/radio_group_controller.js +540 -56
  37. data/dist/controllers/range_slider_controller.js +385 -94
  38. data/dist/controllers/rating_controller.js +274 -89
  39. data/dist/controllers/relative_time_controller.js +2 -0
  40. data/dist/controllers/resizable_controller.js +33 -0
  41. data/dist/controllers/roving_controller.js +60 -5
  42. data/dist/controllers/scroll_area_controller.js +155 -23
  43. data/dist/controllers/scroll_visibility_controller.js +33 -0
  44. data/dist/controllers/separator_controller.js +13 -17
  45. data/dist/controllers/skeleton_controller.js +71 -3
  46. data/dist/controllers/slider_controller.js +325 -48
  47. data/dist/controllers/spinner_controller.js +18 -3
  48. data/dist/controllers/step_indicator_controller.js +3 -1
  49. data/dist/controllers/stepper_controller.js +2 -0
  50. data/dist/controllers/switch_controller.js +162 -18
  51. data/dist/controllers/tags_input_controller.js +356 -120
  52. data/dist/controllers/textarea_autosize_controller.js +1 -1
  53. data/dist/controllers/time_picker_controller.js +296 -104
  54. data/dist/controllers/toggle_group_controller.js +378 -55
  55. data/dist/controllers/toolbar_controller.js +5 -3
  56. data/dist/controllers/tree_view_controller.js +24 -4
  57. data/dist/index.js +3811 -1048
  58. data/lib/stimeo/ui/version.rb +1 -1
  59. metadata +2 -2
@@ -20,6 +20,39 @@ function isReservedArrowChord(event, allow = []) {
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
22
 
23
+ // src/utils/microtask_coalescer.ts
24
+ var MicrotaskCoalescer = class {
25
+ #run;
26
+ #queued = false;
27
+ #active = false;
28
+ #generation = 0;
29
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
30
+ constructor(run) {
31
+ this.#run = run;
32
+ }
33
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
34
+ activate() {
35
+ this.#active = true;
36
+ }
37
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
38
+ cancel() {
39
+ this.#active = false;
40
+ this.#queued = false;
41
+ this.#generation += 1;
42
+ }
43
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
44
+ schedule() {
45
+ if (!this.#active || this.#queued) return;
46
+ this.#queued = true;
47
+ const generation = this.#generation;
48
+ queueMicrotask(() => {
49
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
50
+ this.#queued = false;
51
+ this.#run();
52
+ });
53
+ }
54
+ };
55
+
23
56
  // src/utils/roving_tabindex.ts
24
57
  var RovingTabindex = class {
25
58
  /** Returns the current ordered item elements; called on every operation. */
@@ -42,10 +75,12 @@ var RovingTabindex = class {
42
75
  * "nothing is currently tabbable".
43
76
  *
44
77
  * @param index - Position of the item to make tabbable.
45
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
78
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
79
+ * and `items` to reuse an event-scoped collection snapshot.
46
80
  */
47
- setActive(index, { focus = false } = {}) {
48
- const items = this.#getItems();
81
+ setActive(index, options = {}) {
82
+ const { focus = false } = options;
83
+ const items = options.items ?? this.#getItems();
49
84
  items.forEach((item, i) => {
50
85
  item.tabIndex = i === index ? 0 : -1;
51
86
  });
@@ -120,9 +155,14 @@ var CarouselController = class extends Controller {
120
155
  "resume",
121
156
  "togglePlay"
122
157
  ];
123
- static events = ["change", "pause", "play"];
158
+ static events = ["change", "pause", "play", "reconcile"];
124
159
  #roving = new RovingTabindex(() => this.pickerTargets);
125
- /** Gates the target callback so it does not repaint once per authored picker on mount. */
160
+ #reconcileTargets = new MicrotaskCoalescer(() => this.#reconcileTargetSet());
161
+ /**
162
+ * Whether `connect()` has run. Scheduling is already inert outside that window
163
+ * ({@link MicrotaskCoalescer}), so this only gates the Tab stop a picker
164
+ * present at mount authored for itself.
165
+ */
126
166
  #connected = false;
127
167
  #intervals = new SafeInterval();
128
168
  /** Index of the visible slide. */
@@ -148,6 +188,7 @@ var CarouselController = class extends Controller {
148
188
  this.#render({ focus: false });
149
189
  this.#syncTimer();
150
190
  this.#connected = true;
191
+ this.#reconcileTargets.activate();
151
192
  }
152
193
  /**
153
194
  * Re-establishes the single selected picker when one is added after connect.
@@ -158,9 +199,22 @@ var CarouselController = class extends Controller {
158
199
  * repaint re-derives every picker from `#index`, so a late arrival never steals
159
200
  * the selection.
160
201
  */
161
- pickerTargetConnected() {
202
+ pickerTargetConnected(picker) {
162
203
  if (!this.#connected) return;
163
- this.#render({ focus: false });
204
+ picker.tabIndex = -1;
205
+ this.#reconcileTargets.schedule();
206
+ }
207
+ /** Repairs selection and roving after a picker leaves a retained carousel. */
208
+ pickerTargetDisconnected() {
209
+ this.#reconcileTargets.schedule();
210
+ }
211
+ /** Reconciles a slide added in the same DOM batch as its picker. */
212
+ slideTargetConnected() {
213
+ this.#reconcileTargets.schedule();
214
+ }
215
+ /** Re-clamps the active index after a slide is removed. */
216
+ slideTargetDisconnected() {
217
+ this.#reconcileTargets.schedule();
164
218
  }
165
219
  /**
166
220
  * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
@@ -178,6 +232,7 @@ var CarouselController = class extends Controller {
178
232
  /** Clears the autoplay interval so it never fires after teardown. */
179
233
  disconnect() {
180
234
  this.#connected = false;
235
+ this.#reconcileTargets.cancel();
181
236
  this.#intervals.clearAll();
182
237
  this.#timerId = null;
183
238
  }
@@ -271,17 +326,42 @@ var CarouselController = class extends Controller {
271
326
  this.#syncTimer();
272
327
  if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
273
328
  }
274
- /** Reflects `this.#index` onto slides and pickers (state hooks + roving). */
329
+ /**
330
+ * Reflects `this.#index` onto slides and pickers (state hooks + roving).
331
+ *
332
+ * @stimeoRenderRoot
333
+ */
275
334
  #render({ focus }) {
276
335
  this.slideTargets.forEach((slide, i) => {
277
336
  const active = i === this.#index;
278
337
  slide.setAttribute("data-state", active ? "active" : "inactive");
279
338
  slide.hidden = !active;
280
339
  });
340
+ const pickerIndex = Math.min(this.#index, this.pickerTargets.length - 1);
281
341
  this.pickerTargets.forEach((picker, i) => {
282
- picker.setAttribute("aria-selected", i === this.#index ? "true" : "false");
342
+ picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
283
343
  });
284
- this.#roving.setActive(this.#index, { focus });
344
+ this.#roving.setActive(pickerIndex, { focus });
345
+ }
346
+ /** Keeps the live active slide when possible and otherwise selects the nearest survivor. */
347
+ #reconcileTargetSet() {
348
+ const activeSlide = this.slideTargets.findIndex(
349
+ (slide) => slide.getAttribute("data-state") === "active"
350
+ );
351
+ const selectedPicker = this.pickerTargets.findIndex(
352
+ (picker) => picker.getAttribute("aria-selected") === "true"
353
+ );
354
+ const lastSlide = this.slideTargets.length - 1;
355
+ const candidate = activeSlide !== -1 ? activeSlide : selectedPicker !== -1 ? selectedPicker : this.#index;
356
+ const previous = this.#index;
357
+ this.#index = lastSlide < 0 ? 0 : Math.min(lastSlide, Math.max(0, candidate));
358
+ this.#render({ focus: false });
359
+ this.#syncTimer();
360
+ if (this.#index !== previous) {
361
+ this.dispatch("reconcile", {
362
+ detail: { index: this.#index, total: this.slideTargets.length }
363
+ });
364
+ }
285
365
  }
286
366
  /**
287
367
  * Drives the autoplay interval toward the desired state. Autoplay should run
@@ -1,17 +1,89 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/checkbox_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/checkbox_controller.ts
4
39
  var CheckboxController = class extends Controller {
5
40
  static targets = ["parent", "child"];
6
41
  static actions = ["onChildChange", "onParentChange"];
7
- static events = ["change"];
8
- /** Reflects the initial aggregate (e.g. from server-rendered child states). */
9
- connect() {
10
- if (this.childTargets.length > 0) {
11
- this.#syncFromChildren(false);
12
- } else {
13
- this.element.setAttribute("data-state", this.#aggregate());
42
+ static events = ["change", "reconcile"];
43
+ /** Collapses every lifecycle signal from one DOM update into one derived pass. */
44
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
45
+ /** Aggregate this root last settled on, so a derived repair is reported once. */
46
+ #committedState = null;
47
+ /** Watches authored checked-attribute changes on retained target elements. */
48
+ #checkedObserver = new MutationObserver((records) => {
49
+ if (records.some((record) => this.#isManagedCheckbox(record.target))) {
50
+ this.#reconcile.schedule();
14
51
  }
52
+ });
53
+ /** Reflects the initial aggregate and starts retained-DOM reconciliation. */
54
+ connect() {
55
+ this.#reconcile.activate();
56
+ this.#syncFromChildren();
57
+ this.#checkedObserver.observe(this.element, {
58
+ attributes: true,
59
+ attributeFilter: ["checked"],
60
+ subtree: true
61
+ });
62
+ this.element.addEventListener("turbo:morph-element", this.#onMorph);
63
+ document.addEventListener("reset", this.#onReset, true);
64
+ }
65
+ /** Releases the observer, global reset listener, and every pending reconciliation. */
66
+ disconnect() {
67
+ this.#reconcile.cancel();
68
+ this.#checkedObserver.disconnect();
69
+ this.element.removeEventListener("turbo:morph-element", this.#onMorph);
70
+ document.removeEventListener("reset", this.#onReset, true);
71
+ }
72
+ /** Reconciles the aggregate for a parent added or replaced at runtime. */
73
+ parentTargetConnected() {
74
+ this.#reconcile.schedule();
75
+ }
76
+ /** Reconciles the aggregate after a parent target leaves the group. */
77
+ parentTargetDisconnected() {
78
+ this.#reconcile.schedule();
79
+ }
80
+ /** Reconciles the aggregate for a child added at runtime. */
81
+ childTargetConnected() {
82
+ this.#reconcile.schedule();
83
+ }
84
+ /** Reconciles the aggregate after a child leaves the group. */
85
+ childTargetDisconnected() {
86
+ this.#reconcile.schedule();
15
87
  }
16
88
  /** Cascades the parent's state to every child. Bound via `data-action` (change). */
17
89
  onParentChange() {
@@ -20,35 +92,74 @@ var CheckboxController = class extends Controller {
20
92
  for (const child of this.childTargets) {
21
93
  child.checked = checked;
22
94
  }
23
- this.parentTarget.indeterminate = false;
24
- const state = checked ? "all" : "none";
25
- this.element.setAttribute("data-state", state);
26
- this.dispatch("change", { detail: { checked, indeterminate: false, state } });
95
+ this.#reflect(checked ? "all" : "none", true);
96
+ const detail = this.#settledDetail();
97
+ if (detail) this.dispatch("change", { detail });
27
98
  }
28
99
  /** Recomputes the parent from its children. Bound via `data-action` (change). */
29
100
  onChildChange() {
30
- this.#syncFromChildren(true);
101
+ this.#syncFromChildren();
102
+ const detail = this.#settledDetail();
103
+ if (detail) this.dispatch("change", { detail });
104
+ }
105
+ /**
106
+ * Announces an aggregate this pass derived rather than the user. `change` stays
107
+ * reserved for the two public actions, so automation never reads a repair as an edit.
108
+ */
109
+ #reconcileFromChildren() {
110
+ const previous = this.#committedState;
111
+ this.#syncFromChildren();
112
+ if (this.#committedState === previous) return;
113
+ const detail = this.#settledDetail();
114
+ if (detail) this.dispatch("reconcile", { detail });
31
115
  }
32
116
  /**
33
117
  * Derives the parent's `checked`/`indeterminate` and the root `data-state` from
34
- * the children, optionally dispatching `change`.
118
+ * the children. Writing state and reporting it are separate so the caller — not
119
+ * a flag threaded through the write — decides which event describes the cause.
35
120
  */
36
- #syncFromChildren(dispatch) {
37
- const state = this.#aggregate();
38
- if (this.hasParentTarget) {
121
+ #syncFromChildren() {
122
+ this.#reflect(this.#aggregate(), this.childTargets.length > 0);
123
+ }
124
+ /**
125
+ * Reflects one aggregate state.
126
+ *
127
+ * @param writeParent - whether the state is authoritative over the `parent`
128
+ * target's own `checked` / `indeterminate`.
129
+ */
130
+ #reflect(state, writeParent) {
131
+ if (writeParent && this.hasParentTarget) {
39
132
  this.parentTarget.checked = state === "all";
40
133
  this.parentTarget.indeterminate = state === "partial";
41
134
  }
42
135
  this.element.setAttribute("data-state", state);
43
- if (dispatch) {
44
- this.dispatch("change", {
45
- detail: {
46
- checked: this.hasParentTarget ? this.parentTarget.checked : state === "all",
47
- indeterminate: this.hasParentTarget ? this.parentTarget.indeterminate : false,
48
- state
49
- }
50
- });
51
- }
136
+ this.#committedState = state;
137
+ }
138
+ /** The settled aggregate as event detail, or `null` before anything has settled. */
139
+ #settledDetail() {
140
+ const state = this.#committedState;
141
+ if (state === null) return null;
142
+ return { checked: state === "all", indeterminate: state === "partial", state };
143
+ }
144
+ /** Reconciles retained targets after Turbo has finished morphing their live state. */
145
+ #onMorph = () => {
146
+ this.#reconcile.schedule();
147
+ };
148
+ /** Reconciles after a non-cancelled reset restores any managed checkbox. */
149
+ #onReset = (event) => {
150
+ const form = event.target;
151
+ if (!(form instanceof HTMLFormElement) || !this.#hasCheckboxOwnedBy(form)) return;
152
+ queueMicrotask(() => {
153
+ if (!event.defaultPrevented) this.#reconcile.schedule();
154
+ });
155
+ };
156
+ /** Whether a form owns at least one current parent or child target. */
157
+ #hasCheckboxOwnedBy(form) {
158
+ return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
159
+ }
160
+ /** Whether an observed attribute mutation belongs to this controller's target set. */
161
+ #isManagedCheckbox(node) {
162
+ return this.parentTargets.some((checkbox) => checkbox === node) || this.childTargets.some((checkbox) => checkbox === node);
52
163
  }
53
164
  /**
54
165
  * Computes the aggregate state. With children it counts them; with none it
@@ -2,6 +2,13 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/clipboard_controller.ts
4
4
 
5
+ // src/utils/default_attribute.ts
6
+ function setDefaultAttribute(element, name, value) {
7
+ if (element.hasAttribute(name)) return false;
8
+ element.setAttribute(name, value);
9
+ return true;
10
+ }
11
+
5
12
  // src/utils/safe_timeout.ts
6
13
  var TimerRegistry = class {
7
14
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -75,9 +82,7 @@ var ClipboardController = class extends Controller {
75
82
  */
76
83
  #resetTimerId = null;
77
84
  connect() {
78
- if (!this.element.hasAttribute("data-state")) {
79
- this.element.setAttribute("data-state", "idle");
80
- }
85
+ setDefaultAttribute(this.element, "data-state", "idle");
81
86
  }
82
87
  disconnect() {
83
88
  this.#timers.clearAll();
@@ -297,7 +297,10 @@ var CollapsibleController = class extends Controller {
297
297
  #applyContent(content, open, waitForCloseTransition) {
298
298
  if (open) {
299
299
  content.hidden = false;
300
- content.style.setProperty("--stimeo-collapsible-content-height", `${content.scrollHeight}px`);
300
+ content.style.setProperty(
301
+ "--stimeo--collapsible-content-height",
302
+ `${content.scrollHeight}px`
303
+ );
301
304
  content.setAttribute("data-state", "open");
302
305
  return;
303
306
  }
@@ -59,7 +59,7 @@ var MicrotaskCoalescer = class {
59
59
  };
60
60
 
61
61
  // src/controllers/color_picker_controller.ts
62
- var COLOR_PROPERTY = "--stimeo-color";
62
+ var COLOR_PROPERTY = "--stimeo--color";
63
63
  var CHANNEL_RANGE = {
64
64
  hue: [0, 360],
65
65
  saturation: [0, 100],
@@ -74,7 +74,7 @@ var ColorPickerController = class extends Controller {
74
74
  logicalTrack: { type: Boolean, default: false }
75
75
  };
76
76
  static actions = ["onHexInput", "onKeydown", "onPointerDown"];
77
- static events = ["change"];
77
+ static events = ["change", "reconcile"];
78
78
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
79
79
  get #mirrored() {
80
80
  return this.logicalTrackValue && isRtl(this.element);
@@ -83,14 +83,14 @@ var ColorPickerController = class extends Controller {
83
83
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
84
84
  /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
85
85
  #dragAbort = null;
86
- /** Seeds the model from the initial hex value and renders every surface. */
86
+ /** Color the last repaint settled on, so a configuration-driven move is reported once. */
87
+ #committedHex = null;
87
88
  /**
88
89
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
89
90
  * pass Stimulus delivers before `connect()`.
90
91
  */
91
- #repaint = new MicrotaskCoalescer(() => {
92
- this.#render();
93
- });
92
+ #repaint = new MicrotaskCoalescer(() => this.#reconcileColor());
93
+ /** Seeds the model from the initial hex value and renders every surface. */
94
94
  connect() {
95
95
  this.#repaint.activate();
96
96
  const parsed = hexToHsla(this.valueValue);
@@ -181,14 +181,30 @@ var ColorPickerController = class extends Controller {
181
181
  return;
182
182
  }
183
183
  this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
184
- this.#render();
184
+ this.#commitColor();
185
185
  }
186
186
  /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
187
187
  #setChannel(channel, raw, min, max) {
188
188
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
189
+ this.#commitColor();
190
+ }
191
+ /**
192
+ * Renders the model and reports a color the user actually moved. A key pressed
193
+ * at a bound, a pointer that lands on the step already showing, and a re-confirmed
194
+ * hex all leave the committed color where it was, so no `change` describes them.
195
+ */
196
+ #commitColor() {
197
+ const previous = this.#committedHex;
189
198
  this.#render();
199
+ if (this.#committedHex !== previous) {
200
+ this.dispatch("change", { detail: this.#settledDetail() });
201
+ }
190
202
  }
191
- /** Reflects the model onto sliders, the hex input, preview, and form field. */
203
+ /**
204
+ * Reflects the model onto sliders, the hex input, preview, and form field.
205
+ *
206
+ * @stimeoRenderRoot
207
+ */
192
208
  #render() {
193
209
  for (const slider of this.sliderTargets) {
194
210
  const channel = this.#channelOf(slider);
@@ -198,14 +214,28 @@ var ColorPickerController = class extends Controller {
198
214
  slider.setAttribute("aria-valuetext", valueText(channel, value));
199
215
  }
200
216
  const hex = this.#hexString();
217
+ this.#committedHex = hex;
201
218
  if (this.hasHexTarget) this.hexTarget.value = hex;
202
219
  for (const field of this.fieldTargets) field.value = hex;
203
220
  for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
204
221
  this.element.style.setProperty(COLOR_PROPERTY, hex);
222
+ }
223
+ /**
224
+ * Repaints after `alpha` changed at runtime and reports a color this controller
225
+ * settled on. Disabling alpha drops it from the model, so the committed color can
226
+ * move without a user edit; `change` stays reserved for the picker's own actions.
227
+ */
228
+ #reconcileColor() {
229
+ const previous = this.#committedHex;
230
+ this.#render();
231
+ if (previous !== null && this.#committedHex !== previous) {
232
+ this.dispatch("reconcile", { detail: this.#settledDetail() });
233
+ }
234
+ }
235
+ /** The settled color as event detail, shared by both report paths. */
236
+ #settledDetail() {
205
237
  const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
206
- this.dispatch("change", {
207
- detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } }
208
- });
238
+ return { value: this.#hexString(), rgba: { ...rgb, a: this.#color.alpha / 100 } };
209
239
  }
210
240
  /** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */
211
241
  #hexString() {
@@ -237,8 +237,8 @@ var ContextMenuController = class extends Controller {
237
237
  onDismiss: () => this.#closeAndRestore(),
238
238
  claims: claimsWhileFocusWithin(this.element)
239
239
  });
240
- this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
241
- this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
240
+ this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
241
+ this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
242
242
  this.menuTarget.hidden = false;
243
243
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
244
244
  this.#navigableItems[0]?.focus();
@@ -268,7 +268,11 @@ var CountdownController = class extends Controller {
268
268
  const raw = this.#isDown ? this.#reference - now : now - this.#reference;
269
269
  return Math.max(0, raw);
270
270
  }
271
- /** Writes the amount into the day/hour/minute/second slots. */
271
+ /**
272
+ * Writes the amount into the day/hour/minute/second slots.
273
+ *
274
+ * @stimeoRenderRoot
275
+ */
272
276
  #render(amount) {
273
277
  const totalSeconds = Math.floor(amount / SECOND_MS);
274
278
  this.#renderedAmount = totalSeconds * SECOND_MS;