stimeo-ui 0.12.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cable/index.d.ts +67 -13
  3. package/dist/cable/index.js +438 -152
  4. package/dist/cable/index.js.map +1 -1
  5. package/dist/controllers/alert_dialog_controller.d.ts +1 -1
  6. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  7. package/dist/controllers/announcer_controller.d.ts +1 -1
  8. package/dist/controllers/announcer_controller.js.map +1 -1
  9. package/dist/controllers/auto_submit_controller.js.map +1 -1
  10. package/dist/controllers/breadcrumb_controller.d.ts +1 -1
  11. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  12. package/dist/controllers/calendar_controller.d.ts +2 -1
  13. package/dist/controllers/calendar_controller.js +10 -2
  14. package/dist/controllers/calendar_controller.js.map +1 -1
  15. package/dist/controllers/carousel_controller.js +1 -1
  16. package/dist/controllers/carousel_controller.js.map +1 -1
  17. package/dist/controllers/character_counter_controller.js.map +1 -1
  18. package/dist/controllers/clipboard_controller.d.ts +1 -1
  19. package/dist/controllers/clipboard_controller.js.map +1 -1
  20. package/dist/controllers/collapsible_controller.js.map +1 -1
  21. package/dist/controllers/color_picker_controller.d.ts +1 -1
  22. package/dist/controllers/color_picker_controller.js.map +1 -1
  23. package/dist/controllers/command_palette_controller.d.ts +3 -3
  24. package/dist/controllers/command_palette_controller.js +2 -2
  25. package/dist/controllers/command_palette_controller.js.map +1 -1
  26. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  27. package/dist/controllers/confirm_controller.d.ts +2 -2
  28. package/dist/controllers/confirm_controller.js.map +1 -1
  29. package/dist/controllers/context_menu_controller.d.ts +1 -1
  30. package/dist/controllers/context_menu_controller.js.map +1 -1
  31. package/dist/controllers/countdown_controller.d.ts +1 -1
  32. package/dist/controllers/countdown_controller.js.map +1 -1
  33. package/dist/controllers/data_grid_controller.d.ts +1 -1
  34. package/dist/controllers/data_grid_controller.js.map +1 -1
  35. package/dist/controllers/date_range_picker_controller.js +10 -10
  36. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  37. package/dist/controllers/dialog_controller.d.ts +1 -1
  38. package/dist/controllers/dialog_controller.js.map +1 -1
  39. package/dist/controllers/direct_upload_controller.js.map +1 -1
  40. package/dist/controllers/dirty_form_controller.js.map +1 -1
  41. package/dist/controllers/dismissible_controller.js.map +1 -1
  42. package/dist/controllers/drawer_controller.d.ts +2 -2
  43. package/dist/controllers/drawer_controller.js +2 -2
  44. package/dist/controllers/drawer_controller.js.map +1 -1
  45. package/dist/controllers/dropdown_controller.d.ts +1 -1
  46. package/dist/controllers/dropdown_controller.js.map +1 -1
  47. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  48. package/dist/controllers/flash_controller.js.map +1 -1
  49. package/dist/controllers/focus_controller.d.ts +2 -2
  50. package/dist/controllers/focus_controller.js.map +1 -1
  51. package/dist/controllers/form_validation_controller.js.map +1 -1
  52. package/dist/controllers/frame_loading_controller.d.ts +3 -3
  53. package/dist/controllers/frame_loading_controller.js.map +1 -1
  54. package/dist/controllers/highlight_controller.js.map +1 -1
  55. package/dist/controllers/hover_card_controller.d.ts +1 -1
  56. package/dist/controllers/hover_card_controller.js.map +1 -1
  57. package/dist/controllers/idle_controller.d.ts +1 -1
  58. package/dist/controllers/idle_controller.js.map +1 -1
  59. package/dist/controllers/input_mask_controller.js.map +1 -1
  60. package/dist/controllers/intersection_controller.js.map +1 -1
  61. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  62. package/dist/controllers/listbox_controller.js.map +1 -1
  63. package/dist/controllers/local_time_controller.d.ts +1 -1
  64. package/dist/controllers/local_time_controller.js.map +1 -1
  65. package/dist/controllers/masonry_controller.d.ts +1 -1
  66. package/dist/controllers/masonry_controller.js.map +1 -1
  67. package/dist/controllers/menu_controller.d.ts +1 -1
  68. package/dist/controllers/menu_controller.js.map +1 -1
  69. package/dist/controllers/menubar_controller.d.ts +2 -2
  70. package/dist/controllers/menubar_controller.js.map +1 -1
  71. package/dist/controllers/navigation_menu_controller.d.ts +1 -1
  72. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  73. package/dist/controllers/nested_form_controller.js.map +1 -1
  74. package/dist/controllers/network_status_controller.js.map +1 -1
  75. package/dist/controllers/number_input_controller.js.map +1 -1
  76. package/dist/controllers/optimistic_controller.d.ts +23 -10
  77. package/dist/controllers/optimistic_controller.js +141 -20
  78. package/dist/controllers/optimistic_controller.js.map +1 -1
  79. package/dist/controllers/overflow_indicator_controller.d.ts +1 -1
  80. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  81. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  82. package/dist/controllers/pagination_controller.js.map +1 -1
  83. package/dist/controllers/password_reveal_controller.js.map +1 -1
  84. package/dist/controllers/password_strength_controller.d.ts +2 -1
  85. package/dist/controllers/password_strength_controller.js.map +1 -1
  86. package/dist/controllers/persist_controller.js.map +1 -1
  87. package/dist/controllers/popover_controller.d.ts +1 -1
  88. package/dist/controllers/popover_controller.js.map +1 -1
  89. package/dist/controllers/portal_controller.d.ts +2 -2
  90. package/dist/controllers/portal_controller.js.map +1 -1
  91. package/dist/controllers/radio_group_controller.d.ts +1 -1
  92. package/dist/controllers/radio_group_controller.js.map +1 -1
  93. package/dist/controllers/relative_time_controller.d.ts +3 -3
  94. package/dist/controllers/relative_time_controller.js.map +1 -1
  95. package/dist/controllers/roving_controller.d.ts +1 -1
  96. package/dist/controllers/roving_controller.js.map +1 -1
  97. package/dist/controllers/scroll_area_controller.js.map +1 -1
  98. package/dist/controllers/scrollspy_controller.js.map +1 -1
  99. package/dist/controllers/sidebar_controller.d.ts +1 -1
  100. package/dist/controllers/sidebar_controller.js.map +1 -1
  101. package/dist/controllers/skeleton_controller.d.ts +2 -2
  102. package/dist/controllers/skeleton_controller.js.map +1 -1
  103. package/dist/controllers/spinner_controller.d.ts +3 -3
  104. package/dist/controllers/spinner_controller.js.map +1 -1
  105. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  106. package/dist/controllers/submit_once_controller.d.ts +3 -3
  107. package/dist/controllers/submit_once_controller.js.map +1 -1
  108. package/dist/controllers/switch_controller.js.map +1 -1
  109. package/dist/controllers/textarea_autosize_controller.d.ts +1 -1
  110. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  111. package/dist/controllers/theme_controller.d.ts +3 -2
  112. package/dist/controllers/theme_controller.js.map +1 -1
  113. package/dist/controllers/toast_controller.js +1 -1
  114. package/dist/controllers/toast_controller.js.map +1 -1
  115. package/dist/controllers/toggle_group_controller.js.map +1 -1
  116. package/dist/controllers/toolbar_controller.d.ts +1 -1
  117. package/dist/controllers/toolbar_controller.js.map +1 -1
  118. package/dist/controllers/tooltip_controller.d.ts +1 -1
  119. package/dist/controllers/tooltip_controller.js.map +1 -1
  120. package/dist/controllers/transition_controller.d.ts +1 -1
  121. package/dist/controllers/transition_controller.js.map +1 -1
  122. package/dist/controllers/tree_view_controller.d.ts +4 -3
  123. package/dist/controllers/tree_view_controller.js.map +1 -1
  124. package/dist/index.js +101 -38
  125. package/dist/index.js.map +1 -1
  126. package/dist/inspector/cli.d.ts +1 -1
  127. package/dist/inspector/cli.js +1 -1
  128. package/dist/inspector/cli.js.map +1 -1
  129. package/dist/inspector/cli_bin.js +1 -1
  130. package/dist/inspector/cli_bin.js.map +1 -1
  131. package/dist/inspector/examples.json +1 -1
  132. package/dist/inspector/manifest.json +6 -3
  133. package/package.json +1 -1
