stimeo-ui 0.16.0 → 0.17.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 +31 -0
- data/README.md +8 -1
- data/dist/cable/index.js +363 -436
- data/dist/controllers/accordion_controller.js +166 -55
- data/dist/controllers/alert_dialog_controller.js +862 -221
- data/dist/controllers/announcer_controller.js +135 -198
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +271 -127
- data/dist/controllers/avatar_controller.js +171 -105
- data/dist/controllers/breadcrumb_controller.js +0 -130
- data/dist/controllers/bulk_select_controller.js +262 -52
- data/dist/controllers/calendar_controller.js +175 -209
- data/dist/controllers/carousel_controller.js +461 -399
- data/dist/controllers/character_counter_controller.js +246 -156
- data/dist/controllers/checkbox_controller.js +32 -74
- data/dist/controllers/clipboard_controller.js +168 -147
- data/dist/controllers/collapsible_controller.js +301 -120
- data/dist/controllers/color_picker_controller.js +149 -139
- data/dist/controllers/combobox_controller.js +360 -133
- data/dist/controllers/command_palette_controller.js +970 -358
- data/dist/controllers/conditional_fields_controller.js +166 -124
- data/dist/controllers/confirm_controller.js +939 -248
- data/dist/controllers/context_menu_controller.js +256 -128
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +183 -148
- data/dist/controllers/currency_input_controller.js +134 -158
- data/dist/controllers/data_grid_controller.js +114 -186
- data/dist/controllers/date_range_picker_controller.js +288 -206
- data/dist/controllers/dialog_controller.js +858 -223
- data/dist/controllers/direct_upload_controller.js +73 -153
- data/dist/controllers/dirty_form_controller.js +14 -48
- data/dist/controllers/dismissible_controller.js +175 -21
- data/dist/controllers/drawer_controller.js +933 -341
- data/dist/controllers/dropdown_controller.js +312 -82
- data/dist/controllers/editable_controller.js +158 -68
- data/dist/controllers/empty_state_controller.js +67 -53
- data/dist/controllers/file_dropzone_controller.js +311 -207
- data/dist/controllers/filter_controller.js +67 -39
- data/dist/controllers/flash_controller.js +172 -314
- data/dist/controllers/focus_controller.js +654 -240
- data/dist/controllers/form_field_controller.js +147 -132
- data/dist/controllers/form_validation_controller.js +13 -86
- data/dist/controllers/frame_loading_controller.js +264 -213
- data/dist/controllers/highlight_controller.js +107 -75
- data/dist/controllers/hover_card_controller.js +309 -118
- data/dist/controllers/idle_controller.js +267 -101
- data/dist/controllers/input_mask_controller.js +71 -72
- data/dist/controllers/intersection_controller.js +147 -106
- data/dist/controllers/lazy_frame_controller.js +9 -65
- data/dist/controllers/listbox_controller.js +306 -204
- data/dist/controllers/local_time_controller.js +54 -64
- data/dist/controllers/masonry_controller.js +70 -93
- data/dist/controllers/menu_controller.js +214 -156
- data/dist/controllers/menubar_controller.js +170 -329
- data/dist/controllers/meter_controller.js +151 -67
- data/dist/controllers/multi_select_controller.js +593 -358
- data/dist/controllers/navigation_menu_controller.js +124 -232
- data/dist/controllers/nested_form_controller.js +352 -182
- data/dist/controllers/network_status_controller.js +242 -54
- data/dist/controllers/number_input_controller.js +320 -263
- data/dist/controllers/optimistic_controller.js +16 -76
- data/dist/controllers/otp_controller.js +198 -193
- data/dist/controllers/overflow_indicator_controller.js +116 -113
- data/dist/controllers/overflow_menu_controller.js +343 -283
- data/dist/controllers/pagination_controller.js +295 -119
- data/dist/controllers/password_reveal_controller.js +389 -121
- data/dist/controllers/password_strength_controller.js +99 -153
- data/dist/controllers/persist_controller.js +237 -103
- data/dist/controllers/pointer_drag_controller.js +185 -165
- data/dist/controllers/popover_controller.js +236 -97
- data/dist/controllers/portal_controller.js +209 -76
- data/dist/controllers/preview_guard_controller.js +232 -116
- data/dist/controllers/progress_controller.js +125 -56
- data/dist/controllers/radio_group_controller.js +117 -137
- data/dist/controllers/range_slider_controller.js +192 -105
- data/dist/controllers/rating_controller.js +418 -207
- data/dist/controllers/read_more_controller.js +134 -63
- data/dist/controllers/reading_progress_controller.js +147 -135
- data/dist/controllers/relative_time_controller.js +145 -110
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- data/dist/controllers/resizable_controller.js +255 -139
- data/dist/controllers/roving_controller.js +39 -84
- data/dist/controllers/scroll_area_controller.js +428 -225
- data/dist/controllers/scroll_restore_controller.js +0 -53
- data/dist/controllers/scroll_visibility_controller.js +395 -145
- data/dist/controllers/scrollspy_controller.js +181 -242
- data/dist/controllers/separator_controller.js +267 -140
- data/dist/controllers/sidebar_controller.js +1023 -386
- data/dist/controllers/skeleton_controller.js +248 -97
- data/dist/controllers/slider_controller.js +159 -79
- data/dist/controllers/smart_sticky_header_controller.js +162 -63
- data/dist/controllers/sortable_controller.js +0 -112
- data/dist/controllers/spinner_controller.js +277 -169
- data/dist/controllers/step_indicator_controller.js +99 -46
- data/dist/controllers/stepper_controller.js +114 -73
- data/dist/controllers/stick_to_bottom_controller.js +262 -138
- data/dist/controllers/sticky_observer_controller.js +214 -49
- data/dist/controllers/submit_once_controller.js +294 -205
- data/dist/controllers/switch_controller.js +33 -60
- data/dist/controllers/tabs_controller.js +35 -29
- data/dist/controllers/tags_input_controller.js +136 -145
- data/dist/controllers/textarea_autosize_controller.js +71 -58
- data/dist/controllers/theme_controller.js +130 -112
- data/dist/controllers/time_picker_controller.js +146 -80
- data/dist/controllers/toast_controller.js +186 -297
- data/dist/controllers/toggle_group_controller.js +95 -136
- data/dist/controllers/toolbar_controller.js +68 -98
- data/dist/controllers/tooltip_controller.js +158 -124
- data/dist/controllers/transition_controller.js +123 -119
- data/dist/controllers/tree_view_controller.js +150 -345
- data/dist/index.js +6370 -9346
- data/dist/positioning/index.js +148 -47
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
data/dist/cable/index.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
import { createConsumer } from '@rails/actioncable';
|
|
3
3
|
|
|
4
|
-
// src/cable/live_counter_controller.ts
|
|
5
|
-
|
|
6
|
-
// src/utils/announce.ts
|
|
7
4
|
function announce(message, options = {}) {
|
|
8
5
|
const text = message.trim();
|
|
9
6
|
if (text.length === 0) return;
|
|
@@ -20,7 +17,6 @@ function fillTemplate(template, values) {
|
|
|
20
17
|
});
|
|
21
18
|
}
|
|
22
19
|
|
|
23
|
-
// src/utils/authored_integer.ts
|
|
24
20
|
var TOKEN_BODY = /\d[\d.,'’’ _]*\d|\d/;
|
|
25
21
|
var SIGN_OPENER = /[\s([]/;
|
|
26
22
|
function authoredInteger(text) {
|
|
@@ -42,29 +38,21 @@ function authoredInteger(text) {
|
|
|
42
38
|
return signed ? -magnitude || 0 : magnitude;
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
// src/utils/blur_deferral.ts
|
|
46
41
|
var BlurDeferral = class {
|
|
47
|
-
/** Elements currently holding an update back, mapped to their `blur` listener. */
|
|
48
42
|
#pending = /* @__PURE__ */ new Map();
|
|
49
|
-
/** Called after a pending element blurs and has been detached. */
|
|
50
43
|
#onRelease;
|
|
51
|
-
/** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
|
|
52
44
|
constructor(onRelease) {
|
|
53
45
|
this.#onRelease = onRelease;
|
|
54
46
|
}
|
|
55
|
-
/** Number of elements currently holding an update back. */
|
|
56
47
|
get size() {
|
|
57
48
|
return this.#pending.size;
|
|
58
49
|
}
|
|
59
|
-
/** Snapshot of the pending elements, safe to iterate while releasing them. */
|
|
60
50
|
get elements() {
|
|
61
51
|
return [...this.#pending.keys()];
|
|
62
52
|
}
|
|
63
|
-
/** Whether `element` is currently holding an update back. */
|
|
64
53
|
has(element) {
|
|
65
54
|
return this.#pending.has(element);
|
|
66
55
|
}
|
|
67
|
-
/** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
|
|
68
56
|
defer(element) {
|
|
69
57
|
if (this.#pending.has(element)) return;
|
|
70
58
|
const onBlur = () => {
|
|
@@ -74,22 +62,18 @@ var BlurDeferral = class {
|
|
|
74
62
|
this.#pending.set(element, onBlur);
|
|
75
63
|
element.addEventListener("blur", onBlur);
|
|
76
64
|
}
|
|
77
|
-
/** Defers `element` as the only pending entry, cancelling any others. */
|
|
78
65
|
deferOnly(element) {
|
|
79
66
|
for (const pending of this.elements) {
|
|
80
67
|
if (pending !== element) this.#detach(pending);
|
|
81
68
|
}
|
|
82
69
|
this.defer(element);
|
|
83
70
|
}
|
|
84
|
-
/** Cancels `element`'s deferral without completing it; no-ops when not pending. */
|
|
85
71
|
release(element) {
|
|
86
72
|
this.#detach(element);
|
|
87
73
|
}
|
|
88
|
-
/** Cancels every deferral without completing any of them. */
|
|
89
74
|
releaseAll() {
|
|
90
75
|
for (const element of this.elements) this.#detach(element);
|
|
91
76
|
}
|
|
92
|
-
/** Removes the `blur` listener for `element` and forgets it. */
|
|
93
77
|
#detach(element) {
|
|
94
78
|
const onBlur = this.#pending.get(element);
|
|
95
79
|
if (onBlur) element.removeEventListener("blur", onBlur);
|
|
@@ -97,44 +81,90 @@ var BlurDeferral = class {
|
|
|
97
81
|
}
|
|
98
82
|
};
|
|
99
83
|
|
|
100
|
-
|
|
84
|
+
var MicrotaskCoalescer = class {
|
|
85
|
+
#run;
|
|
86
|
+
#queued = false;
|
|
87
|
+
#active = false;
|
|
88
|
+
#generation = 0;
|
|
89
|
+
constructor(run) {
|
|
90
|
+
this.#run = run;
|
|
91
|
+
}
|
|
92
|
+
activate() {
|
|
93
|
+
this.#active = true;
|
|
94
|
+
}
|
|
95
|
+
cancel() {
|
|
96
|
+
this.#active = false;
|
|
97
|
+
this.#queued = false;
|
|
98
|
+
this.#generation += 1;
|
|
99
|
+
}
|
|
100
|
+
schedule() {
|
|
101
|
+
if (!this.#active || this.#queued) return;
|
|
102
|
+
this.#queued = true;
|
|
103
|
+
const generation = this.#generation;
|
|
104
|
+
queueMicrotask(() => {
|
|
105
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
106
|
+
this.#queued = false;
|
|
107
|
+
this.#run();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
var ListenerSet = class {
|
|
113
|
+
#abort = new AbortController();
|
|
114
|
+
add(target, type, handler, options) {
|
|
115
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
116
|
+
}
|
|
117
|
+
dispose() {
|
|
118
|
+
this.#abort.abort();
|
|
119
|
+
this.#abort = new AbortController();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
var MorphRenderWatcher = class {
|
|
124
|
+
#listeners = new ListenerSet();
|
|
125
|
+
#pass;
|
|
126
|
+
#element = null;
|
|
127
|
+
#onMorph = () => this.#pass.schedule();
|
|
128
|
+
constructor(run) {
|
|
129
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
130
|
+
}
|
|
131
|
+
observe(element) {
|
|
132
|
+
if (this.#element === element) return;
|
|
133
|
+
this.disconnect();
|
|
134
|
+
this.#element = element;
|
|
135
|
+
this.#pass.activate();
|
|
136
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
137
|
+
}
|
|
138
|
+
schedule() {
|
|
139
|
+
this.#pass.schedule();
|
|
140
|
+
}
|
|
141
|
+
disconnect() {
|
|
142
|
+
this.#listeners.dispose();
|
|
143
|
+
this.#pass.cancel();
|
|
144
|
+
this.#element = null;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
101
148
|
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
149
|
+
|
|
102
150
|
var TimerRegistry = class {
|
|
103
|
-
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
104
151
|
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
152
|
clear(id) {
|
|
112
153
|
if (this.ids.delete(id)) {
|
|
113
154
|
this.cancel(id);
|
|
114
155
|
}
|
|
115
156
|
}
|
|
116
|
-
/**
|
|
117
|
-
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
118
|
-
* to guarantee no timer outlives the element.
|
|
119
|
-
*/
|
|
120
157
|
clearAll() {
|
|
121
158
|
for (const id of this.ids) {
|
|
122
159
|
this.cancel(id);
|
|
123
160
|
}
|
|
124
161
|
this.ids.clear();
|
|
125
162
|
}
|
|
126
|
-
/** Number of timers currently tracked (pending). */
|
|
127
163
|
get size() {
|
|
128
164
|
return this.ids.size;
|
|
129
165
|
}
|
|
130
166
|
};
|
|
131
167
|
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
168
|
set(callback, delay) {
|
|
139
169
|
const id = this.schedule(() => {
|
|
140
170
|
this.ids.delete(id);
|
|
@@ -151,7 +181,6 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
151
181
|
}
|
|
152
182
|
};
|
|
153
183
|
var SafeInterval = class extends TimerRegistry {
|
|
154
|
-
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
155
184
|
set(callback, delay) {
|
|
156
185
|
const id = this.schedule(callback, delay);
|
|
157
186
|
this.ids.add(id);
|
|
@@ -165,17 +194,26 @@ var SafeInterval = class extends TimerRegistry {
|
|
|
165
194
|
}
|
|
166
195
|
};
|
|
167
196
|
|
|
168
|
-
// src/utils/transient_hooks.ts
|
|
169
197
|
var TransientHooks = class {
|
|
170
198
|
#attributes;
|
|
171
199
|
constructor(options) {
|
|
172
200
|
this.#attributes = options.attributes;
|
|
173
201
|
}
|
|
174
|
-
/** Drops the declared hooks from `element`; what it carries besides stays. */
|
|
175
202
|
reset(element) {
|
|
176
203
|
for (const attribute of this.#attributes) element.removeAttribute(attribute);
|
|
177
204
|
}
|
|
178
205
|
};
|
|
206
|
+
|
|
207
|
+
function sharedRegistry(key, create) {
|
|
208
|
+
const symbol = Symbol.for(key);
|
|
209
|
+
const scope = globalThis;
|
|
210
|
+
const existing = scope[symbol];
|
|
211
|
+
if (existing !== void 0) return existing;
|
|
212
|
+
const registry = create();
|
|
213
|
+
Object.defineProperty(globalThis, symbol, { value: registry });
|
|
214
|
+
return registry;
|
|
215
|
+
}
|
|
216
|
+
|
|
179
217
|
function parseSubscriptionParams(raw) {
|
|
180
218
|
try {
|
|
181
219
|
const parsed = JSON.parse(raw);
|
|
@@ -185,15 +223,21 @@ function parseSubscriptionParams(raw) {
|
|
|
185
223
|
return {};
|
|
186
224
|
}
|
|
187
225
|
}
|
|
188
|
-
var
|
|
226
|
+
var consumerRegistry = sharedRegistry(
|
|
227
|
+
"stimeo-ui.cable-consumer.registry.v1",
|
|
228
|
+
() => ({ consumer: null })
|
|
229
|
+
);
|
|
189
230
|
function setCableConsumer(consumer) {
|
|
190
|
-
|
|
231
|
+
consumerRegistry.consumer = consumer;
|
|
191
232
|
}
|
|
192
233
|
function getCableConsumer() {
|
|
193
|
-
if (!
|
|
194
|
-
return
|
|
234
|
+
if (!consumerRegistry.consumer) consumerRegistry.consumer = createConsumer();
|
|
235
|
+
return consumerRegistry.consumer;
|
|
195
236
|
}
|
|
196
|
-
var sharedSubscriptions =
|
|
237
|
+
var sharedSubscriptions = sharedRegistry(
|
|
238
|
+
"stimeo-ui.cable-subscriptions.registry.v1",
|
|
239
|
+
() => /* @__PURE__ */ new WeakMap()
|
|
240
|
+
);
|
|
197
241
|
function identifierOf(channel) {
|
|
198
242
|
return JSON.stringify(typeof channel === "string" ? { channel } : channel);
|
|
199
243
|
}
|
|
@@ -257,9 +301,6 @@ function openSharedSubscription(consumer, channel) {
|
|
|
257
301
|
confirmed: false,
|
|
258
302
|
rejected: false,
|
|
259
303
|
subscription: consumer.subscriptions.create(channel, {
|
|
260
|
-
// A refusal is final: Action Cable never confirms a rejected subscription,
|
|
261
|
-
// so a late connected/disconnected (only possible from a misbehaving
|
|
262
|
-
// consumer double) must not reopen the gate `rejected` promised shut.
|
|
263
304
|
connected: () => {
|
|
264
305
|
if (shared.rejected) return;
|
|
265
306
|
shared.confirmed = true;
|
|
@@ -281,7 +322,6 @@ function openSharedSubscription(consumer, channel) {
|
|
|
281
322
|
return shared;
|
|
282
323
|
}
|
|
283
324
|
|
|
284
|
-
// src/cable/live_counter_controller.ts
|
|
285
325
|
var TRANSIENT = new TransientHooks({ attributes: ["data-live-counter-rejected"] });
|
|
286
326
|
var DISABLED_MARKER = "data-live-counter-disabled";
|
|
287
327
|
var LiveCounterController = class _LiveCounterController extends Controller {
|
|
@@ -292,86 +332,42 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
292
332
|
id: { type: String, default: "" },
|
|
293
333
|
announceText: { type: String, default: "" }
|
|
294
334
|
};
|
|
295
|
-
/** Collapses a burst of broadcasts into one announcement. */
|
|
296
335
|
static #announceDelay = 200;
|
|
297
336
|
static actions = ["increment"];
|
|
298
337
|
static events = ["change"];
|
|
299
|
-
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
300
|
-
#params = {};
|
|
301
338
|
#subscription = null;
|
|
302
|
-
#
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
* sent them. An echo belongs to the send that caused it, and the amount is what
|
|
306
|
-
* names that send: a counter that guessed nothing — or guessed a different
|
|
307
|
-
* amount — applies the delta like any other, so siblings and other tabs
|
|
308
|
-
* converge whatever step each of them uses.
|
|
309
|
-
*/
|
|
339
|
+
#follow = new MicrotaskCoalescer(() => this.#followDeclaration());
|
|
340
|
+
#identifier = "";
|
|
341
|
+
#morphRender = new MorphRenderWatcher(() => this.#syncTriggers());
|
|
310
342
|
#outstanding = [];
|
|
311
|
-
/** Holds a disable back while the trigger owns focus, so focus is never taken away. */
|
|
312
343
|
#focusedTriggers = new BlurDeferral((trigger) => this.#syncTrigger(trigger));
|
|
313
344
|
#timers = new SafeTimeout();
|
|
314
|
-
/**
|
|
315
|
-
* Re-parses the identifier parameters and moves the subscription to the
|
|
316
|
-
* identifier they now name.
|
|
317
|
-
*
|
|
318
|
-
* A malformed declaration falls back to no parameters, so the identifier keeps
|
|
319
|
-
* naming the channel instead of the subscription never being created at all.
|
|
320
|
-
*/
|
|
321
345
|
paramsValueChanged() {
|
|
322
|
-
this.#
|
|
323
|
-
this.#resubscribe();
|
|
346
|
+
this.#follow.schedule();
|
|
324
347
|
}
|
|
325
|
-
/** Moves the subscription when the declaration names a different channel. */
|
|
326
348
|
channelValueChanged() {
|
|
327
|
-
this.#
|
|
349
|
+
this.#follow.schedule();
|
|
328
350
|
}
|
|
329
351
|
connect() {
|
|
330
|
-
this.#
|
|
352
|
+
this.#morphRender.observe(this.element);
|
|
353
|
+
this.#follow.activate();
|
|
331
354
|
this.#subscribe();
|
|
332
355
|
}
|
|
333
|
-
/**
|
|
334
|
-
* Opens the subscription the declaration names, and reflects the gate.
|
|
335
|
-
*
|
|
336
|
-
* The rejected hook is transient server state: a Turbo cache snapshot must not
|
|
337
|
-
* resurrect it — the fresh subscription re-decides it. An outstanding guess
|
|
338
|
-
* belongs to the identifier that is going away, so it is dropped with it.
|
|
339
|
-
*
|
|
340
|
-
* @stimeoRuntimeOnly `channel` decides whether this call opens a subscription at all; the
|
|
341
|
-
* rejected hook it clears does not depend on it.
|
|
342
|
-
*/
|
|
343
356
|
#subscribe() {
|
|
344
357
|
TRANSIENT.reset(this.element);
|
|
345
358
|
this.#outstanding.length = 0;
|
|
359
|
+
const descriptor = this.#descriptor;
|
|
360
|
+
this.#identifier = identifierOf(descriptor);
|
|
346
361
|
if (this.channelValue) {
|
|
347
|
-
this.#subscription = this.#open();
|
|
362
|
+
this.#subscription = this.#open(descriptor);
|
|
348
363
|
}
|
|
349
364
|
this.#syncTriggers();
|
|
350
365
|
}
|
|
351
|
-
|
|
352
|
-
* Opens the wire subscription, or returns null when it cannot be opened.
|
|
353
|
-
*
|
|
354
|
-
* A consumer that cannot be built (no Action Cable meta tag, a boot-order
|
|
355
|
-
* mistake) would otherwise throw out of `connect()` and leave the element
|
|
356
|
-
* half-wired: no gate reflected, and a trigger that looks live. The gate reads
|
|
357
|
-
* the declaration, so a null subscription keeps it shut and the display never
|
|
358
|
-
* moves past what the server can receive.
|
|
359
|
-
*
|
|
360
|
-
* @stimeoRuntimeOnly `channel` and its params choose the one subscription this call opens; `id`
|
|
361
|
-
* is read by the handlers it wires.
|
|
362
|
-
*/
|
|
363
|
-
#open() {
|
|
366
|
+
#open(descriptor) {
|
|
364
367
|
try {
|
|
365
|
-
return createConfirmedSubscription(
|
|
368
|
+
return createConfirmedSubscription(descriptor, {
|
|
366
369
|
connected: () => this.#syncTriggers(),
|
|
367
|
-
// A drop closes the send window (the shared subscription tracks it)
|
|
368
|
-
// until Action Cable reconnects and re-confirms — an increment during
|
|
369
|
-
// the outage would bump the display while its perform() is silently
|
|
370
|
-
// discarded by the closed socket.
|
|
371
370
|
disconnected: () => this.#syncTriggers(),
|
|
372
|
-
// The server refused the subscription (auth, bad params): the gate
|
|
373
|
-
// stays shut for good, and the hook lets the consumer's CSS disable
|
|
374
|
-
// or hide the trigger instead of leaving a silently dead button.
|
|
375
371
|
rejected: () => {
|
|
376
372
|
this.element.setAttribute("data-live-counter-rejected", "true");
|
|
377
373
|
this.#syncTriggers();
|
|
@@ -383,26 +379,19 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
383
379
|
return null;
|
|
384
380
|
}
|
|
385
381
|
}
|
|
386
|
-
/**
|
|
387
|
-
* The identifier this declaration names.
|
|
388
|
-
*
|
|
389
|
-
* `channel` comes first so the identifier text matches what Action Cable
|
|
390
|
-
* builds, and a `channel` key inside `params` cannot displace the declared
|
|
391
|
-
* one — `params` names *additional* identifier parameters.
|
|
392
|
-
*/
|
|
393
382
|
get #descriptor() {
|
|
394
|
-
const { channel: _ignored, ...rest } = this
|
|
383
|
+
const { channel: _ignored, ...rest } = parseSubscriptionParams(this.paramsValue);
|
|
395
384
|
return { channel: this.channelValue, ...rest };
|
|
396
385
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (!this.#connected) return;
|
|
386
|
+
#followDeclaration() {
|
|
387
|
+
if (identifierOf(this.#descriptor) === this.#identifier) return;
|
|
400
388
|
this.#subscription?.unsubscribe();
|
|
401
389
|
this.#subscription = null;
|
|
402
390
|
this.#subscribe();
|
|
403
391
|
}
|
|
404
392
|
disconnect() {
|
|
405
|
-
this.#
|
|
393
|
+
this.#morphRender.disconnect();
|
|
394
|
+
this.#follow.cancel();
|
|
406
395
|
this.#timers.clearAll();
|
|
407
396
|
this.#focusedTriggers.releaseAll();
|
|
408
397
|
this.#subscription?.unsubscribe();
|
|
@@ -410,20 +399,13 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
410
399
|
TRANSIENT.reset(this.element);
|
|
411
400
|
for (const trigger of this.triggerTargets) this.#release(trigger);
|
|
412
401
|
}
|
|
413
|
-
/** Late-added triggers (e.g. via a Turbo Stream) pick up the current gate. */
|
|
414
402
|
triggerTargetConnected(target) {
|
|
415
403
|
this.#syncTrigger(target);
|
|
416
404
|
}
|
|
417
|
-
/** A trigger that stops being one keeps nothing this controller lent it. */
|
|
418
405
|
triggerTargetDisconnected(target) {
|
|
419
406
|
this.#focusedTriggers.release(target);
|
|
420
407
|
this.#release(target);
|
|
421
408
|
}
|
|
422
|
-
/**
|
|
423
|
-
* Optimistic local increment: bumps the display immediately, then asks the
|
|
424
|
-
* server to persist and broadcast. The step comes from the action param
|
|
425
|
-
* (`data-stimeo--live-counter-delta-param`), default 1. Bound via `data-action`.
|
|
426
|
-
*/
|
|
427
409
|
increment(event) {
|
|
428
410
|
if (!this.#ready) return;
|
|
429
411
|
const raw = Number(event?.params?.delta ?? 1);
|
|
@@ -436,15 +418,6 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
436
418
|
}
|
|
437
419
|
this.#subscription?.perform("increment", { id: this.idValue, delta });
|
|
438
420
|
}
|
|
439
|
-
/**
|
|
440
|
-
* Reconciles a broadcast: absolute `count` wins; the echo of this client's own
|
|
441
|
-
* guess is skipped.
|
|
442
|
-
*
|
|
443
|
-
* Only whole numbers reach the display. The wire carries values another client
|
|
444
|
-
* named and the server relayed, and the display is the state this controller
|
|
445
|
-
* reads back — a fraction, an infinity or a NaN would be written as text that
|
|
446
|
-
* reads back as a different number, or as none at all.
|
|
447
|
-
*/
|
|
448
421
|
#onReceived(data) {
|
|
449
422
|
const message = data;
|
|
450
423
|
if (Number.isSafeInteger(message?.count)) {
|
|
@@ -463,32 +436,15 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
463
436
|
this.#reconcile(this.#current + delta);
|
|
464
437
|
}
|
|
465
438
|
}
|
|
466
|
-
/** True when no channel is declared: the local-only mode, with no server to diverge from. */
|
|
467
439
|
get #local() {
|
|
468
440
|
return this.channelValue === "";
|
|
469
441
|
}
|
|
470
|
-
/** True while an increment would go through (local-only counters always are). */
|
|
471
442
|
get #ready() {
|
|
472
443
|
return this.#local || (this.#subscription?.confirmed ?? false);
|
|
473
444
|
}
|
|
474
|
-
/**
|
|
475
|
-
* Reflects the send gate onto the optional `trigger` targets as the real
|
|
476
|
-
* `disabled` attribute — the declarative alternative to styling off the
|
|
477
|
-
* `data-live-counter-rejected` hook (a disabled control is also skipped by
|
|
478
|
-
* keyboard focus and announced by AT, which CSS alone cannot do).
|
|
479
|
-
*/
|
|
480
445
|
#syncTriggers() {
|
|
481
446
|
for (const trigger of this.triggerTargets) this.#syncTrigger(trigger);
|
|
482
447
|
}
|
|
483
|
-
/**
|
|
484
|
-
* `disabled` is a shared attribute: only disable what is currently enabled
|
|
485
|
-
* (marking it ours), and only lift a disabled carrying our marker — so an
|
|
486
|
-
* authored-disabled trigger ("disabled until valid", say) is never
|
|
487
|
-
* re-enabled by the gate. A marked disabled restored from a Turbo cache
|
|
488
|
-
* snapshot is recognized as ours and lifted once the gate opens.
|
|
489
|
-
*
|
|
490
|
-
* @stimeoRenderRoot
|
|
491
|
-
*/
|
|
492
448
|
#syncTrigger(trigger) {
|
|
493
449
|
if (this.#ready) {
|
|
494
450
|
this.#focusedTriggers.release(trigger);
|
|
@@ -503,50 +459,26 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
503
459
|
trigger.setAttribute("disabled", "");
|
|
504
460
|
trigger.setAttribute(DISABLED_MARKER, "");
|
|
505
461
|
}
|
|
506
|
-
/** Gives back a `disabled` this controller lent, leaving an authored one alone. */
|
|
507
462
|
#release(trigger) {
|
|
508
463
|
if (!trigger.hasAttribute(DISABLED_MARKER)) return;
|
|
509
464
|
trigger.removeAttribute("disabled");
|
|
510
465
|
trigger.removeAttribute(DISABLED_MARKER);
|
|
511
466
|
}
|
|
512
|
-
/** The displayed element: the `value` target, else the controller element. */
|
|
513
467
|
get #display() {
|
|
514
468
|
return this.hasValueTarget ? this.valueTarget : this.element;
|
|
515
469
|
}
|
|
516
|
-
/** The current count, parsed from the DOM (the single source of truth). */
|
|
517
470
|
get #current() {
|
|
518
471
|
return authoredInteger(this.#display.textContent ?? "") ?? 0;
|
|
519
472
|
}
|
|
520
|
-
/**
|
|
521
|
-
* Writes a whole count — every caller has already established that it is one —
|
|
522
|
-
* and reports whether the display actually moved.
|
|
523
|
-
*/
|
|
524
473
|
#write(count) {
|
|
525
474
|
if (count === this.#current) return false;
|
|
526
475
|
this.#display.textContent = String(count);
|
|
527
476
|
this.dispatch("change", { detail: { count } });
|
|
528
477
|
return true;
|
|
529
478
|
}
|
|
530
|
-
/**
|
|
531
|
-
* Settles the display on a count the server stated, announcing what it settled
|
|
532
|
-
* on. Announcing the optimistic bump instead would read a guess out and then
|
|
533
|
-
* correct it, so the announcement rides this path only.
|
|
534
|
-
*
|
|
535
|
-
* @stimeoRuntimeOnly The count shown is the value received; `announceText` words the one
|
|
536
|
-
* announcement of this change.
|
|
537
|
-
*/
|
|
538
479
|
#reconcile(count) {
|
|
539
480
|
if (this.#write(count)) this.#announce(count);
|
|
540
481
|
}
|
|
541
|
-
/**
|
|
542
|
-
* Hands a reconciled count to the page's shared announcer.
|
|
543
|
-
*
|
|
544
|
-
* Debounced: a burst of broadcasts is one announcement, not one per message,
|
|
545
|
-
* and only the last one is ever sent. Wording comes from the consumer and
|
|
546
|
-
* {@link announce} drops an empty message, so an undeclared template announces
|
|
547
|
-
* nothing at all — a count that ticks constantly should not interrupt a reader
|
|
548
|
-
* unless the page asked for it.
|
|
549
|
-
*/
|
|
550
482
|
#announce(count) {
|
|
551
483
|
this.#timers.clearAll();
|
|
552
484
|
this.#timers.set(() => {
|
|
@@ -555,16 +487,9 @@ var LiveCounterController = class _LiveCounterController extends Controller {
|
|
|
555
487
|
}
|
|
556
488
|
};
|
|
557
489
|
|
|
558
|
-
// src/utils/keyed_timers.ts
|
|
559
490
|
var KeyedTimers = class {
|
|
560
491
|
#timers = new SafeTimeout();
|
|
561
|
-
/** The pending timer of each key; an entry lives exactly as long as its timer. */
|
|
562
492
|
#pending = /* @__PURE__ */ new Map();
|
|
563
|
-
/**
|
|
564
|
-
* Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
|
|
565
|
-
* pending. The entry is dropped before the callback runs, so the callback sees
|
|
566
|
-
* the key unarmed and may arm it again for the next round.
|
|
567
|
-
*/
|
|
568
493
|
set(key, callback, delay) {
|
|
569
494
|
this.clear(key);
|
|
570
495
|
const id = this.#timers.set(() => {
|
|
@@ -573,46 +498,80 @@ var KeyedTimers = class {
|
|
|
573
498
|
}, delay);
|
|
574
499
|
this.#pending.set(key, id);
|
|
575
500
|
}
|
|
576
|
-
/**
|
|
577
|
-
* Cancels `key`'s pending timer, if it has one.
|
|
578
|
-
*
|
|
579
|
-
* A timer id is a positive integer, so `-1` stands for "nothing pending" and
|
|
580
|
-
* the registry ignores an id it does not own — the unarmed case needs no
|
|
581
|
-
* branch of its own, and no other key's timer can be reached from here.
|
|
582
|
-
*/
|
|
583
501
|
clear(key) {
|
|
584
502
|
this.#timers.clear(this.#pending.get(key) ?? -1);
|
|
585
503
|
this.#pending.delete(key);
|
|
586
504
|
}
|
|
587
|
-
/**
|
|
588
|
-
* Cancels every pending timer and forgets every key. Call this from a
|
|
589
|
-
* controller's `disconnect()` so no timer, and no entry, outlives the element.
|
|
590
|
-
*/
|
|
591
505
|
clearAll() {
|
|
592
506
|
this.#timers.clearAll();
|
|
593
507
|
this.#pending.clear();
|
|
594
508
|
}
|
|
595
|
-
/** Whether `key` has a timer pending. */
|
|
596
509
|
has(key) {
|
|
597
510
|
return this.#pending.has(key);
|
|
598
511
|
}
|
|
599
512
|
};
|
|
600
513
|
|
|
601
|
-
|
|
514
|
+
var NUMBER_BOUNDS = {
|
|
515
|
+
nonNegative: { finite: true, min: 0 },
|
|
516
|
+
timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS },
|
|
517
|
+
positiveTimer: { finite: true, exclusiveMin: 0, max: MAX_TIMER_DELAY_MS }
|
|
518
|
+
};
|
|
519
|
+
function matchesNumberBounds(value, bounds) {
|
|
520
|
+
if (!Number.isFinite(value)) {
|
|
521
|
+
const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
|
|
522
|
+
if (direction === null) return false;
|
|
523
|
+
if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
|
|
524
|
+
}
|
|
525
|
+
if (bounds.min !== void 0 && value < bounds.min) return false;
|
|
526
|
+
if (bounds.max !== void 0 && value > bounds.max) return false;
|
|
527
|
+
if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
|
|
528
|
+
if (bounds.integer && !Number.isInteger(value)) return false;
|
|
529
|
+
if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function readNumber(raw, fallback, bounds) {
|
|
534
|
+
return matchesNumberBounds(raw, bounds) ? raw : fallback;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
var NumberValueReader = class {
|
|
538
|
+
#lastRejected = /* @__PURE__ */ new Map();
|
|
539
|
+
read(owner, name, raw, fallback, bounds) {
|
|
540
|
+
const resolved = readNumber(raw, fallback, bounds);
|
|
541
|
+
if (matchesNumberBounds(raw, bounds)) {
|
|
542
|
+
this.#lastRejected.delete(name);
|
|
543
|
+
return resolved;
|
|
544
|
+
}
|
|
545
|
+
const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
|
|
546
|
+
const literal = owner.element.getAttribute(attribute);
|
|
547
|
+
if (literal === null) {
|
|
548
|
+
this.#lastRejected.delete(name);
|
|
549
|
+
return resolved;
|
|
550
|
+
}
|
|
551
|
+
if (this.#lastRejected.get(name) !== literal) {
|
|
552
|
+
this.#lastRejected.set(name, literal);
|
|
553
|
+
console.warn(
|
|
554
|
+
`Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return resolved;
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
602
561
|
function cloneTemplateRoot(template) {
|
|
603
562
|
const root = template.content.firstElementChild;
|
|
604
563
|
return root instanceof HTMLElement ? root.cloneNode(true) : null;
|
|
605
564
|
}
|
|
606
565
|
|
|
607
|
-
// src/cable/presence_controller.ts
|
|
608
566
|
var TRANSIENT2 = new TransientHooks({
|
|
609
567
|
attributes: ["data-present", "data-present-count", "data-presence-rejected"]
|
|
610
568
|
});
|
|
611
569
|
var BEACON_THROTTLE_MS = 2e3;
|
|
612
570
|
var DEFAULT_HEARTBEAT_MS = 15e3;
|
|
613
571
|
var DEFAULT_TIMEOUT_MS = 4e4;
|
|
614
|
-
var speakers = /* @__PURE__ */ new Map();
|
|
572
|
+
var speakers = sharedRegistry("stimeo-ui.presence.registry.v1", () => /* @__PURE__ */ new Map());
|
|
615
573
|
var PresenceController = class _PresenceController extends Controller {
|
|
574
|
+
#numbers = new NumberValueReader();
|
|
616
575
|
static targets = ["count", "list", "template"];
|
|
617
576
|
static values = {
|
|
618
577
|
channel: { type: String, default: "" },
|
|
@@ -624,168 +583,143 @@ var PresenceController = class _PresenceController extends Controller {
|
|
|
624
583
|
announceJoinText: { type: String, default: "" },
|
|
625
584
|
announceLeaveText: { type: String, default: "" }
|
|
626
585
|
};
|
|
586
|
+
static valueConstraints = {
|
|
587
|
+
heartbeat: NUMBER_BOUNDS.positiveTimer,
|
|
588
|
+
timeout: NUMBER_BOUNDS.positiveTimer
|
|
589
|
+
};
|
|
627
590
|
static events = ["join", "leave", "change"];
|
|
628
|
-
/** Delay (ms) before one roster change is sent to the shared announcer. */
|
|
629
591
|
static #announceDelay = 200;
|
|
630
|
-
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
631
|
-
#params = {};
|
|
632
592
|
#subscription = null;
|
|
633
|
-
|
|
593
|
+
#follow = new MicrotaskCoalescer(() => this.#followDeclaration());
|
|
594
|
+
#identifier = "";
|
|
595
|
+
#period = 0;
|
|
634
596
|
#peers = /* @__PURE__ */ new Map();
|
|
635
|
-
/** Each peer's auto-expiry timer, restarted by every beacon from that peer. */
|
|
636
597
|
#expiry = new KeyedTimers();
|
|
637
598
|
#timers = new SafeTimeout();
|
|
638
599
|
#intervals = new SafeInterval();
|
|
639
|
-
/** Epoch ms of the last outgoing beacon, for the convergence throttle. */
|
|
640
600
|
#lastBeaconAt = 0;
|
|
641
|
-
/** Pending trailing-edge convergence beacon (at most one queued). */
|
|
642
601
|
#pendingBeacon = null;
|
|
643
|
-
/** The one outstanding announcement, so a newer roster change supersedes it. */
|
|
644
602
|
#announceId = null;
|
|
645
|
-
|
|
603
|
+
#connected = false;
|
|
646
604
|
#speakerKey = "";
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
* naming the channel instead of the subscription never being created at all.
|
|
652
|
-
*/
|
|
605
|
+
#speakerId = "";
|
|
606
|
+
channelValueChanged() {
|
|
607
|
+
this.#follow.schedule();
|
|
608
|
+
}
|
|
653
609
|
paramsValueChanged() {
|
|
654
|
-
this.#
|
|
610
|
+
this.#follow.schedule();
|
|
611
|
+
}
|
|
612
|
+
idValueChanged() {
|
|
613
|
+
this.#follow.schedule();
|
|
614
|
+
}
|
|
615
|
+
heartbeatValueChanged() {
|
|
616
|
+
this.#follow.schedule();
|
|
655
617
|
}
|
|
656
|
-
/**
|
|
657
|
-
* The identifier this declaration names. `params` *adds* to the identifier, so a
|
|
658
|
-
* `channel` key inside it names an extra parameter and never replaces the channel
|
|
659
|
-
* the element declares.
|
|
660
|
-
*/
|
|
661
618
|
get #descriptor() {
|
|
662
|
-
const { channel: _declared, ...rest } = this
|
|
619
|
+
const { channel: _declared, ...rest } = parseSubscriptionParams(this.paramsValue);
|
|
663
620
|
return { channel: this.channelValue, ...rest };
|
|
664
621
|
}
|
|
665
|
-
/**
|
|
666
|
-
* The beacon period, in ms: a finite, positive number a timer can hold. Anything
|
|
667
|
-
* else names no interval — `setInterval` reads `NaN`, a negative value, `Infinity`
|
|
668
|
-
* and a value past {@link MAX_TIMER_DELAY_MS} alike as "as often as possible", so
|
|
669
|
-
* the declaration would flood the channel rather than heartbeat on it. Zero is out
|
|
670
|
-
* for the same reason, which is why this bound is tighter than the non-negative one
|
|
671
|
-
* a throttle or a plain delay can use. Such a declaration falls back to the default.
|
|
672
|
-
*/
|
|
673
622
|
get #heartbeat() {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* The silence after which a peer is dropped, in ms: a finite, positive number a
|
|
679
|
-
* timer can hold. Anything else names no delay and every peer expires in the task
|
|
680
|
-
* that records it, so the roster could never hold anyone. Such a declaration falls
|
|
681
|
-
* back to the default.
|
|
682
|
-
*/
|
|
623
|
+
return this.#safeHeartbeat;
|
|
624
|
+
}
|
|
683
625
|
get #timeout() {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
687
|
-
/**
|
|
688
|
-
* Draws the roster into a list target, which arrives empty from a Turbo Stream
|
|
689
|
-
* replacement or a morph. Stimulus runs this before `connect()` on the first mount
|
|
690
|
-
* too, where the roster is still empty and clearing whatever a cache snapshot
|
|
691
|
-
* preserved is what `connect()` goes on to do anyway.
|
|
692
|
-
*/
|
|
626
|
+
return this.#safeTimeout;
|
|
627
|
+
}
|
|
693
628
|
listTargetConnected() {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
629
|
+
this.#drawList();
|
|
630
|
+
}
|
|
631
|
+
listTargetDisconnected() {
|
|
632
|
+
if (this.#connected) this.#drawList();
|
|
698
633
|
}
|
|
699
|
-
/** Paints the roster size into a count target that arrives blank, for the same reason. */
|
|
700
634
|
countTargetConnected() {
|
|
701
|
-
this
|
|
635
|
+
this.#drawCount();
|
|
636
|
+
}
|
|
637
|
+
countTargetDisconnected() {
|
|
638
|
+
if (this.#connected) this.#drawCount();
|
|
702
639
|
}
|
|
703
640
|
connect() {
|
|
641
|
+
this.#connected = true;
|
|
642
|
+
this.#follow.activate();
|
|
643
|
+
this.#start();
|
|
644
|
+
}
|
|
645
|
+
disconnect() {
|
|
646
|
+
this.#connected = false;
|
|
647
|
+
this.#follow.cancel();
|
|
648
|
+
this.#close();
|
|
649
|
+
this.#reset();
|
|
650
|
+
}
|
|
651
|
+
#start() {
|
|
704
652
|
this.#reset();
|
|
705
653
|
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
|
|
654
|
+
this.#open();
|
|
655
|
+
}
|
|
656
|
+
#open() {
|
|
657
|
+
const descriptor = this.#descriptor;
|
|
658
|
+
this.#identifier = identifierOf(descriptor);
|
|
706
659
|
if (!this.channelValue) return;
|
|
707
660
|
this.#claimSpeaker();
|
|
708
|
-
this.#subscription = createConfirmedSubscription(
|
|
709
|
-
// The first beacon must wait for the confirmed subscription — a
|
|
710
|
-
// perform() before that is silently dropped by Action Cable. Fires
|
|
711
|
-
// again on every reconnect, so the roster self-heals after an outage.
|
|
661
|
+
this.#subscription = createConfirmedSubscription(descriptor, {
|
|
712
662
|
connected: () => this.#beacon(true),
|
|
713
|
-
// The server refused the subscription: no beacon will ever go through,
|
|
714
|
-
// and the hook lets the consumer's CSS reflect the dead stream.
|
|
715
663
|
rejected: () => {
|
|
716
664
|
this.element.setAttribute("data-presence-rejected", "true");
|
|
717
665
|
},
|
|
718
666
|
received: (data) => this.#onReceived(data)
|
|
719
667
|
});
|
|
720
|
-
this.#
|
|
668
|
+
this.#armHeartbeat();
|
|
721
669
|
window.addEventListener("pagehide", this.#onPageHide);
|
|
722
670
|
}
|
|
723
|
-
|
|
671
|
+
#close() {
|
|
724
672
|
window.removeEventListener("pagehide", this.#onPageHide);
|
|
725
|
-
|
|
673
|
+
this.#sendLeaveNotice(this.#releaseSpeaker());
|
|
726
674
|
this.#subscription?.unsubscribe();
|
|
727
675
|
this.#subscription = null;
|
|
728
676
|
this.#intervals.clearAll();
|
|
729
|
-
this.#reset();
|
|
730
677
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
678
|
+
#armHeartbeat() {
|
|
679
|
+
this.#intervals.clearAll();
|
|
680
|
+
this.#period = this.#heartbeat;
|
|
681
|
+
this.#intervals.set(() => this.#beacon(true), this.#period);
|
|
682
|
+
}
|
|
683
|
+
#followDeclaration() {
|
|
684
|
+
if (identifierOf(this.#descriptor) !== this.#identifier) {
|
|
685
|
+
this.#close();
|
|
686
|
+
this.#start();
|
|
687
|
+
} else if (this.#subscription) {
|
|
688
|
+
if (this.idValue !== this.#speakerId) this.#moveVoice();
|
|
689
|
+
if (this.#heartbeat !== this.#period) this.#armHeartbeat();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
#moveVoice() {
|
|
693
|
+
this.#sendLeaveNotice(this.#releaseSpeaker());
|
|
694
|
+
this.#claimSpeaker();
|
|
695
|
+
this.#forget(this.#speakerId);
|
|
696
|
+
this.#timers.clear(this.#pendingBeacon ?? -1);
|
|
697
|
+
this.#pendingBeacon = null;
|
|
698
|
+
this.#beacon(true);
|
|
699
|
+
}
|
|
738
700
|
#claimSpeaker() {
|
|
739
|
-
this.#
|
|
740
|
-
|
|
701
|
+
this.#speakerId = this.idValue;
|
|
702
|
+
this.#speakerKey = `${this.#identifier}
|
|
703
|
+
${this.#speakerId}`;
|
|
741
704
|
speakers.set(this.#speakerKey, (speakers.get(this.#speakerKey) ?? 0) + 1);
|
|
742
705
|
}
|
|
743
|
-
/**
|
|
744
|
-
* Gives up the claim, reporting whether this element was the last voice for the
|
|
745
|
-
* peer. The key is spent here: an element that goes on to connect without a
|
|
746
|
-
* channel claims nothing, and a stale key would spend a voice its siblings own.
|
|
747
|
-
*/
|
|
748
706
|
#releaseSpeaker() {
|
|
749
707
|
const key = this.#speakerKey;
|
|
708
|
+
const id = this.#speakerId;
|
|
750
709
|
this.#speakerKey = "";
|
|
710
|
+
this.#speakerId = "";
|
|
751
711
|
const remaining = (speakers.get(key) ?? 1) - 1;
|
|
752
712
|
if (remaining > 0) speakers.set(key, remaining);
|
|
753
713
|
else speakers.delete(key);
|
|
754
|
-
return remaining
|
|
714
|
+
return remaining > 0 ? "" : id;
|
|
715
|
+
}
|
|
716
|
+
#sendLeaveNotice(id) {
|
|
717
|
+
if (!this.#subscription?.confirmed || !id) return;
|
|
718
|
+
this.#subscription.perform("appear", { id, leaving: true });
|
|
755
719
|
}
|
|
756
|
-
/**
|
|
757
|
-
* Sends the best-effort leaving notice (skipped without an own `id`, and
|
|
758
|
-
* outside the confirmed window, where Action Cable would discard it anyway).
|
|
759
|
-
*/
|
|
760
|
-
#sendLeaveNotice() {
|
|
761
|
-
if (!this.#subscription?.confirmed || !this.idValue) return;
|
|
762
|
-
this.#subscription.perform("appear", { id: this.idValue, leaving: true });
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* `pagehide` covers the leaves `disconnect()` cannot see: closing the tab or
|
|
766
|
-
* a hard (non-Turbo) navigation destroys the page without running Stimulus
|
|
767
|
-
* teardown, so this listener is the only chance to announce them. Best-effort
|
|
768
|
-
* by nature (the socket may close before the frame flushes); peers' expiry
|
|
769
|
-
* timers stay the safety net. If the page enters the bfcache and is restored
|
|
770
|
-
* instead, the next heartbeat re-announces this client, so an over-eager
|
|
771
|
-
* leave self-heals.
|
|
772
|
-
*/
|
|
773
720
|
#onPageHide = () => {
|
|
774
|
-
this.#sendLeaveNotice();
|
|
721
|
+
this.#sendLeaveNotice(this.#speakerId);
|
|
775
722
|
};
|
|
776
|
-
/**
|
|
777
|
-
* Broadcasts this client's beacon (skipped without an own `id`). A throttled
|
|
778
|
-
* convergence answer is deferred to the trailing edge rather than dropped —
|
|
779
|
-
* otherwise a peer joining right after a heartbeat would not learn about
|
|
780
|
-
* this client until the next full heartbeat period.
|
|
781
|
-
*
|
|
782
|
-
* Gated on the confirmed subscription: before confirmation and during an
|
|
783
|
-
* outage Action Cable silently discards perform(), so a beacon sent then is
|
|
784
|
-
* pure waste — worse, it would burn `#lastBeaconAt` and delay the next real
|
|
785
|
-
* convergence answer by up to the throttle window. The heartbeat interval
|
|
786
|
-
* keeps ticking regardless; `connected` re-fires on reconfirm and
|
|
787
|
-
* force-beacons immediately, so a gated tick is never missed for long.
|
|
788
|
-
*/
|
|
789
723
|
#beacon(force) {
|
|
790
724
|
if (!this.#subscription?.confirmed || !this.idValue) return;
|
|
791
725
|
const now = Date.now();
|
|
@@ -802,14 +736,6 @@ ${this.idValue}`;
|
|
|
802
736
|
this.#lastBeaconAt = now;
|
|
803
737
|
this.#subscription.perform("appear", { id: this.idValue, name: this.nameValue });
|
|
804
738
|
}
|
|
805
|
-
/**
|
|
806
|
-
* Tracks a broadcast beacon: upserts the peer (restarting its expiry timer),
|
|
807
|
-
* removes it on a `leaving` notice, and re-announces this client when the
|
|
808
|
-
* peer was unknown (roster convergence — see the class doc).
|
|
809
|
-
*
|
|
810
|
-
* @stimeoRuntimeOnly `id` filters this client's own echo, `timeout` arms one peer's expiry,
|
|
811
|
-
* `name` answers one beacon and the texts word one announcement.
|
|
812
|
-
*/
|
|
813
739
|
#onReceived(data) {
|
|
814
740
|
const beacon = data;
|
|
815
741
|
const id = beacon?.id;
|
|
@@ -833,11 +759,6 @@ ${this.idValue}`;
|
|
|
833
759
|
this.#render();
|
|
834
760
|
}
|
|
835
761
|
}
|
|
836
|
-
/**
|
|
837
|
-
* Removes a peer (expiry or graceful leave) and reflects the change.
|
|
838
|
-
*
|
|
839
|
-
* @stimeoRuntimeOnly `announceLeaveText` words the one announcement this departure makes.
|
|
840
|
-
*/
|
|
841
762
|
#drop(id) {
|
|
842
763
|
const peer = this.#peers.get(id);
|
|
843
764
|
if (peer === void 0) return;
|
|
@@ -848,25 +769,22 @@ ${this.idValue}`;
|
|
|
848
769
|
this.#announce(this.announceLeaveTextValue, peer.name);
|
|
849
770
|
this.dispatch("leave", { detail: { id } });
|
|
850
771
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
772
|
+
#forget(id) {
|
|
773
|
+
if (!this.#peers.delete(id)) return;
|
|
774
|
+
this.#expiry.clear(id);
|
|
775
|
+
this.#removeClone(id);
|
|
776
|
+
this.#paint();
|
|
777
|
+
}
|
|
854
778
|
#render() {
|
|
779
|
+
this.dispatch("change", { detail: { users: this.#paint() } });
|
|
780
|
+
}
|
|
781
|
+
#paint() {
|
|
855
782
|
const users = [...this.#peers.entries()].map(([id, peer]) => ({ id, name: peer.name }));
|
|
856
783
|
this.element.setAttribute("data-present", users.length > 0 ? "true" : "false");
|
|
857
784
|
this.element.setAttribute("data-present-count", String(users.length));
|
|
858
785
|
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(users.length);
|
|
859
|
-
|
|
860
|
-
}
|
|
861
|
-
/**
|
|
862
|
-
* Hands one roster change to the page's shared announcer.
|
|
863
|
-
*
|
|
864
|
-
* Debounced: a burst of arrivals is one announcement, not one per peer — reading
|
|
865
|
-
* out every entry and exit of a busy room is the noise the default avoids. Wording
|
|
866
|
-
* comes from the consumer (`{name}` / `{count}`, where the count is the roster
|
|
867
|
-
* after the change), and an undeclared template does nothing at all — not even
|
|
868
|
-
* cancel an announcement already pending for the other direction.
|
|
869
|
-
*/
|
|
786
|
+
return users;
|
|
787
|
+
}
|
|
870
788
|
#announce(template, name) {
|
|
871
789
|
const message = fillTemplate(template, { name, count: this.#peers.size });
|
|
872
790
|
if (message.trim() === "") return;
|
|
@@ -876,17 +794,21 @@ ${this.idValue}`;
|
|
|
876
794
|
this.#announceId = null;
|
|
877
795
|
}, _PresenceController.#announceDelay);
|
|
878
796
|
}
|
|
879
|
-
/**
|
|
880
|
-
* Builds the count copy. Localizable through `data-zero` / `data-one` /
|
|
881
|
-
* `data-other` templates on the count target (`%{count}`); the bare number is
|
|
882
|
-
* the fallback (copy-free, so nothing to localize by default).
|
|
883
|
-
*/
|
|
884
797
|
#countMessage(count) {
|
|
885
798
|
const templates = this.countTarget.dataset;
|
|
886
799
|
const template = (count === 0 ? templates.zero : count === 1 ? templates.one : templates.other) ?? templates.other;
|
|
887
800
|
return template ? template.replace("%{count}", String(count)) : String(count);
|
|
888
801
|
}
|
|
889
|
-
|
|
802
|
+
#drawList() {
|
|
803
|
+
if (!this.hasListTarget) return;
|
|
804
|
+
for (const child of this.listTarget.querySelectorAll("[data-presence-id]")) {
|
|
805
|
+
child.remove();
|
|
806
|
+
}
|
|
807
|
+
for (const [id, peer] of this.#peers) this.#appendClone(id, peer.name);
|
|
808
|
+
}
|
|
809
|
+
#drawCount() {
|
|
810
|
+
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(this.#peers.size);
|
|
811
|
+
}
|
|
890
812
|
#appendClone(id, name) {
|
|
891
813
|
if (!this.hasListTarget || !this.hasTemplateTarget) return;
|
|
892
814
|
const root = cloneTemplateRoot(this.templateTarget);
|
|
@@ -909,7 +831,6 @@ ${this.idValue}`;
|
|
|
909
831
|
}
|
|
910
832
|
return null;
|
|
911
833
|
}
|
|
912
|
-
/** Writes the peer's name into the clone's `data-presence-name` slots. */
|
|
913
834
|
#fillName(root, name) {
|
|
914
835
|
const slots = root.querySelectorAll("[data-presence-name]");
|
|
915
836
|
for (const slot of slots) slot.textContent = name;
|
|
@@ -917,7 +838,6 @@ ${this.idValue}`;
|
|
|
917
838
|
root.textContent = name;
|
|
918
839
|
}
|
|
919
840
|
}
|
|
920
|
-
/** Clears the transient roster state (connect reset + disconnect teardown). */
|
|
921
841
|
#reset() {
|
|
922
842
|
this.#timers.clearAll();
|
|
923
843
|
this.#expiry.clearAll();
|
|
@@ -933,6 +853,24 @@ ${this.idValue}`;
|
|
|
933
853
|
}
|
|
934
854
|
}
|
|
935
855
|
}
|
|
856
|
+
get #safeHeartbeat() {
|
|
857
|
+
return this.#numbers.read(
|
|
858
|
+
this,
|
|
859
|
+
"heartbeat",
|
|
860
|
+
this.heartbeatValue,
|
|
861
|
+
_PresenceController.values.heartbeat.default,
|
|
862
|
+
_PresenceController.valueConstraints.heartbeat
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
get #safeTimeout() {
|
|
866
|
+
return this.#numbers.read(
|
|
867
|
+
this,
|
|
868
|
+
"timeout",
|
|
869
|
+
this.timeoutValue,
|
|
870
|
+
_PresenceController.values.timeout.default,
|
|
871
|
+
_PresenceController.valueConstraints.timeout
|
|
872
|
+
);
|
|
873
|
+
}
|
|
936
874
|
};
|
|
937
875
|
var TRANSIENT3 = new TransientHooks({
|
|
938
876
|
attributes: ["data-typing", "data-typing-indicator-rejected"]
|
|
@@ -941,76 +879,95 @@ var DEFAULT_TIMEOUT = 3e3;
|
|
|
941
879
|
var DEFAULT_THROTTLE = 2e3;
|
|
942
880
|
var fillTokens = (template, values) => template.replace(/%\{(name|names|count)\}/g, (match, token) => values[token] ?? match);
|
|
943
881
|
var TypingIndicatorController = class _TypingIndicatorController extends Controller {
|
|
882
|
+
#numbers = new NumberValueReader();
|
|
944
883
|
static targets = ["input", "status"];
|
|
945
884
|
static values = {
|
|
946
885
|
channel: { type: String, default: "" },
|
|
947
886
|
params: { type: String, default: "" },
|
|
948
887
|
name: { type: String, default: "" },
|
|
949
888
|
timeout: { type: Number, default: DEFAULT_TIMEOUT },
|
|
950
|
-
// Must stay below `timeout`: the throttle is leading-edge only (no trailing
|
|
951
|
-
// send), so a receiver's display survives continuous typing only while a
|
|
952
|
-
// fresh signal lands within its timeout window.
|
|
953
889
|
throttle: { type: Number, default: DEFAULT_THROTTLE },
|
|
954
890
|
announceOneText: { type: String, default: "" },
|
|
955
891
|
announceManyText: { type: String, default: "" }
|
|
956
892
|
};
|
|
893
|
+
static valueConstraints = {
|
|
894
|
+
timeout: NUMBER_BOUNDS.timer,
|
|
895
|
+
throttle: NUMBER_BOUNDS.nonNegative
|
|
896
|
+
};
|
|
957
897
|
static events = ["change"];
|
|
958
|
-
/** Delay (ms) before one settled typer set is sent to the shared announcer. */
|
|
959
898
|
static #announceDelay = 200;
|
|
960
|
-
/** Identifier parameters parsed once from their declaration, never in the hot path. */
|
|
961
|
-
#params = {};
|
|
962
899
|
#subscription = null;
|
|
963
|
-
|
|
900
|
+
#follow = new MicrotaskCoalescer(() => this.#followDeclaration());
|
|
901
|
+
#identifier = "";
|
|
964
902
|
#typers = /* @__PURE__ */ new Set();
|
|
965
|
-
/** Each typer's auto-clear timer, restarted by every further signal from that name. */
|
|
966
903
|
#expiry = new KeyedTimers();
|
|
967
904
|
#timers = new SafeTimeout();
|
|
968
|
-
/** Epoch ms of the last broadcast, for leading-edge throttling. */
|
|
969
905
|
#lastSentAt = 0;
|
|
970
|
-
/** The one outstanding announcement, so a newer set supersedes it. */
|
|
971
906
|
#announceId = null;
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
* naming the channel instead of the subscription never being created at all.
|
|
977
|
-
*/
|
|
907
|
+
#connected = false;
|
|
908
|
+
channelValueChanged() {
|
|
909
|
+
this.#follow.schedule();
|
|
910
|
+
}
|
|
978
911
|
paramsValueChanged() {
|
|
979
|
-
this.#
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
*/
|
|
912
|
+
this.#follow.schedule();
|
|
913
|
+
}
|
|
914
|
+
nameValueChanged() {
|
|
915
|
+
this.#follow.schedule();
|
|
916
|
+
}
|
|
917
|
+
get #descriptor() {
|
|
918
|
+
const { channel: _declared, ...rest } = parseSubscriptionParams(this.paramsValue);
|
|
919
|
+
return { channel: this.channelValue, ...rest };
|
|
920
|
+
}
|
|
989
921
|
statusTargetConnected() {
|
|
990
|
-
if (this.#
|
|
922
|
+
if (this.#connected) this.#paintStatus([...this.#typers]);
|
|
923
|
+
}
|
|
924
|
+
statusTargetDisconnected() {
|
|
925
|
+
if (this.#connected) this.#paintStatus([...this.#typers]);
|
|
991
926
|
}
|
|
992
927
|
connect() {
|
|
993
|
-
|
|
994
|
-
|
|
928
|
+
this.#connected = true;
|
|
929
|
+
this.#reset();
|
|
995
930
|
this.element.addEventListener("input", this.#onInput);
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
{ channel: this.channelValue, ...this.#params },
|
|
999
|
-
{
|
|
1000
|
-
// The server refused the subscription: the send gate stays shut for
|
|
1001
|
-
// good, and the hook lets the consumer's CSS reflect the dead stream.
|
|
1002
|
-
rejected: () => {
|
|
1003
|
-
this.element.setAttribute("data-typing-indicator-rejected", "true");
|
|
1004
|
-
},
|
|
1005
|
-
received: (data) => this.#onReceived(data)
|
|
1006
|
-
}
|
|
1007
|
-
);
|
|
1008
|
-
}
|
|
931
|
+
this.#follow.activate();
|
|
932
|
+
this.#open();
|
|
1009
933
|
}
|
|
1010
934
|
disconnect() {
|
|
935
|
+
this.#connected = false;
|
|
936
|
+
this.#follow.cancel();
|
|
1011
937
|
this.element.removeEventListener("input", this.#onInput);
|
|
938
|
+
this.#close();
|
|
939
|
+
this.#reset();
|
|
940
|
+
}
|
|
941
|
+
#open() {
|
|
942
|
+
const descriptor = this.#descriptor;
|
|
943
|
+
this.#identifier = identifierOf(descriptor);
|
|
944
|
+
if (!this.channelValue) return;
|
|
945
|
+
this.#subscription = createConfirmedSubscription(descriptor, {
|
|
946
|
+
rejected: () => {
|
|
947
|
+
this.element.setAttribute("data-typing-indicator-rejected", "true");
|
|
948
|
+
},
|
|
949
|
+
received: (data) => this.#onReceived(data)
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
#close() {
|
|
1012
953
|
this.#subscription?.unsubscribe();
|
|
1013
954
|
this.#subscription = null;
|
|
955
|
+
}
|
|
956
|
+
#followDeclaration() {
|
|
957
|
+
if (identifierOf(this.#descriptor) !== this.#identifier) {
|
|
958
|
+
this.#close();
|
|
959
|
+
this.#reset();
|
|
960
|
+
this.#open();
|
|
961
|
+
} else {
|
|
962
|
+
this.#forget(this.nameValue);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
#forget(name) {
|
|
966
|
+
if (!this.#typers.delete(name)) return;
|
|
967
|
+
this.#expiry.clear(name);
|
|
968
|
+
this.#paint();
|
|
969
|
+
}
|
|
970
|
+
#reset() {
|
|
1014
971
|
this.#timers.clearAll();
|
|
1015
972
|
this.#expiry.clearAll();
|
|
1016
973
|
this.#announceId = null;
|
|
@@ -1019,7 +976,6 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1019
976
|
if (this.hasStatusTarget) this.statusTarget.textContent = "";
|
|
1020
977
|
this.#lastSentAt = 0;
|
|
1021
978
|
}
|
|
1022
|
-
/** Throttled (leading-edge) broadcast of this client's typing signal. */
|
|
1023
979
|
#onInput = () => {
|
|
1024
980
|
if (!this.#subscription?.confirmed) return;
|
|
1025
981
|
const now = Date.now();
|
|
@@ -1027,14 +983,6 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1027
983
|
this.#lastSentAt = now;
|
|
1028
984
|
this.#subscription.perform("typing", { name: this.nameValue });
|
|
1029
985
|
};
|
|
1030
|
-
/**
|
|
1031
|
-
* Tracks a broadcast typer. The own echo is dropped (same `name`); every
|
|
1032
|
-
* further signal from a name restarts its auto-clear timer, so the indicator
|
|
1033
|
-
* survives continuous typing and clears `timeout` ms after the last signal.
|
|
1034
|
-
*
|
|
1035
|
-
* @stimeoRuntimeOnly `name` filters this client's own echo and `timeout` arms one typer's expiry;
|
|
1036
|
-
* the typers shown come from what is received.
|
|
1037
|
-
*/
|
|
1038
986
|
#onReceived(data) {
|
|
1039
987
|
const name = data?.name;
|
|
1040
988
|
if (typeof name !== "string" || name === "" || name === this.nameValue) return;
|
|
@@ -1043,53 +991,21 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1043
991
|
this.#expiry.set(name, () => this.#untrack(name), this.#timeout);
|
|
1044
992
|
if (added) this.#render();
|
|
1045
993
|
}
|
|
1046
|
-
/**
|
|
1047
|
-
* The silence after which a typer is dropped, in ms: a finite, non-negative number
|
|
1048
|
-
* a timer can hold. Anything else names no delay — `setTimeout` reads `NaN`, a
|
|
1049
|
-
* negative value and `Infinity` alike as "now", and a value past
|
|
1050
|
-
* {@link MAX_TIMER_DELAY_MS} overflows to the same place — so the typer would
|
|
1051
|
-
* vanish in the same task it appeared and the indicator could never be seen. Such
|
|
1052
|
-
* a declaration falls back to the default.
|
|
1053
|
-
*/
|
|
1054
994
|
get #timeout() {
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
}
|
|
1058
|
-
/**
|
|
1059
|
-
* The minimum gap between outgoing signals, in ms: a finite, non-negative number.
|
|
1060
|
-
* Anything else names no interval, and the gate then settles the same way at every
|
|
1061
|
-
* keystroke — `NaN` and a negative gap leave it open, so every keystroke broadcasts,
|
|
1062
|
-
* while `Infinity` is never exceeded, so nothing is ever sent. Such a declaration
|
|
1063
|
-
* falls back to the default.
|
|
1064
|
-
*/
|
|
995
|
+
return this.#safeTimeout;
|
|
996
|
+
}
|
|
1065
997
|
get #throttle() {
|
|
1066
|
-
|
|
1067
|
-
return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THROTTLE;
|
|
998
|
+
return this.#safeThrottle;
|
|
1068
999
|
}
|
|
1069
1000
|
#untrack(name) {
|
|
1070
1001
|
this.#typers.delete(name);
|
|
1071
1002
|
this.#render();
|
|
1072
1003
|
}
|
|
1073
|
-
/**
|
|
1074
|
-
* Reflects the typer set onto the display, announces it, and emits `change`.
|
|
1075
|
-
*/
|
|
1076
1004
|
#render() {
|
|
1077
1005
|
const names = this.#paint();
|
|
1078
1006
|
this.#announce(names);
|
|
1079
1007
|
this.dispatch("change", { detail: { names } });
|
|
1080
1008
|
}
|
|
1081
|
-
/**
|
|
1082
|
-
* Hands the settled typer set to the page's shared announcer.
|
|
1083
|
-
*
|
|
1084
|
-
* Debounced: a burst of arrivals is one announcement, not one per name. Only a
|
|
1085
|
-
* non-empty set is announced — that typing stopped is not news worth interrupting
|
|
1086
|
-
* a reader for, and the visible copy already clears. Wording comes from the
|
|
1087
|
-
* consumer, and {@link announce} drops an empty message, so an undeclared template
|
|
1088
|
-
* announces nothing at all.
|
|
1089
|
-
*
|
|
1090
|
-
* @stimeoRuntimeOnly The wording Values are read to compose one announcement; the
|
|
1091
|
-
* visible typer list is rendered from the set, not from them.
|
|
1092
|
-
*/
|
|
1093
1009
|
#announce(names) {
|
|
1094
1010
|
if (this.#announceId !== null) this.#timers.clear(this.#announceId);
|
|
1095
1011
|
this.#announceId = null;
|
|
@@ -1103,20 +1019,15 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1103
1019
|
this.#announceId = null;
|
|
1104
1020
|
}, _TypingIndicatorController.#announceDelay);
|
|
1105
1021
|
}
|
|
1106
|
-
/** Writes the current typer set onto the hook and the status slot. */
|
|
1107
1022
|
#paint() {
|
|
1108
1023
|
const names = [...this.#typers];
|
|
1109
1024
|
this.element.setAttribute("data-typing", names.length > 0 ? "true" : "false");
|
|
1110
|
-
|
|
1111
|
-
this.statusTarget.textContent = this.#message(names);
|
|
1112
|
-
}
|
|
1025
|
+
this.#paintStatus(names);
|
|
1113
1026
|
return names;
|
|
1114
1027
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
* English is the fallback.
|
|
1119
|
-
*/
|
|
1028
|
+
#paintStatus(names) {
|
|
1029
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = this.#message(names);
|
|
1030
|
+
}
|
|
1120
1031
|
#message(names) {
|
|
1121
1032
|
if (names.length === 0) return "";
|
|
1122
1033
|
const joined = names.join(", ");
|
|
@@ -1128,9 +1039,26 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
|
|
|
1128
1039
|
const template = this.statusTarget.dataset.many;
|
|
1129
1040
|
return template ? fillTokens(template, { names: joined, count: String(names.length) }) : `${joined} are typing\u2026`;
|
|
1130
1041
|
}
|
|
1042
|
+
get #safeTimeout() {
|
|
1043
|
+
return this.#numbers.read(
|
|
1044
|
+
this,
|
|
1045
|
+
"timeout",
|
|
1046
|
+
this.timeoutValue,
|
|
1047
|
+
_TypingIndicatorController.values.timeout.default,
|
|
1048
|
+
_TypingIndicatorController.valueConstraints.timeout
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
get #safeThrottle() {
|
|
1052
|
+
return this.#numbers.read(
|
|
1053
|
+
this,
|
|
1054
|
+
"throttle",
|
|
1055
|
+
this.throttleValue,
|
|
1056
|
+
_TypingIndicatorController.values.throttle.default,
|
|
1057
|
+
_TypingIndicatorController.valueConstraints.throttle
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1131
1060
|
};
|
|
1132
1061
|
|
|
1133
|
-
// src/cable/index.ts
|
|
1134
1062
|
var cableControllers = {
|
|
1135
1063
|
"stimeo--live-counter": LiveCounterController,
|
|
1136
1064
|
"stimeo--presence": PresenceController,
|
|
@@ -1143,5 +1071,4 @@ function registerCable(application) {
|
|
|
1143
1071
|
}
|
|
1144
1072
|
|
|
1145
1073
|
export { LiveCounterController, PresenceController, TypingIndicatorController, cableControllers, createConfirmedSubscription, getCableConsumer, registerCable, setCableConsumer };
|
|
1146
|
-
//# sourceMappingURL=index.js.map
|
|
1147
1074
|
//# sourceMappingURL=index.js.map
|