stimeo-ui 0.15.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -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 +54 -10
  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 +56 -11
  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 +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  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 +14 -1
  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 +305 -46
  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 +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  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/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -99,7 +99,12 @@ var SafeInterval = class extends TimerRegistry {
99
99
 
100
100
  // src/controllers/countdown_controller.ts
101
101
  var SECOND_MS = 1e3;
102
+ var OWNED_STATUS = "owns-status";
102
103
  var CountdownController = class extends Controller {
104
+ /** The status marker, in the namespace this controller is registered under. */
105
+ get #ownedStatus() {
106
+ return `data-${this.identifier}-${OWNED_STATUS}`;
107
+ }
103
108
  static targets = ["days", "hours", "minutes", "seconds", "status"];
104
109
  static values = {
105
110
  deadline: { type: String, default: "" },
@@ -115,6 +120,12 @@ var CountdownController = class extends Controller {
115
120
  #intervalId = null;
116
121
  /** Collapses a morph that swaps several render inputs at once into one re-derive. */
117
122
  #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
123
+ /**
124
+ * Whether `connect()` has run for this connection. Stimulus delivers Value
125
+ * callbacks ahead of it, while the run state is still the markup's own and not
126
+ * yet settled; `connect()` brings the status up to date once it is.
127
+ */
128
+ #connected = false;
118
129
  /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
119
130
  #reference = 0;
120
131
  /** Amount (ms) captured at pause, so resume can restore the same display. */
@@ -127,28 +138,25 @@ var CountdownController = class extends Controller {
127
138
  */
128
139
  #renderedAmount = 0;
129
140
  connect() {
141
+ this.#connected = true;
130
142
  this.#resync.activate();
131
143
  this.#initReference();
132
144
  const amount = this.#currentAmount();
133
145
  this.#render(amount);
134
146
  this.#pausedAmount = this.#renderedAmount;
135
147
  const authored = this.element.getAttribute("data-state");
148
+ const settled = authored === "complete" && this.#isSettled(amount);
136
149
  if (authored === null) {
137
- if (this.autostartValue && this.#isValidDeadline) {
138
- this.start();
139
- return;
140
- }
150
+ if (this.autostartValue && this.#isValidDeadline) this.start();
151
+ else this.element.setAttribute("data-state", "paused");
152
+ } else if (!settled) {
141
153
  this.element.setAttribute("data-state", "paused");
142
- return;
154
+ if (authored === "running") this.start();
143
155
  }
144
- if (authored === "complete" && this.#isDown && amount <= 0) {
145
- return;
146
- }
147
- const wasRunning = authored === "running";
148
- this.element.setAttribute("data-state", "paused");
149
- if (wasRunning) this.start();
156
+ this.#syncStatus();
150
157
  }
151
158
  disconnect() {
159
+ this.#connected = false;
152
160
  this.#resync.cancel();
153
161
  this.#intervals.clearAll();
154
162
  this.#intervalId = null;
@@ -161,6 +169,31 @@ var CountdownController = class extends Controller {
161
169
  directionValueChanged() {
162
170
  this.#resync.schedule();
163
171
  }
172
+ /**
173
+ * Follows a completion label swapped in place by a morph.
174
+ *
175
+ * Render only: `complete` is neither dispatched nor announced again. The status
176
+ * changes only after the countdown has completed, and only while it still shows
177
+ * the text this controller wrote there and nothing else, so text or an element
178
+ * the consumer put in the slot stays. A change delivered ahead of `connect()` is
179
+ * left to `connect()`.
180
+ */
181
+ completeLabelValueChanged() {
182
+ if (this.#connected) this.#syncStatus();
183
+ }
184
+ /**
185
+ * Keeps a status that still shows this controller's text, and nothing else, on
186
+ * what the run state calls for: the label in force while complete; otherwise the
187
+ * text is taken back, marker and all. A status holding anything else is the
188
+ * consumer's and is left alone, marker included.
189
+ *
190
+ * @stimeoRenderRoot
191
+ */
192
+ #syncStatus() {
193
+ if (!this.hasStatusTarget || !this.#ownsStatus(this.statusTarget)) return;
194
+ if (this.#state === "complete") this.#writeStatus(this.statusTarget, this.completeLabelValue);
195
+ else this.#releaseStatus(this.statusTarget);
196
+ }
164
197
  /**
165
198
  * Points the anchor at the current `deadline` / `direction` and repaints.
166
199
  *
@@ -168,13 +201,27 @@ var CountdownController = class extends Controller {
168
201
  * paused timer run or replay a milestone. A running one needs no restart either —
169
202
  * every tick reads the anchor, so moving it is enough. While paused the stored
170
203
  * amount follows the new reading, or resume would continue from the old deadline.
204
+ * A completion the new reading no longer settles (a deadline moved forward, or a
205
+ * count turned `up`) is handed back paused, as `connect()` hands back a restored
206
+ * one, so `resume()` counts again; the completion text this controller wrote goes
207
+ * with it, from a status slot that still shows it and nothing else.
208
+ *
209
+ * @stimeoRenderRoot
171
210
  */
172
211
  #resyncToValues() {
173
212
  this.#initReference();
174
- this.#render(this.#currentAmount());
213
+ const amount = this.#currentAmount();
214
+ this.#render(amount);
215
+ if (this.#state === "complete" && !this.#isSettled(amount)) {
216
+ this.element.setAttribute("data-state", "paused");
217
+ this.#syncStatus();
218
+ }
175
219
  if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
176
220
  }
177
- /** Starts (or restarts after pause) ticking toward the deadline. */
221
+ /**
222
+ * Starts (or restarts after pause) ticking toward the deadline. Counting on from a
223
+ * completion takes this controller's completion text back out of the status.
224
+ */
178
225
  start() {
179
226
  if (this.#state === "running" || !this.#isValidDeadline) return;
180
227
  if (this.#isDown && this.#currentAmount() <= 0) {
@@ -212,10 +259,8 @@ var CountdownController = class extends Controller {
212
259
  this.#initReference();
213
260
  const amount = this.#currentAmount();
214
261
  this.#render(amount);
215
- if (this.hasStatusTarget && this.statusTarget.textContent === this.completeLabelValue) {
216
- this.statusTarget.textContent = "";
217
- }
218
262
  this.element.setAttribute("data-state", "paused");
263
+ this.#syncStatus();
219
264
  if (wasRunning && this.#isValidDeadline) {
220
265
  this.#pausedAmount = 0;
221
266
  this.start();
@@ -223,9 +268,19 @@ var CountdownController = class extends Controller {
223
268
  this.#pausedAmount = this.#renderedAmount;
224
269
  }
225
270
  }
226
- /** Schedules the repeating tick and marks the timer running. */
271
+ /**
272
+ * Schedules the repeating tick and marks the timer running. This is the one way
273
+ * into `running`, and a running timer keeps none of this controller's completion
274
+ * text in the status, so the status is settled here as on every other way out of
275
+ * `complete`: `start()` leaves a completion directly when `direction` flips to
276
+ * `up` and `start()` runs in the same task, ahead of the direction callback.
277
+ *
278
+ * @stimeoRuntimeOnly `interval` is the period of the one timer this call arms; the running state
279
+ * it writes does not depend on a Value.
280
+ */
227
281
  #runInterval() {
228
282
  this.element.setAttribute("data-state", "running");
283
+ this.#syncStatus();
229
284
  this.#intervalId = this.#intervals.set(() => this.#tick(), this.intervalValue);
230
285
  }
231
286
  /** Cancels the repeating tick, if any. */
@@ -246,21 +301,73 @@ var CountdownController = class extends Controller {
246
301
  this.#complete();
247
302
  }
248
303
  }
249
- /** Stops at zero, marks completion, announces it, and emits `complete`. */
304
+ /**
305
+ * Stops at zero, marks completion, writes the completion label, emits `complete`,
306
+ * and announces it.
307
+ *
308
+ * @stimeoRenderRoot
309
+ */
250
310
  #complete() {
251
311
  this.#teardownInterval();
252
312
  this.#render(0);
253
313
  this.element.setAttribute("data-state", "complete");
254
- if (this.hasStatusTarget && this.completeLabelValue.length > 0) {
255
- this.statusTarget.textContent = this.completeLabelValue;
256
- }
314
+ this.#claimStatus();
257
315
  this.dispatch("complete", { detail: {} });
316
+ this.#announceCompletion();
317
+ }
318
+ /**
319
+ * Writes the label in force into the status slot as this controller's text. An
320
+ * empty label puts no text there: it claims a slot that is already empty — no
321
+ * text and no element — empties one that is still this controller's, and leaves
322
+ * any other slot, the consumer's, alone.
323
+ */
324
+ #claimStatus() {
325
+ if (!this.hasStatusTarget) return;
326
+ const status = this.statusTarget;
327
+ const label = this.completeLabelValue;
328
+ if (label === "" && !this.#isEmpty(status) && !this.#ownsStatus(status)) return;
329
+ this.#writeStatus(status, label);
330
+ }
331
+ /**
332
+ * Whether the status slot shows the text this controller wrote there and nothing
333
+ * else: the text matches the marker, and the slot holds no element. Writing the
334
+ * slot's text replaces every child node, so an element the consumer puts in the
335
+ * slot makes it theirs even while the text still matches. An empty marker
336
+ * matches the slot whenever it is empty.
337
+ */
338
+ #ownsStatus(status) {
339
+ return status.getAttribute(this.#ownedStatus) === status.textContent && status.firstElementChild === null;
340
+ }
341
+ /** Whether the status slot holds nothing at all: no text and no element. */
342
+ #isEmpty(status) {
343
+ return status.textContent === "" && status.firstElementChild === null;
344
+ }
345
+ /** Puts `text` into the status slot and records it as this controller's. */
346
+ #writeStatus(status, text) {
347
+ if (status.textContent !== text) status.textContent = text;
348
+ status.setAttribute(this.#ownedStatus, text);
349
+ }
350
+ /** Takes this controller's text out of the status slot, marker and all. */
351
+ #releaseStatus(status) {
352
+ status.textContent = "";
353
+ status.removeAttribute(this.#ownedStatus);
354
+ }
355
+ /**
356
+ * Reads the completion out through the shared announcer.
357
+ *
358
+ * @stimeoRuntimeOnly `announceText` words the one announcement a completion makes.
359
+ */
360
+ #announceCompletion() {
258
361
  announce(fillTemplate(this.announceTextValue, {}));
259
362
  }
260
363
  /** Sets the time anchor from the `deadline` value. */
261
364
  #initReference() {
262
365
  this.#reference = Date.parse(this.deadlineValue);
263
366
  }
367
+ /** Whether a reading of `amount` leaves nothing to count: only a countdown at zero. */
368
+ #isSettled(amount) {
369
+ return this.#isDown && amount <= 0;
370
+ }
264
371
  /** Remaining (down) or elapsed (up) ms, never negative. */
265
372
  #currentAmount() {
266
373
  if (!this.#isValidDeadline) return 0;
@@ -268,11 +375,7 @@ var CountdownController = class extends Controller {
268
375
  const raw = this.#isDown ? this.#reference - now : now - this.#reference;
269
376
  return Math.max(0, raw);
270
377
  }
271
- /**
272
- * Writes the amount into the day/hour/minute/second slots.
273
- *
274
- * @stimeoRenderRoot
275
- */
378
+ /** Writes the amount into the day/hour/minute/second slots. */
276
379
  #render(amount) {
277
380
  const totalSeconds = Math.floor(amount / SECOND_MS);
278
381
  this.#renderedAmount = totalSeconds * SECOND_MS;
@@ -3,11 +3,19 @@ import { Controller } from '@hotwired/stimulus';
3
3
  // src/controllers/currency_input_controller.ts
4
4
 
5
5
  // src/utils/composition_tracker.ts
6
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
7
+ "insertCompositionText",
8
+ "insertFromComposition",
9
+ "deleteCompositionText",
10
+ "deleteByComposition"
11
+ ]);
6
12
  var CompositionTracker = class {
7
13
  #observedTargets = /* @__PURE__ */ new Set();
8
14
  #activeTargets = /* @__PURE__ */ new Set();
9
15
  #onStart;
10
16
  #onEnd;
17
+ /** The field whose confirming `input` is still owed, while the window is open. */
18
+ #confirmedTarget = null;
11
19
  constructor(options = {}) {
12
20
  this.#onStart = options.onStart;
13
21
  this.#onEnd = options.onEnd;
@@ -17,6 +25,7 @@ var CompositionTracker = class {
17
25
  if (this.#observedTargets.has(target)) return;
18
26
  target.addEventListener("compositionstart", this.#handleStart);
19
27
  target.addEventListener("compositionend", this.#handleEnd);
28
+ target.addEventListener("keydown", this.#handleKeydown);
20
29
  this.#observedTargets.add(target);
21
30
  }
22
31
  /** Stops tracking one target and clears any active composition it owned. */
@@ -24,29 +33,52 @@ var CompositionTracker = class {
24
33
  if (!this.#observedTargets.delete(target)) return;
25
34
  target.removeEventListener("compositionstart", this.#handleStart);
26
35
  target.removeEventListener("compositionend", this.#handleEnd);
36
+ target.removeEventListener("keydown", this.#handleKeydown);
27
37
  this.#activeTargets.delete(target);
38
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
28
39
  }
29
40
  /** Releases every listener and clears state so reconnect starts cleanly. */
30
41
  disconnect() {
31
42
  for (const target of this.#observedTargets) {
32
43
  target.removeEventListener("compositionstart", this.#handleStart);
33
44
  target.removeEventListener("compositionend", this.#handleEnd);
45
+ target.removeEventListener("keydown", this.#handleKeydown);
34
46
  }
35
47
  this.#observedTargets.clear();
36
48
  this.#activeTargets.clear();
49
+ this.#confirmedTarget = null;
37
50
  }
38
51
  /** True when lifecycle tracking or the current event reports composition. */
39
52
  isComposing(event) {
40
53
  return this.#activeTargets.size > 0 || event?.isComposing === true;
41
54
  }
55
+ /**
56
+ * Whether `event` is the `input` echoing the composition just confirmed.
57
+ *
58
+ * Asking closes the window either way, so one confirmation is folded at most
59
+ * once and a consumer asks once per `input`.
60
+ */
61
+ consumesConfirmedInput(event) {
62
+ const confirmed = this.#confirmedTarget;
63
+ this.#confirmedTarget = null;
64
+ if (confirmed === null || confirmed !== event.target) return false;
65
+ const inputType = event.inputType;
66
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
67
+ }
42
68
  #handleStart = (event) => {
69
+ this.#confirmedTarget = null;
43
70
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
71
  this.#onStart?.(event);
45
72
  };
46
73
  #handleEnd = (event) => {
47
74
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
75
+ this.#confirmedTarget = event.target;
48
76
  this.#onEnd?.(event);
49
77
  };
78
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
79
+ #handleKeydown = () => {
80
+ this.#confirmedTarget = null;
81
+ };
50
82
  };
51
83
 
52
84
  // src/utils/half_width.ts
@@ -103,15 +135,24 @@ var CurrencyInputController = class extends Controller {
103
135
  precision: { type: Number, default: 2 }
104
136
  };
105
137
  static actions = ["format", "onInput"];
106
- static events = ["change"];
107
- /** Last committed numeric value, to suppress duplicate `change` dispatches. */
138
+ static events = ["change", "reconcile"];
139
+ /** The numeric value last committed; both events report only a move away from it. */
108
140
  #lastValue = null;
109
141
  #started = false;
142
+ /** A declaration change that arrived mid-composition, applied once nothing composes. */
143
+ #changeHeld = false;
144
+ /** The display a composition is running on, until that composition ends. */
145
+ #composing = null;
146
+ /**
147
+ * The display text this controller last rendered. It tells this controller's own
148
+ * text, written with the separators in force, from text the page wrote.
149
+ */
150
+ #rendered = null;
110
151
  /** Validated mirrors of the Values; the hot path never reads a raw Value. */
111
152
  #locale;
112
153
  #currency = "";
113
154
  #precision = 2;
114
- /** Formatters rebuilt only when a Value changes, never per keystroke. */
155
+ /** Formatters rebuilt when the declarations are taken in, never per keystroke. */
115
156
  #grouping;
116
157
  #fixed;
117
158
  #accessible;
@@ -119,11 +160,25 @@ var CurrencyInputController = class extends Controller {
119
160
  #decimal = ".";
120
161
  /** The locale's non-Latin digits mapped back to ASCII (empty for Latin locales). */
121
162
  #digits = /* @__PURE__ */ new Map();
122
- /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */
163
+ /**
164
+ * Holds mid-composition input so the IME's uncommitted text is never rewritten.
165
+ * The confirmed text is formatted once, and a declaration change the composition
166
+ * held back is applied after it.
167
+ */
123
168
  #composition = new CompositionTracker({
124
- onEnd: () => this.#reformat(false)
169
+ onStart: (event) => {
170
+ this.#composing = event.currentTarget;
171
+ },
172
+ onEnd: () => {
173
+ this.#composing = null;
174
+ this.#reformat(false, "edit");
175
+ this.#applyHeldChange();
176
+ }
125
177
  });
126
- /** Re-validates on declaration changes and re-renders the committed display. */
178
+ /**
179
+ * Re-validates on declaration changes and re-renders the committed display,
180
+ * reporting a value the re-render moved as `reconcile`.
181
+ */
127
182
  localeValueChanged() {
128
183
  this.#applyValueChange();
129
184
  }
@@ -134,52 +189,123 @@ var CurrencyInputController = class extends Controller {
134
189
  this.#applyValueChange();
135
190
  }
136
191
  /**
137
- * Scans the display under the *outgoing* configuration (its separators wrote
138
- * that text), then revalidates and re-renders under the new one — so a locale
139
- * switch re-interprets the value, never the old text with new separators.
192
+ * Reads the display with the declarations its text was written for, then
193
+ * re-renders it under the new ones — so a locale switch re-interprets the
194
+ * value, never the old text with new separators, and text the page wrote for
195
+ * the declarations it changes is never read with the old ones.
196
+ *
197
+ * While the display is composing, its text is the IME's: the change is held,
198
+ * configuration and all, until the composition ends, so the confirmed text is
199
+ * read with the separators it was typed against before the change applies.
200
+ *
201
+ * @stimeoRenderRoot
140
202
  */
141
203
  #applyValueChange() {
142
- const scan = this.#started && this.hasDisplayTarget ? this.#scan(this.displayTarget.value) : null;
143
- this.#revalidate();
144
- if (!scan || !this.hasDisplayTarget) return;
204
+ if (this.#composition.isComposing()) {
205
+ this.#changeHeld = true;
206
+ return;
207
+ }
208
+ if (!this.#started || !this.hasDisplayTarget) return;
209
+ const scan = this.#readDisplay();
145
210
  if (document.activeElement === this.displayTarget) {
146
211
  const formatted = this.#render(scan.parts);
147
- this.displayTarget.value = formatted;
148
- this.#reflect(scan.value, formatted);
149
- } else if (scan.value === null) {
150
- this.displayTarget.value = "";
151
- this.#reflect(null, "");
212
+ this.#show(formatted);
213
+ this.#reflect(scan.value, formatted, "reconcile");
152
214
  } else {
153
- const rounded = round(scan.value, this.#precision);
154
- const formatted = this.#fixed.format(rounded);
155
- this.displayTarget.value = formatted;
156
- this.#reflect(rounded, formatted);
215
+ this.#renderFixed(scan.value, "reconcile");
157
216
  }
158
217
  }
159
- /** Normalizes any pre-filled display value to its fixed-precision form. */
218
+ /** Applies a declaration change a composition held back. */
219
+ #applyHeldChange() {
220
+ if (!this.#changeHeld) return;
221
+ this.#changeHeld = false;
222
+ this.#applyValueChange();
223
+ }
224
+ /**
225
+ * Reads the display and takes in the declarations the page carries now. This
226
+ * controller's own rendering was written with the separators in force, so it
227
+ * is read before they change; any other text is the page's, written for the
228
+ * declarations it carries, so it is read after.
229
+ */
230
+ #readDisplay() {
231
+ const text = this.displayTarget.value;
232
+ const own = text === this.#rendered ? this.#scan(text) : null;
233
+ this.#takeInDeclarations();
234
+ return own ?? this.#scan(text);
235
+ }
236
+ /** Takes in the declarations the page carries now, a change a composition held among them. */
237
+ #takeInDeclarations() {
238
+ this.#changeHeld = false;
239
+ this.#revalidate();
240
+ }
241
+ /**
242
+ * Takes in the declarations the page carries now and normalizes the display
243
+ * to its fixed-precision form.
244
+ *
245
+ * A display still showing this controller's own last rendering is not read
246
+ * back: its separators may belong to declarations that changed while the
247
+ * controller was away, and the committed value is known anyway. That value is
248
+ * re-rendered at the current fixed precision, which rounds it whether
249
+ * `precision` changed meanwhile or the entry was left unrounded mid-typing, and
250
+ * a move the rounding causes is reported as `reconcile`. Any other text is the
251
+ * page's, written for the declarations it carries, so it is read with them.
252
+ */
160
253
  connect() {
161
254
  this.#started = true;
255
+ this.#takeInDeclarations();
162
256
  if (!this.hasDisplayTarget) return;
257
+ if (this.displayTarget.value === this.#rendered) {
258
+ this.#renderFixed(this.#lastValue, "reconcile");
259
+ return;
260
+ }
163
261
  const { value } = this.#scan(this.displayTarget.value);
164
262
  this.#lastValue = value === null ? null : round(value, this.#precision);
165
- if (value === null) {
166
- this.displayTarget.value = "";
167
- this.#reflect(null, "");
168
- } else {
169
- this.#reformat(true);
170
- }
263
+ this.#renderFixed(value, "reconcile");
171
264
  }
265
+ /**
266
+ * Releases the composition listeners. A composition still running hears no
267
+ * `compositionend` once they are gone, so it ends here; a change it held is
268
+ * taken in on the next `connect()`.
269
+ */
172
270
  disconnect() {
173
- this.#started = false;
271
+ this.#endUntrackedComposition();
174
272
  this.#composition.disconnect();
273
+ this.#started = false;
175
274
  }
176
- /** Tracks composition on an arriving (or swapped-in) display and normalizes it. */
275
+ /**
276
+ * Tracks composition on an arriving (or swapped-in) display and normalizes it
277
+ * under the declarations the page carries now, reading this controller's own
278
+ * rendering with the separators it was written under; an amount that differs
279
+ * from the committed one is the page's, so it is reported as `reconcile`.
280
+ */
177
281
  displayTargetConnected(target) {
178
282
  this.#composition.observe(target);
179
- if (this.#started) this.#reformat(true);
283
+ if (!this.#started) return;
284
+ this.#renderFixed(this.#readDisplay().value, "reconcile");
180
285
  }
286
+ /**
287
+ * Stops tracking a display that leaves. A composition running on it hears no
288
+ * `compositionend` any more, so it ends here; a change it held waits for the
289
+ * next display to be read.
290
+ */
181
291
  displayTargetDisconnected(target) {
182
292
  this.#composition.unobserve(target);
293
+ this.#endUntrackedComposition();
294
+ }
295
+ /**
296
+ * Ends a composition no `compositionend` will reach. A display still in place —
297
+ * one a move re-inserted, or the display of a controller that moves — holds the
298
+ * text the composition left, typed against the declarations in force, so it is
299
+ * read as `compositionend` would read it and becomes this controller's own
300
+ * rendering. The page's move ended the composition, so a value it moves is
301
+ * reported as `reconcile`. A display that left takes its composition along.
302
+ */
303
+ #endUntrackedComposition() {
304
+ const display = this.#composing;
305
+ this.#composing = null;
306
+ if (this.hasDisplayTarget && this.displayTarget === display) {
307
+ this.#reformat(false, "reconcile");
308
+ }
183
309
  }
184
310
  /** Syncs a late-arriving hidden field without touching the display or events. */
185
311
  fieldTargetConnected() {
@@ -192,33 +318,23 @@ var CurrencyInputController = class extends Controller {
192
318
  /** Re-groups digits as the user types, preserving the caret position. */
193
319
  onInput(event) {
194
320
  if (this.#composition.isComposing(event)) return;
195
- this.#reformat(false);
321
+ this.#reformat(false, "edit");
196
322
  }
197
323
  /** Applies the fixed-precision rounding on blur. */
198
324
  format() {
199
- this.#reformat(true);
325
+ this.#reformat(true, "edit");
200
326
  }
201
327
  /**
202
328
  * Parses the display value, rewrites it grouped (optionally at fixed
203
329
  * precision), keeps the caret stable by significant characters, and syncs the
204
- * field, the screen-reader span, and the `change` event.
205
- *
206
- * @stimeoRenderRoot
330
+ * field, the screen-reader span, and the event `cause` selects.
207
331
  */
208
- #reformat(fixedPrecision) {
332
+ #reformat(fixedPrecision, cause) {
209
333
  if (!this.hasDisplayTarget) return;
210
334
  const raw = this.displayTarget.value;
211
335
  const { parts, value } = this.#scan(raw);
212
336
  if (fixedPrecision) {
213
- if (value === null) {
214
- this.displayTarget.value = "";
215
- this.#reflect(null, "");
216
- return;
217
- }
218
- const rounded = round(value, this.#precision);
219
- const formatted2 = this.#fixed.format(rounded);
220
- this.displayTarget.value = formatted2;
221
- this.#reflect(rounded, formatted2);
337
+ this.#renderFixed(value, cause);
222
338
  return;
223
339
  }
224
340
  const formatted = this.#render(parts);
@@ -228,12 +344,31 @@ var CurrencyInputController = class extends Controller {
228
344
  this.displayTarget.value = formatted;
229
345
  if (anchor !== null) this.#restoreCaret(formatted, anchor);
230
346
  }
231
- this.#reflect(value, value === null ? "" : formatted);
347
+ this.#rendered = formatted;
348
+ this.#reflect(value, formatted, cause);
349
+ }
350
+ /**
351
+ * Shows `value` at the fixed precision — an empty display for no value — and
352
+ * reports a move under the event `cause` selects.
353
+ */
354
+ #renderFixed(value, cause) {
355
+ if (value === null) {
356
+ this.#show("");
357
+ this.#reflect(null, "", cause);
358
+ return;
359
+ }
360
+ const rounded = round(value, this.#precision);
361
+ const formatted = this.#fixed.format(rounded);
362
+ this.#show(formatted);
363
+ this.#reflect(rounded, formatted, cause);
364
+ }
365
+ /** Writes `text` to the display as this controller's own rendering. */
366
+ #show(text) {
367
+ this.displayTarget.value = text;
368
+ this.#rendered = text;
232
369
  }
233
370
  /**
234
371
  * The in-progress rendering: grouped integer, sign and fraction as typed.
235
- *
236
- * @stimeoRenderRoot
237
372
  */
238
373
  #render(parts) {
239
374
  const int = parts.int === "" ? "" : this.#grouping.format(BigInt(parts.int));
@@ -293,15 +428,22 @@ var CurrencyInputController = class extends Controller {
293
428
  if (this.hasSrValueTarget) {
294
429
  this.srValueTarget.textContent = isEmpty ? "" : this.#accessible.format(value);
295
430
  }
296
- this.element.toggleAttribute("data-stimeo--currency-input-empty", isEmpty);
431
+ this.element.toggleAttribute(`data-${this.identifier}-empty`, isEmpty);
297
432
  }
298
- /** {@link #write}, then reports a moved value as `change` (`""` rides with `null`). */
299
- #reflect(value, formatted) {
433
+ /**
434
+ * {@link #write}, then reports a moved value under the event `cause` selects:
435
+ * `change` for the user's edit, `reconcile` for a move the page caused. The
436
+ * display may hold an in-progress `-`, but a `null` value always rides with an
437
+ * empty `formatted`, whichever event reports it, so consumers can treat the
438
+ * pair as cleared.
439
+ */
440
+ #reflect(value, formatted, cause) {
300
441
  this.#write(value);
301
- if (value !== this.#lastValue) {
302
- this.#lastValue = value;
303
- this.dispatch("change", { detail: { value, formatted } });
304
- }
442
+ if (value === this.#lastValue) return;
443
+ this.#lastValue = value;
444
+ const detail = { value, formatted: value === null ? "" : formatted };
445
+ if (cause === "edit") this.dispatch("change", { detail });
446
+ else this.dispatch("reconcile", { detail });
305
447
  }
306
448
  /**
307
449
  * Scans arbitrary input text into its number-shaped parts and value. Keeps
@@ -333,10 +475,15 @@ var CurrencyInputController = class extends Controller {
333
475
  #isDecimalMark(ch) {
334
476
  return ch === this.#decimal || ch === "." && this.#group !== ".";
335
477
  }
336
- /** Re-syncs field / srValue / hook from the current display without dispatching. */
478
+ /**
479
+ * Re-syncs field / srValue / hook without dispatching: from the display, or —
480
+ * while it is composing, when its text is not committed yet — from the value
481
+ * last committed.
482
+ */
337
483
  #resync() {
338
484
  if (!this.hasDisplayTarget) return;
339
- this.#write(this.#scan(this.displayTarget.value).value);
485
+ const value = this.#composition.isComposing() ? this.#lastValue : this.#scan(this.displayTarget.value).value;
486
+ this.#write(value);
340
487
  }
341
488
  /**
342
489
  * Validates the declared Values, falling back to each Value's default when a