stimeo-ui 0.3.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 +124 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- 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 +52 -2
- data/dist/controllers/command_palette_controller.js +32 -5
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +2 -2
- data/dist/controllers/countdown_controller.js +117 -13
- data/dist/controllers/date_range_picker_controller.js +55 -1
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/direct_upload_controller.js +3 -3
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/empty_state_controller.js +128 -23
- data/dist/controllers/flash_controller.js +161 -21
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +8 -2
- data/dist/controllers/frame_loading_controller.js +261 -27
- data/dist/controllers/highlight_controller.js +38 -1
- data/dist/controllers/idle_controller.js +13 -2
- data/dist/controllers/local_time_controller.js +102 -6
- data/dist/controllers/masonry_controller.js +1 -1
- data/dist/controllers/meter_controller.js +147 -26
- data/dist/controllers/network_status_controller.js +29 -11
- data/dist/controllers/number_input_controller.js +191 -24
- data/dist/controllers/overflow_menu_controller.js +34 -7
- 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 +123 -12
- data/dist/controllers/range_slider_controller.js +449 -93
- data/dist/controllers/rating_controller.js +55 -0
- data/dist/controllers/relative_time_controller.js +135 -12
- data/dist/controllers/scroll_area_controller.js +1 -1
- data/dist/controllers/separator_controller.js +13 -17
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +143 -22
- data/dist/controllers/slider_controller.js +342 -50
- data/dist/controllers/spinner_controller.js +244 -28
- data/dist/controllers/step_indicator_controller.js +85 -6
- data/dist/controllers/stepper_controller.js +2 -0
- data/dist/controllers/stick_to_bottom_controller.js +60 -10
- 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 +2278 -596
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,155 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/frame_loading_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/before_cache_reset.ts
|
|
23
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
24
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
25
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
26
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
27
|
+
static #onBeforeCache = () => {
|
|
28
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
29
|
+
};
|
|
30
|
+
#rewind;
|
|
31
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
32
|
+
constructor(rewind) {
|
|
33
|
+
this.#rewind = rewind;
|
|
34
|
+
}
|
|
35
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
36
|
+
activate() {
|
|
37
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
38
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
39
|
+
if (first) {
|
|
40
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
44
|
+
deactivate() {
|
|
45
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
46
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
47
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/utils/detach_gate.ts
|
|
52
|
+
var DetachGate = class _DetachGate {
|
|
53
|
+
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
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
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* True when the disconnect is definitely a real detach — the element left
|
|
67
|
+
* the document, or `data-controller` no longer lists the identifier. False
|
|
68
|
+
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
69
|
+
*/
|
|
70
|
+
static isDetached(host) {
|
|
71
|
+
if (!host.element.isConnected) return true;
|
|
72
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
73
|
+
return !tokens.includes(host.identifier);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
77
|
+
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
78
|
+
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
79
|
+
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
80
|
+
* element within the same mutation batch, before the checkpoint drains.
|
|
81
|
+
*/
|
|
82
|
+
disconnected(host, teardown) {
|
|
83
|
+
if (_DetachGate.isDetached(host)) {
|
|
84
|
+
this.#pending = false;
|
|
85
|
+
teardown();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.#pending = true;
|
|
89
|
+
queueMicrotask(() => {
|
|
90
|
+
if (!this.#pending) return;
|
|
91
|
+
this.#pending = false;
|
|
92
|
+
teardown();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
97
|
+
* an in-page move) and from the head of any teardown path not routed through
|
|
98
|
+
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
99
|
+
* never run the teardown a second time.
|
|
100
|
+
*/
|
|
101
|
+
cancel() {
|
|
102
|
+
this.#pending = false;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// src/utils/min_duration_floor.ts
|
|
107
|
+
var MinDurationFloor = class {
|
|
108
|
+
#timers;
|
|
109
|
+
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
110
|
+
#timerId = null;
|
|
111
|
+
/** Epoch ms the floor is measured from. */
|
|
112
|
+
#since = 0;
|
|
113
|
+
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
114
|
+
constructor(timers) {
|
|
115
|
+
this.#timers = timers;
|
|
116
|
+
}
|
|
117
|
+
/** Starts the floor: call when the state being held becomes visible. */
|
|
118
|
+
begin() {
|
|
119
|
+
this.#since = Date.now();
|
|
120
|
+
}
|
|
121
|
+
/** True while a finish is held back waiting for the floor to elapse. */
|
|
122
|
+
get pending() {
|
|
123
|
+
return this.#timerId !== null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
127
|
+
*
|
|
128
|
+
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
129
|
+
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
130
|
+
* end a state that has since restarted. Controllers that want the first signal
|
|
131
|
+
* to win guard on {@link pending} before calling.
|
|
132
|
+
*/
|
|
133
|
+
schedule(minDuration, finish) {
|
|
134
|
+
this.cancel();
|
|
135
|
+
const remaining = minDuration - (Date.now() - this.#since);
|
|
136
|
+
if (remaining > 0) {
|
|
137
|
+
this.#timerId = this.#timers.set(() => {
|
|
138
|
+
this.#timerId = null;
|
|
139
|
+
finish();
|
|
140
|
+
}, remaining);
|
|
141
|
+
} else {
|
|
142
|
+
finish();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
146
|
+
cancel() {
|
|
147
|
+
if (this.#timerId !== null) {
|
|
148
|
+
this.#timers.clear(this.#timerId);
|
|
149
|
+
this.#timerId = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
5
154
|
// src/utils/safe_timeout.ts
|
|
6
155
|
var TimerRegistry = class {
|
|
7
156
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,32 +208,42 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
208
|
var FrameLoadingController = class extends Controller {
|
|
60
209
|
static targets = ["content", "skeleton", "overlay"];
|
|
61
210
|
static values = {
|
|
211
|
+
announceText: { type: String, default: "" },
|
|
212
|
+
announceReadyText: { type: String, default: "" },
|
|
62
213
|
minDuration: { type: Number, default: 0 },
|
|
63
214
|
restoreFocus: { type: Boolean, default: true }
|
|
64
215
|
};
|
|
65
216
|
static events = ["start", "end"];
|
|
66
217
|
#timeouts = new SafeTimeout();
|
|
218
|
+
#floor = new MinDurationFloor(this.#timeouts);
|
|
219
|
+
#gate = new DetachGate();
|
|
220
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
67
221
|
#loading = false;
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
|
70
233
|
#previousFocus = null;
|
|
71
234
|
/** The id of the retreated element, used to re-find it if the load replaced it. */
|
|
72
235
|
#previousFocusId = "";
|
|
73
236
|
#onStart = () => {
|
|
74
|
-
this.#
|
|
237
|
+
this.#floor.cancel();
|
|
75
238
|
if (!this.#loading) this.#begin();
|
|
76
239
|
};
|
|
77
240
|
#onEnd = () => {
|
|
78
241
|
if (!this.#loading) return;
|
|
79
|
-
|
|
80
|
-
if (remaining > 0) {
|
|
81
|
-
this.#timeouts.clearAll();
|
|
82
|
-
this.#timeouts.set(() => this.#finish(), remaining);
|
|
83
|
-
} else {
|
|
84
|
-
this.#finish();
|
|
85
|
-
}
|
|
242
|
+
this.#floor.schedule(this.minDurationValue, () => this.#finish());
|
|
86
243
|
};
|
|
87
244
|
connect() {
|
|
245
|
+
this.#gate.cancel();
|
|
246
|
+
this.#beforeCache.activate();
|
|
88
247
|
this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
|
|
89
248
|
this.element.addEventListener("turbo:frame-load", this.#onEnd);
|
|
90
249
|
this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
@@ -93,48 +252,123 @@ var FrameLoadingController = class extends Controller {
|
|
|
93
252
|
this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
|
|
94
253
|
this.element.removeEventListener("turbo:frame-load", this.#onEnd);
|
|
95
254
|
this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
255
|
+
this.#beforeCache.deactivate();
|
|
256
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
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.
|
|
265
|
+
*/
|
|
266
|
+
#teardown() {
|
|
267
|
+
this.#gate.cancel();
|
|
96
268
|
this.#timeouts.clearAll();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.element.removeAttribute("data-frame-loading");
|
|
100
|
-
this.#clearInert();
|
|
101
|
-
}
|
|
269
|
+
this.#floor.cancel();
|
|
270
|
+
if (this.#loading) this.#rewindHooks();
|
|
102
271
|
this.#loading = false;
|
|
103
272
|
this.#previousFocus = null;
|
|
104
273
|
}
|
|
274
|
+
/**
|
|
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.
|
|
286
|
+
*/
|
|
287
|
+
#rewindForCache() {
|
|
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() {
|
|
299
|
+
this.element.removeAttribute("aria-busy");
|
|
300
|
+
this.element.removeAttribute("data-frame-loading");
|
|
301
|
+
if (this.#revealedSkeleton) this.#revealedSkeleton.hidden = true;
|
|
302
|
+
if (this.#revealedOverlay) this.#revealedOverlay.hidden = true;
|
|
303
|
+
this.#revealedSkeleton = null;
|
|
304
|
+
this.#revealedOverlay = null;
|
|
305
|
+
this.#clearInert();
|
|
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
|
+
}
|
|
105
342
|
/** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
|
|
106
343
|
#begin() {
|
|
107
344
|
this.#loading = true;
|
|
108
|
-
this.#
|
|
345
|
+
this.#floor.begin();
|
|
109
346
|
this.element.setAttribute("aria-busy", "true");
|
|
110
347
|
this.element.setAttribute("data-frame-loading", "true");
|
|
111
|
-
|
|
112
|
-
|
|
348
|
+
this.#revealSkeleton();
|
|
349
|
+
this.#revealOverlay();
|
|
113
350
|
this.#applyInert();
|
|
114
351
|
this.#retreatFocus();
|
|
115
352
|
this.dispatch("start", { detail: {} });
|
|
353
|
+
announce(fillTemplate(this.announceTextValue, {}));
|
|
116
354
|
}
|
|
117
355
|
/** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
|
|
118
356
|
#finish() {
|
|
119
357
|
this.#loading = false;
|
|
120
|
-
this
|
|
121
|
-
this.element.removeAttribute("data-frame-loading");
|
|
122
|
-
if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
|
|
123
|
-
if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
|
|
124
|
-
this.#clearInert();
|
|
358
|
+
this.#rewindHooks();
|
|
125
359
|
this.#restoreFocus();
|
|
126
360
|
this.dispatch("end", { detail: {} });
|
|
361
|
+
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
127
362
|
}
|
|
128
363
|
/** Marks the content inert to block double-submits while stale (if we own it). */
|
|
129
364
|
#applyInert() {
|
|
130
365
|
if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
|
|
131
366
|
this.contentTarget.setAttribute("inert", "");
|
|
132
|
-
this.#
|
|
367
|
+
this.#inertTarget = this.contentTarget;
|
|
133
368
|
}
|
|
134
369
|
#clearInert() {
|
|
135
|
-
|
|
136
|
-
this.#
|
|
137
|
-
if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
|
|
370
|
+
this.#inertTarget?.removeAttribute("inert");
|
|
371
|
+
this.#inertTarget = null;
|
|
138
372
|
}
|
|
139
373
|
/** Saves and blurs focus if it sits inside the frame about to go stale. */
|
|
140
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
|
}
|
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/local_time_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/microtask_coalescer.ts
|
|
6
|
+
var MicrotaskCoalescer = class {
|
|
7
|
+
#run;
|
|
8
|
+
#queued = false;
|
|
9
|
+
#active = false;
|
|
10
|
+
#generation = 0;
|
|
11
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
12
|
+
constructor(run) {
|
|
13
|
+
this.#run = run;
|
|
14
|
+
}
|
|
15
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
16
|
+
activate() {
|
|
17
|
+
this.#active = true;
|
|
18
|
+
}
|
|
19
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
20
|
+
cancel() {
|
|
21
|
+
this.#active = false;
|
|
22
|
+
this.#queued = false;
|
|
23
|
+
this.#generation += 1;
|
|
24
|
+
}
|
|
25
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
26
|
+
schedule() {
|
|
27
|
+
if (!this.#active || this.#queued) return;
|
|
28
|
+
this.#queued = true;
|
|
29
|
+
const generation = this.#generation;
|
|
30
|
+
queueMicrotask(() => {
|
|
31
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
32
|
+
this.#queued = false;
|
|
33
|
+
this.#run();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
3
38
|
// src/controllers/local_time_controller.ts
|
|
4
39
|
var STYLES = /* @__PURE__ */ new Set(["full", "long", "medium", "short"]);
|
|
5
40
|
function toStyle(value) {
|
|
@@ -14,7 +49,55 @@ var LocalTimeController = class extends Controller {
|
|
|
14
49
|
titleFormat: { type: String, default: "" }
|
|
15
50
|
};
|
|
16
51
|
static events = ["format"];
|
|
52
|
+
/** Collapses a morph that swaps several render inputs at once into one repaint. */
|
|
53
|
+
#resync = new MicrotaskCoalescer(() => this.#render());
|
|
54
|
+
/**
|
|
55
|
+
* Watches the one render input that is not a Value. Only `datetime` is filtered
|
|
56
|
+
* in, so the text and `title` this controller writes cannot re-enter the pass.
|
|
57
|
+
*/
|
|
58
|
+
#datetimeWatch = new MutationObserver(() => {
|
|
59
|
+
this.#resync.schedule();
|
|
60
|
+
});
|
|
17
61
|
connect() {
|
|
62
|
+
this.#resync.activate();
|
|
63
|
+
this.#datetimeWatch.observe(this.element, { attributeFilter: ["datetime"] });
|
|
64
|
+
this.#render();
|
|
65
|
+
}
|
|
66
|
+
disconnect() {
|
|
67
|
+
this.#resync.cancel();
|
|
68
|
+
this.#datetimeWatch.disconnect();
|
|
69
|
+
}
|
|
70
|
+
/** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
|
|
71
|
+
localeValueChanged() {
|
|
72
|
+
this.#resync.schedule();
|
|
73
|
+
}
|
|
74
|
+
/** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
|
|
75
|
+
timeZoneValueChanged() {
|
|
76
|
+
this.#resync.schedule();
|
|
77
|
+
}
|
|
78
|
+
/** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
|
|
79
|
+
dateStyleValueChanged() {
|
|
80
|
+
this.#resync.schedule();
|
|
81
|
+
}
|
|
82
|
+
/** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
|
|
83
|
+
timeStyleValueChanged() {
|
|
84
|
+
this.#resync.schedule();
|
|
85
|
+
}
|
|
86
|
+
/** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
|
|
87
|
+
titleFormatValueChanged() {
|
|
88
|
+
this.#resync.schedule();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Formats the instant in `datetime` against the current Values and writes it out.
|
|
92
|
+
*
|
|
93
|
+
* The `format` event rides with every pass, including a repaint a morph triggers:
|
|
94
|
+
* its condition is that formatting was applied, and a repaint applies it with a
|
|
95
|
+
* new result. A pass that cannot format writes nothing and emits nothing, so the
|
|
96
|
+
* authored absolute text stays as the fallback.
|
|
97
|
+
*
|
|
98
|
+
* @stimeoRenderRoot
|
|
99
|
+
*/
|
|
100
|
+
#render() {
|
|
18
101
|
const date = this.#parse();
|
|
19
102
|
if (date === null) return;
|
|
20
103
|
const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
|
|
@@ -24,7 +107,10 @@ var LocalTimeController = class extends Controller {
|
|
|
24
107
|
if (title !== null) this.element.setAttribute("title", title);
|
|
25
108
|
this.dispatch("format", { detail: { formatted } });
|
|
26
109
|
}
|
|
27
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace
|
|
112
|
+
* around the attribute value is tolerated.
|
|
113
|
+
*/
|
|
28
114
|
#parse() {
|
|
29
115
|
const raw = this.element.getAttribute("datetime");
|
|
30
116
|
if (!raw) return null;
|
|
@@ -37,11 +123,21 @@ var LocalTimeController = class extends Controller {
|
|
|
37
123
|
* the *runtime's* local zone, contradicting "the server emits UTC". Values that
|
|
38
124
|
* already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
|
|
39
125
|
* parsed as UTC) are returned unchanged.
|
|
126
|
+
*
|
|
127
|
+
* HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is
|
|
128
|
+
* left to each engine, so a whole value shaped that way is normalized to the `T`
|
|
129
|
+
* separator first. The pattern is anchored: a value trailing anything else — a
|
|
130
|
+
* zone word such as `"2026-06-08 12:30:00 UTC"` — is handed to `Date.parse` as
|
|
131
|
+
* authored instead of being turned into a string nothing can parse.
|
|
40
132
|
*/
|
|
41
133
|
#asUtc(value) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
134
|
+
const isoLike = value.replace(
|
|
135
|
+
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$/,
|
|
136
|
+
"$1T$2"
|
|
137
|
+
);
|
|
138
|
+
const hasTime = /T\d{2}:\d{2}/.test(isoLike);
|
|
139
|
+
const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(isoLike);
|
|
140
|
+
return hasTime && !hasZone ? `${isoLike}Z` : isoLike;
|
|
45
141
|
}
|
|
46
142
|
/**
|
|
47
143
|
* Builds the optional detailed `title`. `titleFormat` is an `Intl` style
|
|
@@ -70,9 +166,9 @@ var LocalTimeController = class extends Controller {
|
|
|
70
166
|
return null;
|
|
71
167
|
}
|
|
72
168
|
}
|
|
73
|
-
/** Locale precedence: the value, then the
|
|
169
|
+
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
74
170
|
get #locale() {
|
|
75
|
-
return this.localeValue || this.element.lang
|
|
171
|
+
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
76
172
|
}
|
|
77
173
|
};
|
|
78
174
|
|
|
@@ -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 = {
|