stimeo-ui 0.10.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9426006a2ca0153a2f91db44ca72beff51851141b42ab82bf64f55a68f853651
4
- data.tar.gz: fdbd9afa0e2c79f74a823c69b04f55295626fec0b8315e3fec7ec1f88f0406d8
3
+ metadata.gz: 7f691ab87c2a0b5092226d8d319ebff3c7b6d8a658b8a62c42017b7dc3e50807
4
+ data.tar.gz: e2aebaed7d717a580cbd9af42ae6149e7cda083fbbdd3465488525fdac8ee01e
5
5
  SHA512:
6
- metadata.gz: ac9a8e4008547f68a709b27964c767bae3adf601770a353b6834e58866f6d84ce68f257cb0d5a8e80aa685cbc2a12e4068414f8351952e2d9afeaf1582764d7f
7
- data.tar.gz: ac6559ced3a65c78f00b836d8a5f6a6c7aed7dfe50b091b13f537418a32f9e76d5bf00c487faa23d13851670069dd2d7a1bfdb50c67cd17a29fd073c16be86f1
6
+ metadata.gz: 182402ce77c5e8a010e3c6f2229c8865f339ea20447907f776f7e557a3c52de7aa9b3eada614e9c64c2cb4d3a7850a1a1e5b3724e1f836ed666f6cfadf221787
7
+ data.tar.gz: 7061380101c2b4c7a1ad996e11ba1a6816ed276ba96944b55e1bd5d5efecefb8ee9607c466604f6ad97b7da990c86c1dbd5376256ef29d8c8c7d39dada7fd50b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,149 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.12.0] - 2026-09-06
11
+
12
+ Minor release with no new components. Six existing ones are reworked — count-up,
13
+ intersection, lazy-frame, reading-progress, smart-sticky-header, and sortable —
14
+ and the number reading count-up gained reaches live-counter too. Most of them
15
+ changed a contract, so read Removed and Changed before upgrading. The Inspector
16
+ manifest stays on schema v12.
17
+
18
+ ### Removed
19
+
20
+ - sortable: the `status` target, its `data-grabbed` / `data-moved` /
21
+ `data-dropped` / `data-canceled` templates, and the English fallback wording.
22
+ Seat a `stimeo--announcer` on the page and set `announceGrabbedText` /
23
+ `announceMovedText` / `announceDroppedText` / `announceCanceledText`
24
+ (`{name}` / `{position}` / `{total}`); an unset one announces nothing.
25
+ `stimeo check` stops asking for the live region and warns when no announcer
26
+ is seated.
27
+
28
+ ### Changed
29
+
30
+ - count-up: only the text node holding the number is animated, and while it
31
+ ticks it is wrapped in a `role="img"` element named with the authored text —
32
+ the host's own `aria-label` and `role` are never touched, and sibling markup
33
+ stays where it was.
34
+ - count-up and live-counter read a formatted number by one rule: the first
35
+ numeric token, group separators dropped, the fraction truncated, and a hyphen
36
+ a sign only where it opens the token — `"Sign-ups: 1,200"` reads 1200, not
37
+ -1200.
38
+ - intersection: `0` is always among the observed lines, so a non-zero
39
+ `threshold` also reports the element leaving for good (one more `change`;
40
+ `data-passed` and the ratio property follow the departure to its end), and an
41
+ exit across the start edge reports `position: "before"` while the element
42
+ still overlaps the root.
43
+ - lazy-frame: a re-fetch needs a genuine re-entry — the frame is seen inside the
44
+ observed area, leaves it, and comes back; where a connection or a
45
+ focus-started load first finds it is the baseline. A frame with `once` off is
46
+ armed again after a cache restore, and `load` names the URL that was fetched.
47
+ - reading-progress: an article with no layout box is not measured, the
48
+ article's own box is watched so late-settling content re-measures, and the
49
+ connect frame is the baseline — a restored scroll position does not fire
50
+ `complete`. Both faces of the custom property are leased: the element's copy
51
+ is returned on `disconnect()` too, an authored `:root` declaration comes back,
52
+ and a later writer is left alone.
53
+ - smart-sticky-header: `change` fires on transitions only (connecting is
54
+ silent), and the `offset` zone is decided ahead of the jitter guard.
55
+ - sortable: insertion is relative to the neighbouring item — the `list`-less
56
+ markup works and the last slot is after the last item — and a drag signal from
57
+ a pointer-drag nested inside an item no longer moves the card. The pickup slot
58
+ is remembered as neighbours, so rows inserted or removed mid-drag shift neither
59
+ the restore nor `from`; only laid-out siblings take part in pointer following;
60
+ and a session whose item leaves the item set ends instead of refusing every
61
+ later grab. pointer-drag consumes `Home` / `End` while grabbed.
62
+ - A declaration that cannot be read falls back to its default: count-up's
63
+ `duration` / `from`, smart-sticky-header's `offset`, and a `rootSelector` /
64
+ `containerSelector` that does not parse (intersection, sticky-observer,
65
+ smart-sticky-header) observes the viewport or the window. Runtime changes to
66
+ intersection's `threshold`, lazy-frame's `url` / `rootMargin`, and
67
+ smart-sticky-header's `offset` are followed.
68
+
69
+ ### Fixed
70
+
71
+ - lazy-frame: focus moving inside a loaded frame no longer re-fetches it, the
72
+ first intersection after a focus-started load is not a re-entry, and an empty
73
+ `url` is never written to `src`.
74
+ - intersection: with `once`, a handler calling `refresh()` from `enter` no longer
75
+ clears the one-shot marker, so a cache restore does not fire `enter` again.
76
+ - reading-progress: hiding a half-read article no longer publishes `1` and
77
+ `complete`.
78
+
79
+ ## [0.11.0] - 2026-09-05
80
+
81
+ Minor release with no new components. Eight existing ones are reworked — the three
82
+ cable controllers (live-counter, presence, typing-indicator) plus pointer-drag,
83
+ portal, preview-guard, roving, and stick-to-bottom. Two of them changed a contract,
84
+ so read Removed before upgrading. The Inspector manifest stays on schema v12.
85
+
86
+ ### Removed
87
+
88
+ - preview-guard: the `mode` Value. `placeholder` alone decides the guard's form —
89
+ empty (the default) hides the element with `visibility`, and any other text takes
90
+ the place of its content.
91
+ - typing-indicator: the live region on the `status` target. It is a plain visible
92
+ slot now — drop its `aria-live`, seat a `stimeo--announcer` on the page, and set
93
+ `announceOneText` / `announceManyText`. `stimeo check` stops asking for the
94
+ live-region semantics too.
95
+
96
+ ### Added
97
+
98
+ - typing-indicator: `announceOneText` / `announceManyText` (`{name}` / `{names}` /
99
+ `{count}`), debounced through the shared announcer.
100
+ - pointer-drag: a `reset` action that returns the element to its origin — dropping
101
+ the committed follow offset and cancelling an in-flight drag.
102
+
103
+ ### Changed
104
+
105
+ - The cable controllers share one wire subscription per identifier (channel +
106
+ params), so a second widget on the same stream sends instead of waiting on a
107
+ confirmation that never comes. A caller joining a confirmed or refused
108
+ identifier is told so on the next microtask, and the wire is released when the
109
+ last one leaves.
110
+ - Cable identifier parameters are read by the controller, so an unreadable
111
+ `params` declaration falls back to the channel alone instead of stopping the
112
+ subscription from being created at all.
113
+ - An element moved within the page keeps what it was carrying: pointer-drag's
114
+ session and the attributes it lends its handles, portal's teleport,
115
+ preview-guard's guard, and roving's tab stop.
116
+ - A declaration that cannot be read falls back to its default — typing-indicator's
117
+ `timeout` / `throttle`, stick-to-bottom's `threshold`, and portal's `to`, which
118
+ now lands the node at the default destination instead of leaving it in place.
119
+ - A key that steers an IME conversion is left to the composition (pointer-drag,
120
+ roving), roving passes a modified `Home` / `End` through to the browser, and
121
+ pointer-drag leaves a press to a native control or editing surface inside the
122
+ handle.
123
+ - roving: an item that cannot take focus — `hidden`, natively `disabled`,
124
+ including through a `fieldset` — is neither a move target nor a tab-stop
125
+ candidate, and the horizontal arrow pair follows the writing direction.
126
+ - pointer-drag: the `touch-action` and `tabindex` lent to a handle are given back
127
+ when an element stops being one, and a handle that leaves the controller ends
128
+ its session in `cancel`.
129
+ - preview-guard: the rewind Turbo's snapshot needs runs on `turbo:before-cache`,
130
+ the inline `visibility` is leased so an authored declaration survives, and the
131
+ child markup a placeholder displaced is put back intact.
132
+ - Transient state is re-derived on connect rather than trusted from a restored
133
+ snapshot: the cable controllers' rejection hooks, stick-to-bottom's
134
+ `data-pinned` / `data-has-new`, and preview-guard's `data-preview-hidden`.
135
+ - A swap at runtime is followed rather than left behind: typing-indicator repaints
136
+ a `status` slot swapped in mid-conversation, stick-to-bottom moves its append
137
+ watch onto a `content` target that arrives or leaves and re-derives pinned from a
138
+ `threshold` changed on the element, and preview-guard re-forms a guard that is
139
+ already up to match a changed `placeholder`.
140
+
141
+ ### Fixed
142
+
143
+ - stick-to-bottom: a container connected without a box holds unpinned until the
144
+ layout arrives, so what arrives meanwhile is flagged rather than followed into a
145
+ box that cannot move.
146
+ - pointer-drag: an in-flight follow offset leaves the DOM on teardown, so a
147
+ re-inserted element does not read it back as a committed base.
148
+ - portal: an instance that has been replaced on the same element no longer rewinds
149
+ the teleport the live one holds.
150
+ - typing-indicator: a name carrying `$&`, `` $` ``, `$'` or `$$` is written into the
151
+ status copy literally instead of expanding into the template's own text.
152
+
10
153
  ## [0.10.0] - 2026-08-31
