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
|
@@ -13,6 +13,39 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
13
13
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// src/utils/microtask_coalescer.ts
|
|
17
|
+
var MicrotaskCoalescer = class {
|
|
18
|
+
#run;
|
|
19
|
+
#queued = false;
|
|
20
|
+
#active = false;
|
|
21
|
+
#generation = 0;
|
|
22
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
23
|
+
constructor(run) {
|
|
24
|
+
this.#run = run;
|
|
25
|
+
}
|
|
26
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
27
|
+
activate() {
|
|
28
|
+
this.#active = true;
|
|
29
|
+
}
|
|
30
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
31
|
+
cancel() {
|
|
32
|
+
this.#active = false;
|
|
33
|
+
this.#queued = false;
|
|
34
|
+
this.#generation += 1;
|
|
35
|
+
}
|
|
36
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
37
|
+
schedule() {
|
|
38
|
+
if (!this.#active || this.#queued) return;
|
|
39
|
+
this.#queued = true;
|
|
40
|
+
const generation = this.#generation;
|
|
41
|
+
queueMicrotask(() => {
|
|
42
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
43
|
+
this.#queued = false;
|
|
44
|
+
this.#run();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
16
49
|
// src/utils/roving_tabindex.ts
|
|
17
50
|
var RovingTabindex = class {
|
|
18
51
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -59,13 +92,33 @@ var RatingController = class extends Controller {
|
|
|
59
92
|
static events = ["change"];
|
|
60
93
|
#roving = new RovingTabindex(() => this.symbolTargets);
|
|
61
94
|
/** Reflects the initial value, or switches to the non-interactive readonly view. */
|
|
95
|
+
/**
|
|
96
|
+
* Collapses a morph that swaps render inputs into one repaint, and refuses the
|
|
97
|
+
* pass Stimulus delivers before `connect()`.
|
|
98
|
+
*/
|
|
99
|
+
#repaint = new MicrotaskCoalescer(() => {
|
|
100
|
+
if (this.readonlyValue) {
|
|
101
|
+
this.#applyReadonly();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.#apply(this.#clamp(this.valueValue), { focus: false });
|
|
105
|
+
});
|
|
62
106
|
connect() {
|
|
107
|
+
this.#repaint.activate();
|
|
63
108
|
if (this.readonlyValue) {
|
|
64
109
|
this.#applyReadonly();
|
|
65
110
|
return;
|
|
66
111
|
}
|
|
67
112
|
this.#apply(this.#clamp(this.valueValue), { focus: false });
|
|
68
113
|
}
|
|
114
|
+
/** Closes the window in which a queued repaint may still run. */
|
|
115
|
+
disconnect() {
|
|
116
|
+
this.#repaint.cancel();
|
|
117
|
+
}
|
|
118
|
+
/** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
|
|
119
|
+
valueValueChanged() {
|
|
120
|
+
this.#repaint.schedule();
|
|
121
|
+
}
|
|
69
122
|
/** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
|
|
70
123
|
select(event) {
|
|
71
124
|
if (this.readonlyValue) return;
|
|
@@ -122,6 +175,8 @@ var RatingController = class extends Controller {
|
|
|
122
175
|
* Applies `value` (already clamped) everywhere, then dispatches `change`.
|
|
123
176
|
* Use for user-driven changes; on connect call `#apply` directly so
|
|
124
177
|
* initialization mirrors state without emitting an event.
|
|
178
|
+
*
|
|
179
|
+
* @stimeoRenderRoot
|
|
125
180
|
*/
|
|
126
181
|
#render(value, { focus }) {
|
|
127
182
|
this.#apply(value, { focus });
|
|
@@ -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();
|
|
89
158
|
this.#timers.clearAll();
|
|
90
159
|
}
|
|
91
|
-
/**
|
|
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;
|
|
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");
|
|
198
|
+
}
|
|
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,46 @@ 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.
|
|
211
|
+
*
|
|
212
|
+
* @stimeoRenderRoot
|
|
101
213
|
*/
|
|
102
214
|
#applyAndComputeDelay() {
|
|
103
215
|
const deltaMs = this.#targetMs - Date.now();
|
|
104
216
|
const absSeconds = Math.abs(deltaMs) / 1e3;
|
|
217
|
+
const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
|
|
218
|
+
const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
|
|
219
|
+
const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
105
220
|
if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
|
|
106
221
|
this.element.textContent = this.#absoluteText;
|
|
107
222
|
this.element.setAttribute("data-state", "absolute");
|
|
108
|
-
return null;
|
|
223
|
+
if (deltaMs <= 0) return null;
|
|
224
|
+
return Math.min(nextDelay, deltaMs - this.thresholdValue * 1e3 + 1);
|
|
109
225
|
}
|
|
110
|
-
const
|
|
226
|
+
const formatter = this.#formatter;
|
|
227
|
+
if (formatter === null) return null;
|
|
111
228
|
const value = Math.round(deltaMs / scale.ms);
|
|
112
|
-
this.element.textContent =
|
|
229
|
+
this.element.textContent = formatter.format(value, scale.unit);
|
|
113
230
|
this.element.setAttribute("data-state", "relative");
|
|
114
|
-
|
|
115
|
-
return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
231
|
+
return nextDelay;
|
|
116
232
|
}
|
|
117
|
-
/**
|
|
233
|
+
/**
|
|
234
|
+
* A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`), or `null`
|
|
235
|
+
* when the runtime rejects that locale.
|
|
236
|
+
*/
|
|
118
237
|
get #formatter() {
|
|
119
|
-
|
|
238
|
+
try {
|
|
239
|
+
return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
120
243
|
}
|
|
121
|
-
/** Locale precedence: the value, then the
|
|
244
|
+
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
122
245
|
get #locale() {
|
|
123
|
-
return this.localeValue || this.element.lang
|
|
246
|
+
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
124
247
|
}
|
|
125
248
|
};
|
|
126
249
|
|
|
@@ -165,7 +165,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
165
165
|
this.#syncKeyboardReach(vp, overflowing);
|
|
166
166
|
const { position, progress } = this.#measurePosition(vp);
|
|
167
167
|
this.element.setAttribute("data-scroll", position);
|
|
168
|
-
this.element.style.setProperty("--stimeo
|
|
168
|
+
this.element.style.setProperty("--stimeo--scroll-progress", String(progress));
|
|
169
169
|
const edge = position === "start" ? "start" : position === "end" ? "end" : null;
|
|
170
170
|
if (overflowing && edge && edge !== this.#lastEdge) {
|
|
171
171
|
this.#lastEdge = edge;
|
|
@@ -8,6 +8,13 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
8
8
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/utils/default_attribute.ts
|
|
12
|
+
function setDefaultAttribute(element, name, value) {
|
|
13
|
+
if (element.hasAttribute(name)) return false;
|
|
14
|
+
element.setAttribute(name, value);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
// src/controllers/separator_controller.ts
|
|
12
19
|
var SeparatorController = class extends Controller {
|
|
13
20
|
static values = {
|
|
@@ -18,19 +25,13 @@ var SeparatorController = class extends Controller {
|
|
|
18
25
|
static actions = ["onKeydown"];
|
|
19
26
|
static events = ["change"];
|
|
20
27
|
connect() {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
if (!this.element.hasAttribute("aria-orientation")) {
|
|
25
|
-
this.element.setAttribute("aria-orientation", this.orientationValue);
|
|
26
|
-
}
|
|
28
|
+
setDefaultAttribute(this.element, "role", "separator");
|
|
29
|
+
setDefaultAttribute(this.element, "aria-orientation", this.orientationValue);
|
|
27
30
|
if (this.focusableValue) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this
|
|
32
|
-
this.#setDefault("aria-valuemax", "100");
|
|
33
|
-
this.#setDefault("aria-valuenow", String(this.#clamp(this.#value)));
|
|
31
|
+
setDefaultAttribute(this.element, "tabindex", "0");
|
|
32
|
+
setDefaultAttribute(this.element, "aria-valuemin", "0");
|
|
33
|
+
setDefaultAttribute(this.element, "aria-valuemax", "100");
|
|
34
|
+
setDefaultAttribute(this.element, "aria-valuenow", String(this.#clamp(this.#value)));
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
/** Adjusts the value on arrow / Home / End keys (focusable variant only). */
|
|
@@ -84,11 +85,6 @@ var SeparatorController = class extends Controller {
|
|
|
84
85
|
const parsed = Number.parseFloat(this.element.getAttribute(name) ?? "");
|
|
85
86
|
return Number.isNaN(parsed) ? fallback : parsed;
|
|
86
87
|
}
|
|
87
|
-
#setDefault(name, value) {
|
|
88
|
-
if (!this.element.hasAttribute(name)) {
|
|
89
|
-
this.element.setAttribute(name, value);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
88
|
};
|
|
93
89
|
|
|
94
90
|
export { SeparatorController };
|
|
@@ -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,126 @@ 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/detach_gate.ts
|
|
23
|
+
var DetachGate = class _DetachGate {
|
|
24
|
+
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
25
|
+
#pending = false;
|
|
26
|
+
/**
|
|
27
|
+
* True while a probe is queued — the last disconnect was ambiguous and no
|
|
28
|
+
* reconnect has cancelled it yet. Read it from `connect()` to tell the
|
|
29
|
+
* reconnect half of an in-page move from a first connect: a controller whose
|
|
30
|
+
* initialisation restarts a measurement (a min-duration floor, an elapsed
|
|
31
|
+
* counter) must skip it for the move, where nothing actually restarted.
|
|
32
|
+
*/
|
|
33
|
+
get pending() {
|
|
34
|
+
return this.#pending;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* True when the disconnect is definitely a real detach — the element left
|
|
38
|
+
* the document, or `data-controller` no longer lists the identifier. False
|
|
39
|
+
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
40
|
+
*/
|
|
41
|
+
static isDetached(host) {
|
|
42
|
+
if (!host.element.isConnected) return true;
|
|
43
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
44
|
+
return !tokens.includes(host.identifier);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
48
|
+
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
49
|
+
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
50
|
+
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
51
|
+
* element within the same mutation batch, before the checkpoint drains.
|
|
52
|
+
*/
|
|
53
|
+
disconnected(host, teardown) {
|
|
54
|
+
if (_DetachGate.isDetached(host)) {
|
|
55
|
+
this.#pending = false;
|
|
56
|
+
teardown();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.#pending = true;
|
|
60
|
+
queueMicrotask(() => {
|
|
61
|
+
if (!this.#pending) return;
|
|
62
|
+
this.#pending = false;
|
|
63
|
+
teardown();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
68
|
+
* an in-page move) and from the head of any teardown path not routed through
|
|
69
|
+
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
70
|
+
* never run the teardown a second time.
|
|
71
|
+
*/
|
|
72
|
+
cancel() {
|
|
73
|
+
this.#pending = false;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// src/utils/min_duration_floor.ts
|
|
78
|
+
var MinDurationFloor = class {
|
|
79
|
+
#timers;
|
|
80
|
+
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
81
|
+
#timerId = null;
|
|
82
|
+
/** Epoch ms the floor is measured from. */
|
|
83
|
+
#since = 0;
|
|
84
|
+
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
85
|
+
constructor(timers) {
|
|
86
|
+
this.#timers = timers;
|
|
87
|
+
}
|
|
88
|
+
/** Starts the floor: call when the state being held becomes visible. */
|
|
89
|
+
begin() {
|
|
90
|
+
this.#since = Date.now();
|
|
91
|
+
}
|
|
92
|
+
/** True while a finish is held back waiting for the floor to elapse. */
|
|
93
|
+
get pending() {
|
|
94
|
+
return this.#timerId !== null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
98
|
+
*
|
|
99
|
+
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
100
|
+
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
101
|
+
* end a state that has since restarted. Controllers that want the first signal
|
|
102
|
+
* to win guard on {@link pending} before calling.
|
|
103
|
+
*/
|
|
104
|
+
schedule(minDuration, finish) {
|
|
105
|
+
this.cancel();
|
|
106
|
+
const remaining = minDuration - (Date.now() - this.#since);
|
|
107
|
+
if (remaining > 0) {
|
|
108
|
+
this.#timerId = this.#timers.set(() => {
|
|
109
|
+
this.#timerId = null;
|
|
110
|
+
finish();
|
|
111
|
+
}, remaining);
|
|
112
|
+
} else {
|
|
113
|
+
finish();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
117
|
+
cancel() {
|
|
118
|
+
if (this.#timerId !== null) {
|
|
119
|
+
this.#timers.clear(this.#timerId);
|
|
120
|
+
this.#timerId = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
5
125
|
// src/utils/safe_timeout.ts
|
|
6
126
|
var TimerRegistry = class {
|
|
7
127
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,48 +179,37 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
179
|
var SkeletonController = class extends Controller {
|
|
60
180
|
static targets = ["placeholder", "content"];
|
|
61
181
|
static values = {
|
|
182
|
+
announceReadyText: { type: String, default: "" },
|
|
62
183
|
minDuration: { type: Number, default: 0 }
|
|
63
184
|
};
|
|
64
185
|
static actions = ["ready", "reset"];
|
|
65
186
|
static events = ["ready"];
|
|
66
187
|
#timers = new SafeTimeout();
|
|
67
|
-
|
|
68
|
-
#
|
|
69
|
-
/** Epoch ms when the loading state began; `minDuration` is measured from it. */
|
|
70
|
-
#loadingSince = 0;
|
|
188
|
+
#floor = new MinDurationFloor(this.#timers);
|
|
189
|
+
#gate = new DetachGate();
|
|
71
190
|
connect() {
|
|
72
|
-
|
|
191
|
+
const moved = this.#gate.pending;
|
|
192
|
+
this.#gate.cancel();
|
|
193
|
+
if (!moved && this.#state !== "ready") {
|
|
73
194
|
this.#enterLoading();
|
|
74
195
|
}
|
|
75
196
|
}
|
|
76
197
|
disconnect() {
|
|
77
|
-
this.#
|
|
78
|
-
this.#revealTimerId = null;
|
|
198
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
79
199
|
}
|
|
80
200
|
/** Swaps to the real content. Honors `minDuration` to prevent a flash. */
|
|
81
201
|
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
|
-
}
|
|
202
|
+
if (this.#state === "ready" || this.#floor.pending) return;
|
|
203
|
+
this.#floor.schedule(this.minDurationValue, () => this.#reveal());
|
|
92
204
|
}
|
|
93
205
|
/** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
|
|
94
206
|
reset() {
|
|
95
|
-
|
|
96
|
-
this.#timers.clear(this.#revealTimerId);
|
|
97
|
-
this.#revealTimerId = null;
|
|
98
|
-
}
|
|
207
|
+
this.#floor.cancel();
|
|
99
208
|
this.#enterLoading();
|
|
100
209
|
}
|
|
101
210
|
/** Shows the placeholder, hides content, and marks the region busy. */
|
|
102
211
|
#enterLoading() {
|
|
103
|
-
this.#
|
|
212
|
+
this.#floor.begin();
|
|
104
213
|
if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;
|
|
105
214
|
if (this.hasContentTarget) this.contentTarget.hidden = true;
|
|
106
215
|
this.element.setAttribute("aria-busy", "true");
|
|
@@ -113,6 +222,18 @@ var SkeletonController = class extends Controller {
|
|
|
113
222
|
this.element.setAttribute("aria-busy", "false");
|
|
114
223
|
this.element.setAttribute("data-state", "ready");
|
|
115
224
|
this.dispatch("ready", { detail: {} });
|
|
225
|
+
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Drops the held reveal on a real detach. The markup keeps whatever it last
|
|
229
|
+
* held: an element on its way out of the document has no reader left, and one
|
|
230
|
+
* whose `data-controller` dropped the identifier no longer resolves its own
|
|
231
|
+
* targets, so the rollback could only ever be partial.
|
|
232
|
+
*/
|
|
233
|
+
#teardown() {
|
|
234
|
+
this.#gate.cancel();
|
|
235
|
+
this.#timers.clearAll();
|
|
236
|
+
this.#floor.cancel();
|
|
116
237
|
}
|
|
117
238
|
/** Current lifecycle phase as reflected on `data-state`. */
|
|
118
239
|
get #state() {
|