stimeo-ui 0.4.0 → 0.5.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 +68 -0
- data/dist/controllers/aspect_ratio_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +5 -1
- data/dist/controllers/carousel_controller.js +5 -1
- data/dist/controllers/clipboard_controller.js +8 -3
- data/dist/controllers/collapsible_controller.js +4 -1
- data/dist/controllers/color_picker_controller.js +6 -2
- data/dist/controllers/context_menu_controller.js +2 -2
- data/dist/controllers/countdown_controller.js +5 -1
- data/dist/controllers/date_range_picker_controller.js +5 -1
- data/dist/controllers/direct_upload_controller.js +3 -3
- data/dist/controllers/empty_state_controller.js +107 -16
- data/dist/controllers/flash_controller.js +161 -21
- data/dist/controllers/form_validation_controller.js +8 -2
- data/dist/controllers/frame_loading_controller.js +94 -22
- data/dist/controllers/highlight_controller.js +38 -1
- data/dist/controllers/idle_controller.js +13 -2
- data/dist/controllers/local_time_controller.js +2 -0
- data/dist/controllers/masonry_controller.js +1 -1
- data/dist/controllers/meter_controller.js +3 -1
- data/dist/controllers/network_status_controller.js +1 -3
- data/dist/controllers/number_input_controller.js +191 -24
- data/dist/controllers/overflow_menu_controller.js +1 -1
- data/dist/controllers/pagination_controller.js +5 -1
- data/dist/controllers/password_strength_controller.js +1 -1
- data/dist/controllers/pointer_drag_controller.js +10 -0
- data/dist/controllers/portal_controller.js +10 -0
- data/dist/controllers/progress_controller.js +7 -3
- data/dist/controllers/range_slider_controller.js +385 -94
- data/dist/controllers/rating_controller.js +2 -0
- data/dist/controllers/relative_time_controller.js +2 -0
- data/dist/controllers/scroll_area_controller.js +1 -1
- data/dist/controllers/separator_controller.js +13 -17
- data/dist/controllers/skeleton_controller.js +71 -3
- data/dist/controllers/slider_controller.js +325 -48
- data/dist/controllers/spinner_controller.js +18 -3
- data/dist/controllers/step_indicator_controller.js +3 -1
- data/dist/controllers/stepper_controller.js +2 -0
- data/dist/controllers/switch_controller.js +162 -18
- data/dist/controllers/textarea_autosize_controller.js +1 -1
- data/dist/controllers/time_picker_controller.js +6 -3
- data/dist/controllers/tree_view_controller.js +19 -1
- data/dist/index.js +1214 -307
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,13 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/form_validation_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/default_attribute.ts
|
|
6
|
+
function setDefaultAttribute(element, name, value) {
|
|
7
|
+
if (element.hasAttribute(name)) return false;
|
|
8
|
+
element.setAttribute(name, value);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
// src/utils/focus_trap.ts
|
|
6
13
|
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
14
|
|
|
@@ -79,8 +86,7 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
79
86
|
};
|
|
80
87
|
/** Suppresses native bubbles and binds the submit / blur / input listeners. */
|
|
81
88
|
connect() {
|
|
82
|
-
if (
|
|
83
|
-
this.element.setAttribute("novalidate", "");
|
|
89
|
+
if (setDefaultAttribute(this.element, "novalidate", "")) {
|
|
84
90
|
this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
|
|
85
91
|
}
|
|
86
92
|
document.addEventListener("submit", this.#onSubmit, true);
|
|
@@ -52,6 +52,16 @@ var BeforeCacheReset = class _BeforeCacheReset {
|
|
|
52
52
|
var DetachGate = class _DetachGate {
|
|
53
53
|
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
54
54
|
#pending = false;
|
|
55
|
+
/**
|
|
56
|
+
* True while a probe is queued — the last disconnect was ambiguous and no
|
|
57
|
+
* reconnect has cancelled it yet. Read it from `connect()` to tell the
|
|
58
|
+
* reconnect half of an in-page move from a first connect: a controller whose
|
|
59
|
+
* initialisation restarts a measurement (a min-duration floor, an elapsed
|
|
60
|
+
* counter) must skip it for the move, where nothing actually restarted.
|
|
61
|
+
*/
|
|
62
|
+
get pending() {
|
|
63
|
+
return this.#pending;
|
|
64
|
+
}
|
|
55
65
|
/**
|
|
56
66
|
* True when the disconnect is definitely a real detach — the element left
|
|
57
67
|
* the document, or `data-controller` no longer lists the identifier. False
|
|
@@ -209,7 +219,17 @@ var FrameLoadingController = class extends Controller {
|
|
|
209
219
|
#gate = new DetachGate();
|
|
210
220
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
211
221
|
#loading = false;
|
|
212
|
-
|
|
222
|
+
/**
|
|
223
|
+
* The optional targets this controller revealed, and the content it marked inert.
|
|
224
|
+
* Held as references rather than re-resolved on the way out: a detach that keeps
|
|
225
|
+
* the element takes the identifier off `data-controller` first, and a scope
|
|
226
|
+
* without its identifier stops resolving targets — the elements to tidy would be
|
|
227
|
+
* unreachable exactly when the tidying matters. They double as the ownership
|
|
228
|
+
* marker, so a `hidden` or an `inert` the consumer wrote is never taken over.
|
|
229
|
+
*/
|
|
230
|
+
#revealedSkeleton = null;
|
|
231
|
+
#revealedOverlay = null;
|
|
232
|
+
#inertTarget = null;
|
|
213
233
|
#previousFocus = null;
|
|
214
234
|
/** The id of the retreated element, used to re-find it if the load replaced it. */
|
|
215
235
|
#previousFocusId = "";
|
|
@@ -236,40 +256,97 @@ var FrameLoadingController = class extends Controller {
|
|
|
236
256
|
this.#gate.disconnected(this, () => this.#teardown());
|
|
237
257
|
}
|
|
238
258
|
/**
|
|
239
|
-
* Drops the held finish and the loading bookkeeping on a real detach
|
|
240
|
-
*
|
|
241
|
-
*
|
|
259
|
+
* Drops the held finish and the loading bookkeeping on a real detach, returning
|
|
260
|
+
* the frame to its idle form. No reconnect is coming, so nothing is left that
|
|
261
|
+
* could finish the load and clear the hooks — a detach that keeps the element
|
|
262
|
+
* (a morph dropping the identifier, an exit from a scoped observed root) would
|
|
263
|
+
* otherwise strand it busy and inert. Focus is left where it is: the element is
|
|
264
|
+
* leaving this controller's care, and moving it now would be an unexplained jump.
|
|
242
265
|
*/
|
|
243
266
|
#teardown() {
|
|
244
267
|
this.#gate.cancel();
|
|
245
268
|
this.#timeouts.clearAll();
|
|
246
269
|
this.#floor.cancel();
|
|
270
|
+
if (this.#loading) this.#rewindHooks();
|
|
247
271
|
this.#loading = false;
|
|
248
272
|
this.#previousFocus = null;
|
|
249
273
|
}
|
|
250
274
|
/**
|
|
251
|
-
* Returns the frame to its
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
275
|
+
* Returns the frame to its idle form for the snapshot Turbo is about to take, so
|
|
276
|
+
* a page reached with the Back button does not restore a frame that is busy and
|
|
277
|
+
* inert with nothing left to finish it. State only — no `end` event and no focus
|
|
278
|
+
* move, because the load did not actually complete.
|
|
279
|
+
*
|
|
280
|
+
* The load is abandoned rather than paused, so the flag and any finish the floor
|
|
281
|
+
* still holds drop along with the hooks. A kept finish would surface after the
|
|
282
|
+
* rewind as exactly the three things this pass exists to avoid — an `end`, a
|
|
283
|
+
* completion announcement, and a focus move — and a kept flag would leave the
|
|
284
|
+
* next fetch on a page that survives a cancelled visit skipping the loading
|
|
285
|
+
* state, its idempotence guard already satisfied.
|
|
256
286
|
*/
|
|
257
287
|
#rewindForCache() {
|
|
258
288
|
if (!this.#loading) return;
|
|
289
|
+
this.#loading = false;
|
|
290
|
+
this.#floor.cancel();
|
|
291
|
+
this.#rewindHooks();
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Clears every hook the loading state writes. Shared by the three ways a load can
|
|
295
|
+
* stop — completion, detach, snapshot — so none of them can drift into tidying
|
|
296
|
+
* only part of it.
|
|
297
|
+
*/
|
|
298
|
+
#rewindHooks() {
|
|
259
299
|
this.element.removeAttribute("aria-busy");
|
|
260
300
|
this.element.removeAttribute("data-frame-loading");
|
|
261
|
-
if (this
|
|
262
|
-
if (this
|
|
301
|
+
if (this.#revealedSkeleton) this.#revealedSkeleton.hidden = true;
|
|
302
|
+
if (this.#revealedOverlay) this.#revealedOverlay.hidden = true;
|
|
303
|
+
this.#revealedSkeleton = null;
|
|
304
|
+
this.#revealedOverlay = null;
|
|
263
305
|
this.#clearInert();
|
|
264
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Re-shows a `skeleton` that arrived mid-load. Turbo's frame renderer empties the
|
|
309
|
+
* frame and re-inserts the response's children, so a response's authored (hidden)
|
|
310
|
+
* skeleton can land while a later fetch is still running, and only the controller
|
|
311
|
+
* knows the frame is still busy.
|
|
312
|
+
*/
|
|
313
|
+
skeletonTargetConnected() {
|
|
314
|
+
if (this.#loading) this.#revealSkeleton();
|
|
315
|
+
}
|
|
316
|
+
/** Re-shows an `overlay` that arrived mid-load — the same swap as the skeleton. */
|
|
317
|
+
overlayTargetConnected() {
|
|
318
|
+
if (this.#loading) this.#revealOverlay();
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Re-blocks a `content` that arrived mid-load, so the stale copy stays unusable.
|
|
322
|
+
* The element that left is released first and ownership is then decided afresh, so
|
|
323
|
+
* an `inert` the replacement authored stays the consumer's.
|
|
324
|
+
*/
|
|
325
|
+
contentTargetConnected() {
|
|
326
|
+
if (!this.#loading) return;
|
|
327
|
+
this.#clearInert();
|
|
328
|
+
this.#applyInert();
|
|
329
|
+
}
|
|
330
|
+
/** Reveals the optional `skeleton`, noting it as this controller's to hide again. */
|
|
331
|
+
#revealSkeleton() {
|
|
332
|
+
if (!this.hasSkeletonTarget) return;
|
|
333
|
+
this.#revealedSkeleton = this.skeletonTarget;
|
|
334
|
+
this.skeletonTarget.hidden = false;
|
|
335
|
+
}
|
|
336
|
+
/** Reveals the optional `overlay`, noting it as this controller's to hide again. */
|
|
337
|
+
#revealOverlay() {
|
|
338
|
+
if (!this.hasOverlayTarget) return;
|
|
339
|
+
this.#revealedOverlay = this.overlayTarget;
|
|
340
|
+
this.overlayTarget.hidden = false;
|
|
341
|
+
}
|
|
265
342
|
/** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
|
|
266
343
|
#begin() {
|
|
267
344
|
this.#loading = true;
|
|
268
345
|
this.#floor.begin();
|
|
269
346
|
this.element.setAttribute("aria-busy", "true");
|
|
270
347
|
this.element.setAttribute("data-frame-loading", "true");
|
|
271
|
-
|
|
272
|
-
|
|
348
|
+
this.#revealSkeleton();
|
|
349
|
+
this.#revealOverlay();
|
|
273
350
|
this.#applyInert();
|
|
274
351
|
this.#retreatFocus();
|
|
275
352
|
this.dispatch("start", { detail: {} });
|
|
@@ -278,11 +355,7 @@ var FrameLoadingController = class extends Controller {
|
|
|
278
355
|
/** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
|
|
279
356
|
#finish() {
|
|
280
357
|
this.#loading = false;
|
|
281
|
-
this
|
|
282
|
-
this.element.removeAttribute("data-frame-loading");
|
|
283
|
-
if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
|
|
284
|
-
if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
|
|
285
|
-
this.#clearInert();
|
|
358
|
+
this.#rewindHooks();
|
|
286
359
|
this.#restoreFocus();
|
|
287
360
|
this.dispatch("end", { detail: {} });
|
|
288
361
|
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
@@ -291,12 +364,11 @@ var FrameLoadingController = class extends Controller {
|
|
|
291
364
|
#applyInert() {
|
|
292
365
|
if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
|
|
293
366
|
this.contentTarget.setAttribute("inert", "");
|
|
294
|
-
this.#
|
|
367
|
+
this.#inertTarget = this.contentTarget;
|
|
295
368
|
}
|
|
296
369
|
#clearInert() {
|
|
297
|
-
|
|
298
|
-
this.#
|
|
299
|
-
if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
|
|
370
|
+
this.#inertTarget?.removeAttribute("inert");
|
|
371
|
+
this.#inertTarget = null;
|
|
300
372
|
}
|
|
301
373
|
/** Saves and blurs focus if it sits inside the frame about to go stale. */
|
|
302
374
|
#retreatFocus() {
|
|
@@ -61,6 +61,7 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
// src/controllers/highlight_controller.ts
|
|
64
|
+
var hookOwners = /* @__PURE__ */ new WeakMap();
|
|
64
65
|
var HighlightController = class extends Controller {
|
|
65
66
|
static values = {
|
|
66
67
|
duration: { type: Number, default: 1500 },
|
|
@@ -68,9 +69,18 @@ var HighlightController = class extends Controller {
|
|
|
68
69
|
};
|
|
69
70
|
static events = ["start", "end"];
|
|
70
71
|
#timeouts = new SafeTimeout();
|
|
72
|
+
/**
|
|
73
|
+
* The removal timer this connection has outstanding for an element. Held weakly so
|
|
74
|
+
* a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
|
|
75
|
+
* so a cleared id can never be matched against a recycled one. Which connection owns
|
|
76
|
+
* an element's hook is answered by the shared owner registry above.
|
|
77
|
+
*/
|
|
78
|
+
#pending = /* @__PURE__ */ new WeakMap();
|
|
71
79
|
#observer = null;
|
|
72
80
|
connect() {
|
|
81
|
+
this.#clearArrivedHook(this.element);
|
|
73
82
|
if (this.observeValue) {
|
|
83
|
+
for (const child of this.element.children) this.#clearArrivedHook(child);
|
|
74
84
|
if (typeof MutationObserver !== "undefined") {
|
|
75
85
|
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
76
86
|
this.#observer.observe(this.element, { childList: true });
|
|
@@ -83,6 +93,12 @@ var HighlightController = class extends Controller {
|
|
|
83
93
|
this.#observer?.disconnect();
|
|
84
94
|
this.#observer = null;
|
|
85
95
|
this.#timeouts.clearAll();
|
|
96
|
+
this.#pending = /* @__PURE__ */ new WeakMap();
|
|
97
|
+
}
|
|
98
|
+
/** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
|
|
99
|
+
#clearArrivedHook(el) {
|
|
100
|
+
if (hookOwners.get(el) === this) hookOwners.delete(el);
|
|
101
|
+
el.removeAttribute("data-highlight");
|
|
86
102
|
}
|
|
87
103
|
/** Highlights every element child added by a childList mutation. */
|
|
88
104
|
#onMutations(mutations) {
|
|
@@ -95,12 +111,33 @@ var HighlightController = class extends Controller {
|
|
|
95
111
|
/** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
|
|
96
112
|
#highlight(el) {
|
|
97
113
|
if (prefersReducedMotion()) return;
|
|
114
|
+
this.#releasePending(el);
|
|
98
115
|
el.setAttribute("data-highlight", "true");
|
|
99
116
|
this.dispatch("start", { target: el, detail: { element: el } });
|
|
100
|
-
this.#timeouts.set(() => {
|
|
117
|
+
const id = this.#timeouts.set(() => {
|
|
118
|
+
this.#pending.delete(el);
|
|
119
|
+
hookOwners.delete(el);
|
|
101
120
|
el.removeAttribute("data-highlight");
|
|
102
121
|
this.dispatch("end", { target: el, detail: { element: el } });
|
|
103
122
|
}, this.durationValue);
|
|
123
|
+
this.#pending.set(el, id);
|
|
124
|
+
hookOwners.set(el, this);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Releases whichever removal timer holds `el`'s hook. The row may have been
|
|
128
|
+
* highlighted inside a different watched container before it moved here, and that
|
|
129
|
+
* container's timer is reachable only through the shared owner registry.
|
|
130
|
+
*/
|
|
131
|
+
#releasePending(el) {
|
|
132
|
+
const owner = hookOwners.get(el);
|
|
133
|
+
if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
|
|
134
|
+
this.#cancelPending(el);
|
|
135
|
+
}
|
|
136
|
+
/** Releases `el`'s pending removal timer, if it has one. */
|
|
137
|
+
#cancelPending(el) {
|
|
138
|
+
this.#timeouts.clear(this.#pending.get(el) ?? -1);
|
|
139
|
+
this.#pending.delete(el);
|
|
140
|
+
hookOwners.delete(el);
|
|
104
141
|
}
|
|
105
142
|
};
|
|
106
143
|
|
|
@@ -71,6 +71,12 @@ var IdleController = class extends Controller {
|
|
|
71
71
|
#prompted = false;
|
|
72
72
|
/** Timestamp of the last activity; the timers self-reschedule against it. */
|
|
73
73
|
#lastActivity = 0;
|
|
74
|
+
/**
|
|
75
|
+
* Activity types actually registered on `document`, so `disconnect()` unbinds the
|
|
76
|
+
* same set even when `events` changed while connected (a Turbo morph can rewrite
|
|
77
|
+
* the Value in place, and the removal must match the registration, not the Value).
|
|
78
|
+
*/
|
|
79
|
+
#boundEvents = [];
|
|
74
80
|
#onActivity = () => {
|
|
75
81
|
this.#lastActivity = Date.now();
|
|
76
82
|
if (this.#idle || this.#prompted) {
|
|
@@ -85,16 +91,21 @@ var IdleController = class extends Controller {
|
|
|
85
91
|
if (document.visibilityState === "visible") this.#onActivity();
|
|
86
92
|
};
|
|
87
93
|
connect() {
|
|
88
|
-
|
|
94
|
+
this.#idle = false;
|
|
95
|
+
this.#prompted = false;
|
|
96
|
+
this.element.removeAttribute("data-idle");
|
|
97
|
+
this.#boundEvents = [...this.eventsValue];
|
|
98
|
+
for (const type of this.#boundEvents) {
|
|
89
99
|
document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
|
|
90
100
|
}
|
|
91
101
|
document.addEventListener("visibilitychange", this.#onVisibility);
|
|
92
102
|
this.#arm();
|
|
93
103
|
}
|
|
94
104
|
disconnect() {
|
|
95
|
-
for (const type of this
|
|
105
|
+
for (const type of this.#boundEvents) {
|
|
96
106
|
document.removeEventListener(type, this.#onActivity, { capture: true });
|
|
97
107
|
}
|
|
108
|
+
this.#boundEvents = [];
|
|
98
109
|
document.removeEventListener("visibilitychange", this.#onVisibility);
|
|
99
110
|
this.#timeouts.clearAll();
|
|
100
111
|
}
|
|
@@ -94,6 +94,8 @@ var LocalTimeController = class extends Controller {
|
|
|
94
94
|
* its condition is that formatting was applied, and a repaint applies it with a
|
|
95
95
|
* new result. A pass that cannot format writes nothing and emits nothing, so the
|
|
96
96
|
* authored absolute text stays as the fallback.
|
|
97
|
+
*
|
|
98
|
+
* @stimeoRenderRoot
|
|
97
99
|
*/
|
|
98
100
|
#render() {
|
|
99
101
|
const date = this.#parse();
|
|
@@ -59,7 +59,7 @@ var LayoutObserver = class {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
// src/controllers/masonry_controller.ts
|
|
62
|
-
var COLUMNS_PROPERTY = "--stimeo
|
|
62
|
+
var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
|
|
63
63
|
var MasonryController = class extends Controller {
|
|
64
64
|
static targets = ["item"];
|
|
65
65
|
static values = {
|
|
@@ -173,6 +173,8 @@ var MeterController = class extends Controller {
|
|
|
173
173
|
* Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.
|
|
174
174
|
* The reading is derived once and returned, so the `change` detail reports the
|
|
175
175
|
* same numbers the DOM just received.
|
|
176
|
+
*
|
|
177
|
+
* @stimeoRenderRoot
|
|
176
178
|
*/
|
|
177
179
|
#render() {
|
|
178
180
|
const value = this.#clamp(this.valueValue);
|
|
@@ -184,7 +186,7 @@ var MeterController = class extends Controller {
|
|
|
184
186
|
this.element.setAttribute("aria-valuemin", String(this.minValue));
|
|
185
187
|
this.element.setAttribute("aria-valuemax", String(this.maxValue));
|
|
186
188
|
this.element.setAttribute("aria-valuenow", String(reading.value));
|
|
187
|
-
this.element.style.setProperty("--stimeo
|
|
189
|
+
this.element.style.setProperty("--stimeo--meter-ratio", String(reading.ratio));
|
|
188
190
|
this.element.setAttribute("data-state", reading.state);
|
|
189
191
|
this.#applyValueText(reading);
|
|
190
192
|
return reading;
|
|
@@ -124,9 +124,7 @@ var NetworkStatusController = class extends Controller {
|
|
|
124
124
|
#showOffline() {
|
|
125
125
|
this.#timers.clearAll();
|
|
126
126
|
if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
|
|
127
|
-
if (this.hasOfflineTarget)
|
|
128
|
-
this.offlineTarget.hidden = false;
|
|
129
|
-
}
|
|
127
|
+
if (this.hasOfflineTarget) this.offlineTarget.hidden = false;
|
|
130
128
|
}
|
|
131
129
|
/** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
|
|
132
130
|
#showOnline() {
|
|
@@ -8,6 +8,39 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
8
8
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/utils/microtask_coalescer.ts
|
|
12
|
+
var MicrotaskCoalescer = class {
|
|
13
|
+
#run;
|
|
14
|
+
#queued = false;
|
|
15
|
+
#active = false;
|
|
16
|
+
#generation = 0;
|
|
17
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
18
|
+
constructor(run) {
|
|
19
|
+
this.#run = run;
|
|
20
|
+
}
|
|
21
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
22
|
+
activate() {
|
|
23
|
+
this.#active = true;
|
|
24
|
+
}
|
|
25
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
26
|
+
cancel() {
|
|
27
|
+
this.#active = false;
|
|
28
|
+
this.#queued = false;
|
|
29
|
+
this.#generation += 1;
|
|
30
|
+
}
|
|
31
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
32
|
+
schedule() {
|
|
33
|
+
if (!this.#active || this.#queued) return;
|
|
34
|
+
this.#queued = true;
|
|
35
|
+
const generation = this.#generation;
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
38
|
+
this.#queued = false;
|
|
39
|
+
this.#run();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
11
44
|
// src/utils/safe_timeout.ts
|
|
12
45
|
var TimerRegistry = class {
|
|
13
46
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -75,6 +108,112 @@ var SafeInterval = class extends TimerRegistry {
|
|
|
75
108
|
}
|
|
76
109
|
};
|
|
77
110
|
|
|
111
|
+
// src/utils/stepped_value.ts
|
|
112
|
+
function effectiveStep(step) {
|
|
113
|
+
return Number.isFinite(step) && step > 0 ? step : 1;
|
|
114
|
+
}
|
|
115
|
+
function snapSteppedValue(raw, range) {
|
|
116
|
+
if (!(range.min <= range.max)) return finiteFallback(range.min, range.max);
|
|
117
|
+
const input = Number.isNaN(raw) ? finiteFallback(range.min, range.max) : raw;
|
|
118
|
+
const clamped = Math.min(range.max, Math.max(range.min, input));
|
|
119
|
+
const step = effectiveStep(range.step);
|
|
120
|
+
const base = stepBase(range);
|
|
121
|
+
const candidates = [];
|
|
122
|
+
if (Number.isFinite(range.min)) candidates.push(range.min);
|
|
123
|
+
if (Number.isFinite(range.max)) candidates.push(range.max);
|
|
124
|
+
const gridPosition = (clamped - base) / step;
|
|
125
|
+
if (Number.isFinite(gridPosition)) {
|
|
126
|
+
addGridCandidate(candidates, Math.floor(gridPosition), range, base, step);
|
|
127
|
+
addGridCandidate(candidates, Math.ceil(gridPosition), range, base, step);
|
|
128
|
+
}
|
|
129
|
+
if (candidates.length === 0) return clamped;
|
|
130
|
+
let nearest = candidates[0];
|
|
131
|
+
let nearestDistance = Math.abs(clamped - nearest);
|
|
132
|
+
for (const candidate of candidates.slice(1)) {
|
|
133
|
+
const distance = Math.abs(clamped - candidate);
|
|
134
|
+
if (distance < nearestDistance || nearlyEqual(distance, nearestDistance) && candidate > nearest) {
|
|
135
|
+
nearest = candidate;
|
|
136
|
+
nearestDistance = distance;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return nearest;
|
|
140
|
+
}
|
|
141
|
+
function stepSteppedValue(current, count, range) {
|
|
142
|
+
const value = snapSteppedValue(current, range);
|
|
143
|
+
const distance = Math.abs(Math.trunc(count));
|
|
144
|
+
if (!Number.isFinite(distance) || distance === 0) return value;
|
|
145
|
+
const direction = Math.sign(count);
|
|
146
|
+
const adjacent = adjacentSteppedValue(value, direction, range);
|
|
147
|
+
const raw = adjacent + direction * (distance - 1) * effectiveStep(range.step);
|
|
148
|
+
return snapSteppedValue(raw, range);
|
|
149
|
+
}
|
|
150
|
+
function adjacentSteppedValue(current, direction, range) {
|
|
151
|
+
const step = effectiveStep(range.step);
|
|
152
|
+
const base = stepBase(range);
|
|
153
|
+
const candidates = [];
|
|
154
|
+
if (direction > 0) {
|
|
155
|
+
const position2 = (current - base) / step;
|
|
156
|
+
if (Number.isFinite(position2)) {
|
|
157
|
+
let gridIndex = Math.floor(position2) + 1;
|
|
158
|
+
let candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
159
|
+
if (candidate < current || nearlyEqual(candidate, current)) {
|
|
160
|
+
gridIndex += 1;
|
|
161
|
+
candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
162
|
+
}
|
|
163
|
+
if (candidate > current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
|
|
164
|
+
candidates.push(clamp(candidate, range));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return clamp(Math.min(...candidates), range);
|
|
168
|
+
}
|
|
169
|
+
const position = (current - base) / step;
|
|
170
|
+
if (Number.isFinite(position)) {
|
|
171
|
+
let gridIndex = Math.ceil(position) - 1;
|
|
172
|
+
let candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
173
|
+
if (candidate > current || nearlyEqual(candidate, current)) {
|
|
174
|
+
gridIndex -= 1;
|
|
175
|
+
candidate = cleanGridValue(base + gridIndex * step, base, step);
|
|
176
|
+
}
|
|
177
|
+
if (candidate < current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
|
|
178
|
+
candidates.push(clamp(candidate, range));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return clamp(Math.max(...candidates), range);
|
|
182
|
+
}
|
|
183
|
+
function addGridCandidate(candidates, index, range, base, step) {
|
|
184
|
+
const candidate = cleanGridValue(base + index * step, base, step);
|
|
185
|
+
if (within(candidate, range.min, range.max)) candidates.push(clamp(candidate, range));
|
|
186
|
+
}
|
|
187
|
+
function stepBase(range) {
|
|
188
|
+
if (range.base !== void 0 && Number.isFinite(range.base)) return range.base;
|
|
189
|
+
return Number.isFinite(range.min) ? range.min : 0;
|
|
190
|
+
}
|
|
191
|
+
function cleanGridValue(value, base, step) {
|
|
192
|
+
const precision = Math.max(decimalPlaces(base), decimalPlaces(step));
|
|
193
|
+
return precision <= 100 ? Number(value.toFixed(precision)) : value;
|
|
194
|
+
}
|
|
195
|
+
function decimalPlaces(value) {
|
|
196
|
+
const [coefficient = "", exponentText] = Math.abs(value).toString().toLowerCase().split("e");
|
|
197
|
+
const fractionLength = coefficient.split(".")[1]?.length ?? 0;
|
|
198
|
+
const exponent = exponentText === void 0 ? 0 : Number(exponentText);
|
|
199
|
+
return Math.max(0, fractionLength - exponent);
|
|
200
|
+
}
|
|
201
|
+
function within(value, min, max) {
|
|
202
|
+
return (value > min || nearlyEqual(value, min)) && (value < max || nearlyEqual(value, max));
|
|
203
|
+
}
|
|
204
|
+
function clamp(value, range) {
|
|
205
|
+
return Math.min(range.max, Math.max(range.min, value));
|
|
206
|
+
}
|
|
207
|
+
function nearlyEqual(left, right) {
|
|
208
|
+
const scale = Math.max(Math.abs(left), Math.abs(right));
|
|
209
|
+
return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) <= Number.EPSILON * scale;
|
|
210
|
+
}
|
|
211
|
+
function finiteFallback(min, max) {
|
|
212
|
+
if (Number.isFinite(min)) return min;
|
|
213
|
+
if (Number.isFinite(max)) return max;
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
78
217
|
// src/controllers/number_input_controller.ts
|
|
79
218
|
var NumberInputController = class _NumberInputController extends Controller {
|
|
80
219
|
static targets = ["input", "increment", "decrement"];
|
|
@@ -109,24 +248,24 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
109
248
|
#repeatedDuringHold = false;
|
|
110
249
|
/** True when the next `click` is the trailing one after a hold and must be ignored. */
|
|
111
250
|
#suppressNextClick = false;
|
|
251
|
+
/** Collapses runtime range/step changes into one silent input reconciliation. */
|
|
252
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcile());
|
|
112
253
|
/** Normalizes any initial value and wires the focus/hold pointer guards. */
|
|
113
254
|
connect() {
|
|
255
|
+
this.#repaint.activate();
|
|
114
256
|
if (!this.hasInputTarget) return;
|
|
115
|
-
|
|
116
|
-
this.#write(this.#normalize(this.#currentValue()));
|
|
117
|
-
} else {
|
|
118
|
-
this.#updateButtons(this.#currentValue());
|
|
119
|
-
}
|
|
257
|
+
this.#reconcile();
|
|
120
258
|
this.#guards = new AbortController();
|
|
121
259
|
const { signal } = this.#guards;
|
|
122
|
-
if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget,
|
|
123
|
-
if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -
|
|
260
|
+
if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, 1, signal);
|
|
261
|
+
if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -1, signal);
|
|
124
262
|
window.addEventListener("pointerup", this.#stopHold, { signal });
|
|
125
263
|
window.addEventListener("pointercancel", this.#stopHold, { signal });
|
|
126
264
|
window.addEventListener("blur", this.#stopHold, { signal });
|
|
127
265
|
}
|
|
128
266
|
/** Releases the pointer guards and tears down every pending timer and hold state. */
|
|
129
267
|
disconnect() {
|
|
268
|
+
this.#repaint.cancel();
|
|
130
269
|
this.#guards?.abort();
|
|
131
270
|
this.#guards = null;
|
|
132
271
|
this.#holdActive = false;
|
|
@@ -135,16 +274,28 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
135
274
|
this.#holdTimeouts.clearAll();
|
|
136
275
|
this.#holdIntervals.clearAll();
|
|
137
276
|
}
|
|
277
|
+
/** Silently reconciles a minimum changed by application code or a Turbo morph. */
|
|
278
|
+
minValueChanged() {
|
|
279
|
+
this.#repaint.schedule();
|
|
280
|
+
}
|
|
281
|
+
/** Silently reconciles a maximum changed by application code or a Turbo morph. */
|
|
282
|
+
maxValueChanged() {
|
|
283
|
+
this.#repaint.schedule();
|
|
284
|
+
}
|
|
285
|
+
/** Silently reconciles a step changed by application code or a Turbo morph. */
|
|
286
|
+
stepValueChanged() {
|
|
287
|
+
this.#repaint.schedule();
|
|
288
|
+
}
|
|
138
289
|
/** Increases by one step. Bound via `data-action` (click). */
|
|
139
290
|
increment() {
|
|
140
291
|
if (this.#consumeSuppressedClick()) return;
|
|
141
|
-
this.#
|
|
292
|
+
this.#commitStep(1);
|
|
142
293
|
this.inputTarget.focus();
|
|
143
294
|
}
|
|
144
295
|
/** Decreases by one step. Bound via `data-action` (click). */
|
|
145
296
|
decrement() {
|
|
146
297
|
if (this.#consumeSuppressedClick()) return;
|
|
147
|
-
this.#
|
|
298
|
+
this.#commitStep(-1);
|
|
148
299
|
this.inputTarget.focus();
|
|
149
300
|
}
|
|
150
301
|
/** Clamps and snaps a typed value. Bound via `data-action` (change). */
|
|
@@ -155,20 +306,19 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
155
306
|
/** Keyboard stepping per the APG spinbutton model. */
|
|
156
307
|
onKeydown(event) {
|
|
157
308
|
if (isReservedArrowChord(event)) return;
|
|
158
|
-
const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
|
|
159
309
|
let next = null;
|
|
160
310
|
switch (event.key) {
|
|
161
311
|
case "ArrowUp":
|
|
162
|
-
next = this.#currentValue()
|
|
312
|
+
next = stepSteppedValue(this.#currentValue(), 1, this.#steppedRange);
|
|
163
313
|
break;
|
|
164
314
|
case "ArrowDown":
|
|
165
|
-
next = this.#currentValue() - this
|
|
315
|
+
next = stepSteppedValue(this.#currentValue(), -1, this.#steppedRange);
|
|
166
316
|
break;
|
|
167
317
|
case "PageUp":
|
|
168
|
-
next = this.#currentValue() +
|
|
318
|
+
next = this.pageStepValue > 0 ? this.#currentValue() + this.pageStepValue : stepSteppedValue(this.#currentValue(), 10, this.#steppedRange);
|
|
169
319
|
break;
|
|
170
320
|
case "PageDown":
|
|
171
|
-
next = this.#currentValue() -
|
|
321
|
+
next = this.pageStepValue > 0 ? this.#currentValue() - this.pageStepValue : stepSteppedValue(this.#currentValue(), -10, this.#steppedRange);
|
|
172
322
|
break;
|
|
173
323
|
case "Home":
|
|
174
324
|
if (!Number.isFinite(this.minValue)) return;
|
|
@@ -189,14 +339,14 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
189
339
|
* hold; leaving the button while held stops it (the global listeners cover
|
|
190
340
|
* release/cancel/blur).
|
|
191
341
|
*/
|
|
192
|
-
#wireButton(button,
|
|
193
|
-
button.addEventListener("pointerdown", (event) => this.#armHold(event, button,
|
|
342
|
+
#wireButton(button, direction, signal) {
|
|
343
|
+
button.addEventListener("pointerdown", (event) => this.#armHold(event, button, direction), {
|
|
194
344
|
signal
|
|
195
345
|
});
|
|
196
346
|
button.addEventListener("pointerleave", this.#stopHold, { signal });
|
|
197
347
|
}
|
|
198
348
|
/** Starts a hold: focus the input, then schedule the first repeat after a delay. */
|
|
199
|
-
#armHold(event, button,
|
|
349
|
+
#armHold(event, button, direction) {
|
|
200
350
|
const pointerButton = event.button;
|
|
201
351
|
if (typeof pointerButton === "number" && pointerButton !== 0) return;
|
|
202
352
|
if (button.disabled) return;
|
|
@@ -207,13 +357,13 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
207
357
|
this.#repeatedDuringHold = false;
|
|
208
358
|
this.#suppressNextClick = false;
|
|
209
359
|
this.#holdTimeouts.set(() => {
|
|
210
|
-
if (!this.#
|
|
360
|
+
if (!this.#commitStep(direction)) {
|
|
211
361
|
this.#stopHold();
|
|
212
362
|
return;
|
|
213
363
|
}
|
|
214
364
|
this.#repeatedDuringHold = true;
|
|
215
365
|
this.#holdIntervals.set(() => {
|
|
216
|
-
if (!this.#
|
|
366
|
+
if (!this.#commitStep(direction)) this.#stopHold();
|
|
217
367
|
}, _NumberInputController.#HOLD_REPEAT_MS);
|
|
218
368
|
}, _NumberInputController.#HOLD_DELAY_MS);
|
|
219
369
|
}
|
|
@@ -254,6 +404,23 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
254
404
|
if (changed) this.dispatch("change", { detail: { value } });
|
|
255
405
|
return changed;
|
|
256
406
|
}
|
|
407
|
+
/** Commits an adjacent endpoint/grid value and reports whether it moved. */
|
|
408
|
+
#commitStep(count) {
|
|
409
|
+
return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Silently reflects the current value after a range or step morph.
|
|
413
|
+
*
|
|
414
|
+
* @stimeoRenderRoot
|
|
415
|
+
*/
|
|
416
|
+
#reconcile() {
|
|
417
|
+
if (!this.hasInputTarget) return;
|
|
418
|
+
if (this.inputTarget.value.trim() !== "") {
|
|
419
|
+
this.#write(this.#normalize(this.#currentValue()));
|
|
420
|
+
} else {
|
|
421
|
+
this.#updateButtons(this.#currentValue());
|
|
422
|
+
}
|
|
423
|
+
}
|
|
257
424
|
/** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
|
|
258
425
|
#write(value) {
|
|
259
426
|
this.inputTarget.value = String(value);
|
|
@@ -300,11 +467,11 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
300
467
|
}
|
|
301
468
|
/** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
|
|
302
469
|
#normalize(raw) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return
|
|
470
|
+
return snapSteppedValue(raw, this.#steppedRange);
|
|
471
|
+
}
|
|
472
|
+
/** Shared range configuration; finite endpoints remain allowed off the grid. */
|
|
473
|
+
get #steppedRange() {
|
|
474
|
+
return { min: this.minValue, max: this.maxValue, step: this.stepValue };
|
|
308
475
|
}
|
|
309
476
|
};
|
|
310
477
|
|