@@ -3,6 +3,23 @@ import { createConsumer } from '@rails/actioncable';
3
3
 
4
4
  // src/cable/live_counter_controller.ts
5
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
+
6
23
  // src/utils/authored_integer.ts
7
24
  var TOKEN_BODY = /\d[\d.,'’’    _]*\d|\d/;
8
25
  var SIGN_OPENER = /[\s([]/;
@@ -24,6 +41,129 @@ function authoredInteger(text) {
24
41
  const magnitude = Number.parseInt(digits, 10);
25
42
  return signed ? -magnitude || 0 : magnitude;
26
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
+ };
27
167
  function parseSubscriptionParams(raw) {
28
168
  try {
29
169
  const parsed = JSON.parse(raw);
@@ -131,92 +271,186 @@ function openSharedSubscription(consumer, channel) {
131
271
 
132
272
  // src/cable/live_counter_controller.ts
133
273
  var DISABLED_MARKER = "data-live-counter-disabled";
134
- var LiveCounterController = class extends Controller {
274
+ var LiveCounterController = class _LiveCounterController extends Controller {
135
275
  static targets = ["value", "trigger"];
136
276
  static values = {
137
277
  channel: { type: String, default: "" },
138
278
  params: { type: String, default: "" },
139
- id: { type: String, default: "" }
279
+ id: { type: String, default: "" },
280
+ announceText: { type: String, default: "" }
140
281
  };
282
+ /** Collapses a burst of broadcasts into one announcement. */
283
+ static #announceDelay = 200;
141
284
  static actions = ["increment"];
142
285
  static events = ["change"];
143
286
  /** Identifier parameters parsed once from their declaration, never in the hot path. */
144
287
  #params = {};
145
288
  #subscription = null;
289
+ #connected = false;
146
290
  /**
147
- * Re-parses the identifier parameters when the declaration changes.
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.
148
304
  *
149
305
  * A malformed declaration falls back to no parameters, so the identifier keeps
150
306
  * naming the channel instead of the subscription never being created at all.
151
307
  */
152
308
  paramsValueChanged() {
153
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();
154
315
  }
155
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() {
156
328
  this.element.removeAttribute("data-live-counter-rejected");
329
+ this.#outstanding.length = 0;
157
330
  if (this.channelValue) {
158
- this.#subscription = createConfirmedSubscription(
159
- { channel: this.channelValue, ...this.#params },
160
- {
161
- connected: () => this.#syncTriggers(),
162
- // A drop closes the send window (the shared subscription tracks it)
163
- // until Action Cable reconnects and re-confirms — an increment during
164
- // the outage would bump the display while its perform() is silently
165
- // discarded by the closed socket.
166
- disconnected: () => this.#syncTriggers(),
167
- // The server refused the subscription (auth, bad params): the gate
168
- // stays shut for good, and the hook lets the consumer's CSS disable
169
- // or hide the trigger instead of leaving a silently dead button.
170
- rejected: () => {
171
- this.element.setAttribute("data-live-counter-rejected", "true");
172
- this.#syncTriggers();
173
- },
174
- received: (data) => this.#onReceived(data)
175
- }
176
- );
331
+ this.#subscription = this.#open();
177
332
  }
178
333
  this.#syncTriggers();
179
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
+ }
180
385
  disconnect() {
386
+ this.#connected = false;
387
+ this.#timers.clearAll();
388
+ this.#focusedTriggers.releaseAll();
181
389
  this.#subscription?.unsubscribe();
182
390
  this.#subscription = null;
183
391
  this.element.removeAttribute("data-live-counter-rejected");
392
+ for (const trigger of this.triggerTargets) this.#release(trigger);
184
393
  }
185
394
  /** Late-added triggers (e.g. via a Turbo Stream) pick up the current gate. */
186
395
  triggerTargetConnected(target) {
187
396
  this.#syncTrigger(target);
188
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
+ }
189
403
  /**
190
404
  * Optimistic local increment: bumps the display immediately, then asks the
191
405
  * server to persist and broadcast. The step comes from the action param
192
406
  * (`data-stimeo--live-counter-delta-param`), default 1. Bound via `data-action`.
193
407
  */
194
408
  increment(event) {
195
- if (this.#subscription && !this.#subscription.confirmed) return;
409
+ if (!this.#ready) return;
196
410
  const raw = Number(event?.params?.delta ?? 1);
197
- const delta = Number.isFinite(raw) ? raw : 1;
198
- if (!this.#subscription || this.idValue !== "") {
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);
199
416
  this.#write(this.#current + delta);
200
417
  }
201
418
  this.#subscription?.perform("increment", { id: this.idValue, delta });
202
419
  }
203
- /** Reconciles a broadcast: absolute `count` wins; own-echo deltas are skipped. */
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
+ */
204
429
  #onReceived(data) {
205
430
  const message = data;
206
- if (typeof message?.count === "number") {
207
- this.#write(message.count);
431
+ if (Number.isSafeInteger(message?.count)) {
432
+ this.#outstanding.length = 0;
433
+ this.#reconcile(message?.count);
208
434
  return;
209
435
  }
210
- if (typeof message?.delta === "number") {
211
- if (typeof message.by === "string" && message.by !== "" && message.by === this.idValue) {
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);
212
442
  return;
213
443
  }
214
- this.#write(this.#current + message.delta);
444
+ this.#reconcile(this.#current + delta);
215
445
  }
216
446
  }
217
- /** True while an increment would go through (channel-less counters always are). */
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). */
218
452
  get #ready() {
219
- return !this.#subscription || this.#subscription.confirmed;
453
+ return this.#local || (this.#subscription?.confirmed ?? false);
220
454
  }
221
455
  /**
222
456
  * Reflects the send gate onto the optional `trigger` targets as the real
@@ -236,14 +470,23 @@ var LiveCounterController = class extends Controller {
236
470
  */
237
471
  #syncTrigger(trigger) {
238
472
  if (this.#ready) {
239
- if (trigger.hasAttribute(DISABLED_MARKER)) {
240
- trigger.removeAttribute("disabled");
241
- trigger.removeAttribute(DISABLED_MARKER);
242
- }
243
- } else if (!trigger.hasAttribute("disabled")) {
244
- trigger.setAttribute("disabled", "");
245
- trigger.setAttribute(DISABLED_MARKER, "");
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;
246
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);
247
490
  }
248
491
  /** The displayed element: the `value` target, else the controller element. */
249
492
  get #display() {
@@ -253,93 +496,59 @@ var LiveCounterController = class extends Controller {
253
496
  get #current() {
254
497
  return authoredInteger(this.#display.textContent ?? "") ?? 0;
255
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
+ */
256
503
  #write(count) {
257
- if (count === this.#current) return;
504
+ if (count === this.#current) return false;
258
505
  this.#display.textContent = String(count);
259
506
  this.dispatch("change", { detail: { count } });
507
+ return true;
260
508
  }
261
- };
262
-
263
- // src/utils/safe_timeout.ts
264
- var TimerRegistry = class {
265
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
266
- ids = /* @__PURE__ */ new Set();
267
509
  /**
268
- * Cancels a single tracked timer.
269
- *
270
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
271
- * registry), so callers can clear defensively without guarding.
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.
272
513
  */
273
- clear(id) {
274
- if (this.ids.delete(id)) {
275
- this.cancel(id);
276
- }
514
+ #reconcile(count) {
515
+ if (this.#write(count)) this.#announce(count);
277
516
  }
278
517
  /**
279
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
280
- * to guarantee no timer outlives the element.
281
- */
282
- clearAll() {
283
- for (const id of this.ids) {
284
- this.cancel(id);
285
- }
286
- this.ids.clear();
287
- }
288
- /** Number of timers currently tracked (pending). */
289
- get size() {
290
- return this.ids.size;
291
- }
292
- };
293
- var SafeTimeout = class extends TimerRegistry {
294
- /**
295
- * Schedules `callback` after `delay` ms and returns the timer id.
518
+ * Hands a reconciled count to the page's shared announcer.
296
519
  *
297
- * The id is removed from the registry automatically when the timeout fires,
298
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
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.
299
525
  */
300
- set(callback, delay) {
301
- const id = this.schedule(() => {
302
- this.ids.delete(id);
303
- callback();
304
- }, delay);
305
- this.ids.add(id);
306
- return id;
307
- }
308
- schedule(callback, delay) {
309
- return window.setTimeout(callback, delay);
310
- }
311
- cancel(id) {
312
- window.clearTimeout(id);
313
- }
314
- };
315
- var SafeInterval = class extends TimerRegistry {
316
- /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
317
- set(callback, delay) {
318
- const id = this.schedule(callback, delay);
319
- this.ids.add(id);
320
- return id;
321
- }
322
- schedule(callback, delay) {
323
- return window.setInterval(callback, delay);
324
- }
325
- cancel(id) {
326
- window.clearInterval(id);
526
+ #announce(count) {
527
+ this.#timers.clearAll();
528
+ this.#timers.set(() => {
529
+ announce(fillTemplate(this.announceTextValue, { count }));
530
+ }, _LiveCounterController.#announceDelay);
327
531
  }
328
532
  };
329
-
330
- // src/cable/presence_controller.ts
331
533
  var BEACON_THROTTLE_MS = 2e3;
332
- var PresenceController = class extends Controller {
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 {
333
538
  static targets = ["count", "list", "template"];
334
539
  static values = {
335
540
  channel: { type: String, default: "" },
336
541
  params: { type: String, default: "" },
337
542
  id: { type: String, default: "" },
338
543
  name: { type: String, default: "" },
339
- heartbeat: { type: Number, default: 15e3 },
340
- timeout: { type: Number, default: 4e4 }
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: "" }
341
548
  };
342
549
  static events = ["join", "leave", "change"];
550
+ /** Delay (ms) before one roster change is sent to the shared announcer. */
551
+ static #announceDelay = 200;
343
552
  /** Identifier parameters parsed once from their declaration, never in the hot path. */
344
553
  #params = {};
345
554
  #subscription = null;
@@ -351,6 +560,10 @@ var PresenceController = class extends Controller {
351
560
  #lastBeaconAt = 0;
352
561
  /** Pending trailing-edge convergence beacon (at most one queued). */
353
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 = "";
354
567
  /**
355
568
  * Re-parses the identifier parameters when the declaration changes.
356
569
  *
@@ -360,38 +573,108 @@ var PresenceController = class extends Controller {
360
573
  paramsValueChanged() {
361
574
  this.#params = parseSubscriptionParams(this.paramsValue);
362
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
+ }
363
623
  connect() {
364
624
  this.#reset();
365
625
  this.element.removeAttribute("data-presence-rejected");
366
626
  if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
367
627
  if (!this.channelValue) return;
368
- this.#subscription = createConfirmedSubscription(
369
- { channel: this.channelValue, ...this.#params },
370
- {
371
- // The first beacon must wait for the confirmed subscription — a
372
- // perform() before that is silently dropped by Action Cable. Fires
373
- // again on every reconnect, so the roster self-heals after an outage.
374
- connected: () => this.#beacon(true),
375
- // The server refused the subscription: no beacon will ever go through,
376
- // and the hook lets the consumer's CSS reflect the dead stream.
377
- rejected: () => {
378
- this.element.setAttribute("data-presence-rejected", "true");
379
- },
380
- received: (data) => this.#onReceived(data)
381
- }
382
- );
383
- 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);
384
642
  window.addEventListener("pagehide", this.#onPageHide);
385
643
  }
386
644
  disconnect() {
387
645
  window.removeEventListener("pagehide", this.#onPageHide);
388
- this.#sendLeaveNotice();
646
+ if (this.#releaseSpeaker()) this.#sendLeaveNotice();
389
647
  this.#subscription?.unsubscribe();
390
648
  this.#subscription = null;
391
649
  this.#intervals.clearAll();
392
650
  this.#reset();
393
651
  this.element.removeAttribute("data-presence-rejected");
394
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
+ }
395
678
  /**
396
679
  * Sends the best-effort leaving notice (skipped without an own `id`, and
397
680
  * outside the confirmed window, where Action Cable would discard it anyway).
@@ -457,11 +740,12 @@ var PresenceController = class extends Controller {
457
740
  const name = typeof beacon?.name === "string" ? beacon.name : "";
458
741
  const existing = this.#peers.get(id);
459
742
  if (existing !== void 0) this.#timers.clear(existing.timer);
460
- const timer = this.#timers.set(() => this.#drop(id), this.timeoutValue);
743
+ const timer = this.#timers.set(() => this.#drop(id), this.#timeout);
461
744
  this.#peers.set(id, { name, timer });
462
745
  if (existing === void 0) {
463
746
  this.#appendClone(id, name);
464
747
  this.#render();
748
+ this.#announce(this.announceJoinTextValue, name);
465
749
  this.dispatch("join", { detail: { id, name } });
466
750
  this.#beacon(false);
467
751
  } else if (existing.name !== name) {
@@ -477,6 +761,7 @@ var PresenceController = class extends Controller {
477
761
  this.#peers.delete(id);
478
762
  this.#removeClone(id);
479
763
  this.#render();
764
+ this.#announce(this.announceLeaveTextValue, peer.name);
480
765
  this.dispatch("leave", { detail: { id } });
481
766
  }
482
767
  /** Reflects the roster onto the hooks + count target and emits `change`. */
@@ -487,6 +772,24 @@ var PresenceController = class extends Controller {
487
772
  if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(users.length);
488
773
  this.dispatch("change", { detail: { users } });
489
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
+ }
490
793
  /**
491
794
  * Builds the count copy. Localizable through `data-zero` / `data-one` /
492
795
  * `data-other` templates on the count target (`%{count}`); the bare number is
@@ -500,12 +803,11 @@ var PresenceController = class extends Controller {
500
803
  /** Appends one template clone for a newly present peer (list + template only). */
501
804
  #appendClone(id, name) {
502
805
  if (!this.hasListTarget || !this.hasTemplateTarget) return;
503
- const clone = this.templateTarget.content.cloneNode(true);
504
- const root = clone.firstElementChild;
505
- if (!root) return;
806
+ const root = this.templateTarget.content.firstElementChild?.cloneNode(true);
807
+ if (!(root instanceof Element)) return;
506
808
  root.setAttribute("data-presence-id", id);
507
809
  this.#fillName(root, name);
508
- this.listTarget.appendChild(clone);
810
+ this.listTarget.appendChild(root);
509
811
  }
510
812
  #updateClone(id, name) {
511
813
  const root = this.#cloneFor(id);
@@ -533,6 +835,7 @@ var PresenceController = class extends Controller {
533
835
  #reset() {
534
836
  this.#timers.clearAll();
535
837
  this.#pendingBeacon = null;
838
+ this.#announceId = null;
536
839
  this.#peers.clear();
537
840
  this.#lastBeaconAt = 0;
538
841
  this.element.removeAttribute("data-present");
@@ -545,25 +848,6 @@ var PresenceController = class extends Controller {
545
848
  }
546
849
  }
547
850
  };
548
-
549
- // src/utils/announce.ts
550
- function announce(message, options = {}) {
551
- const text = message.trim();
552
- if (text.length === 0) return;
553
- window.dispatchEvent(
554
- new CustomEvent("stimeo--announcer:announce", {
555
- detail: { message: text, assertive: options.assertive === true }
556
- })
557
- );
558
- }
559
- function fillTemplate(template, values) {
560
- return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
561
- const replacement = values[name];
562
- return replacement === void 0 ? match : String(replacement);
563
- });
564
- }
565
-
566
- // src/cable/typing_indicator_controller.ts
567
851
  var DEFAULT_TIMEOUT = 3e3;
568
852
  var DEFAULT_THROTTLE = 2e3;
569
853
  var fillTokens = (template, values) => template.replace(/%\{(name|names|count)\}/g, (match, token) => values[token] ?? match);
@@ -671,14 +955,16 @@ var TypingIndicatorController = class _TypingIndicatorController extends Control
671
955
  if (added) this.#render();
672
956
  }
673
957
  /**
674
- * The silence after which a typer is dropped, in ms: a finite, non-negative number.
675
- * Anything else names no delay — `setTimeout` reads `NaN`, a negative value and
676
- * `Infinity` alike as "now", so the typer would vanish in the same task it appeared
677
- * and the indicator could never be seen. Such a declaration falls back to the default.
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.
678
964
  */
679
965
  get #timeout() {
680
966
  const declared = this.timeoutValue;
681
- return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_TIMEOUT;
967
+ return Number.isFinite(declared) && declared >= 0 && declared <= MAX_TIMER_DELAY_MS ? declared : DEFAULT_TIMEOUT;
682
968
  }
683
969
  /**
684
970
  * The minimum gap between outgoing signals, in ms: a finite, non-negative number.