stimeo-ui 0.11.0 → 0.13.0

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