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
|
@@ -25,8 +25,41 @@ function toFiniteNumber(raw) {
|
|
|
25
25
|
return Number.isFinite(value) ? value : null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// src/utils/microtask_coalescer.ts
|
|
29
|
+
var MicrotaskCoalescer = class {
|
|
30
|
+
#run;
|
|
31
|
+
#queued = false;
|
|
32
|
+
#active = false;
|
|
33
|
+
#generation = 0;
|
|
34
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
35
|
+
constructor(run) {
|
|
36
|
+
this.#run = run;
|
|
37
|
+
}
|
|
38
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
39
|
+
activate() {
|
|
40
|
+
this.#active = true;
|
|
41
|
+
}
|
|
42
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
43
|
+
cancel() {
|
|
44
|
+
this.#active = false;
|
|
45
|
+
this.#queued = false;
|
|
46
|
+
this.#generation += 1;
|
|
47
|
+
}
|
|
48
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
49
|
+
schedule() {
|
|
50
|
+
if (!this.#active || this.#queued) return;
|
|
51
|
+
this.#queued = true;
|
|
52
|
+
const generation = this.#generation;
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
55
|
+
this.#queued = false;
|
|
56
|
+
this.#run();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
28
61
|
// src/controllers/color_picker_controller.ts
|
|
29
|
-
var COLOR_PROPERTY = "--stimeo
|
|
62
|
+
var COLOR_PROPERTY = "--stimeo--color";
|
|
30
63
|
var CHANNEL_RANGE = {
|
|
31
64
|
hue: [0, 360],
|
|
32
65
|
saturation: [0, 100],
|
|
@@ -51,16 +84,29 @@ var ColorPickerController = class extends Controller {
|
|
|
51
84
|
/** Aborts in-progress pointer-drag listeners on drag end / teardown. */
|
|
52
85
|
#dragAbort = null;
|
|
53
86
|
/** Seeds the model from the initial hex value and renders every surface. */
|
|
87
|
+
/**
|
|
88
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
89
|
+
* pass Stimulus delivers before `connect()`.
|
|
90
|
+
*/
|
|
91
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
92
|
+
this.#render();
|
|
93
|
+
});
|
|
54
94
|
connect() {
|
|
95
|
+
this.#repaint.activate();
|
|
55
96
|
const parsed = hexToHsla(this.valueValue);
|
|
56
97
|
if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
|
|
57
98
|
this.#render();
|
|
58
99
|
}
|
|
59
100
|
/** Cancels any active pointer drag so document listeners never leak. */
|
|
60
101
|
disconnect() {
|
|
102
|
+
this.#repaint.cancel();
|
|
61
103
|
this.#dragAbort?.abort();
|
|
62
104
|
this.#dragAbort = null;
|
|
63
105
|
}
|
|
106
|
+
/** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
|
|
107
|
+
alphaValueChanged() {
|
|
108
|
+
this.#repaint.schedule();
|
|
109
|
+
}
|
|
64
110
|
/** Keyboard stepping on the focused channel slider (APG Slider model). */
|
|
65
111
|
onKeydown(event) {
|
|
66
112
|
if (isReservedArrowChord(event)) return;
|
|
@@ -142,7 +188,11 @@ var ColorPickerController = class extends Controller {
|
|
|
142
188
|
this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
|
|
143
189
|
this.#render();
|
|
144
190
|
}
|
|
145
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Reflects the model onto sliders, the hex input, preview, and form field.
|
|
193
|
+
*
|
|
194
|
+
* @stimeoRenderRoot
|
|
195
|
+
*/
|
|
146
196
|
#render() {
|
|
147
197
|
for (const slider of this.sliderTargets) {
|
|
148
198
|
const channel = this.#channelOf(slider);
|
|
@@ -67,6 +67,35 @@ var CompositionTracker = class {
|
|
|
67
67
|
};
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
// src/utils/before_cache_reset.ts
|
|
71
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
72
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
73
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
74
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
75
|
+
static #onBeforeCache = () => {
|
|
76
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
77
|
+
};
|
|
78
|
+
#rewind;
|
|
79
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
80
|
+
constructor(rewind) {
|
|
81
|
+
this.#rewind = rewind;
|
|
82
|
+
}
|
|
83
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
84
|
+
activate() {
|
|
85
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
86
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
87
|
+
if (first) {
|
|
88
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
92
|
+
deactivate() {
|
|
93
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
94
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
95
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
70
99
|
// src/utils/escape_layer.ts
|
|
71
100
|
var EscapeLayer = class _EscapeLayer {
|
|
72
101
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -200,7 +229,7 @@ var FocusTrap = class {
|
|
|
200
229
|
}
|
|
201
230
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
202
231
|
document.addEventListener("keydown", this.#onKeydown);
|
|
203
|
-
|
|
232
|
+
this.#beforeCache.activate();
|
|
204
233
|
const onEscape = this.#options.onEscape;
|
|
205
234
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
206
235
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -217,7 +246,7 @@ var FocusTrap = class {
|
|
|
217
246
|
this.#activeState = false;
|
|
218
247
|
this.#escapeLayer.deactivate();
|
|
219
248
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
220
|
-
|
|
249
|
+
this.#beforeCache.deactivate();
|
|
221
250
|
if (this.#scrollLocked) {
|
|
222
251
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
223
252
|
this.#scrollLocked = false;
|
|
@@ -241,9 +270,7 @@ var FocusTrap = class {
|
|
|
241
270
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
242
271
|
* is left alone mid-navigation. The listener lives only while active.
|
|
243
272
|
*/
|
|
244
|
-
#
|
|
245
|
-
this.deactivate({ restoreFocus: false });
|
|
246
|
-
};
|
|
273
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
247
274
|
/**
|
|
248
275
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
249
276
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/confirm_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
|
|
@@ -237,8 +237,8 @@ var ContextMenuController = class extends Controller {
|
|
|
237
237
|
onDismiss: () => this.#closeAndRestore(),
|
|
238
238
|
claims: claimsWhileFocusWithin(this.element)
|
|
239
239
|
});
|
|
240
|
-
this.menuTarget.style.setProperty("--stimeo
|
|
241
|
-
this.menuTarget.style.setProperty("--stimeo
|
|
240
|
+
this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
|
|
241
|
+
this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
|
|
242
242
|
this.menuTarget.hidden = false;
|
|
243
243
|
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
|
|
244
244
|
this.#navigableItems[0]?.focus();
|
|
@@ -2,6 +2,56 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/countdown_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/microtask_coalescer.ts
|
|
23
|
+
var MicrotaskCoalescer = class {
|
|
24
|
+
#run;
|
|
25
|
+
#queued = false;
|
|
26
|
+
#active = false;
|
|
27
|
+
#generation = 0;
|
|
28
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
29
|
+
constructor(run) {
|
|
30
|
+
this.#run = run;
|
|
31
|
+
}
|
|
32
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
33
|
+
activate() {
|
|
34
|
+
this.#active = true;
|
|
35
|
+
}
|
|
36
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
37
|
+
cancel() {
|
|
38
|
+
this.#active = false;
|
|
39
|
+
this.#queued = false;
|
|
40
|
+
this.#generation += 1;
|
|
41
|
+
}
|
|
42
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
43
|
+
schedule() {
|
|
44
|
+
if (!this.#active || this.#queued) return;
|
|
45
|
+
this.#queued = true;
|
|
46
|
+
const generation = this.#generation;
|
|
47
|
+
queueMicrotask(() => {
|
|
48
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
49
|
+
this.#queued = false;
|
|
50
|
+
this.#run();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
5
55
|
// src/utils/safe_timeout.ts
|
|
6
56
|
var TimerRegistry = class {
|
|
7
57
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -48,6 +98,7 @@ var SafeInterval = class extends TimerRegistry {
|
|
|
48
98
|
};
|
|
49
99
|
|
|
50
100
|
// src/controllers/countdown_controller.ts
|
|
101
|
+
var SECOND_MS = 1e3;
|
|
51
102
|
var CountdownController = class extends Controller {
|
|
52
103
|
static targets = ["days", "hours", "minutes", "seconds", "status"];
|
|
53
104
|
static values = {
|
|
@@ -55,34 +106,79 @@ var CountdownController = class extends Controller {
|
|
|
55
106
|
interval: { type: Number, default: 1e3 },
|
|
56
107
|
direction: { type: String, default: "down" },
|
|
57
108
|
autostart: { type: Boolean, default: true },
|
|
58
|
-
completeLabel: { type: String, default: "" }
|
|
109
|
+
completeLabel: { type: String, default: "" },
|
|
110
|
+
announceText: { type: String, default: "" }
|
|
59
111
|
};
|
|
60
112
|
static actions = ["pause", "reset", "resume", "start"];
|
|
61
113
|
static events = ["complete", "tick"];
|
|
62
114
|
#intervals = new SafeInterval();
|
|
63
115
|
#intervalId = null;
|
|
116
|
+
/** Collapses a morph that swaps several render inputs at once into one re-derive. */
|
|
117
|
+
#resync = new MicrotaskCoalescer(() => this.#resyncToValues());
|
|
64
118
|
/** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
|
|
65
119
|
#reference = 0;
|
|
66
120
|
/** Amount (ms) captured at pause, so resume can restore the same display. */
|
|
67
121
|
#pausedAmount = 0;
|
|
122
|
+
/**
|
|
123
|
+
* The amount the slots are currently showing, floored to the second they render.
|
|
124
|
+
* It lags {@link currentAmount} by up to one tick, and it — not the live reading —
|
|
125
|
+
* is what a pause has to preserve: storing the fraction behind the display instead
|
|
126
|
+
* makes the first tick after a resume step by two units.
|
|
127
|
+
*/
|
|
128
|
+
#renderedAmount = 0;
|
|
68
129
|
connect() {
|
|
130
|
+
this.#resync.activate();
|
|
69
131
|
this.#initReference();
|
|
70
|
-
this.#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
132
|
+
const amount = this.#currentAmount();
|
|
133
|
+
this.#render(amount);
|
|
134
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
135
|
+
const authored = this.element.getAttribute("data-state");
|
|
136
|
+
if (authored === null) {
|
|
137
|
+
if (this.autostartValue && this.#isValidDeadline) {
|
|
138
|
+
this.start();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
74
141
|
this.element.setAttribute("data-state", "paused");
|
|
142
|
+
return;
|
|
75
143
|
}
|
|
144
|
+
if (authored === "complete" && this.#isDown && amount <= 0) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const wasRunning = authored === "running";
|
|
148
|
+
this.element.setAttribute("data-state", "paused");
|
|
149
|
+
if (wasRunning) this.start();
|
|
76
150
|
}
|
|
77
151
|
disconnect() {
|
|
152
|
+
this.#resync.cancel();
|
|
78
153
|
this.#intervals.clearAll();
|
|
79
154
|
this.#intervalId = null;
|
|
80
155
|
}
|
|
156
|
+
/** Re-derives the display when a morph swaps the deadline in place. */
|
|
157
|
+
deadlineValueChanged() {
|
|
158
|
+
this.#resync.schedule();
|
|
159
|
+
}
|
|
160
|
+
/** Re-derives the display when a morph flips the counting direction in place. */
|
|
161
|
+
directionValueChanged() {
|
|
162
|
+
this.#resync.schedule();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Points the anchor at the current `deadline` / `direction` and repaints.
|
|
166
|
+
*
|
|
167
|
+
* Render only: it starts no interval and emits no event, so a morph cannot make a
|
|
168
|
+
* paused timer run or replay a milestone. A running one needs no restart either —
|
|
169
|
+
* every tick reads the anchor, so moving it is enough. While paused the stored
|
|
170
|
+
* amount follows the new reading, or resume would continue from the old deadline.
|
|
171
|
+
*/
|
|
172
|
+
#resyncToValues() {
|
|
173
|
+
this.#initReference();
|
|
174
|
+
this.#render(this.#currentAmount());
|
|
175
|
+
if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
|
|
176
|
+
}
|
|
81
177
|
/** Starts (or restarts after pause) ticking toward the deadline. */
|
|
82
178
|
start() {
|
|
83
179
|
if (this.#state === "running" || !this.#isValidDeadline) return;
|
|
84
180
|
if (this.#isDown && this.#currentAmount() <= 0) {
|
|
85
|
-
this.#complete();
|
|
181
|
+
if (this.#state !== "complete") this.#complete();
|
|
86
182
|
return;
|
|
87
183
|
}
|
|
88
184
|
this.#runInterval();
|
|
@@ -90,7 +186,7 @@ var CountdownController = class extends Controller {
|
|
|
90
186
|
/** Pauses ticking, preserving the currently displayed amount. */
|
|
91
187
|
pause() {
|
|
92
188
|
if (this.#state !== "running") return;
|
|
93
|
-
this.#pausedAmount = this.#
|
|
189
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
94
190
|
this.#teardownInterval();
|
|
95
191
|
this.element.setAttribute("data-state", "paused");
|
|
96
192
|
}
|
|
@@ -106,8 +202,8 @@ var CountdownController = class extends Controller {
|
|
|
106
202
|
* run state**: a running timer keeps counting down from the reset amount, while a
|
|
107
203
|
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
108
204
|
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
109
|
-
* not re-derived from the declarative `autostart` Value (which only
|
|
110
|
-
*
|
|
205
|
+
* not re-derived from the declarative `autostart` Value (which governs only markup
|
|
206
|
+
* that states no run state at all); re-deriving it would override a user's pause —
|
|
111
207
|
* the DOM, not a re-run of declarative config, is the source of truth.
|
|
112
208
|
*/
|
|
113
209
|
reset() {
|
|
@@ -116,13 +212,15 @@ var CountdownController = class extends Controller {
|
|
|
116
212
|
this.#initReference();
|
|
117
213
|
const amount = this.#currentAmount();
|
|
118
214
|
this.#render(amount);
|
|
119
|
-
if (this.hasStatusTarget
|
|
215
|
+
if (this.hasStatusTarget && this.statusTarget.textContent === this.completeLabelValue) {
|
|
216
|
+
this.statusTarget.textContent = "";
|
|
217
|
+
}
|
|
120
218
|
this.element.setAttribute("data-state", "paused");
|
|
121
219
|
if (wasRunning && this.#isValidDeadline) {
|
|
122
220
|
this.#pausedAmount = 0;
|
|
123
221
|
this.start();
|
|
124
222
|
} else {
|
|
125
|
-
this.#pausedAmount =
|
|
223
|
+
this.#pausedAmount = this.#renderedAmount;
|
|
126
224
|
}
|
|
127
225
|
}
|
|
128
226
|
/** Schedules the repeating tick and marks the timer running. */
|
|
@@ -157,6 +255,7 @@ var CountdownController = class extends Controller {
|
|
|
157
255
|
this.statusTarget.textContent = this.completeLabelValue;
|
|
158
256
|
}
|
|
159
257
|
this.dispatch("complete", { detail: {} });
|
|
258
|
+
announce(fillTemplate(this.announceTextValue, {}));
|
|
160
259
|
}
|
|
161
260
|
/** Sets the time anchor from the `deadline` value. */
|
|
162
261
|
#initReference() {
|
|
@@ -169,9 +268,14 @@ var CountdownController = class extends Controller {
|
|
|
169
268
|
const raw = this.#isDown ? this.#reference - now : now - this.#reference;
|
|
170
269
|
return Math.max(0, raw);
|
|
171
270
|
}
|
|
172
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Writes the amount into the day/hour/minute/second slots.
|
|
273
|
+
*
|
|
274
|
+
* @stimeoRenderRoot
|
|
275
|
+
*/
|
|
173
276
|
#render(amount) {
|
|
174
|
-
const totalSeconds = Math.floor(amount /
|
|
277
|
+
const totalSeconds = Math.floor(amount / SECOND_MS);
|
|
278
|
+
this.#renderedAmount = totalSeconds * SECOND_MS;
|
|
175
279
|
const days = Math.floor(totalSeconds / 86400);
|
|
176
280
|
const hours = Math.floor(totalSeconds % 86400 / 3600);
|
|
177
281
|
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
@@ -52,6 +52,39 @@ function toISOMonthString(date) {
|
|
|
52
52
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// src/utils/microtask_coalescer.ts
|
|
56
|
+
var MicrotaskCoalescer = class {
|
|
57
|
+
#run;
|
|
58
|
+
#queued = false;
|
|
59
|
+
#active = false;
|
|
60
|
+
#generation = 0;
|
|
61
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
62
|
+
constructor(run) {
|
|
63
|
+
this.#run = run;
|
|
64
|
+
}
|
|
65
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
66
|
+
activate() {
|
|
67
|
+
this.#active = true;
|
|
68
|
+
}
|
|
69
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
70
|
+
cancel() {
|
|
71
|
+
this.#active = false;
|
|
72
|
+
this.#queued = false;
|
|
73
|
+
this.#generation += 1;
|
|
74
|
+
}
|
|
75
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
76
|
+
schedule() {
|
|
77
|
+
if (!this.#active || this.#queued) return;
|
|
78
|
+
this.#queued = true;
|
|
79
|
+
const generation = this.#generation;
|
|
80
|
+
queueMicrotask(() => {
|
|
81
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
82
|
+
this.#queued = false;
|
|
83
|
+
this.#run();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
55
88
|
// src/utils/safe_timeout.ts
|
|
56
89
|
var TimerRegistry = class {
|
|
57
90
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -129,7 +162,15 @@ var DateRangePickerController = class extends Controller {
|
|
|
129
162
|
/** Deferred focus after an async month transition (cancelled on teardown). */
|
|
130
163
|
#focusTimer = new SafeTimeout();
|
|
131
164
|
/** Seeds the range from any pre-filled hidden fields and renders the grid. */
|
|
165
|
+
/**
|
|
166
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
167
|
+
* pass Stimulus delivers before `connect()`.
|
|
168
|
+
*/
|
|
169
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
170
|
+
this.#render();
|
|
171
|
+
});
|
|
132
172
|
connect() {
|
|
173
|
+
this.#repaint.activate();
|
|
133
174
|
this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
|
|
134
175
|
this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
|
|
135
176
|
const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
|
|
@@ -140,8 +181,17 @@ var DateRangePickerController = class extends Controller {
|
|
|
140
181
|
}
|
|
141
182
|
/** Cancels any pending deferred focus so it never fires on a detached element. */
|
|
142
183
|
disconnect() {
|
|
184
|
+
this.#repaint.cancel();
|
|
143
185
|
this.#focusTimer.clearAll();
|
|
144
186
|
}
|
|
187
|
+
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
188
|
+
minValueChanged() {
|
|
189
|
+
this.#repaint.schedule();
|
|
190
|
+
}
|
|
191
|
+
/** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
|
|
192
|
+
maxValueChanged() {
|
|
193
|
+
this.#repaint.schedule();
|
|
194
|
+
}
|
|
145
195
|
/** Navigates to the previous month. */
|
|
146
196
|
prev(event) {
|
|
147
197
|
event?.preventDefault();
|
|
@@ -305,7 +355,11 @@ var DateRangePickerController = class extends Controller {
|
|
|
305
355
|
this.#focusedDate = target;
|
|
306
356
|
this.#render();
|
|
307
357
|
}
|
|
308
|
-
/**
|
|
358
|
+
/**
|
|
359
|
+
* Builds the six-week grid and binds range/roving/disabled state per cell.
|
|
360
|
+
*
|
|
361
|
+
* @stimeoRenderRoot
|
|
362
|
+
*/
|
|
309
363
|
#render() {
|
|
310
364
|
const info = parseISOMonthString(this.#viewMonth);
|
|
311
365
|
if (!info) return;
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/dialog_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
|
|
@@ -110,7 +110,7 @@ var DirectUploadController = class extends Controller {
|
|
|
110
110
|
const clamped = Math.max(0, Math.min(100, percent));
|
|
111
111
|
row.setAttribute("aria-valuenow", String(clamped));
|
|
112
112
|
row.setAttribute("aria-valuetext", `${clamped}%`);
|
|
113
|
-
row.style.setProperty("--stimeo
|
|
113
|
+
row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
|
|
114
114
|
this.#setField(row, "percent", `${clamped}%`);
|
|
115
115
|
this.#syncAggregate();
|
|
116
116
|
this.dispatch("progress", { detail: { id, percent: clamped } });
|
|
@@ -148,7 +148,7 @@ var DirectUploadController = class extends Controller {
|
|
|
148
148
|
}
|
|
149
149
|
clone.setAttribute("aria-valuenow", "0");
|
|
150
150
|
clone.setAttribute("data-upload-state", "uploading");
|
|
151
|
-
clone.style.setProperty("--stimeo
|
|
151
|
+
clone.style.setProperty("--stimeo--upload-progress", "0%");
|
|
152
152
|
this.listTarget.appendChild(clone);
|
|
153
153
|
this.#rows.set(key, clone);
|
|
154
154
|
return clone;
|
|
@@ -172,7 +172,7 @@ var DirectUploadController = class extends Controller {
|
|
|
172
172
|
}
|
|
173
173
|
const overall = Math.round(total / this.#rows.size);
|
|
174
174
|
this.element.setAttribute("data-upload-progress", String(overall));
|
|
175
|
-
this.element.style.setProperty("--stimeo
|
|
175
|
+
this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
|
|
176
176
|
}
|
|
177
177
|
/** Writes a consumer label (with `%{name}` substituted) to the status region. */
|
|
178
178
|
#announce(label, row) {
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/drawer_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
|