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,162 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/spinner_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/default_attribute.ts
|
|
52
|
+
function setDefaultAttribute(element, name, value) {
|
|
53
|
+
if (element.hasAttribute(name)) return false;
|
|
54
|
+
element.setAttribute(name, value);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/utils/detach_gate.ts
|
|
59
|
+
var DetachGate = class _DetachGate {
|
|
60
|
+
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
61
|
+
#pending = false;
|
|
62
|
+
/**
|
|
63
|
+
* True while a probe is queued — the last disconnect was ambiguous and no
|
|
64
|
+
* reconnect has cancelled it yet. Read it from `connect()` to tell the
|
|
65
|
+
* reconnect half of an in-page move from a first connect: a controller whose
|
|
66
|
+
* initialisation restarts a measurement (a min-duration floor, an elapsed
|
|
67
|
+
* counter) must skip it for the move, where nothing actually restarted.
|
|
68
|
+
*/
|
|
69
|
+
get pending() {
|
|
70
|
+
return this.#pending;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* True when the disconnect is definitely a real detach — the element left
|
|
74
|
+
* the document, or `data-controller` no longer lists the identifier. False
|
|
75
|
+
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
76
|
+
*/
|
|
77
|
+
static isDetached(host) {
|
|
78
|
+
if (!host.element.isConnected) return true;
|
|
79
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
80
|
+
return !tokens.includes(host.identifier);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
84
|
+
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
85
|
+
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
86
|
+
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
87
|
+
* element within the same mutation batch, before the checkpoint drains.
|
|
88
|
+
*/
|
|
89
|
+
disconnected(host, teardown) {
|
|
90
|
+
if (_DetachGate.isDetached(host)) {
|
|
91
|
+
this.#pending = false;
|
|
92
|
+
teardown();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.#pending = true;
|
|
96
|
+
queueMicrotask(() => {
|
|
97
|
+
if (!this.#pending) return;
|
|
98
|
+
this.#pending = false;
|
|
99
|
+
teardown();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
104
|
+
* an in-page move) and from the head of any teardown path not routed through
|
|
105
|
+
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
106
|
+
* never run the teardown a second time.
|
|
107
|
+
*/
|
|
108
|
+
cancel() {
|
|
109
|
+
this.#pending = false;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// src/utils/min_duration_floor.ts
|
|
114
|
+
var MinDurationFloor = class {
|
|
115
|
+
#timers;
|
|
116
|
+
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
117
|
+
#timerId = null;
|
|
118
|
+
/** Epoch ms the floor is measured from. */
|
|
119
|
+
#since = 0;
|
|
120
|
+
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
121
|
+
constructor(timers) {
|
|
122
|
+
this.#timers = timers;
|
|
123
|
+
}
|
|
124
|
+
/** Starts the floor: call when the state being held becomes visible. */
|
|
125
|
+
begin() {
|
|
126
|
+
this.#since = Date.now();
|
|
127
|
+
}
|
|
128
|
+
/** True while a finish is held back waiting for the floor to elapse. */
|
|
129
|
+
get pending() {
|
|
130
|
+
return this.#timerId !== null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
134
|
+
*
|
|
135
|
+
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
136
|
+
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
137
|
+
* end a state that has since restarted. Controllers that want the first signal
|
|
138
|
+
* to win guard on {@link pending} before calling.
|
|
139
|
+
*/
|
|
140
|
+
schedule(minDuration, finish) {
|
|
141
|
+
this.cancel();
|
|
142
|
+
const remaining = minDuration - (Date.now() - this.#since);
|
|
143
|
+
if (remaining > 0) {
|
|
144
|
+
this.#timerId = this.#timers.set(() => {
|
|
145
|
+
this.#timerId = null;
|
|
146
|
+
finish();
|
|
147
|
+
}, remaining);
|
|
148
|
+
} else {
|
|
149
|
+
finish();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
153
|
+
cancel() {
|
|
154
|
+
if (this.#timerId !== null) {
|
|
155
|
+
this.#timers.clear(this.#timerId);
|
|
156
|
+
this.#timerId = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
5
161
|
// src/utils/safe_timeout.ts
|
|
6
162
|
var TimerRegistry = class {
|
|
7
163
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,38 +215,59 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
215
|
var SpinnerController = class extends Controller {
|
|
60
216
|
static targets = ["indicator", "region", "message"];
|
|
61
217
|
static values = {
|
|
218
|
+
announceText: { type: String, default: "" },
|
|
219
|
+
announceReadyText: { type: String, default: "" },
|
|
62
220
|
delay: { type: Number, default: 0 },
|
|
63
|
-
minDuration: { type: Number, default: 0 }
|
|
221
|
+
minDuration: { type: Number, default: 0 },
|
|
222
|
+
timeout: { type: Number, default: 0 }
|
|
64
223
|
};
|
|
65
224
|
static actions = ["start", "stop"];
|
|
66
|
-
static events = ["hide", "show"];
|
|
225
|
+
static events = ["hide", "show", "timeout"];
|
|
67
226
|
#timers = new SafeTimeout();
|
|
227
|
+
#floor = new MinDurationFloor(this.#timers);
|
|
228
|
+
#gate = new DetachGate();
|
|
229
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
68
230
|
/** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
|
|
69
231
|
#delayTimerId = null;
|
|
70
|
-
/** Pending
|
|
71
|
-
#
|
|
72
|
-
/** Epoch ms when the spinner became visible; `minDuration` is measured from it. */
|
|
73
|
-
#shownAt = 0;
|
|
232
|
+
/** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
|
|
233
|
+
#timeoutTimerId = null;
|
|
74
234
|
connect() {
|
|
75
|
-
|
|
235
|
+
this.#gate.cancel();
|
|
236
|
+
this.#beforeCache.activate();
|
|
237
|
+
if (this.#state === "pending" && this.#delayTimerId === null) {
|
|
238
|
+
this.#setBusy(false);
|
|
76
239
|
this.element.setAttribute("data-state", "idle");
|
|
240
|
+
return;
|
|
77
241
|
}
|
|
242
|
+
setDefaultAttribute(this.element, "data-state", "idle");
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Re-applies the current phase to an indicator that arrived after `connect()`.
|
|
246
|
+
*
|
|
247
|
+
* A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
|
|
248
|
+
* carries the markup contract's `hidden`. Without this the spinner would vanish
|
|
249
|
+
* while `data-state` still says `loading`, and nothing but the next cycle would
|
|
250
|
+
* bring it back.
|
|
251
|
+
*/
|
|
252
|
+
indicatorTargetConnected(target) {
|
|
253
|
+
target.hidden = this.#state !== "loading";
|
|
78
254
|
}
|
|
79
255
|
disconnect() {
|
|
80
|
-
this.#
|
|
81
|
-
this.#
|
|
82
|
-
this.#hideTimerId = null;
|
|
256
|
+
this.#beforeCache.deactivate();
|
|
257
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
83
258
|
}
|
|
84
259
|
/** Begins loading. Honors `delay` before the spinner actually appears. */
|
|
85
260
|
start() {
|
|
86
261
|
if (this.#state === "loading") {
|
|
87
262
|
this.#setBusy(true);
|
|
88
|
-
this.#
|
|
263
|
+
this.#floor.cancel();
|
|
264
|
+
this.#armTimeout();
|
|
89
265
|
return;
|
|
90
266
|
}
|
|
91
267
|
if (this.#state !== "idle") return;
|
|
92
268
|
this.#setBusy(true);
|
|
93
|
-
this.#
|
|
269
|
+
this.#floor.cancel();
|
|
270
|
+
this.#armTimeout();
|
|
94
271
|
if (this.delayValue > 0) {
|
|
95
272
|
this.element.setAttribute("data-state", "pending");
|
|
96
273
|
this.#delayTimerId = this.#timers.set(() => {
|
|
@@ -104,6 +281,7 @@ var SpinnerController = class extends Controller {
|
|
|
104
281
|
/** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
|
|
105
282
|
stop() {
|
|
106
283
|
const state = this.#state;
|
|
284
|
+
this.#cancelTimeout();
|
|
107
285
|
if (state === "pending") {
|
|
108
286
|
this.#cancelDelay();
|
|
109
287
|
this.#setBusy(false);
|
|
@@ -112,28 +290,52 @@ var SpinnerController = class extends Controller {
|
|
|
112
290
|
}
|
|
113
291
|
if (state !== "loading") return;
|
|
114
292
|
this.#setBusy(false);
|
|
115
|
-
|
|
116
|
-
if (remaining > 0) {
|
|
117
|
-
this.#hideTimerId = this.#timers.set(() => {
|
|
118
|
-
this.#hideTimerId = null;
|
|
119
|
-
this.#hide();
|
|
120
|
-
}, remaining);
|
|
121
|
-
} else {
|
|
122
|
-
this.#hide();
|
|
123
|
-
}
|
|
293
|
+
this.#floor.schedule(this.minDurationValue, () => this.#hide());
|
|
124
294
|
}
|
|
125
295
|
/** Reveals the indicator, marks the moment shown, and announces via the live region. */
|
|
126
296
|
#show() {
|
|
127
|
-
this.#
|
|
297
|
+
this.#floor.begin();
|
|
298
|
+
this.#setBusy(true);
|
|
128
299
|
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
|
|
129
300
|
this.element.setAttribute("data-state", "loading");
|
|
130
301
|
this.dispatch("show", { detail: {} });
|
|
302
|
+
announce(fillTemplate(this.announceTextValue, {}));
|
|
131
303
|
}
|
|
132
304
|
/** Hides the indicator and returns to the idle state. */
|
|
133
305
|
#hide() {
|
|
134
306
|
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
|
|
135
307
|
this.element.setAttribute("data-state", "idle");
|
|
136
308
|
this.dispatch("hide", { detail: {} });
|
|
309
|
+
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Drops both timers on a real detach. The markup keeps whatever it last held: an
|
|
313
|
+
* element on its way out of the document has no reader left, and one whose
|
|
314
|
+
* `data-controller` dropped the identifier no longer resolves its own targets, so
|
|
315
|
+
* the rollback could only ever be partial. The snapshot is rewound where it is
|
|
316
|
+
* still whole, on `turbo:before-cache`.
|
|
317
|
+
*/
|
|
318
|
+
#teardown() {
|
|
319
|
+
this.#gate.cancel();
|
|
320
|
+
this.#timers.clearAll();
|
|
321
|
+
this.#delayTimerId = null;
|
|
322
|
+
this.#timeoutTimerId = null;
|
|
323
|
+
this.#floor.cancel();
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Returns the loading state to idle for the snapshot Turbo is about to take,
|
|
327
|
+
* so a page reached with the Back button is not restored mid-load with a
|
|
328
|
+
* spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
|
|
329
|
+
* and `aria-busy`. No `hide` is dispatched — the load was never observed to
|
|
330
|
+
* finish, and a snapshot rewind is not a lifecycle event the consumer can act
|
|
331
|
+
* on. The live page keeps its timers, so a navigation that never completes
|
|
332
|
+
* leaves the running cycle intact.
|
|
333
|
+
*/
|
|
334
|
+
#rewindForCache() {
|
|
335
|
+
this.#cancelTimeout();
|
|
336
|
+
this.#setBusy(false);
|
|
337
|
+
if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
|
|
338
|
+
this.element.setAttribute("data-state", "idle");
|
|
137
339
|
}
|
|
138
340
|
/** Reflects busy state onto the controlled region (if present). */
|
|
139
341
|
#setBusy(busy) {
|
|
@@ -141,18 +343,32 @@ var SpinnerController = class extends Controller {
|
|
|
141
343
|
this.regionTarget.setAttribute("aria-busy", String(busy));
|
|
142
344
|
}
|
|
143
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Arms the safety net so a `stop` that never arrives cannot strand the spinner.
|
|
348
|
+
* Off by default: the consumer owns the async work, so only it knows whether a
|
|
349
|
+
* ceiling makes sense. Re-arming on a restart measures from the newest start.
|
|
350
|
+
*/
|
|
351
|
+
#armTimeout() {
|
|
352
|
+
this.#cancelTimeout();
|
|
353
|
+
if (this.timeoutValue <= 0) return;
|
|
354
|
+
this.#timeoutTimerId = this.#timers.set(() => {
|
|
355
|
+
this.#timeoutTimerId = null;
|
|
356
|
+
this.dispatch("timeout", { detail: {} });
|
|
357
|
+
this.stop();
|
|
358
|
+
}, this.timeoutValue);
|
|
359
|
+
}
|
|
360
|
+
#cancelTimeout() {
|
|
361
|
+
if (this.#timeoutTimerId !== null) {
|
|
362
|
+
this.#timers.clear(this.#timeoutTimerId);
|
|
363
|
+
this.#timeoutTimerId = null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
144
366
|
#cancelDelay() {
|
|
145
367
|
if (this.#delayTimerId !== null) {
|
|
146
368
|
this.#timers.clear(this.#delayTimerId);
|
|
147
369
|
this.#delayTimerId = null;
|
|
148
370
|
}
|
|
149
371
|
}
|
|
150
|
-
#cancelHide() {
|
|
151
|
-
if (this.#hideTimerId !== null) {
|
|
152
|
-
this.#timers.clear(this.#hideTimerId);
|
|
153
|
-
this.#hideTimerId = null;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
372
|
/** Current lifecycle phase as reflected on `data-state`. */
|
|
157
373
|
get #state() {
|
|
158
374
|
return this.element.getAttribute("data-state") ?? "idle";
|
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/step_indicator_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/step_indicator_controller.ts
|
|
4
39
|
var StepIndicatorController = class extends Controller {
|
|
5
40
|
static targets = ["step"];
|
|
@@ -8,26 +43,65 @@ var StepIndicatorController = class extends Controller {
|
|
|
8
43
|
};
|
|
9
44
|
static actions = ["setCurrent"];
|
|
10
45
|
static events = ["change"];
|
|
46
|
+
/**
|
|
47
|
+
* Whether the target callbacks may render. Stimulus reports the authored steps
|
|
48
|
+
* as connected before `connect()` and the remaining ones as disconnected after
|
|
49
|
+
* `disconnect()`, so this keeps a connect at one render pass, not one per step.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* Collapses a batch of step callbacks — and a morph that swaps `current` with
|
|
53
|
+
* them — into one repaint. Replacing a list of N steps delivers N callbacks, and
|
|
54
|
+
* each one would otherwise rewrite every step's state.
|
|
55
|
+
*/
|
|
56
|
+
#repaint = new MicrotaskCoalescer(() => this.#render());
|
|
11
57
|
/** Renders the initial state from the `current` value. */
|
|
12
58
|
connect() {
|
|
59
|
+
this.#repaint.activate();
|
|
13
60
|
this.#render();
|
|
14
61
|
}
|
|
62
|
+
/** Closes the window in which a queued repaint may still run. */
|
|
63
|
+
disconnect() {
|
|
64
|
+
this.#repaint.cancel();
|
|
65
|
+
}
|
|
66
|
+
/** Syncs a step appended or replaced at runtime (the consumer owns the list). */
|
|
67
|
+
stepTargetConnected() {
|
|
68
|
+
this.#repaint.schedule();
|
|
69
|
+
}
|
|
70
|
+
/** Re-derives the remaining steps when one is removed at runtime. */
|
|
71
|
+
stepTargetDisconnected() {
|
|
72
|
+
this.#repaint.schedule();
|
|
73
|
+
}
|
|
74
|
+
/** Repaints when application code (or a Turbo morph) changes `current` at runtime. */
|
|
75
|
+
currentValueChanged() {
|
|
76
|
+
this.#repaint.schedule();
|
|
77
|
+
}
|
|
15
78
|
/**
|
|
16
79
|
* Updates the current step from an external event (`detail.current`, 0-based)
|
|
17
|
-
* and dispatches `change`. Out-of-range indices are clamped to the step set
|
|
80
|
+
* and dispatches `change`. Out-of-range indices are clamped to the step set,
|
|
81
|
+
* and both sides of the no-op test are clamped, so moving onto the step an
|
|
82
|
+
* out-of-range `current` already renders is not reported as a change.
|
|
18
83
|
*/
|
|
19
84
|
setCurrent(event) {
|
|
20
85
|
const next = event.detail?.current;
|
|
21
86
|
if (typeof next !== "number" || !Number.isFinite(next)) return;
|
|
22
87
|
const clamped = this.#clamp(next);
|
|
23
|
-
|
|
88
|
+
const moved = clamped !== this.#clamp(this.currentValue);
|
|
24
89
|
this.currentValue = clamped;
|
|
90
|
+
if (!moved) return;
|
|
25
91
|
this.#render();
|
|
26
92
|
this.dispatch("change", {
|
|
27
93
|
detail: { current: clamped, total: this.stepTargets.length }
|
|
28
94
|
});
|
|
29
95
|
}
|
|
30
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Applies `data-state`, `aria-current`, and the progress ratio custom property.
|
|
98
|
+
*
|
|
99
|
+
* A pure function of the step set and `current`, so running it again writes the
|
|
100
|
+
* same values — which is what lets the action path paint synchronously (the event
|
|
101
|
+
* goes out after the DOM is updated) while a coalesced pass may still follow.
|
|
102
|
+
*
|
|
103
|
+
* @stimeoRenderRoot
|
|
104
|
+
*/
|
|
31
105
|
#render() {
|
|
32
106
|
const total = this.stepTargets.length;
|
|
33
107
|
const current = this.#clamp(this.currentValue);
|
|
@@ -40,12 +114,17 @@ var StepIndicatorController = class extends Controller {
|
|
|
40
114
|
}
|
|
41
115
|
});
|
|
42
116
|
const ratio = total > 1 ? current / (total - 1) : 0;
|
|
43
|
-
this.element.style.setProperty("--stimeo
|
|
117
|
+
this.element.style.setProperty("--stimeo--step-indicator-ratio", String(ratio));
|
|
44
118
|
}
|
|
45
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* Constrains an index to `[0, total-1]` (or `0` when there are no steps). A
|
|
121
|
+
* non-finite index falls back to the first step: `current` is read from markup,
|
|
122
|
+
* so an unparsable attribute arrives as `NaN` and would otherwise propagate
|
|
123
|
+
* into every state hook.
|
|
124
|
+
*/
|
|
46
125
|
#clamp(index) {
|
|
47
126
|
const last = this.stepTargets.length - 1;
|
|
48
|
-
if (last < 0) return 0;
|
|
127
|
+
if (last < 0 || !Number.isFinite(index)) return 0;
|
|
49
128
|
return Math.min(last, Math.max(0, Math.trunc(index)));
|
|
50
129
|
}
|
|
51
130
|
};
|
|
@@ -68,6 +68,8 @@ var StepperController = class extends Controller {
|
|
|
68
68
|
* `aria-current="step"` is placed on the step's **first** `<button>`; the markup
|
|
69
69
|
* contract assumes one operable button per step. If a step needs multiple
|
|
70
70
|
* buttons, mark the navigational one first (or this would target the wrong one).
|
|
71
|
+
*
|
|
72
|
+
* @stimeoRenderRoot
|
|
71
73
|
*/
|
|
72
74
|
#render(current) {
|
|
73
75
|
this.stepTargets.forEach((step, index) => {
|
|
@@ -17,14 +17,18 @@ var StickToBottomController = class extends Controller {
|
|
|
17
17
|
static targets = ["content"];
|
|
18
18
|
static values = {
|
|
19
19
|
threshold: { type: Number, default: 80 },
|
|
20
|
-
behavior: { type: String, default: "auto" }
|
|
20
|
+
behavior: { type: String, default: "auto" },
|
|
21
|
+
pinOnConnect: { type: Boolean, default: false }
|
|
21
22
|
};
|
|
22
23
|
static actions = ["scrollToBottom"];
|
|
23
24
|
static events = ["pin", "new"];
|
|
24
25
|
#observer = null;
|
|
26
|
+
/** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
|
|
27
|
+
#layout = null;
|
|
25
28
|
#pinned = false;
|
|
26
29
|
#onScroll = () => this.#updatePinned();
|
|
27
30
|
connect() {
|
|
31
|
+
if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
|
|
28
32
|
this.#pinned = this.#isPinned();
|
|
29
33
|
this.#reflectPinned();
|
|
30
34
|
this.element.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
@@ -32,21 +36,29 @@ var StickToBottomController = class extends Controller {
|
|
|
32
36
|
this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
|
|
33
37
|
this.#observer.observe(this.#watched(), { childList: true });
|
|
34
38
|
}
|
|
39
|
+
if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
|
|
35
40
|
}
|
|
36
41
|
disconnect() {
|
|
37
42
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
38
43
|
this.#observer?.disconnect();
|
|
39
44
|
this.#observer = null;
|
|
45
|
+
this.#stopWaitingForLayout();
|
|
40
46
|
}
|
|
41
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Jumps to the bottom and re-pins (wired to a "new messages" button).
|
|
49
|
+
*
|
|
50
|
+
* The has-new flag clears on request — the user has acknowledged the arrival — while
|
|
51
|
+
* pinned is read back from where the scroll landed: a jump that arrives by the time
|
|
52
|
+
* this returns pins immediately, an animated one settles from its own scroll events,
|
|
53
|
+
* and a jump the engine cannot honor leaves the container unpinned, so the next append
|
|
54
|
+
* flags it again instead of being swallowed by a pinned state that does not hold.
|
|
55
|
+
*
|
|
56
|
+
* Which of those happens is not this method's to decide — see {@link behaviorValue}.
|
|
57
|
+
*/
|
|
42
58
|
scrollToBottom() {
|
|
43
59
|
this.#scrollToBottom();
|
|
44
60
|
this.element.removeAttribute("data-has-new");
|
|
45
|
-
|
|
46
|
-
this.#pinned = true;
|
|
47
|
-
this.element.setAttribute("data-pinned", "true");
|
|
48
|
-
this.dispatch("pin", { detail: { pinned: true } });
|
|
49
|
-
}
|
|
61
|
+
this.#updatePinned();
|
|
50
62
|
}
|
|
51
63
|
/** Follows appended children while pinned; otherwise flags new content. */
|
|
52
64
|
#onMutations(mutations) {
|
|
@@ -81,10 +93,43 @@ var StickToBottomController = class extends Controller {
|
|
|
81
93
|
const el = this.element;
|
|
82
94
|
return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
|
|
83
95
|
}
|
|
84
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Whether the container has a box to scroll and to measure. One that is not rendered
|
|
98
|
+
* (inside a closed panel) reports every metric as 0, which reads as "already at the
|
|
99
|
+
* bottom" — a position describing no layout the user will ever see.
|
|
100
|
+
*/
|
|
101
|
+
#measurable() {
|
|
102
|
+
return this.element.clientHeight > 0;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Holds the `pinOnConnect` jump until the container is laid out, then runs it and
|
|
106
|
+
* re-reads the state — otherwise the panel opens at the top still claiming the bottom.
|
|
107
|
+
*/
|
|
108
|
+
#pinWhenLaidOut() {
|
|
109
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
110
|
+
this.#layout = new ResizeObserver(() => {
|
|
111
|
+
if (!this.#measurable()) return;
|
|
112
|
+
this.#stopWaitingForLayout();
|
|
113
|
+
this.#scrollToBottom("instant");
|
|
114
|
+
this.#updatePinned();
|
|
115
|
+
});
|
|
116
|
+
this.#layout.observe(this.element);
|
|
117
|
+
}
|
|
118
|
+
/** Releases the layout watch, whether or not the deferred jump ever ran. */
|
|
119
|
+
#stopWaitingForLayout() {
|
|
120
|
+
this.#layout?.disconnect();
|
|
121
|
+
this.#layout = null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
|
|
125
|
+
* 0 for a container tall enough to hold its whole content, so the jump moves nothing
|
|
126
|
+
* there. `behavior` defaults to the configured follow behavior; pass `"instant"` for a
|
|
127
|
+
* jump that must not animate.
|
|
128
|
+
*/
|
|
129
|
+
#scrollToBottom(behavior = this.#behavior()) {
|
|
85
130
|
const top = this.element.scrollHeight;
|
|
86
131
|
if (typeof this.element.scrollTo === "function") {
|
|
87
|
-
this.element.scrollTo({ top, behavior
|
|
132
|
+
this.element.scrollTo({ top, behavior });
|
|
88
133
|
} else {
|
|
89
134
|
this.element.scrollTop = top;
|
|
90
135
|
}
|
|
@@ -93,7 +138,12 @@ var StickToBottomController = class extends Controller {
|
|
|
93
138
|
#watched() {
|
|
94
139
|
return this.hasContentTarget ? this.contentTarget : this.element;
|
|
95
140
|
}
|
|
96
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
|
|
143
|
+
* once: it hands the decision to the element's computed `scroll-behavior`, so a
|
|
144
|
+
* consumer stylesheet saying `smooth` animates these scrolls too. Only `"instant"`
|
|
145
|
+
* overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.
|
|
146
|
+
*/
|
|
97
147
|
#behavior() {
|
|
98
148
|
if (prefersReducedMotion()) return "instant";
|
|
99
149
|
return this.behaviorValue === "smooth" ? "smooth" : "auto";
|