stimeo-ui 0.11.0 → 0.13.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.
- package/CHANGELOG.md +107 -0
- package/dist/cable/index.d.ts +67 -13
- package/dist/cable/index.js +461 -154
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +1 -1
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +1 -1
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.d.ts +1 -1
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +2 -1
- package/dist/controllers/calendar_controller.js +10 -2
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.js +1 -1
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +1 -1
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +1 -1
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +3 -3
- package/dist/controllers/command_palette_controller.js +2 -2
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.d.ts +2 -2
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +1 -1
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.d.ts +12 -10
- package/dist/controllers/count_up_controller.js +74 -35
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +1 -1
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +1 -1
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.js +10 -10
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.d.ts +1 -1
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.d.ts +2 -2
- package/dist/controllers/drawer_controller.js +2 -2
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +1 -1
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +2 -2
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +3 -3
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +1 -1
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +1 -1
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.d.ts +20 -7
- package/dist/controllers/intersection_controller.js +55 -8
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.d.ts +31 -9
- package/dist/controllers/lazy_frame_controller.js +81 -19
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +1 -1
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +1 -1
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +1 -1
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +2 -2
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +1 -1
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/optimistic_controller.d.ts +23 -10
- package/dist/controllers/optimistic_controller.js +141 -20
- package/dist/controllers/optimistic_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +1 -1
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/password_strength_controller.d.ts +2 -1
- package/dist/controllers/password_strength_controller.js.map +1 -1
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +4 -0
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +1 -1
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +2 -2
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +1 -1
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.d.ts +18 -9
- package/dist/controllers/reading_progress_controller.js +177 -6
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.d.ts +3 -3
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/roving_controller.d.ts +1 -1
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js +13 -2
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +1 -1
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +2 -2
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.d.ts +17 -8
- package/dist/controllers/smart_sticky_header_controller.js +52 -10
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +38 -8
- package/dist/controllers/sortable_controller.js +241 -67
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +3 -3
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +13 -2
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +3 -3
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.d.ts +1 -1
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +3 -2
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/toast_controller.js +1 -1
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.d.ts +1 -1
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +1 -1
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +1 -1
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +4 -3
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +587 -183
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +1 -1
- package/dist/inspector/cli.js +2 -1
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +2 -1
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +2 -2
- package/dist/inspector/manifest.json +13 -30
- package/package.json +1 -1
package/dist/cable/index.js
CHANGED
|
@@ -2,6 +2,168 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
import { createConsumer } from '@rails/actioncable';
|
|
3
3
|
|
|
4
4
|
// src/cable/live_counter_controller.ts
|
|
5
|
+
|
|
6
|
+
// src/utils/announce.ts
|
|
7
|
+
function announce(message, options = {}) {
|
|
8
|
+
const text = message.trim();
|
|
9
|
+
if (text.length === 0) return;
|
|
10
|
+
window.dispatchEvent(
|
|
11
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
12
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
13
|
+
})
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
function fillTemplate(template, values) {
|
|
17
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
18
|
+
const replacement = values[name];
|
|
19
|
+
return replacement === void 0 ? match : String(replacement);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/utils/authored_integer.ts
|
|
24
|
+
var TOKEN_BODY = /\d[\d.,'’’ _]*\d|\d/;
|
|
25
|
+
var SIGN_OPENER = /[\s([]/;
|
|
26
|
+
function authoredInteger(text) {
|
|
27
|
+
const firstDigit = /\d/.exec(text);
|
|
28
|
+
if (firstDigit === null) return null;
|
|
29
|
+
const at = firstDigit.index;
|
|
30
|
+
const signed = at > 0 && text[at - 1] === "-" && (at === 1 || SIGN_OPENER.test(text[at - 2]));
|
|
31
|
+
const body = TOKEN_BODY.exec(text.slice(at));
|
|
32
|
+
const runs = body[0].split(/\D+/);
|
|
33
|
+
const separators = body[0].match(/\D+/g) ?? [];
|
|
34
|
+
const lead = runs[0];
|
|
35
|
+
const heads = lead.length <= 3 && !lead.startsWith("0");
|
|
36
|
+
let digits = lead;
|
|
37
|
+
for (let i = 1; heads && i < runs.length; i += 1) {
|
|
38
|
+
if (separators[i - 1]?.length !== 1 || runs[i]?.length !== 3) break;
|
|
39
|
+
digits += runs[i];
|
|
40
|
+
}
|
|
41
|
+
const magnitude = Number.parseInt(digits, 10);
|
|
42
|
+
return signed ? -magnitude || 0 : magnitude;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/utils/blur_deferral.ts
|
|
46
|
+
var BlurDeferral = class {
|
|
47
|
+
/** Elements currently holding an update back, mapped to their `blur` listener. */
|
|
48
|
+
#pending = /* @__PURE__ */ new Map();
|
|
49
|
+
/** Called after a pending element blurs and has been detached. */
|
|
50
|
+
#onRelease;
|
|
51
|
+
/** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
|
|
52
|
+
constructor(onRelease) {
|
|
53
|
+
this.#onRelease = onRelease;
|
|
54
|
+
}
|
|
55
|
+
/** Number of elements currently holding an update back. */
|
|
56
|
+
get size() {
|
|
57
|
+
return this.#pending.size;
|
|
58
|
+
}
|
|
59
|
+
/** Snapshot of the pending elements, safe to iterate while releasing them. */
|
|
60
|
+
get elements() {
|
|
61
|
+
return [...this.#pending.keys()];
|
|
62
|
+
}
|
|
63
|
+
/** Whether `element` is currently holding an update back. */
|
|
64
|
+
has(element) {
|
|
65
|
+
return this.#pending.has(element);
|
|
66
|
+
}
|
|
67
|
+
/** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
|
|
68
|
+
defer(element) {
|
|
69
|
+
if (this.#pending.has(element)) return;
|
|
70
|
+
const onBlur = () => {
|
|
71
|
+
this.#detach(element);
|
|
72
|
+
this.#onRelease(element);
|
|
73
|
+
};
|
|
74
|
+
this.#pending.set(element, onBlur);
|
|
75
|
+
element.addEventListener("blur", onBlur);
|
|
76
|
+
}
|
|
77
|
+
/** Defers `element` as the only pending entry, cancelling any others. */
|
|
78
|
+
deferOnly(element) {
|
|
79
|
+
for (const pending of this.elements) {
|
|
80
|
+
if (pending !== element) this.#detach(pending);
|
|
81
|
+
}
|
|
82
|
+
this.defer(element);
|
|
83
|
+
}
|
|
84
|
+
/** Cancels `element`'s deferral without completing it; no-ops when not pending. */
|
|
85
|
+
release(element) {
|
|
86
|
+
this.#detach(element);
|
|
87
|
+
}
|
|
88
|
+
/** Cancels every deferral without completing any of them. */
|
|
89
|
+
releaseAll() {
|
|
90
|
+
for (const element of this.elements) this.#detach(element);
|
|
91
|
+
}
|
|
92
|
+
/** Removes the `blur` listener for `element` and forgets it. */
|
|
93
|
+
#detach(element) {
|
|
94
|
+
const onBlur = this.#pending.get(element);
|
|
95
|
+
if (onBlur) element.removeEventListener("blur", onBlur);
|
|
96
|
+
this.#pending.delete(element);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// src/utils/safe_timeout.ts
|
|
101
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
102
|
+
var TimerRegistry = class {
|
|
103
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
104
|
+
ids = /* @__PURE__ */ new Set();
|
|
105
|
+
/**
|
|
106
|
+
* Cancels a single tracked timer.
|
|
107
|
+
*
|
|
108
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
109
|
+
* registry), so callers can clear defensively without guarding.
|
|
110
|
+
*/
|
|
111
|
+
clear(id) {
|
|
112
|
+
if (this.ids.delete(id)) {
|
|
113
|
+
this.cancel(id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
118
|
+
* to guarantee no timer outlives the element.
|
|
119
|
+
*/
|
|
120
|
+
clearAll() {
|
|
121
|
+
for (const id of this.ids) {
|
|
122
|
+
this.cancel(id);
|
|
123
|
+
}
|
|
124
|
+
this.ids.clear();
|
|
125
|
+
}
|
|
126
|
+
/** Number of timers currently tracked (pending). */
|
|
127
|
+
get size() {
|
|
128
|
+
return this.ids.size;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
132
|
+
/**
|
|
133
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
134
|
+
*
|
|
135
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
136
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
137
|
+
*/
|
|
138
|
+
set(callback, delay) {
|
|
139
|
+
const id = this.schedule(() => {
|
|
140
|
+
this.ids.delete(id);
|
|
141
|
+
callback();
|
|
142
|
+
}, delay);
|
|
143
|
+
this.ids.add(id);
|
|
144
|
+
return id;
|
|
145
|
+
}
|
|
146
|
+
schedule(callback, delay) {
|
|
147
|
+
return window.setTimeout(callback, delay);
|
|
148
|
+
}
|
|
149
|
+
cancel(id) {
|
|
150
|
+
window.clearTimeout(id);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
var SafeInterval = class extends TimerRegistry {
|
|
154
|
+
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
155
|
+
set(callback, delay) {
|
|
156
|
+
const id = this.schedule(callback, delay);
|
|
157
|
+
this.ids.add(id);
|
|
158
|
+
return id;
|
|
159
|
+
}
|
|
160
|
+
schedule(callback, delay) {
|
|
161
|
+
return window.setInterval(callback, delay);
|
|
162
|
+
}
|
|
163
|
+
cancel(id) {
|
|
164
|
+
window.clearInterval(id);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
5
167
|
function parseSubscriptionParams(raw) {
|
|
6
168
|
try {
|
|
7
169
|
const parsed = JSON.parse(raw);
|
|
@@ -109,92 +271,186 @@ function openSharedSubscription(consumer, channel) {
|
|
|
109
271
|
|
|
110
272
|
// src/cable/live_counter_controller.ts
|
|
111
273
|
var DISABLED_MARKER = "data-live-counter-disabled";
|
|
112
|
-
var LiveCounterController = class extends Controller {
|
|
274
|
+
var LiveCounterController = class _LiveCounterController extends Controller {
|
|
113
275
|
static targets = ["value", "trigger"];
|
|
114
276
|
static values = {
|
|
115
277
|
channel: { type: String, default: "" },
|
|
116
278
|
params: { type: String, default: "" },
|
|
117
|
-
id: { type: String, default: "" }
|
|
279
|
+
id: { type: String, default: "" },
|
|
280
|
+
announceText: { type: String, default: "" }
|
|
118
281
|
};
|
|
282
|
+
/** Collapses a burst of broadcasts into one announcement. */
|
|
283
|
+
static #announceDelay = 200;
|
|
119
284
|
static actions = ["increment"];
|
|
120
285
|
static events = ["change"];
|
|
121
286
|
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
122
287
|
#params = {};
|
|
123
288
|
#subscription = null;
|
|
289
|
+
#connected = false;
|
|
124
290
|
/**
|
|
125
|
-
*
|
|
291
|
+
* The optimistic bumps this client has yet to see echoed back, in the order it
|
|
292
|
+
* sent them. An echo belongs to the send that caused it, and the amount is what
|
|
293
|
+
* names that send: a counter that guessed nothing — or guessed a different
|
|
294
|
+
* amount — applies the delta like any other, so siblings and other tabs
|
|
295
|
+
* converge whatever step each of them uses.
|
|
296
|
+
*/
|
|
297
|
+
#outstanding = [];
|
|
298
|
+
/** Holds a disable back while the trigger owns focus, so focus is never taken away. */
|
|
299
|
+
#focusedTriggers = new BlurDeferral((trigger) => this.#syncTrigger(trigger));
|
|
300
|
+
#timers = new SafeTimeout();
|
|
301
|
+
/**
|
|
302
|
+
* Re-parses the identifier parameters and moves the subscription to the
|
|
303
|
+
* identifier they now name.
|
|
126
304
|
*
|
|
127
305
|
* A malformed declaration falls back to no parameters, so the identifier keeps
|
|
128
306
|
* naming the channel instead of the subscription never being created at all.
|
|
129
307
|
*/
|
|
130
308
|
paramsValueChanged() {
|
|
131
309
|
this.#params = parseSubscriptionParams(this.paramsValue);
|
|
310
|
+
this.#resubscribe();
|
|
311
|
+
}
|
|
312
|
+
/** Moves the subscription when the declaration names a different channel. */
|
|
313
|
+
channelValueChanged() {
|
|
314
|
+
this.#resubscribe();
|
|
132
315
|
}
|
|
133
316
|
connect() {
|
|
317
|
+
this.#connected = true;
|
|
318
|
+
this.#subscribe();
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Opens the subscription the declaration names, and reflects the gate.
|
|
322
|
+
*
|
|
323
|
+
* The rejected hook is transient server state: a Turbo cache snapshot must not
|
|
324
|
+
* resurrect it — the fresh subscription re-decides it. An outstanding guess
|
|
325
|
+
* belongs to the identifier that is going away, so it is dropped with it.
|
|
326
|
+
*/
|
|
327
|
+
#subscribe() {
|
|
134
328
|
this.element.removeAttribute("data-live-counter-rejected");
|
|
329
|
+
this.#outstanding.length = 0;
|
|
135
330
|
if (this.channelValue) {
|
|
136
|
-
this.#subscription =
|
|
137
|
-
{ channel: this.channelValue, ...this.#params },
|
|
138
|
-
{
|
|
139
|
-
connected: () => this.#syncTriggers(),
|
|
140
|
-
// A drop closes the send window (the shared subscription tracks it)
|
|
141
|
-
// until Action Cable reconnects and re-confirms — an increment during
|
|
142
|
-
// the outage would bump the display while its perform() is silently
|
|
143
|
-
// discarded by the closed socket.
|
|
144
|
-
disconnected: () => this.#syncTriggers(),
|
|
145
|
-
// The server refused the subscription (auth, bad params): the gate
|
|
146
|
-
// stays shut for good, and the hook lets the consumer's CSS disable
|
|
147
|
-
// or hide the trigger instead of leaving a silently dead button.
|
|
148
|
-
rejected: () => {
|
|
149
|
-
this.element.setAttribute("data-live-counter-rejected", "true");
|
|
150
|
-
this.#syncTriggers();
|
|
151
|
-
},
|
|
152
|
-
received: (data) => this.#onReceived(data)
|
|
153
|
-
}
|
|
154
|
-
);
|
|
331
|
+
this.#subscription = this.#open();
|
|
155
332
|
}
|
|
156
333
|
this.#syncTriggers();
|
|
157
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Opens the wire subscription, or returns null when it cannot be opened.
|
|
337
|
+
*
|
|
338
|
+
* A consumer that cannot be built (no Action Cable meta tag, a boot-order
|
|
339
|
+
* mistake) would otherwise throw out of `connect()` and leave the element
|
|
340
|
+
* half-wired: no gate reflected, and a trigger that looks live. The gate reads
|
|
341
|
+
* the declaration, so a null subscription keeps it shut and the display never
|
|
342
|
+
* moves past what the server can receive.
|
|
343
|
+
*/
|
|
344
|
+
#open() {
|
|
345
|
+
try {
|
|
346
|
+
return createConfirmedSubscription(this.#descriptor, {
|
|
347
|
+
connected: () => this.#syncTriggers(),
|
|
348
|
+
// A drop closes the send window (the shared subscription tracks it)
|
|
349
|
+
// until Action Cable reconnects and re-confirms — an increment during
|
|
350
|
+
// the outage would bump the display while its perform() is silently
|
|
351
|
+
// discarded by the closed socket.
|
|
352
|
+
disconnected: () => this.#syncTriggers(),
|
|
353
|
+
// The server refused the subscription (auth, bad params): the gate
|
|
354
|
+
// stays shut for good, and the hook lets the consumer's CSS disable
|
|
355
|
+
// or hide the trigger instead of leaving a silently dead button.
|
|
356
|
+
rejected: () => {
|
|
357
|
+
this.element.setAttribute("data-live-counter-rejected", "true");
|
|
358
|
+
this.#syncTriggers();
|
|
359
|
+
},
|
|
360
|
+
received: (data) => this.#onReceived(data)
|
|
361
|
+
});
|
|
362
|
+
} catch (error) {
|
|
363
|
+
console.warn("Stimeo UI: the live counter could not open its subscription.", error);
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The identifier this declaration names.
|
|
369
|
+
*
|
|
370
|
+
* `channel` comes first so the identifier text matches what Action Cable
|
|
371
|
+
* builds, and a `channel` key inside `params` cannot displace the declared
|
|
372
|
+
* one — `params` names *additional* identifier parameters.
|
|
373
|
+
*/
|
|
374
|
+
get #descriptor() {
|
|
375
|
+
const { channel: _ignored, ...rest } = this.#params;
|
|
376
|
+
return { channel: this.channelValue, ...rest };
|
|
377
|
+
}
|
|
378
|
+
/** Moves to the identifier the declaration now names. */
|
|
379
|
+
#resubscribe() {
|
|
380
|
+
if (!this.#connected) return;
|
|
381
|
+
this.#subscription?.unsubscribe();
|
|
382
|
+
this.#subscription = null;
|
|
383
|
+
this.#subscribe();
|
|
384
|
+
}
|
|
158
385
|
disconnect() {
|
|
386
|
+
this.#connected = false;
|
|
387
|
+
this.#timers.clearAll();
|
|
388
|
+
this.#focusedTriggers.releaseAll();
|
|
159
389
|
this.#subscription?.unsubscribe();
|
|
160
390
|
this.#subscription = null;
|
|
161
391
|
this.element.removeAttribute("data-live-counter-rejected");
|
|
392
|
+
for (const trigger of this.triggerTargets) this.#release(trigger);
|
|
162
393
|
}
|
|
163
394
|
/** Late-added triggers (e.g. via a Turbo Stream) pick up the current gate. */
|
|
164
395
|
triggerTargetConnected(target) {
|
|
165
396
|
this.#syncTrigger(target);
|
|
166
397
|
}
|
|
398
|
+
/** A trigger that stops being one keeps nothing this controller lent it. */
|
|
399
|
+
triggerTargetDisconnected(target) {
|
|
400
|
+
this.#focusedTriggers.release(target);
|
|
401
|
+
this.#release(target);
|
|
402
|
+
}
|
|
167
403
|
/**
|
|
168
404
|
* Optimistic local increment: bumps the display immediately, then asks the
|
|
169
405
|
* server to persist and broadcast. The step comes from the action param
|
|
170
406
|
* (`data-stimeo--live-counter-delta-param`), default 1. Bound via `data-action`.
|
|
171
407
|
*/
|
|
172
408
|
increment(event) {
|
|
173
|
-
if (
|
|
409
|
+
if (!this.#ready) return;
|
|
174
410
|
const raw = Number(event?.params?.delta ?? 1);
|
|
175
|
-
const delta = Number.
|
|
176
|
-
if (
|
|
411
|
+
const delta = Number.isSafeInteger(raw) ? raw : 1;
|
|
412
|
+
if (this.#local) {
|
|
413
|
+
this.#write(this.#current + delta);
|
|
414
|
+
} else if (this.idValue !== "") {
|
|
415
|
+
this.#outstanding.push(delta);
|
|
177
416
|
this.#write(this.#current + delta);
|
|
178
417
|
}
|
|
179
418
|
this.#subscription?.perform("increment", { id: this.idValue, delta });
|
|
180
419
|
}
|
|
181
|
-
/**
|
|
420
|
+
/**
|
|
421
|
+
* Reconciles a broadcast: absolute `count` wins; the echo of this client's own
|
|
422
|
+
* guess is skipped.
|
|
423
|
+
*
|
|
424
|
+
* Only whole numbers reach the display. The wire carries values another client
|
|
425
|
+
* named and the server relayed, and the display is the state this controller
|
|
426
|
+
* reads back — a fraction, an infinity or a NaN would be written as text that
|
|
427
|
+
* reads back as a different number, or as none at all.
|
|
428
|
+
*/
|
|
182
429
|
#onReceived(data) {
|
|
183
430
|
const message = data;
|
|
184
|
-
if (
|
|
185
|
-
this.#
|
|
431
|
+
if (Number.isSafeInteger(message?.count)) {
|
|
432
|
+
this.#outstanding.length = 0;
|
|
433
|
+
this.#reconcile(message?.count);
|
|
186
434
|
return;
|
|
187
435
|
}
|
|
188
|
-
if (
|
|
189
|
-
|
|
436
|
+
if (Number.isSafeInteger(message?.delta)) {
|
|
437
|
+
const delta = message?.delta;
|
|
438
|
+
const own = typeof message?.by === "string" && message.by !== "" && message.by === this.idValue;
|
|
439
|
+
const guess = own ? this.#outstanding.indexOf(delta) : -1;
|
|
440
|
+
if (guess !== -1) {
|
|
441
|
+
this.#outstanding.splice(guess, 1);
|
|
190
442
|
return;
|
|
191
443
|
}
|
|
192
|
-
this.#
|
|
444
|
+
this.#reconcile(this.#current + delta);
|
|
193
445
|
}
|
|
194
446
|
}
|
|
195
|
-
/** True
|
|
447
|
+
/** True when no channel is declared: the local-only mode, with no server to diverge from. */
|
|
448
|
+
get #local() {
|
|
449
|
+
return this.channelValue === "";
|
|
450
|
+
}
|
|
451
|
+
/** True while an increment would go through (local-only counters always are). */
|
|
196
452
|
get #ready() {
|
|
197
|
-
return
|
|
453
|
+
return this.#local || (this.#subscription?.confirmed ?? false);
|
|
198
454
|
}
|
|
199
455
|
/**
|
|
200
456
|
* Reflects the send gate onto the optional `trigger` targets as the real
|
|
@@ -214,14 +470,23 @@ var LiveCounterController = class extends Controller {
|
|
|
214
470
|
*/
|
|
215
471
|
#syncTrigger(trigger) {
|
|
216
472
|
if (this.#ready) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
473
|
+
this.#focusedTriggers.release(trigger);
|
|
474
|
+
this.#release(trigger);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (trigger.hasAttribute("disabled")) return;
|
|
478
|
+
if (trigger.contains(document.activeElement)) {
|
|
479
|
+
this.#focusedTriggers.defer(trigger);
|
|
480
|
+
return;
|
|
224
481
|
}
|
|
482
|
+
trigger.setAttribute("disabled", "");
|
|
483
|
+
trigger.setAttribute(DISABLED_MARKER, "");
|
|
484
|
+
}
|
|
485
|
+
/** Gives back a `disabled` this controller lent, leaving an authored one alone. */
|
|
486
|
+
#release(trigger) {
|
|
487
|
+
if (!trigger.hasAttribute(DISABLED_MARKER)) return;
|
|
488
|
+
trigger.removeAttribute("disabled");
|
|
489
|
+
trigger.removeAttribute(DISABLED_MARKER);
|
|
225
490
|
}
|
|
226
491
|
/** The displayed element: the `value` target, else the controller element. */
|
|
227
492
|
get #display() {
|
|
@@ -229,96 +494,61 @@ var LiveCounterController = class extends Controller {
|
|
|
229
494
|
}
|
|
230
495
|
/** The current count, parsed from the DOM (the single source of truth). */
|
|
231
496
|
get #current() {
|
|
232
|
-
|
|
233
|
-
return Number.isNaN(parsed) ? 0 : parsed;
|
|
497
|
+
return authoredInteger(this.#display.textContent ?? "") ?? 0;
|
|
234
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Writes a whole count — every caller has already established that it is one —
|
|
501
|
+
* and reports whether the display actually moved.
|
|
502
|
+
*/
|
|
235
503
|
#write(count) {
|
|
236
|
-
if (count === this.#current) return;
|
|
504
|
+
if (count === this.#current) return false;
|
|
237
505
|
this.#display.textContent = String(count);
|
|
238
506
|
this.dispatch("change", { detail: { count } });
|
|
239
|
-
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
// src/utils/safe_timeout.ts
|
|
243
|
-
var TimerRegistry = class {
|
|
244
|
-
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
245
|
-
ids = /* @__PURE__ */ new Set();
|
|
246
|
-
/**
|
|
247
|
-
* Cancels a single tracked timer.
|
|
248
|
-
*
|
|
249
|
-
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
250
|
-
* registry), so callers can clear defensively without guarding.
|
|
251
|
-
*/
|
|
252
|
-
clear(id) {
|
|
253
|
-
if (this.ids.delete(id)) {
|
|
254
|
-
this.cancel(id);
|
|
255
|
-
}
|
|
507
|
+
return true;
|
|
256
508
|
}
|
|
257
509
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
510
|
+
* Settles the display on a count the server stated, announcing what it settled
|
|
511
|
+
* on. Announcing the optimistic bump instead would read a guess out and then
|
|
512
|
+
* correct it, so the announcement rides this path only.
|
|
260
513
|
*/
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
this.cancel(id);
|
|
264
|
-
}
|
|
265
|
-
this.ids.clear();
|
|
514
|
+
#reconcile(count) {
|
|
515
|
+
if (this.#write(count)) this.#announce(count);
|
|
266
516
|
}
|
|
267
|
-
/** Number of timers currently tracked (pending). */
|
|
268
|
-
get size() {
|
|
269
|
-
return this.ids.size;
|
|
270
|
-
}
|
|
271
|
-
};
|
|
272
|
-
var SafeTimeout = class extends TimerRegistry {
|
|
273
517
|
/**
|
|
274
|
-
*
|
|
518
|
+
* Hands a reconciled count to the page's shared announcer.
|
|
275
519
|
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
520
|
+
* Debounced: a burst of broadcasts is one announcement, not one per message,
|
|
521
|
+
* and only the last one is ever sent. Wording comes from the consumer and
|
|
522
|
+
* {@link announce} drops an empty message, so an undeclared template announces
|
|
523
|
+
* nothing at all — a count that ticks constantly should not interrupt a reader
|
|
524
|
+
* unless the page asked for it.
|
|
278
525
|
*/
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
},
|
|
284
|
-
this.ids.add(id);
|
|
285
|
-
return id;
|
|
286
|
-
}
|
|
287
|
-
schedule(callback, delay) {
|
|
288
|
-
return window.setTimeout(callback, delay);
|
|
289
|
-
}
|
|
290
|
-
cancel(id) {
|
|
291
|
-
window.clearTimeout(id);
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
var SafeInterval = class extends TimerRegistry {
|
|
295
|
-
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
296
|
-
set(callback, delay) {
|
|
297
|
-
const id = this.schedule(callback, delay);
|
|
298
|
-
this.ids.add(id);
|
|
299
|
-
return id;
|
|
300
|
-
}
|
|
301
|
-
schedule(callback, delay) {
|
|
302
|
-
return window.setInterval(callback, delay);
|
|
303
|
-
}
|
|
304
|
-
cancel(id) {
|
|
305
|
-
window.clearInterval(id);
|
|
526
|
+
#announce(count) {
|
|
527
|
+
this.#timers.clearAll();
|
|
528
|
+
this.#timers.set(() => {
|
|
529
|
+
announce(fillTemplate(this.announceTextValue, { count }));
|
|
530
|
+
}, _LiveCounterController.#announceDelay);
|
|
306
531
|
}
|
|
307
532
|
};
|
|
308
|
-
|
|
309
|
-
// src/cable/presence_controller.ts
|
|
310
533
|
var BEACON_THROTTLE_MS = 2e3;
|
|
311
|
-
var
|
|
534
|
+
var DEFAULT_HEARTBEAT_MS = 15e3;
|
|
535
|
+
var DEFAULT_TIMEOUT_MS = 4e4;
|
|
536
|
+
var speakers = /* @__PURE__ */ new Map();
|
|
537
|
+
var PresenceController = class _PresenceController extends Controller {
|
|
312
538
|
static targets = ["count", "list", "template"];
|
|
313
539
|
static values = {
|
|
314
540
|
channel: { type: String, default: "" },
|
|
315
541
|
params: { type: String, default: "" },
|
|
316
542
|
id: { type: String, default: "" },
|
|
317
543
|
name: { type: String, default: "" },
|
|
318
|
-
heartbeat: { type: Number, default:
|
|
319
|
-
timeout: { type: Number, default:
|
|
544
|
+
heartbeat: { type: Number, default: DEFAULT_HEARTBEAT_MS },
|
|
545
|
+
timeout: { type: Number, default: DEFAULT_TIMEOUT_MS },
|
|
546
|
+
announceJoinText: { type: String, default: "" },
|
|
547
|
+
announceLeaveText: { type: String, default: "" }
|
|
320
548
|
};
|
|
321
549
|
static events = ["join", "leave", "change"];
|
|
550
|
+
/** Delay (ms) before one roster change is sent to the shared announcer. */
|
|
551
|
+
static #announceDelay = 200;
|
|
322
552
|
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
323
553
|
#params = {};
|
|
324
554
|
#subscription = null;
|
|
@@ -330,6 +560,10 @@ var PresenceController = class extends Controller {
|
|
|
330
560
|
#lastBeaconAt = 0;
|
|
331
561
|
/** Pending trailing-edge convergence beacon (at most one queued). */
|
|
332
562
|
#pendingBeacon = null;
|
|
563
|
+
/** The one outstanding announcement, so a newer roster change supersedes it. */
|
|
564
|
+
#announceId = null;
|
|
565
|
+
/** The `speakers` key this element holds, recomputed on every `connect()`. */
|
|
566
|
+
#speakerKey = "";
|
|
333
567
|
/**
|
|
334
568
|
* Re-parses the identifier parameters when the declaration changes.
|
|
335
569
|
*
|
|
@@ -339,38 +573,108 @@ var PresenceController = class extends Controller {
|
|
|
339
573
|
paramsValueChanged() {
|
|
340
574
|
this.#params = parseSubscriptionParams(this.paramsValue);
|
|
341
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* The identifier this declaration names. `params` *adds* to the identifier, so a
|
|
578
|
+
* `channel` key inside it names an extra parameter and never replaces the channel
|
|
579
|
+
* the element declares.
|
|
580
|
+
*/
|
|
581
|
+
get #descriptor() {
|
|
582
|
+
const { channel: _declared, ...rest } = this.#params;
|
|
583
|
+
return { channel: this.channelValue, ...rest };
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* The beacon period, in ms: a finite, positive number a timer can hold. Anything
|
|
587
|
+
* else names no interval — `setInterval` reads `NaN`, a negative value, `Infinity`
|
|
588
|
+
* and a value past {@link MAX_TIMER_DELAY_MS} alike as "as often as possible", so
|
|
589
|
+
* the declaration would flood the channel rather than heartbeat on it. Zero is out
|
|
590
|
+
* for the same reason, which is why this bound is tighter than the non-negative one
|
|
591
|
+
* a throttle or a plain delay can use. Such a declaration falls back to the default.
|
|
592
|
+
*/
|
|
593
|
+
get #heartbeat() {
|
|
594
|
+
const declared = this.heartbeatValue;
|
|
595
|
+
return Number.isFinite(declared) && declared > 0 && declared <= MAX_TIMER_DELAY_MS ? declared : DEFAULT_HEARTBEAT_MS;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The silence after which a peer is dropped, in ms: a finite, positive number a
|
|
599
|
+
* timer can hold. Anything else names no delay and every peer expires in the task
|
|
600
|
+
* that records it, so the roster could never hold anyone. Such a declaration falls
|
|
601
|
+
* back to the default.
|
|
602
|
+
*/
|
|
603
|
+
get #timeout() {
|
|
604
|
+
const declared = this.timeoutValue;
|
|
605
|
+
return Number.isFinite(declared) && declared > 0 && declared <= MAX_TIMER_DELAY_MS ? declared : DEFAULT_TIMEOUT_MS;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Draws the roster into a list target, which arrives empty from a Turbo Stream
|
|
609
|
+
* replacement or a morph. Stimulus runs this before `connect()` on the first mount
|
|
610
|
+
* too, where the roster is still empty and clearing whatever a cache snapshot
|
|
611
|
+
* preserved is what `connect()` goes on to do anyway.
|
|
612
|
+
*/
|
|
613
|
+
listTargetConnected() {
|
|
614
|
+
for (const child of this.listTarget.querySelectorAll("[data-presence-id]")) {
|
|
615
|
+
child.remove();
|
|
616
|
+
}
|
|
617
|
+
for (const [id, peer] of this.#peers) this.#appendClone(id, peer.name);
|
|
618
|
+
}
|
|
619
|
+
/** Paints the roster size into a count target that arrives blank, for the same reason. */
|
|
620
|
+
countTargetConnected() {
|
|
621
|
+
this.countTarget.textContent = this.#countMessage(this.#peers.size);
|
|
622
|
+
}
|
|
342
623
|
connect() {
|
|
343
624
|
this.#reset();
|
|
344
625
|
this.element.removeAttribute("data-presence-rejected");
|
|
345
626
|
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
|
|
346
627
|
if (!this.channelValue) return;
|
|
347
|
-
this.#
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
rejected
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
);
|
|
362
|
-
this.#intervals.set(() => this.#beacon(true), this.heartbeatValue);
|
|
628
|
+
this.#claimSpeaker();
|
|
629
|
+
this.#subscription = createConfirmedSubscription(this.#descriptor, {
|
|
630
|
+
// The first beacon must wait for the confirmed subscription — a
|
|
631
|
+
// perform() before that is silently dropped by Action Cable. Fires
|
|
632
|
+
// again on every reconnect, so the roster self-heals after an outage.
|
|
633
|
+
connected: () => this.#beacon(true),
|
|
634
|
+
// The server refused the subscription: no beacon will ever go through,
|
|
635
|
+
// and the hook lets the consumer's CSS reflect the dead stream.
|
|
636
|
+
rejected: () => {
|
|
637
|
+
this.element.setAttribute("data-presence-rejected", "true");
|
|
638
|
+
},
|
|
639
|
+
received: (data) => this.#onReceived(data)
|
|
640
|
+
});
|
|
641
|
+
this.#intervals.set(() => this.#beacon(true), this.#heartbeat);
|
|
363
642
|
window.addEventListener("pagehide", this.#onPageHide);
|
|
364
643
|
}
|
|
365
644
|
disconnect() {
|
|
366
645
|
window.removeEventListener("pagehide", this.#onPageHide);
|
|
367
|
-
this.#sendLeaveNotice();
|
|
646
|
+
if (this.#releaseSpeaker()) this.#sendLeaveNotice();
|
|
368
647
|
this.#subscription?.unsubscribe();
|
|
369
648
|
this.#subscription = null;
|
|
370
649
|
this.#intervals.clearAll();
|
|
371
650
|
this.#reset();
|
|
372
651
|
this.element.removeAttribute("data-presence-rejected");
|
|
373
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Registers this element as one of the voices for its peer, so a sibling on the
|
|
655
|
+
* same channel claiming the same `id` keeps the peer present until they all go.
|
|
656
|
+
* Whether the peer is worth announcing at all is {@link #sendLeaveNotice}'s
|
|
657
|
+
* question, so an element with no own `id` claims a voice like any other and is
|
|
658
|
+
* simply never heard.
|
|
659
|
+
*/
|
|
660
|
+
#claimSpeaker() {
|
|
661
|
+
this.#speakerKey = `${identifierOf(this.#descriptor)}
|
|
662
|
+
${this.idValue}`;
|
|
663
|
+
speakers.set(this.#speakerKey, (speakers.get(this.#speakerKey) ?? 0) + 1);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Gives up the claim, reporting whether this element was the last voice for the
|
|
667
|
+
* peer. The key is spent here: an element that goes on to connect without a
|
|
668
|
+
* channel claims nothing, and a stale key would spend a voice its siblings own.
|
|
669
|
+
*/
|
|
670
|
+
#releaseSpeaker() {
|
|
671
|
+
const key = this.#speakerKey;
|
|
672
|
+
this.#speakerKey = "";
|
|
673
|
+
const remaining = (speakers.get(key) ?? 1) - 1;
|
|
674
|
+
if (remaining > 0) speakers.set(key, remaining);
|
|
675
|
+
else speakers.delete(key);
|
|
676
|
+
return remaining <= 0;
|
|
677
|
+
}
|
|
374
678
|
/**
|
|
375
679
|
* Sends the best-effort leaving notice (skipped without an own `id`, and
|
|
376
680
|
* outside the confirmed window, where Action Cable would discard it anyway).
|
|
@@ -436,11 +740,12 @@ var PresenceController = class extends Controller {
|
|
|
436
740
|
const name = typeof beacon?.name === "string" ? beacon.name : "";
|
|
437
741
|
const existing = this.#peers.get(id);
|
|
438
742
|
if (existing !== void 0) this.#timers.clear(existing.timer);
|
|
439
|
-
const timer = this.#timers.set(() => this.#drop(id), this
|
|
743
|
+
const timer = this.#timers.set(() => this.#drop(id), this.#timeout);
|
|
440
744
|
this.#peers.set(id, { name, timer });
|
|
441
745
|
if (existing === void 0) {
|
|
442
746
|
this.#appendClone(id, name);
|
|
443
747
|
this.#render();
|
|
748
|
+
this.#announce(this.announceJoinTextValue, name);
|
|
444
749
|
this.dispatch("join", { detail: { id, name } });
|
|
445
750
|
this.#beacon(false);
|
|
446
751
|
} else if (existing.name !== name) {
|
|
@@ -456,6 +761,7 @@ var PresenceController = class extends Controller {
|
|
|
456
761
|
this.#peers.delete(id);
|
|
457
762
|
this.#removeClone(id);
|
|
458
763
|
this.#render();
|
|
764
|
+
this.#announce(this.announceLeaveTextValue, peer.name);
|
|
459
765
|
this.dispatch("leave", { detail: { id } });
|
|
460
766
|
}
|
|
461
767
|
/** Reflects the roster onto the hooks + count target and emits `change`. */
|
|
@@ -466,6 +772,24 @@ var PresenceController = class extends Controller {
|
|
|
466
772
|
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(users.length);
|
|
467
773
|
this.dispatch("change", { detail: { users } });
|
|
468
774
|
}
|
|
775
|
+
/**
|
|
776
|
+
* Hands one roster change to the page's shared announcer.
|
|
777
|
+
*
|
|
778
|
+
* Debounced: a burst of arrivals is one announcement, not one per peer — reading
|
|
779
|
+
* out every entry and exit of a busy room is the noise the default avoids. Wording
|
|
780
|
+
* comes from the consumer (`{name}` / `{count}`, where the count is the roster
|
|
781
|
+
* after the change), and an undeclared template does nothing at all — not even
|
|
782
|
+
* cancel an announcement already pending for the other direction.
|
|
783
|
+
*/
|
|
784
|
+
#announce(template, name) {
|
|
785
|
+
const message = fillTemplate(template, { name, count: this.#peers.size });
|
|
786
|
+
if (message.trim() === "") return;
|
|
787
|
+
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
788
|
+
this.#announceId = this.#timers.set(() => {
|
|
789
|
+
announce(message);
|
|
790
|
+
this.#announceId = null;
|
|
791
|
+
}, _PresenceController.#announceDelay);
|
|
792
|
+
}
|
|
469
793
|
/**
|
|
470
794
|
* Builds the count copy. Localizable through `data-zero` / `data-one` /
|
|
471
795
|
* `data-other` templates on the count target (`%{count}`); the bare number is
|
|
@@ -479,12 +803,11 @@ var PresenceController = class extends Controller {
|
|
|
479
803
|
/** Appends one template clone for a newly present peer (list + template only). */
|
|
480
804
|
#appendClone(id, name) {
|
|
481
805
|
if (!this.hasListTarget || !this.hasTemplateTarget) return;
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
if (!root) return;
|
|
806
|
+
const root = this.templateTarget.content.firstElementChild?.cloneNode(true);
|
|
807
|
+
if (!(root instanceof Element)) return;
|
|
485
808
|
root.setAttribute("data-presence-id", id);
|
|
486
809
|
this.#fillName(root, name);
|
|
487
|
-
this.listTarget.appendChild(
|
|
810
|
+
this.listTarget.appendChild(root);
|
|
488
811
|
}
|
|
489
812
|
#updateClone(id, name) {
|
|
490
813
|
const root = this.#cloneFor(id);
|
|
@@ -512,6 +835,7 @@ var PresenceController = class extends Controller {
|
|
|
512
835
|
#reset() {
|
|
513
836
|
this.#timers.clearAll();
|
|
514
837
|
this.#pendingBeacon = null;
|
|
838
|
+
this.#announceId = null;
|
|
515
839
|
this.#peers.clear();
|
|
516
840
|
this.#lastBeaconAt = 0;
|
|
517
841
|
this.element.removeAttribute("data-present");
|
|
@@ -524,25 +848,6 @@ var PresenceController = class extends Controller {
|
|
|
524
848
|
}
|
|
525
849
|
}
|
|
526
850
|
};
|
|
527
|
-
|
|
528
|
-
// src/utils/announce.ts
|
|
529
|
-
function announce(message, options = {}) {
|
|
530
|
-
const text = message.trim();
|
|
531
|
-
if (text.length === 0) return;
|
|
532
|
-
window.dispatchEvent(
|
|
533
|
-
new CustomEvent("stimeo--announcer:announce", {
|
|
534
|
-
detail: { message: text, assertive: options.assertive === true }
|
|
535
|
-
})
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
function fillTemplate(template, values) {
|
|
539
|
-
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
540
|
-
const replacement = values[name];
|
|
541
|
-
return replacement === void 0 ? match : String(replacement);
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// src/cable/typing_indicator_controller.ts
|
|
546
851
|
var DEFAULT_TIMEOUT = 3e3;
|
|
547
852
|
var DEFAULT_THROTTLE = 2e3;
|
|
548
853
|
var fillTokens = (template, values) => template.replace(/%\{(name|names|count)\}/g, (match, token) => values[token] ?? match);
|
|
@@ -650,14 +955,16 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
650
955
|
if (added) this.#render();
|
|
651
956
|
}
|
|
652
957
|
/**
|
|
653
|
-
* The silence after which a typer is dropped, in ms: a finite, non-negative number
|
|
654
|
-
* Anything else names no delay — `setTimeout` reads `NaN`, a
|
|
655
|
-
* `Infinity` alike as "now",
|
|
656
|
-
*
|
|
958
|
+
* The silence after which a typer is dropped, in ms: a finite, non-negative number
|
|
959
|
+
* a timer can hold. Anything else names no delay — `setTimeout` reads `NaN`, a
|
|
960
|
+
* negative value and `Infinity` alike as "now", and a value past
|
|
961
|
+
* {@link MAX_TIMER_DELAY_MS} overflows to the same place — so the typer would
|
|
962
|
+
* vanish in the same task it appeared and the indicator could never be seen. Such
|
|
963
|
+
* a declaration falls back to the default.
|
|
657
964
|
*/
|
|
658
965
|
get #timeout() {
|
|
659
966
|
const declared = this.timeoutValue;
|
|
660
|
-
return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_TIMEOUT;
|
|
967
|
+
return Number.isFinite(declared) && declared >= 0 && declared <= MAX_TIMER_DELAY_MS ? declared : DEFAULT_TIMEOUT;
|
|
661
968
|
}
|
|
662
969
|
/**
|
|
663
970
|
* The minimum gap between outgoing signals, in ms: a finite, non-negative number.
|