stimeo-ui 0.14.0 → 0.16.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +81 -12
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +85 -17
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +46 -13
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +432 -71
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -2,6 +2,78 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/checkbox_controller.ts
4
4
 
5
+ // src/utils/form_reset_watcher.ts
6
+ var FormResetWatcher = class {
7
+ #owns;
8
+ #onReset;
9
+ #frames = /* @__PURE__ */ new Set();
10
+ #generation = 0;
11
+ /**
12
+ * @param owns - whether `form` owns a control this controller derives from.
13
+ * @param onReset - the reconciliation pass, run once per honoured reset.
14
+ */
15
+ constructor(owns, onReset) {
16
+ this.#owns = owns;
17
+ this.#onReset = onReset;
18
+ }
19
+ /** Subscribes to `reset` at the document; call from `connect()`. Idempotent. */
20
+ observe() {
21
+ document.addEventListener("reset", this.#handleReset, true);
22
+ }
23
+ /** Unsubscribes and drops every pending pass; call from `disconnect()`. */
24
+ disconnect() {
25
+ document.removeEventListener("reset", this.#handleReset, true);
26
+ this.#generation += 1;
27
+ for (const frame of this.#frames) cancelAnimationFrame(frame);
28
+ this.#frames.clear();
29
+ }
30
+ #handleReset = (event) => {
31
+ const form = event.target;
32
+ if (!(form instanceof HTMLFormElement) || !this.#owns(form)) return;
33
+ const generation = this.#generation;
34
+ queueMicrotask(() => {
35
+ if (generation !== this.#generation) return;
36
+ if (event.eventPhase === Event.NONE) {
37
+ this.#settle(event);
38
+ return;
39
+ }
40
+ const frame = requestAnimationFrame(() => {
41
+ this.#frames.delete(frame);
42
+ this.#settle(event);
43
+ });
44
+ this.#frames.add(frame);
45
+ });
46
+ };
47
+ /** Runs the pass unless a listener cancelled the reset before it landed. */
48
+ #settle(event) {
49
+ if (!event.defaultPrevented) this.#onReset();
50
+ }
51
+ };
52
+
53
+ // src/utils/listener_set.ts
54
+ var ListenerSet = class {
55
+ /** The generation every `add` joins until the next `dispose()`. */
56
+ #abort = new AbortController();
57
+ /**
58
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
59
+ *
60
+ * The set supplies the signal, so `options` carries everything else the DOM
61
+ * accepts — `capture` included, which has to match at release time and no
62
+ * longer has a second place to drift from.
63
+ */
64
+ add(target, type, handler, options) {
65
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
66
+ }
67
+ /**
68
+ * Releases every listener of the open generation, synchronously, and opens the
69
+ * next one. Idempotent, and safe before anything has been added.
70
+ */
71
+ dispose() {
72
+ this.#abort.abort();
73
+ this.#abort = new AbortController();
74
+ }
75
+ };
76
+
5
77
  // src/utils/microtask_coalescer.ts
6
78
  var MicrotaskCoalescer = class {
7
79
  #run;
@@ -42,6 +114,11 @@ var CheckboxController = class extends Controller {
42
114
  static events = ["change", "reconcile"];
43
115
  /** Collapses every lifecycle signal from one DOM update into one derived pass. */
44
116
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
117
+ #listeners = new ListenerSet();
118
+ #formReset = new FormResetWatcher(
119
+ (form) => this.#hasCheckboxOwnedBy(form),
120
+ () => this.#reconcile.schedule()
121
+ );
45
122
  /** Aggregate this root last settled on, so a derived repair is reported once. */
46
123
  #committedState = null;
47
124
  /** Watches authored checked-attribute changes on retained target elements. */
@@ -59,15 +136,15 @@ var CheckboxController = class extends Controller {
59
136
  attributeFilter: ["checked"],
60
137
  subtree: true
61
138
  });
62
- this.element.addEventListener("turbo:morph-element", this.#onMorph);
63
- document.addEventListener("reset", this.#onReset, true);
139
+ this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
140
+ this.#formReset.observe();
64
141
  }
65
142
  /** Releases the observer, global reset listener, and every pending reconciliation. */
66
143
  disconnect() {
67
144
  this.#reconcile.cancel();
68
145
  this.#checkedObserver.disconnect();
69
- this.element.removeEventListener("turbo:morph-element", this.#onMorph);
70
- document.removeEventListener("reset", this.#onReset, true);
146
+ this.#listeners.dispose();
147
+ this.#formReset.disconnect();
71
148
  }
72
149
  /** Reconciles the aggregate for a parent added or replaced at runtime. */
73
150
  parentTargetConnected() {
@@ -145,14 +222,6 @@ var CheckboxController = class extends Controller {
145
222
  #onMorph = () => {
146
223
  this.#reconcile.schedule();
147
224
  };
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
225
  /** Whether a form owns at least one current parent or child target. */
157
226
  #hasCheckboxOwnedBy(form) {
158
227
  return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
@@ -127,7 +127,9 @@ var ClipboardController = class extends Controller {
127
127
  * Whether this connection is still live. `copy()` suspends on the Clipboard API,
128
128
  * and a teardown that lands while it is suspended must win: the continuation
129
129
  * would otherwise write to an element nobody owns and arm a timer past the
130
- * `clearAll()` that was supposed to be the last word.
130
+ * `clearAll()` that was supposed to be the last word. Stimulus also runs the label
131
+ * callbacks ahead of `connect()`, while this is still false: a result restored with
132
+ * the markup is `connect()`'s to settle, not theirs.
131
133
  */
132
134
  #connected = false;
133
135
  connect() {
@@ -140,10 +142,33 @@ var ClipboardController = class extends Controller {
140
142
  this.#beforeCache.deactivate();
141
143
  this.#timers.clearAll();
142
144
  }
145
+ /**
146
+ * Follows a `copiedLabel` swapped in place while a successful copy is shown.
147
+ *
148
+ * Render only: nothing is announced or dispatched, and the return to idle keeps the
149
+ * deadline the copy set. The slot changes only while `data-state` is `copied` and it
150
+ * still reads the previous label — the one the copy wrote there — so text someone
151
+ * else put in the slot stays. Stimulus also runs this ahead of `connect()`, which
152
+ * settles a result restored with the markup itself, so nothing is written then.
153
+ */
154
+ copiedLabelValueChanged(label, previous) {
155
+ this.#followLabel("copied", label, previous);
156
+ }
157
+ /**
158
+ * Follows an `errorLabel` swapped in place while a failed copy is shown, on the same
159
+ * terms as {@link ClipboardController.copiedLabelValueChanged}: only while
160
+ * `data-state` is `error` and the slot still reads the previous label.
161
+ */
162
+ errorLabelValueChanged(label, previous) {
163
+ this.#followLabel("error", label, previous);
164
+ }
143
165
  /**
144
166
  * Copies the resolved text and reports the outcome. Bound via `data-action`
145
- * (click). Dispatches `stimeo--clipboard:copy` with `{ success, text }` once per
146
- * completed attempt — including on failure — so consumers can react either way.
167
+ * (click). Dispatches `stimeo--clipboard:copy` with `{ success, text, message }`
168
+ * once per completed attempt — including on failure — so consumers can react
169
+ * either way. `message` is the authored label for that outcome, so a part that
170
+ * shows wording — `stimeo--clipboard:copy->stimeo--toast#show` — needs nothing
171
+ * else; wiring both that and the announcer reads the same result twice.
147
172
  * An attempt whose connection ended while it was in flight reports nothing.
148
173
  */
149
174
  async copy() {
@@ -157,7 +182,8 @@ var ClipboardController = class extends Controller {
157
182
  }
158
183
  if (!this.#connected) return;
159
184
  this.#reportResult(success);
160
- this.dispatch("copy", { detail: { success, text } });
185
+ const message = success ? this.copiedLabelValue : this.errorLabelValue;
186
+ this.dispatch("copy", { detail: { success, text, message } });
161
187
  }
162
188
  /**
163
189
  * The text to copy: the explicit `text` value when set, otherwise the source
@@ -205,18 +231,50 @@ var ClipboardController = class extends Controller {
205
231
  this.#timers.clearAll();
206
232
  this.#reset();
207
233
  }
208
- /** Reflects the result, announces it, and schedules the return to idle. */
234
+ /**
235
+ * Reflects the result, announces it, and schedules the return to idle.
236
+ *
237
+ * @stimeoRenderRoot
238
+ */
209
239
  #reportResult(success) {
210
240
  this.element.setAttribute("data-state", success ? "copied" : "error");
211
241
  if (this.hasFeedbackTarget) {
212
242
  this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
213
243
  }
244
+ this.#announceResult(success);
245
+ this.#scheduleReset();
246
+ }
247
+ /**
248
+ * Reads the result out through the shared announcer.
249
+ *
250
+ * @stimeoRuntimeOnly `announceCopiedText` / `announceErrorText` word the one announcement a copy
251
+ * makes.
252
+ */
253
+ #announceResult(success) {
214
254
  announce(success ? this.announceCopiedTextValue : this.announceErrorTextValue);
255
+ }
256
+ /**
257
+ * Arms the return to idle for the result just shown.
258
+ *
259
+ * @stimeoRuntimeOnly `feedbackDuration` is the delay of the one timer this call arms, and the
260
+ * reset that timer runs reads no Value.
261
+ */
262
+ #scheduleReset() {
215
263
  this.#timers.clearAll();
216
264
  if (this.feedbackDurationValue > 0) {
217
265
  this.#timers.set(() => this.#reset(), this.feedbackDurationValue);
218
266
  }
219
267
  }
268
+ /**
269
+ * Rewrites the slot to `label` while `state` is shown and the slot still reads
270
+ * `previous`, the label this controller wrote for that result.
271
+ */
272
+ #followLabel(state, label, previous) {
273
+ if (!this.#connected) return;
274
+ if (this.element.getAttribute("data-state") !== state || !this.hasFeedbackTarget) return;
275
+ if (this.feedbackTarget.textContent !== previous) return;
276
+ this.feedbackTarget.textContent = label;
277
+ }
220
278
  /** Returns to the idle state and empties the completion slot. */
221
279
  #reset() {
222
280
  this.element.setAttribute("data-state", "idle");
@@ -2,6 +2,57 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/collapsible_controller.ts
4
4
 
5
+ // src/utils/state_reason.ts
6
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
7
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
8
+ function stateReasonFor(event) {
9
+ if (!event) return "api";
10
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
11
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
12
+ return "user";
13
+ }
14
+
15
+ // src/utils/state_regions.ts
16
+ var StateRegions = class {
17
+ #whenTrue;
18
+ #whenFalse;
19
+ #taken = /* @__PURE__ */ new WeakSet();
20
+ constructor(options) {
21
+ this.#whenTrue = options.whenTrue;
22
+ this.#whenFalse = options.whenFalse ?? null;
23
+ }
24
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
25
+ reflect(host, isTrue) {
26
+ const shown = this.#inside(host, this.#whenTrue());
27
+ if (!this.#whenFalse) {
28
+ for (const region of shown) this.#write(region, !isTrue);
29
+ return;
30
+ }
31
+ const hiddenSide = this.#inside(host, this.#whenFalse());
32
+ if (shown.length === 0 || hiddenSide.length === 0) {
33
+ for (const region of shown) this.#give(region);
34
+ for (const region of hiddenSide) this.#give(region);
35
+ return;
36
+ }
37
+ for (const region of shown) this.#write(region, !isTrue);
38
+ for (const region of hiddenSide) this.#write(region, isTrue);
39
+ }
40
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
41
+ #write(region, hidden) {
42
+ if (hidden) this.#taken.add(region);
43
+ else this.#taken.delete(region);
44
+ if (region.hidden !== hidden) region.hidden = hidden;
45
+ }
46
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
47
+ #give(region) {
48
+ if (this.#taken.delete(region)) region.hidden = false;
49
+ }
50
+ /** The declared regions that sit within `host`, which may be the host itself. */
51
+ #inside(host, regions) {
52
+ return regions.filter((region) => host.contains(region));
53
+ }
54
+ };
55
+
5
56
  // src/utils/safe_timeout.ts
6
57
  var TimerRegistry = class {
7
58
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -198,15 +249,23 @@ var TransitionCompletion = class {
198
249
 
199
250
  // src/controllers/collapsible_controller.ts
200
251
  var CollapsibleController = class extends Controller {
201
- static targets = ["trigger", "content"];
252
+ static targets = ["trigger", "content", "expandedLabel", "collapsedLabel"];
202
253
  static values = {
203
254
  open: { type: Boolean, default: false }
204
255
  };
205
256
  static actions = ["toggle"];
257
+ static events = ["close", "open"];
206
258
  /** Owns the cancellable close-transition wait and its bounded fallback. */
207
259
  #transition = new TransitionCompletion();
260
+ /** Owns `hidden` on the trigger's label pair, which follows `aria-expanded`. */
261
+ #labels = new StateRegions({
262
+ whenTrue: () => this.expandedLabelTargets,
263
+ whenFalse: () => this.collapsedLabelTargets
264
+ });
208
265
  /** Distinguishes dynamic target churn from the callbacks that precede `connect()`. */
209
266
  #connected = false;
267
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
268
+ #reporting = false;
210
269
  /**
211
270
  * Establishes the initial open/closed state without waiting for a close transition.
212
271
  *
@@ -221,7 +280,8 @@ var CollapsibleController = class extends Controller {
221
280
  */
222
281
  connect() {
223
282
  this.#connected = true;
224
- this.#apply(this.#initialOpen(), false);
283
+ this.#apply(this.#initialOpen(), false, "api");
284
+ this.#reporting = true;
225
285
  }
226
286
  /** Resolves the connect-time state: explicit DOM state wins, else the `open` Value. */
227
287
  #initialOpen() {
@@ -238,15 +298,33 @@ var CollapsibleController = class extends Controller {
238
298
  }
239
299
  disconnect() {
240
300
  this.#connected = false;
301
+ this.#reporting = false;
241
302
  this.#transition.cancel();
242
303
  }
243
304
  /** Reconciles a replacement trigger target with the content's live state. */
244
305
  triggerTargetConnected(trigger) {
245
- if (!this.#connected || !this.hasContentTarget) return;
246
- trigger.setAttribute(
247
- "aria-expanded",
248
- this.contentTarget.getAttribute("data-state") === "open" ? "true" : "false"
249
- );
306
+ if (!this.#connected) return;
307
+ if (this.hasContentTarget) {
308
+ trigger.setAttribute(
309
+ "aria-expanded",
310
+ this.contentTarget.getAttribute("data-state") === "open" ? "true" : "false"
311
+ );
312
+ }
313
+ this.#labels.reflect(trigger, trigger.getAttribute("aria-expanded") === "true");
314
+ }
315
+ /** Settles a label that arrived after the state it belongs to was written. */
316
+ expandedLabelTargetConnected() {
317
+ this.#reflectLabels();
318
+ }
319
+ /** Settles a label that arrived after the state it belongs to was written. */
320
+ collapsedLabelTargetConnected() {
321
+ this.#reflectLabels();
322
+ }
323
+ /** Shows the label pair's side that belongs to the trigger's expanded state. */
324
+ #reflectLabels() {
325
+ if (this.#connected && this.hasTriggerTarget) {
326
+ this.#labels.reflect(this.triggerTarget, this.#isOpen);
327
+ }
250
328
  }
251
329
  /** Reconciles a replacement content target with the disclosure's live state. */
252
330
  contentTargetConnected(content) {
@@ -260,8 +338,8 @@ var CollapsibleController = class extends Controller {
260
338
  this.#transition.cancel();
261
339
  }
262
340
  /** Toggles the region open/closed. Bound via `data-action` (click). */
263
- toggle() {
264
- this.#apply(!this.#isOpen, true);
341
+ toggle(event) {
342
+ this.#apply(!this.#isOpen, true, stateReasonFor(event));
265
343
  }
266
344
  /**
267
345
  * Whether the region is logically open. Read from `aria-expanded` (not the
@@ -283,15 +361,22 @@ var CollapsibleController = class extends Controller {
283
361
  * @param waitForCloseTransition - When `false` (initial `connect`) the close
284
362
  * path applies `hidden` immediately. This flag does not suppress consumer CSS
285
363
  * on the open path.
364
+ * @param reason - What drove the move, reported when the state actually moved.
286
365
  */
287
- #apply(open, waitForCloseTransition) {
366
+ #apply(open, waitForCloseTransition, reason) {
367
+ const was = this.#isOpen;
288
368
  if (this.hasTriggerTarget) {
289
369
  this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false");
370
+ this.#labels.reflect(this.triggerTarget, open);
290
371
  }
291
- if (!this.hasContentTarget) return;
292
- const content = this.contentTarget;
293
- this.#transition.cancel();
294
- this.#applyContent(content, open, waitForCloseTransition);
372
+ if (this.hasContentTarget) {
373
+ const content = this.contentTarget;
374
+ this.#transition.cancel();
375
+ this.#applyContent(content, open, waitForCloseTransition);
376
+ }
377
+ if (was === open || !this.#reporting) return;
378
+ if (open) this.dispatch("open", { detail: { reason }, cancelable: false });
379
+ else this.dispatch("close", { detail: { reason }, cancelable: false });
295
380
  }
296
381
  /** Reflects one content target without relying on a later target lookup. */
297
382
  #applyContent(content, open, waitForCloseTransition) {
@@ -25,6 +25,16 @@ function toFiniteNumber(raw) {
25
25
  return Number.isFinite(value) ? value : null;
26
26
  }
27
27
 
28
+ // src/utils/field_mirror.ts
29
+ function writeField(field, value) {
30
+ if (field.value === value) return false;
31
+ field.value = value;
32
+ return true;
33
+ }
34
+ function commitField(target) {
35
+ target.dispatchEvent(new Event("change", { bubbles: true }));
36
+ }
37
+
28
38
  // src/utils/microtask_coalescer.ts
29
39
  var MicrotaskCoalescer = class {
30
40
  #run;
@@ -60,54 +70,46 @@ var MicrotaskCoalescer = class {
60
70
 
61
71
  // src/utils/owned_pointer_session.ts
62
72
  var OwnedPointerSession = class {
63
- pointerId;
73
+ #pointerId;
64
74
  #owner;
65
75
  #handlers;
66
76
  #abort = new AbortController();
67
77
  #active = true;
68
78
  constructor(start, owner, handlers) {
69
- this.pointerId = start.pointerId;
79
+ this.#pointerId = start.pointerId;
70
80
  this.#owner = owner;
71
81
  this.#handlers = handlers;
72
82
  const { signal } = this.#abort;
73
83
  owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
74
84
  owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
75
85
  owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
76
- owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
77
86
  try {
78
- owner.setPointerCapture?.(this.pointerId);
87
+ owner.setPointerCapture?.(this.#pointerId);
79
88
  } catch {
80
89
  }
81
90
  }
82
- /** Whether this session still owns its pointer and listeners. */
83
- get active() {
84
- return this.#active;
85
- }
86
- /** Whether `event` belongs to the initiating pointer of the live session. */
87
- owns(event) {
88
- return this.#active && event.pointerId === this.pointerId;
89
- }
90
- /** Releases capture/listeners and invokes the end callback exactly once. */
91
+ /** Releases capture and listeners, then reports the end exactly once. */
91
92
  end() {
93
+ this.#finish("teardown");
94
+ }
95
+ #finish(kind) {
92
96
  if (!this.#active) return;
93
97
  this.#active = false;
94
98
  this.#abort.abort();
95
99
  try {
96
- this.#owner.releasePointerCapture?.(this.pointerId);
100
+ this.#owner.releasePointerCapture?.(this.#pointerId);
97
101
  } catch {
98
102
  }
99
- this.#handlers.end?.();
103
+ this.#handlers.end?.(kind);
104
+ }
105
+ #owns(event) {
106
+ return this.#active && event.pointerId === this.#pointerId;
100
107
  }
101
108
  #onMove = (event) => {
102
- if (this.owns(event)) this.#handlers.move(event);
109
+ if (this.#owns(event)) this.#handlers.move(event);
103
110
  };
104
111
  #onEndEvent = (event) => {
105
- if (this.owns(event)) this.end();
106
- };
107
- #onLostCapture = (event) => {
108
- const pointerId = event.pointerId;
109
- if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
110
- this.end();
112
+ if (this.#owns(event)) this.#finish(event.type === "pointerup" ? "up" : "cancel");
111
113
  };
112
114
  };
113
115
 
@@ -139,6 +141,19 @@ var ColorPickerController = class extends Controller {
139
141
  #drag = null;
140
142
  /** Color the last repaint settled on, so a configuration-driven move is reported once. */
141
143
  #committedHex = null;
144
+ /**
145
+ * Whether the paint about to run was asked for by this picker's own controls.
146
+ * The `value` Value is shared with the page — application code and a Turbo
147
+ * morph write it too — so the form fields and the hex input take their "did
148
+ * the user commit this" answer from the route, not from the Value.
149
+ */
150
+ #movedByUser = false;
151
+ /**
152
+ * The hex this picker last wrote into its hex input, or `null` before it wrote
153
+ * one. A repaint the page drives compares with it rather than with the input's
154
+ * text, so a color that did not move leaves what the reader is typing alone.
155
+ */
156
+ #writtenHex = null;
142
157
  /**
143
158
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
144
159
  * pass Stimulus delivers before `connect()`.
@@ -164,9 +179,14 @@ var ColorPickerController = class extends Controller {
164
179
  if (this.valueValue === this.#committedHex) return;
165
180
  this.#repaint.schedule();
166
181
  }
167
- /** Hydrates a channel slider inserted or replaced at runtime. */
168
- sliderTargetConnected(slider) {
169
- this.#renderSlider(slider);
182
+ /**
183
+ * Hydrates a channel slider inserted or replaced at runtime through the repaint
184
+ * pass, which renders every slider after the batch and reports a committed
185
+ * color that moved. A slider arriving moves no color, so that pass reports
186
+ * nothing.
187
+ */
188
+ sliderTargetConnected() {
189
+ this.#repaint.schedule();
170
190
  }
171
191
  /** Ends a gesture whose geometry target disappeared or ceased being a target. */
172
192
  sliderTargetDisconnected(slider) {
@@ -174,7 +194,7 @@ var ColorPickerController = class extends Controller {
174
194
  }
175
195
  /** Fills a hex input inserted or replaced at runtime with the current color. */
176
196
  hexTargetConnected(hex) {
177
- this.#mirrorColor(hex, this.#hexString());
197
+ this.#writeHex(hex, this.#hexString());
178
198
  }
179
199
  /** Fills a form field inserted or replaced at runtime with the current color. */
180
200
  fieldTargetConnected(field) {
@@ -256,7 +276,7 @@ var ColorPickerController = class extends Controller {
256
276
  if (!this.hasHexTarget) return;
257
277
  const parsed = hexToHsla(this.hexTarget.value);
258
278
  if (!parsed) {
259
- this.hexTarget.value = this.#hexString();
279
+ this.#writeHex(this.hexTarget, this.#hexString());
260
280
  return;
261
281
  }
262
282
  this.#color = this.#opaqueUnlessEnabled(parsed);
@@ -284,27 +304,44 @@ var ColorPickerController = class extends Controller {
284
304
  * Renders the model and reports a color the user actually moved. A key pressed
285
305
  * at a bound, a pointer that lands on the step already showing, and a re-confirmed
286
306
  * hex all leave the committed color where it was, so no `change` describes them.
307
+ *
308
+ * The color is written into `value` first — the one path that writes it — so a
309
+ * Turbo snapshot and a morph read the color the user picked, and a listener of
310
+ * the field's native `change` already finds it there.
287
311
  */
288
312
  #commitColor() {
289
313
  const previous = this.#committedHex;
314
+ const hex = this.#hexString();
315
+ if (this.valueValue !== hex) this.valueValue = hex;
316
+ this.#movedByUser = true;
290
317
  this.#render();
291
318
  if (this.#committedHex !== previous) {
292
319
  this.dispatch("change", { detail: this.#settledDetail() });
293
320
  }
294
321
  }
295
322
  /**
296
- * Reflects the model onto sliders, the hex input, preview, form field, and the
297
- * `value` Value it serializes into.
323
+ * Reflects the model onto sliders, the hex input, preview, and form field. The
324
+ * `value` Value is left as it is.
325
+ *
326
+ * The hex input is written for the reader's own commit, which shows the color
327
+ * in its canonical form, and otherwise only when the hex it shows moved: a
328
+ * repaint the page drives that leaves the color where it is keeps text the
329
+ * reader has typed there and not committed.
298
330
  *
299
331
  * @stimeoRenderRoot
300
332
  */
301
333
  #render() {
334
+ const byUser = this.#movedByUser;
335
+ this.#movedByUser = false;
302
336
  for (const slider of this.sliderTargets) this.#renderSlider(slider);
303
337
  const hex = this.#hexString();
304
338
  this.#committedHex = hex;
305
- if (this.valueValue !== hex) this.valueValue = hex;
306
- if (this.hasHexTarget) this.#mirrorColor(this.hexTarget, hex);
307
- for (const field of this.fieldTargets) this.#mirrorColor(field, hex);
339
+ if (this.hasHexTarget && (byUser || hex !== this.#writtenHex)) {
340
+ this.#writeHex(this.hexTarget, hex);
341
+ }
342
+ for (const field of this.fieldTargets) {
343
+ if (this.#mirrorColor(field, hex) && byUser) commitField(field);
344
+ }
308
345
  for (const preview of this.previewTargets) this.#publishColor(preview, hex);
309
346
  this.#publishColor(this.element, hex);
310
347
  }
@@ -324,9 +361,18 @@ var ColorPickerController = class extends Controller {
324
361
  if (slider.getAttribute(name) !== next) slider.setAttribute(name, next);
325
362
  }
326
363
  }
327
- /** Mirrors the color into an input, leaving an already-equal value untouched. */
364
+ /** Writes `hex` into a hex input and keeps it as the hex last written there. */
365
+ #writeHex(input, hex) {
366
+ this.#writtenHex = hex;
367
+ this.#mirrorColor(input, hex);
368
+ }
369
+ /**
370
+ * Mirrors the color into an input, leaving an already-equal value untouched.
371
+ *
372
+ * @returns Whether the input's value moved.
373
+ */
328
374
  #mirrorColor(input, hex) {
329
- if (input.value !== hex) input.value = hex;
375
+ return writeField(input, hex);
330
376
  }
331
377
  /** Publishes the color as the consumer's CSS hook, skipping an equal value. */
332
378
  #publishColor(element, hex) {