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.
Files changed (50) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/dist/cable/index.d.ts +79 -14
  3. package/dist/cable/index.js +249 -45
  4. package/dist/cable/index.js.map +1 -1
  5. package/dist/controllers/count_up_controller.d.ts +12 -10
  6. package/dist/controllers/count_up_controller.js +74 -35
  7. package/dist/controllers/count_up_controller.js.map +1 -1
  8. package/dist/controllers/intersection_controller.d.ts +20 -7
  9. package/dist/controllers/intersection_controller.js +55 -8
  10. package/dist/controllers/intersection_controller.js.map +1 -1
  11. package/dist/controllers/lazy_frame_controller.d.ts +31 -9
  12. package/dist/controllers/lazy_frame_controller.js +81 -19
  13. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  14. package/dist/controllers/pointer_drag_controller.d.ts +20 -3
  15. package/dist/controllers/pointer_drag_controller.js +68 -18
  16. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  17. package/dist/controllers/portal_controller.d.ts +16 -3
  18. package/dist/controllers/portal_controller.js +32 -11
  19. package/dist/controllers/portal_controller.js.map +1 -1
  20. package/dist/controllers/preview_guard_controller.d.ts +22 -14
  21. package/dist/controllers/preview_guard_controller.js +180 -20
  22. package/dist/controllers/preview_guard_controller.js.map +1 -1
  23. package/dist/controllers/reading_progress_controller.d.ts +18 -9
  24. package/dist/controllers/reading_progress_controller.js +177 -6
  25. package/dist/controllers/reading_progress_controller.js.map +1 -1
  26. package/dist/controllers/roving_controller.d.ts +15 -3
  27. package/dist/controllers/roving_controller.js +127 -14
  28. package/dist/controllers/roving_controller.js.map +1 -1
  29. package/dist/controllers/scrollspy_controller.js +13 -2
  30. package/dist/controllers/scrollspy_controller.js.map +1 -1
  31. package/dist/controllers/smart_sticky_header_controller.d.ts +17 -8
  32. package/dist/controllers/smart_sticky_header_controller.js +52 -10
  33. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  34. package/dist/controllers/sortable_controller.d.ts +38 -8
  35. package/dist/controllers/sortable_controller.js +241 -67
  36. package/dist/controllers/sortable_controller.js.map +1 -1
  37. package/dist/controllers/stick_to_bottom_controller.d.ts +23 -7
  38. package/dist/controllers/stick_to_bottom_controller.js +149 -28
  39. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  40. package/dist/controllers/sticky_observer_controller.js +13 -2
  41. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  42. package/dist/index.js +934 -285
  43. package/dist/index.js.map +1 -1
  44. package/dist/inspector/cli.js +1 -0
  45. package/dist/inspector/cli.js.map +1 -1
  46. package/dist/inspector/cli_bin.js +1 -0
  47. package/dist/inspector/cli_bin.js.map +1 -1
  48. package/dist/inspector/examples.json +4 -4
  49. package/dist/inspector/manifest.json +16 -56
  50. package/package.json +3 -3
@@ -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