11
154
 
12
155
  Minor release with no new components. Eight existing ones are reworked — anchored,
@@ -856,6 +999,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
856
999
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
857
1000
  RubyGems.
858
1001
 
1002
+ [0.12.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.12.0
1003
+ [0.11.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.11.0
859
1004
  [0.10.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.10.0
860
1005
  [0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
861
1006
  [0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
data/dist/cable/index.js CHANGED
@@ -2,6 +2,37 @@ 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/authored_integer.ts
7
+ var TOKEN_BODY = /\d[\d.,'’’    _]*\d|\d/;
8
+ var SIGN_OPENER = /[\s([]/;
9
+ function authoredInteger(text) {
10
+ const firstDigit = /\d/.exec(text);
11
+ if (firstDigit === null) return null;
12
+ const at = firstDigit.index;
13
+ const signed = at > 0 && text[at - 1] === "-" && (at === 1 || SIGN_OPENER.test(text[at - 2]));
14
+ const body = TOKEN_BODY.exec(text.slice(at));
15
+ const runs = body[0].split(/\D+/);
16
+ const separators = body[0].match(/\D+/g) ?? [];
17
+ const lead = runs[0];
18
+ const heads = lead.length <= 3 && !lead.startsWith("0");
19
+ let digits = lead;
20
+ for (let i = 1; heads && i < runs.length; i += 1) {
21
+ if (separators[i - 1]?.length !== 1 || runs[i]?.length !== 3) break;
22
+ digits += runs[i];
23
+ }
24
+ const magnitude = Number.parseInt(digits, 10);
25
+ return signed ? -magnitude || 0 : magnitude;
26
+ }
27
+ function parseSubscriptionParams(raw) {
28
+ try {
29
+ const parsed = JSON.parse(raw);
30
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
31
+ return parsed;
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
5
36
  var sharedConsumer = null;
6
37
  function setCableConsumer(consumer) {
7
38
  sharedConsumer = consumer;
@@ -10,40 +41,92 @@ function getCableConsumer() {
10
41
  if (!sharedConsumer) sharedConsumer = createConsumer();
11
42
  return sharedConsumer;
12
43
  }
44
+ var sharedSubscriptions = /* @__PURE__ */ new WeakMap();
45
+ function identifierOf(channel) {
46
+ return JSON.stringify(typeof channel === "string" ? { channel } : channel);
47
+ }
13
48
  function createConfirmedSubscription(channel, mixin) {
14
- let confirmed = false;
15
- let rejected = false;
16
- const subscription = getCableConsumer().subscriptions.create(channel, {
17
- // A refusal is final: Action Cable never confirms a rejected subscription,
18
- // so a late connected/disconnected (only possible from a misbehaving
19
- // consumer double) must not reopen the gate `rejected` promised shut.
20
- connected: () => {
21
- if (rejected) return;
22
- confirmed = true;
23
- mixin.connected?.();
24
- },
25
- disconnected: () => {
26
- if (rejected) return;
27
- confirmed = false;
28
- mixin.disconnected?.();
29
- },
30
- rejected: () => {
31
- confirmed = false;
32
- rejected = true;
33
- mixin.rejected?.();
34
- },
35
- received: (data) => mixin.received?.(data)
36
- });
49
+ const consumer = getCableConsumer();
50
+ let registry = sharedSubscriptions.get(consumer);
51
+ if (!registry) {
52
+ registry = /* @__PURE__ */ new Map();
53
+ sharedSubscriptions.set(consumer, registry);
54
+ }
55
+ const identifier = identifierOf(channel);
56
+ const member = { mixin, catchUp: false };
57
+ let shared = registry.get(identifier);
58
+ if (!shared) {
59
+ shared = openSharedSubscription(consumer, channel);
60
+ registry.set(identifier, shared);
61
+ } else if (shared.rejected || shared.confirmed) {
62
+ const owed = shared;
63
+ member.catchUp = true;
64
+ queueMicrotask(() => {
65
+ if (!member.catchUp) return;
66
+ member.catchUp = false;
67
+ if (owed.rejected) mixin.rejected?.();
68
+ else mixin.connected?.();
69
+ });
70
+ }
71
+ shared.members.add(member);
72
+ const owner = shared;
73
+ const registered = registry;
74
+ let active = true;
37
75
  return {
38
- perform: (action, data) => subscription.perform(action, data),
39
- unsubscribe: () => subscription.unsubscribe(),
76
+ perform: (action, data) => owner.subscription.perform(action, data),
77
+ unsubscribe: () => {
78
+ if (!active) return;
79
+ active = false;
80
+ member.catchUp = false;
81
+ owner.members.delete(member);
82
+ if (owner.members.size === 0) {
83
+ registered.delete(identifier);
84
+ owner.subscription.unsubscribe();
85
+ }
86
+ },
40
87
  get confirmed() {
41
- return confirmed;
88
+ return owner.confirmed;
42
89
  },
43
90
  get rejected() {
44
- return rejected;
91
+ return owner.rejected;
92
+ }
93
+ };
94
+ }
95
+ function openSharedSubscription(consumer, channel) {
96
+ const members = /* @__PURE__ */ new Set();
97
+ const fanOut = (deliver) => {
98
+ for (const member of [...members]) {
99
+ member.catchUp = false;
100
+ deliver(member.mixin);
45
101
  }
46
102
  };
103
+ const shared = {
104
+ members,
105
+ confirmed: false,
106
+ rejected: false,
107
+ subscription: consumer.subscriptions.create(channel, {
108
+ // A refusal is final: Action Cable never confirms a rejected subscription,
109
+ // so a late connected/disconnected (only possible from a misbehaving
110
+ // consumer double) must not reopen the gate `rejected` promised shut.
111
+ connected: () => {
112
+ if (shared.rejected) return;
113
+ shared.confirmed = true;
114
+ fanOut((mixin) => mixin.connected?.());
115
+ },
116
+ disconnected: () => {
117
+ if (shared.rejected) return;
118
+ shared.confirmed = false;
119
+ fanOut((mixin) => mixin.disconnected?.());
120
+ },
121
+ rejected: () => {
122
+ shared.confirmed = false;
123
+ shared.rejected = true;
124
+ fanOut((mixin) => mixin.rejected?.());
125
+ },
126
+ received: (data) => fanOut((mixin) => mixin.received?.(data))
127
+ })
128
+ };
129
+ return shared;
47
130
  }
48
131
 
49
132
  // src/cable/live_counter_controller.ts
@@ -52,17 +135,28 @@ var LiveCounterController = class extends Controller {
52
135
  static targets = ["value", "trigger"];
53
136
  static values = {
54
137
  channel: { type: String, default: "" },
55
- params: { type: Object, default: {} },
138
+ params: { type: String, default: "" },
56
139
  id: { type: String, default: "" }
57
140
  };
58
141
  static actions = ["increment"];
59
142
  static events = ["change"];
143
+ /** Identifier parameters parsed once from their declaration, never in the hot path. */
144
+ #params = {};
60
145
  #subscription = null;
146
+ /**
147
+ * Re-parses the identifier parameters when the declaration changes.
148
+ *
149
+ * A malformed declaration falls back to no parameters, so the identifier keeps
150
+ * naming the channel instead of the subscription never being created at all.
151
+ */
152
+ paramsValueChanged() {
153
+ this.#params = parseSubscriptionParams(this.paramsValue);
154
+ }
61
155
  connect() {
62
156
  this.element.removeAttribute("data-live-counter-rejected");
63
157
  if (this.channelValue) {
64
158
  this.#subscription = createConfirmedSubscription(
65
- { channel: this.channelValue, ...this.paramsValue },
159
+ { channel: this.channelValue, ...this.#params },
66
160
  {
67
161
  connected: () => this.#syncTriggers(),
68
162
  // A drop closes the send window (the shared subscription tracks it)
@@ -157,8 +251,7 @@ var LiveCounterController = class extends Controller {
157
251
  }
158
252
  /** The current count, parsed from the DOM (the single source of truth). */
159
253
  get #current() {
160
- const parsed = Number.parseInt((this.#display.textContent ?? "").replace(/[^0-9-]/g, ""), 10);
161
- return Number.isNaN(parsed) ? 0 : parsed;
254
+ return authoredInteger(this.#display.textContent ?? "") ?? 0;
162
255
  }
163
256
  #write(count) {
164
257
  if (count === this.#current) return;
@@ -240,13 +333,15 @@ var PresenceController = class extends Controller {
240
333
  static targets = ["count", "list", "template"];
241
334
  static values = {
242
335
  channel: { type: String, default: "" },
243
- params: { type: Object, default: {} },
336
+ params: { type: String, default: "" },
244
337
  id: { type: String, default: "" },
245
338
  name: { type: String, default: "" },
246
339
  heartbeat: { type: Number, default: 15e3 },
247
340
  timeout: { type: Number, default: 4e4 }
248
341
  };
249
342
  static events = ["join", "leave", "change"];
343
+ /** Identifier parameters parsed once from their declaration, never in the hot path. */
344
+ #params = {};
250
345
  #subscription = null;
251
346
  /** Present peers keyed by id (insertion order = join order). */
252
347
  #peers = /* @__PURE__ */ new Map();
@@ -256,13 +351,22 @@ var PresenceController = class extends Controller {
256
351
  #lastBeaconAt = 0;
257
352
  /** Pending trailing-edge convergence beacon (at most one queued). */
258
353
  #pendingBeacon = null;
354
+ /**
355
+ * Re-parses the identifier parameters when the declaration changes.
356
+ *
357
+ * A malformed declaration falls back to no parameters, so the identifier keeps
358
+ * naming the channel instead of the subscription never being created at all.
359
+ */
360
+ paramsValueChanged() {
361
+ this.#params = parseSubscriptionParams(this.paramsValue);
362
+ }
259
363
  connect() {
260
364
  this.#reset();
261
365
  this.element.removeAttribute("data-presence-rejected");
262
366
  if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
263
367
  if (!this.channelValue) return;
264
368
  this.#subscription = createConfirmedSubscription(
265
- { channel: this.channelValue, ...this.paramsValue },
369
+ { channel: this.channelValue, ...this.#params },
266
370
  {
267
371
  // The first beacon must wait for the confirmed subscription — a
268
372
  // perform() before that is silently dropped by Action Cable. Fires
@@ -441,25 +545,75 @@ var PresenceController = class extends Controller {
441
545
  }
442
546
  }
443
547
  };
444
- var TypingIndicatorController = class extends Controller {
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
+ var DEFAULT_TIMEOUT = 3e3;
568
+ var DEFAULT_THROTTLE = 2e3;
569
+ var fillTokens = (template, values) => template.replace(/%\{(name|names|count)\}/g, (match, token) => values[token] ?? match);
570
+ var TypingIndicatorController = class _TypingIndicatorController extends Controller {
445
571
  static targets = ["input", "status"];
446
572
  static values = {
447
573
  channel: { type: String, default: "" },
448
- params: { type: Object, default: {} },
574
+ params: { type: String, default: "" },
449
575
  name: { type: String, default: "" },
450
- timeout: { type: Number, default: 3e3 },
576
+ timeout: { type: Number, default: DEFAULT_TIMEOUT },
451
577
  // Must stay below `timeout`: the throttle is leading-edge only (no trailing
452
578
  // send), so a receiver's display survives continuous typing only while a
453
579
  // fresh signal lands within its timeout window.
454
- throttle: { type: Number, default: 2e3 }
580
+ throttle: { type: Number, default: DEFAULT_THROTTLE },
581
+ announceOneText: { type: String, default: "" },
582
+ announceManyText: { type: String, default: "" }
455
583
  };
456
584
  static events = ["change"];
585
+ /** Delay (ms) before one settled typer set is sent to the shared announcer. */
586
+ static #announceDelay = 200;
587
+ /** Identifier parameters parsed once from their declaration, never in the hot path. */
588
+ #params = {};
457
589
  #subscription = null;
458
590
  /** Names currently typing (other clients), each with its auto-clear timer id. */
459
591
  #typers = /* @__PURE__ */ new Map();
460
592
  #timers = new SafeTimeout();
461
593
  /** Epoch ms of the last broadcast, for leading-edge throttling. */
462
594
  #lastSentAt = 0;
595
+ /** The one outstanding announcement, so a newer set supersedes it. */
596
+ #announceId = null;
597
+ /**
598
+ * Re-parses the identifier parameters when the declaration changes.
599
+ *
600
+ * A malformed declaration falls back to no parameters, so the identifier keeps
601
+ * naming the channel instead of the subscription never being created at all.
602
+ */
603
+ paramsValueChanged() {
604
+ this.#params = parseSubscriptionParams(this.paramsValue);
605
+ }
606
+ /**
607
+ * Paints the current copy into a `status` target that arrived at runtime.
608
+ *
609
+ * No event: the set of typers did not change, only the element that displays it.
610
+ * Without this a region swapped in mid-conversation stays empty while `data-typing`
611
+ * still says someone is typing, leaving the state in the visual hook alone. An empty
612
+ * set needs no paint — a fresh region already shows it.
613
+ */
614
+ statusTargetConnected() {
615
+ if (this.#typers.size > 0) this.#paint();
616
+ }
463
617
  connect() {
464
618
  this.element.removeAttribute("data-typing");
465
619
  this.element.removeAttribute("data-typing-indicator-rejected");
@@ -467,7 +621,7 @@ var TypingIndicatorController = class extends Controller {
467
621
  this.element.addEventListener("input", this.#onInput);
468
622
  if (this.channelValue) {
469
623
  this.#subscription = createConfirmedSubscription(
470
- { channel: this.channelValue, ...this.paramsValue },
624
+ { channel: this.channelValue, ...this.#params },
471
625
  {
472
626
  // The server refused the subscription: the send gate stays shut for
473
627
  // good, and the hook lets the consumer's CSS reflect the dead stream.
@@ -484,6 +638,7 @@ var TypingIndicatorController = class extends Controller {
484
638
  this.#subscription?.unsubscribe();
485
639
  this.#subscription = null;
486
640
  this.#timers.clearAll();
641
+ this.#announceId = null;
487
642
  this.#typers.clear();
488
643
  this.element.removeAttribute("data-typing");
489
644
  this.element.removeAttribute("data-typing-indicator-rejected");
@@ -494,7 +649,7 @@ var TypingIndicatorController = class extends Controller {
494
649
  #onInput = () => {
495
650
  if (!this.#subscription?.confirmed) return;
496
651
  const now = Date.now();
497
- if (now - this.#lastSentAt < this.throttleValue) return;
652
+ if (now - this.#lastSentAt < this.#throttle) return;
498
653
  this.#lastSentAt = now;
499
654
  this.#subscription.perform("typing", { name: this.nameValue });
500
655
  };
@@ -511,25 +666,74 @@ var TypingIndicatorController = class extends Controller {
511
666
  const added = existing === void 0;
512
667
  this.#typers.set(
513
668
  name,
514
- this.#timers.set(() => this.#untrack(name), this.timeoutValue)
669
+ this.#timers.set(() => this.#untrack(name), this.#timeout)
515
670
  );
516
671
  if (added) this.#render();
517
672
  }
673
+ /**
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.
678
+ */
679
+ get #timeout() {
680
+ const declared = this.timeoutValue;
681
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_TIMEOUT;
682
+ }
683
+ /**
684
+ * The minimum gap between outgoing signals, in ms: a finite, non-negative number.
685
+ * Anything else names no interval, and the gate then settles the same way at every
686
+ * keystroke — `NaN` and a negative gap leave it open, so every keystroke broadcasts,
687
+ * while `Infinity` is never exceeded, so nothing is ever sent. Such a declaration
688
+ * falls back to the default.
689
+ */
690
+ get #throttle() {
691
+ const declared = this.throttleValue;
692
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THROTTLE;
693
+ }
518
694
  #untrack(name) {
519
695
  this.#typers.delete(name);
520
696
  this.#render();
521
697
  }
522
- /** Reflects the typer set onto the hook + live region and emits `change`. */
698
+ /** Reflects the typer set onto the display, announces it, and emits `change`. */
523
699
  #render() {
700
+ const names = this.#paint();
701
+ this.#announce(names);
702
+ this.dispatch("change", { detail: { names } });
703
+ }
704
+ /**
705
+ * Hands the settled typer set to the page's shared announcer.
706
+ *
707
+ * Debounced: a burst of arrivals is one announcement, not one per name. Only a
708
+ * non-empty set is announced — that typing stopped is not news worth interrupting
709
+ * a reader for, and the visible copy already clears. Wording comes from the
710
+ * consumer, and {@link announce} drops an empty message, so an undeclared template
711
+ * announces nothing at all.
712
+ */
713
+ #announce(names) {
714
+ if (this.#announceId !== null) this.#timers.clear(this.#announceId);
715
+ this.#announceId = null;
716
+ if (names.length === 0) return;
717
+ const message = names.length === 1 ? fillTemplate(this.announceOneTextValue, { name: names[0] ?? "" }) : fillTemplate(this.announceManyTextValue, {
718
+ names: names.join(", "),
719
+ count: names.length
720
+ });
721
+ this.#announceId = this.#timers.set(() => {
722
+ announce(message);
723
+ this.#announceId = null;
724
+ }, _TypingIndicatorController.#announceDelay);
725
+ }
726
+ /** Writes the current typer set onto the hook and the status slot. */
727
+ #paint() {
524
728
  const names = [...this.#typers.keys()];
525
729
  this.element.setAttribute("data-typing", names.length > 0 ? "true" : "false");
526
730
  if (this.hasStatusTarget) {
527
731
  this.statusTarget.textContent = this.#message(names);
528
732
  }
529
- this.dispatch("change", { detail: { names } });
733
+ return names;
530
734
  }
531
735
  /**
532
- * Builds the live-region copy. Localizable through `data-one` / `data-many`
736
+ * Builds the status copy. Localizable through `data-one` / `data-many`
533
737
  * templates on the status target (`%{name}` / `%{names}` / `%{count}`); terse
534
738
  * English is the fallback.
535
739
  */
@@ -539,10 +743,10 @@ var TypingIndicatorController = class extends Controller {
539
743
  if (names.length === 1) {
540
744
  const template2 = this.statusTarget.dataset.one;
541
745
  const name = names[0] ?? "";
542
- return template2 ? template2.replace("%{name}", name) : `${name} is typing\u2026`;
746
+ return template2 ? fillTokens(template2, { name }) : `${name} is typing\u2026`;
543
747
  }
544
748
  const template = this.statusTarget.dataset.many;
545
- return template ? template.replace("%{names}", joined).replace("%{count}", String(names.length)) : `${joined} are typing\u2026`;
749
+ return template ? fillTokens(template, { names: joined, count: String(names.length) }) : `${joined} are typing\u2026`;
546
750
  }
547
751
  };
548
752