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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +178 -0
- data/README.md +120 -0
- data/dist/cable/index.js +63 -15
- data/dist/controllers/accordion_controller.js +98 -9
- data/dist/controllers/announcer_controller.js +8 -1
- data/dist/controllers/auto_submit_controller.js +43 -2
- data/dist/controllers/avatar_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +9 -8
- data/dist/controllers/bulk_select_controller.js +7 -6
- data/dist/controllers/calendar_controller.js +323 -123
- data/dist/controllers/carousel_controller.js +263 -38
- data/dist/controllers/character_counter_controller.js +40 -2
- data/dist/controllers/checkbox_controller.js +54 -10
- data/dist/controllers/clipboard_controller.js +63 -5
- data/dist/controllers/collapsible_controller.js +99 -14
- data/dist/controllers/color_picker_controller.js +80 -34
- data/dist/controllers/combobox_controller.js +106 -16
- data/dist/controllers/command_palette_controller.js +35 -3
- data/dist/controllers/conditional_fields_controller.js +56 -11
- data/dist/controllers/confirm_controller.js +3 -0
- data/dist/controllers/context_menu_controller.js +32 -12
- data/dist/controllers/countdown_controller.js +129 -26
- data/dist/controllers/currency_input_controller.js +205 -58
- data/dist/controllers/data_grid_controller.js +195 -29
- data/dist/controllers/date_range_picker_controller.js +139 -27
- data/dist/controllers/dialog_controller.js +35 -8
- data/dist/controllers/direct_upload_controller.js +22 -4
- data/dist/controllers/dirty_form_controller.js +14 -1
- data/dist/controllers/dismissible_controller.js +1 -0
- data/dist/controllers/drawer_controller.js +54 -19
- data/dist/controllers/dropdown_controller.js +36 -9
- data/dist/controllers/editable_controller.js +34 -0
- data/dist/controllers/file_dropzone_controller.js +144 -51
- data/dist/controllers/filter_controller.js +20 -6
- data/dist/controllers/flash_controller.js +305 -46
- data/dist/controllers/focus_controller.js +1 -0
- data/dist/controllers/form_field_controller.js +7 -5
- data/dist/controllers/form_validation_controller.js +19 -13
- data/dist/controllers/frame_loading_controller.js +16 -2
- data/dist/controllers/highlight_controller.js +19 -2
- data/dist/controllers/hover_card_controller.js +40 -14
- data/dist/controllers/idle_controller.js +90 -5
- data/dist/controllers/input_mask_controller.js +65 -9
- data/dist/controllers/intersection_controller.js +3 -0
- data/dist/controllers/lazy_frame_controller.js +11 -2
- data/dist/controllers/listbox_controller.js +203 -45
- data/dist/controllers/masonry_controller.js +0 -2
- data/dist/controllers/menu_controller.js +45 -16
- data/dist/controllers/menubar_controller.js +58 -24
- data/dist/controllers/meter_controller.js +9 -5
- data/dist/controllers/multi_select_controller.js +278 -104
- data/dist/controllers/navigation_menu_controller.js +48 -15
- data/dist/controllers/nested_form_controller.js +37 -8
- data/dist/controllers/network_status_controller.js +9 -1
- data/dist/controllers/number_input_controller.js +124 -21
- data/dist/controllers/optimistic_controller.js +14 -1
- data/dist/controllers/otp_controller.js +198 -55
- data/dist/controllers/overflow_indicator_controller.js +84 -13
- data/dist/controllers/overflow_menu_controller.js +112 -41
- data/dist/controllers/pagination_controller.js +74 -28
- data/dist/controllers/password_reveal_controller.js +59 -2
- data/dist/controllers/persist_controller.js +30 -8
- data/dist/controllers/pointer_drag_controller.js +131 -52
- data/dist/controllers/popover_controller.js +45 -11
- data/dist/controllers/portal_controller.js +6 -2
- data/dist/controllers/preview_guard_controller.js +16 -1
- data/dist/controllers/progress_controller.js +8 -4
- data/dist/controllers/radio_group_controller.js +42 -17
- data/dist/controllers/range_slider_controller.js +88 -42
- data/dist/controllers/rating_controller.js +39 -15
- data/dist/controllers/read_more_controller.js +69 -2
- data/dist/controllers/resizable_controller.js +82 -22
- data/dist/controllers/scroll_area_controller.js +5 -1
- data/dist/controllers/scroll_visibility_controller.js +0 -1
- data/dist/controllers/scrollspy_controller.js +6 -0
- data/dist/controllers/separator_controller.js +66 -37
- data/dist/controllers/sidebar_controller.js +77 -18
- data/dist/controllers/skeleton_controller.js +6 -1
- data/dist/controllers/slider_controller.js +82 -47
- data/dist/controllers/smart_sticky_header_controller.js +11 -1
- data/dist/controllers/sortable_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +10 -2
- data/dist/controllers/step_indicator_controller.js +18 -17
- data/dist/controllers/stepper_controller.js +101 -19
- data/dist/controllers/stick_to_bottom_controller.js +75 -5
- data/dist/controllers/submit_once_controller.js +16 -3
- data/dist/controllers/switch_controller.js +101 -10
- data/dist/controllers/tabs_controller.js +21 -2
- data/dist/controllers/tags_input_controller.js +209 -59
- data/dist/controllers/theme_controller.js +64 -14
- data/dist/controllers/time_picker_controller.js +23 -8
- data/dist/controllers/toast_controller.js +337 -54
- data/dist/controllers/toggle_group_controller.js +159 -23
- data/dist/controllers/toolbar_controller.js +32 -0
- data/dist/controllers/tooltip_controller.js +39 -13
- data/dist/controllers/transition_controller.js +4 -0
- data/dist/controllers/tree_view_controller.js +169 -16
- data/dist/index.js +4588 -1535
- data/dist/positioning/index.js +2 -0
- data/lib/stimeo/ui/version.rb +1 -1
- 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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
154
|
+
if (authored === "running") this.start();
|
|
143
155
|
}
|
|
144
|
-
|
|
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.#
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.#
|
|
271
|
+
this.#endUntrackedComposition();
|
|
174
272
|
this.#composition.disconnect();
|
|
273
|
+
this.#started = false;
|
|
175
274
|
}
|
|
176
|
-
/**
|
|
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)
|
|
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 `
|
|
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
|
-
|
|
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.#
|
|
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(
|
|
431
|
+
this.element.toggleAttribute(`data-${this.identifier}-empty`, isEmpty);
|
|
297
432
|
}
|
|
298
|
-
/**
|
|
299
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
/**
|
|
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.#
|
|
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
|