stimeo-ui 0.3.0 → 0.4.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 +56 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/color_picker_controller.js +46 -0
- data/dist/controllers/command_palette_controller.js +32 -5
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/date_range_picker_controller.js +50 -0
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/local_time_controller.js +100 -6
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/overflow_menu_controller.js +33 -6
- data/dist/controllers/progress_controller.js +116 -9
- data/dist/controllers/range_slider_controller.js +68 -3
- data/dist/controllers/rating_controller.js +53 -0
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +60 -10
- data/dist/index.js +1056 -281
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,68 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/relative_time_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/utils/microtask_coalescer.ts
|
|
35
|
+
var MicrotaskCoalescer = class {
|
|
36
|
+
#run;
|
|
37
|
+
#queued = false;
|
|
38
|
+
#active = false;
|
|
39
|
+
#generation = 0;
|
|
40
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
41
|
+
constructor(run) {
|
|
42
|
+
this.#run = run;
|
|
43
|
+
}
|
|
44
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
45
|
+
activate() {
|
|
46
|
+
this.#active = true;
|
|
47
|
+
}
|
|
48
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
49
|
+
cancel() {
|
|
50
|
+
this.#active = false;
|
|
51
|
+
this.#queued = false;
|
|
52
|
+
this.#generation += 1;
|
|
53
|
+
}
|
|
54
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
55
|
+
schedule() {
|
|
56
|
+
if (!this.#active || this.#queued) return;
|
|
57
|
+
this.#queued = true;
|
|
58
|
+
const generation = this.#generation;
|
|
59
|
+
queueMicrotask(() => {
|
|
60
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
61
|
+
this.#queued = false;
|
|
62
|
+
this.#run();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
5
67
|
// src/utils/safe_timeout.ts
|
|
6
68
|
var TimerRegistry = class {
|
|
7
69
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -73,11 +135,16 @@ var RelativeTimeController = class extends Controller {
|
|
|
73
135
|
tickInterval: { type: Number, default: 6e4 }
|
|
74
136
|
};
|
|
75
137
|
#timers = new SafeTimeout();
|
|
138
|
+
/** Collapses a morph that swaps several render inputs at once into one repaint. */
|
|
139
|
+
#resync = new MicrotaskCoalescer(() => this.#resyncToValues());
|
|
140
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
76
141
|
/** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */
|
|
77
142
|
#targetMs = Number.NaN;
|
|
78
143
|
/** The authored absolute text, restored when the threshold fallback kicks in. */
|
|
79
144
|
#absoluteText = "";
|
|
80
145
|
connect() {
|
|
146
|
+
this.#resync.activate();
|
|
147
|
+
this.#beforeCache.activate();
|
|
81
148
|
if (this.element.getAttribute("data-state") !== "relative") {
|
|
82
149
|
this.#absoluteText = (this.element.textContent ?? "").trim();
|
|
83
150
|
}
|
|
@@ -86,9 +153,50 @@ var RelativeTimeController = class extends Controller {
|
|
|
86
153
|
this.#schedule();
|
|
87
154
|
}
|
|
88
155
|
disconnect() {
|
|
156
|
+
this.#resync.cancel();
|
|
157
|
+
this.#beforeCache.deactivate();
|
|
158
|
+
this.#timers.clearAll();
|
|
159
|
+
}
|
|
160
|
+
/** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
|
|
161
|
+
localeValueChanged() {
|
|
162
|
+
this.#resync.schedule();
|
|
163
|
+
}
|
|
164
|
+
/** Repaints when application code (or a Turbo morph) changes `threshold` at runtime. */
|
|
165
|
+
thresholdValueChanged() {
|
|
166
|
+
this.#resync.schedule();
|
|
167
|
+
}
|
|
168
|
+
/** Repaints when application code (or a Turbo morph) changes `tickInterval` at runtime. */
|
|
169
|
+
tickIntervalValueChanged() {
|
|
170
|
+
this.#resync.schedule();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Renders against the current Values and re-arms the poll from now.
|
|
174
|
+
*
|
|
175
|
+
* Render only: it emits no event, and clearing first keeps the single self-arming
|
|
176
|
+
* timer single — scheduling on top of a pending one would double the poll rate for
|
|
177
|
+
* the rest of the session. A stamp whose `datetime` never parsed has nothing to
|
|
178
|
+
* render, and one that already reached its terminal fallback simply renders it
|
|
179
|
+
* again and stops.
|
|
180
|
+
*/
|
|
181
|
+
#resyncToValues() {
|
|
182
|
+
if (Number.isNaN(this.#targetMs)) return;
|
|
89
183
|
this.#timers.clearAll();
|
|
184
|
+
this.#schedule();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Restores the authored absolute text and the pre-render state for the snapshot
|
|
188
|
+
* Turbo is about to take, leaving the live page's poll timer alone.
|
|
189
|
+
*
|
|
190
|
+
* With no authored text held there is nothing to restore, and `data-state` has to
|
|
191
|
+
* stay as it is: that marker is what tells the next `connect()` the visible text is
|
|
192
|
+
* a rendered relative form rather than an absolute fallback to hold on to.
|
|
193
|
+
*/
|
|
194
|
+
#rewindForCache() {
|
|
195
|
+
if (!this.#absoluteText) return;
|
|
196
|
+
this.element.textContent = this.#absoluteText;
|
|
197
|
+
this.element.removeAttribute("data-state");
|
|
90
198
|
}
|
|
91
|
-
/** Renders the current representation and reschedules unless
|
|
199
|
+
/** Renders the current representation and reschedules unless polling can stop. */
|
|
92
200
|
#schedule() {
|
|
93
201
|
const nextDelay = this.#applyAndComputeDelay();
|
|
94
202
|
if (nextDelay !== null) {
|
|
@@ -96,31 +204,44 @@ var RelativeTimeController = class extends Controller {
|
|
|
96
204
|
}
|
|
97
205
|
}
|
|
98
206
|
/**
|
|
99
|
-
* Updates the visible text and returns the next poll delay (ms), or `null`
|
|
100
|
-
*
|
|
207
|
+
* Updates the visible text and returns the next poll delay (ms), or `null` when
|
|
208
|
+
* polling can stop: a *past* timestamp that fell back to the absolute text can
|
|
209
|
+
* never leave it again, and a locale the runtime rejects has nothing to render
|
|
210
|
+
* until that value is corrected.
|
|
101
211
|
*/
|
|
102
212
|
#applyAndComputeDelay() {
|
|
103
213
|
const deltaMs = this.#targetMs - Date.now();
|
|
104
214
|
const absSeconds = Math.abs(deltaMs) / 1e3;
|
|
215
|
+
const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
|
|
216
|
+
const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
|
|
217
|
+
const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
105
218
|
if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
|
|
106
219
|
this.element.textContent = this.#absoluteText;
|
|
107
220
|
this.element.setAttribute("data-state", "absolute");
|
|
108
|
-
return null;
|
|
221
|
+
if (deltaMs <= 0) return null;
|
|
222
|
+
return Math.min(nextDelay, deltaMs - this.thresholdValue * 1e3 + 1);
|
|
109
223
|
}
|
|
110
|
-
const
|
|
224
|
+
const formatter = this.#formatter;
|
|
225
|
+
if (formatter === null) return null;
|
|
111
226
|
const value = Math.round(deltaMs / scale.ms);
|
|
112
|
-
this.element.textContent =
|
|
227
|
+
this.element.textContent = formatter.format(value, scale.unit);
|
|
113
228
|
this.element.setAttribute("data-state", "relative");
|
|
114
|
-
|
|
115
|
-
return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
229
|
+
return nextDelay;
|
|
116
230
|
}
|
|
117
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`), or `null`
|
|
233
|
+
* when the runtime rejects that locale.
|
|
234
|
+
*/
|
|
118
235
|
get #formatter() {
|
|
119
|
-
|
|
236
|
+
try {
|
|
237
|
+
return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
120
241
|
}
|
|
121
|
-
/** Locale precedence: the value, then the
|
|
242
|
+
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
122
243
|
get #locale() {
|
|
123
|
-
return this.localeValue || this.element.lang
|
|
244
|
+
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
124
245
|
}
|
|
125
246
|
};
|
|
126
247
|
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/sidebar_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -474,7 +501,7 @@ var SidebarController = class extends Controller {
|
|
|
474
501
|
key: { type: String, default: "" },
|
|
475
502
|
collapsed: { type: Boolean, default: false }
|
|
476
503
|
};
|
|
477
|
-
static actions = ["
|
|
504
|
+
static actions = ["close", "open", "toggle"];
|
|
478
505
|
/** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
|
|
479
506
|
#activePanel = null;
|
|
480
507
|
/** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
|
|
@@ -496,6 +523,7 @@ var SidebarController = class extends Controller {
|
|
|
496
523
|
#connected = false;
|
|
497
524
|
connect() {
|
|
498
525
|
this.#connected = true;
|
|
526
|
+
this.#beforeCache.activate();
|
|
499
527
|
this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
|
|
500
528
|
this.#collapsed = this.#restoreCollapsed();
|
|
501
529
|
this.#mqlQuery = this.#breakpointQuery;
|
|
@@ -505,6 +533,7 @@ var SidebarController = class extends Controller {
|
|
|
505
533
|
}
|
|
506
534
|
disconnect() {
|
|
507
535
|
this.#connected = false;
|
|
536
|
+
this.#beforeCache.deactivate();
|
|
508
537
|
this.#mql?.removeEventListener("change", this.#onMediaChange);
|
|
509
538
|
this.#mql = null;
|
|
510
539
|
this.#mqlQuery = null;
|
|
@@ -556,12 +585,12 @@ var SidebarController = class extends Controller {
|
|
|
556
585
|
* closed immediately, and modal side effects are released without moving
|
|
557
586
|
* focus during navigation.
|
|
558
587
|
*/
|
|
559
|
-
beforeCache() {
|
|
588
|
+
#beforeCache = new BeforeCacheReset(() => {
|
|
560
589
|
if (!this.#connected || !this.#isOverlay) return;
|
|
561
590
|
this.#transition.cancel();
|
|
562
591
|
this.#trap.deactivate({ restoreFocus: false });
|
|
563
592
|
this.#setOverlayClosedImmediate();
|
|
564
|
-
}
|
|
593
|
+
});
|
|
565
594
|
/** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
|
|
566
595
|
toggle() {
|
|
567
596
|
if (this.#isOverlay) {
|
|
@@ -2,6 +2,71 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/skeleton_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/min_duration_floor.ts
|
|
23
|
+
var MinDurationFloor = class {
|
|
24
|
+
#timers;
|
|
25
|
+
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
26
|
+
#timerId = null;
|
|
27
|
+
/** Epoch ms the floor is measured from. */
|
|
28
|
+
#since = 0;
|
|
29
|
+
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
30
|
+
constructor(timers) {
|
|
31
|
+
this.#timers = timers;
|
|
32
|
+
}
|
|
33
|
+
/** Starts the floor: call when the state being held becomes visible. */
|
|
34
|
+
begin() {
|
|
35
|
+
this.#since = Date.now();
|
|
36
|
+
}
|
|
37
|
+
/** True while a finish is held back waiting for the floor to elapse. */
|
|
38
|
+
get pending() {
|
|
39
|
+
return this.#timerId !== null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
43
|
+
*
|
|
44
|
+
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
45
|
+
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
46
|
+
* end a state that has since restarted. Controllers that want the first signal
|
|
47
|
+
* to win guard on {@link pending} before calling.
|
|
48
|
+
*/
|
|
49
|
+
schedule(minDuration, finish) {
|
|
50
|
+
this.cancel();
|
|
51
|
+
const remaining = minDuration - (Date.now() - this.#since);
|
|
52
|
+
if (remaining > 0) {
|
|
53
|
+
this.#timerId = this.#timers.set(() => {
|
|
54
|
+
this.#timerId = null;
|
|
55
|
+
finish();
|
|
56
|
+
}, remaining);
|
|
57
|
+
} else {
|
|
58
|
+
finish();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
62
|
+
cancel() {
|
|
63
|
+
if (this.#timerId !== null) {
|
|
64
|
+
this.#timers.clear(this.#timerId);
|
|
65
|
+
this.#timerId = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
5
70
|
// src/utils/safe_timeout.ts
|
|
6
71
|
var TimerRegistry = class {
|
|
7
72
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,15 +124,13 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
124
|
var SkeletonController = class extends Controller {
|
|
60
125
|
static targets = ["placeholder", "content"];
|
|
61
126
|
static values = {
|
|
127
|
+
announceReadyText: { type: String, default: "" },
|
|
62
128
|
minDuration: { type: Number, default: 0 }
|
|
63
129
|
};
|
|
64
130
|
static actions = ["ready", "reset"];
|
|
65
131
|
static events = ["ready"];
|
|
66
132
|
#timers = new SafeTimeout();
|
|
67
|
-
|
|
68
|
-
#revealTimerId = null;
|
|
69
|
-
/** Epoch ms when the loading state began; `minDuration` is measured from it. */
|
|
70
|
-
#loadingSince = 0;
|
|
133
|
+
#floor = new MinDurationFloor(this.#timers);
|
|
71
134
|
connect() {
|
|
72
135
|
if (this.#state !== "ready") {
|
|
73
136
|
this.#enterLoading();
|
|
@@ -75,32 +138,21 @@ var SkeletonController = class extends Controller {
|
|
|
75
138
|
}
|
|
76
139
|
disconnect() {
|
|
77
140
|
this.#timers.clearAll();
|
|
78
|
-
this.#
|
|
141
|
+
this.#floor.cancel();
|
|
79
142
|
}
|
|
80
143
|
/** Swaps to the real content. Honors `minDuration` to prevent a flash. */
|
|
81
144
|
ready() {
|
|
82
|
-
if (this.#state === "ready" || this.#
|
|
83
|
-
|
|
84
|
-
if (remaining > 0) {
|
|
85
|
-
this.#revealTimerId = this.#timers.set(() => {
|
|
86
|
-
this.#revealTimerId = null;
|
|
87
|
-
this.#reveal();
|
|
88
|
-
}, remaining);
|
|
89
|
-
} else {
|
|
90
|
-
this.#reveal();
|
|
91
|
-
}
|
|
145
|
+
if (this.#state === "ready" || this.#floor.pending) return;
|
|
146
|
+
this.#floor.schedule(this.minDurationValue, () => this.#reveal());
|
|
92
147
|
}
|
|
93
148
|
/** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
|
|
94
149
|
reset() {
|
|
95
|
-
|
|
96
|
-
this.#timers.clear(this.#revealTimerId);
|
|
97
|
-
this.#revealTimerId = null;
|
|
98
|
-
}
|
|
150
|
+
this.#floor.cancel();
|
|
99
151
|
this.#enterLoading();
|
|
100
152
|
}
|
|
101
153
|
/** Shows the placeholder, hides content, and marks the region busy. */
|
|
102
154
|
#enterLoading() {
|
|
103
|
-
this.#
|
|
155
|
+
this.#floor.begin();
|
|
104
156
|
if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;
|
|
105
157
|
if (this.hasContentTarget) this.contentTarget.hidden = true;
|
|
106
158
|
this.element.setAttribute("aria-busy", "true");
|
|
@@ -113,6 +165,7 @@ var SkeletonController = class extends Controller {
|
|
|
113
165
|
this.element.setAttribute("aria-busy", "false");
|
|
114
166
|
this.element.setAttribute("data-state", "ready");
|
|
115
167
|
this.dispatch("ready", { detail: {} });
|
|
168
|
+
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
116
169
|
}
|
|
117
170
|
/** Current lifecycle phase as reflected on `data-state`. */
|
|
118
171
|
get #state() {
|
|
@@ -18,6 +18,22 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
18
18
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// src/utils/range.ts
|
|
22
|
+
function rangeFraction(value, min, max) {
|
|
23
|
+
const span = max - min;
|
|
24
|
+
if (!(span > 0)) return 0;
|
|
25
|
+
const clamped = Math.min(max, Math.max(min, value));
|
|
26
|
+
let fraction;
|
|
27
|
+
if (Number.isFinite(span)) {
|
|
28
|
+
fraction = (clamped - min) / span;
|
|
29
|
+
} else {
|
|
30
|
+
const scale = Math.max(Math.abs(min), Math.abs(max));
|
|
31
|
+
fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
|
|
32
|
+
}
|
|
33
|
+
if (!Number.isFinite(fraction)) return 0;
|
|
34
|
+
return Math.min(1, Math.max(0, fraction));
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
// src/controllers/slider_controller.ts
|
|
22
38
|
var FRACTION_PROPERTY = "--stimeo--slider-fraction";
|
|
23
39
|
var SliderController = class extends Controller {
|
|
@@ -123,8 +139,7 @@ var SliderController = class extends Controller {
|
|
|
123
139
|
this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
|
|
124
140
|
this.thumbTarget.setAttribute("aria-valuenow", String(value));
|
|
125
141
|
}
|
|
126
|
-
const
|
|
127
|
-
const fraction = span > 0 ? (value - this.minValue) / span : 0;
|
|
142
|
+
const fraction = rangeFraction(value, this.minValue, this.maxValue);
|
|
128
143
|
this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
|
|
129
144
|
if (changed && !silent) this.dispatch("change", { detail: { value } });
|
|
130
145
|
}
|