stimeo-ui 0.10.0 → 0.11.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: dbc740b1497e7c760dce2eb0207e1e513a64e944aef2d666b8785c1244bd4618
4
+ data.tar.gz: e687fa19aae7ea8432130228a1bb0bfeb82868f56b18c8d823edb5d6dd1b77c7
5
5
  SHA512:
6
- metadata.gz: ac9a8e4008547f68a709b27964c767bae3adf601770a353b6834e58866f6d84ce68f257cb0d5a8e80aa685cbc2a12e4068414f8351952e2d9afeaf1582764d7f
7
- data.tar.gz: ac6559ced3a65c78f00b836d8a5f6a6c7aed7dfe50b091b13f537418a32f9e76d5bf00c487faa23d13851670069dd2d7a1bfdb50c67cd17a29fd073c16be86f1
6
+ metadata.gz: eeeef9df2dedeb5aa23da6de1c08bc6921f0c85554de9162999895d48ddd89950dfe5dfa32b895d3fd80a780470966a2ae9f9517e8743b6b7868c9a1e441b2da
7
+ data.tar.gz: 3a40f5f32596616f06fa52a93a446ddf73ed25421c610c54209d200eb8c631d5b334e1f014852e8331e4fd9b09ad0933929ad1de54d36aaa4d14361e05a2bcc0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,80 @@ 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.11.0] - 2026-09-05
11
+
12
+ Minor release with no new components. Eight existing ones are reworked — the three
13
+ cable controllers (live-counter, presence, typing-indicator) plus pointer-drag,
14
+ portal, preview-guard, roving, and stick-to-bottom. Two of them changed a contract,
15
+ so read Removed before upgrading. The Inspector manifest stays on schema v12.
16
+
17
+ ### Removed
18
+
19
+ - preview-guard: the `mode` Value. `placeholder` alone decides the guard's form —
20
+ empty (the default) hides the element with `visibility`, and any other text takes
21
+ the place of its content.
22
+ - typing-indicator: the live region on the `status` target. It is a plain visible
23
+ slot now — drop its `aria-live`, seat a `stimeo--announcer` on the page, and set
24
+ `announceOneText` / `announceManyText`. `stimeo check` stops asking for the
25
+ live-region semantics too.
26
+
27
+ ### Added
28
+
29
+ - typing-indicator: `announceOneText` / `announceManyText` (`{name}` / `{names}` /
30
+ `{count}`), debounced through the shared announcer.
31
+ - pointer-drag: a `reset` action that returns the element to its origin — dropping
32
+ the committed follow offset and cancelling an in-flight drag.
33
+
34
+ ### Changed
35
+
36
+ - The cable controllers share one wire subscription per identifier (channel +
37
+ params), so a second widget on the same stream sends instead of waiting on a
38
+ confirmation that never comes. A caller joining a confirmed or refused
39
+ identifier is told so on the next microtask, and the wire is released when the
40
+ last one leaves.
41
+ - Cable identifier parameters are read by the controller, so an unreadable
42
+ `params` declaration falls back to the channel alone instead of stopping the
43
+ subscription from being created at all.
44
+ - An element moved within the page keeps what it was carrying: pointer-drag's
45
+ session and the attributes it lends its handles, portal's teleport,
46
+ preview-guard's guard, and roving's tab stop.
47
+ - A declaration that cannot be read falls back to its default — typing-indicator's
48
+ `timeout` / `throttle`, stick-to-bottom's `threshold`, and portal's `to`, which
49
+ now lands the node at the default destination instead of leaving it in place.
50
+ - A key that steers an IME conversion is left to the composition (pointer-drag,
51
+ roving), roving passes a modified `Home` / `End` through to the browser, and
52
+ pointer-drag leaves a press to a native control or editing surface inside the
53
+ handle.
54
+ - roving: an item that cannot take focus — `hidden`, natively `disabled`,
55
+ including through a `fieldset` — is neither a move target nor a tab-stop
56
+ candidate, and the horizontal arrow pair follows the writing direction.
57
+ - pointer-drag: the `touch-action` and `tabindex` lent to a handle are given back
58
+ when an element stops being one, and a handle that leaves the controller ends
59
+ its session in `cancel`.
60
+ - preview-guard: the rewind Turbo's snapshot needs runs on `turbo:before-cache`,
61
+ the inline `visibility` is leased so an authored declaration survives, and the
62
+ child markup a placeholder displaced is put back intact.
63
+ - Transient state is re-derived on connect rather than trusted from a restored
64
+ snapshot: the cable controllers' rejection hooks, stick-to-bottom's
65
+ `data-pinned` / `data-has-new`, and preview-guard's `data-preview-hidden`.
66
+ - A swap at runtime is followed rather than left behind: typing-indicator repaints
67
+ a `status` slot swapped in mid-conversation, stick-to-bottom moves its append
68
+ watch onto a `content` target that arrives or leaves and re-derives pinned from a
69
+ `threshold` changed on the element, and preview-guard re-forms a guard that is
70
+ already up to match a changed `placeholder`.
71
+
72
+ ### Fixed
73
+
74
+ - stick-to-bottom: a container connected without a box holds unpinned until the
75
+ layout arrives, so what arrives meanwhile is flagged rather than followed into a
76
+ box that cannot move.
77
+ - pointer-drag: an in-flight follow offset leaves the DOM on teardown, so a
78
+ re-inserted element does not read it back as a committed base.
79
+ - portal: an instance that has been replaced on the same element no longer rewinds
80
+ the teleport the live one holds.
81
+ - typing-indicator: a name carrying `$&`, `` $` ``, `$'` or `$$` is written into the
82
+ status copy literally instead of expanding into the template's own text.
83
+
10
84
  ## [0.10.0] - 2026-08-31
11
85
 
12
86
  Minor release with no new components. Eight existing ones are reworked — anchored,
@@ -856,6 +930,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
856
930
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
857
931
  RubyGems.
858
932
 
933
+ [0.11.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.11.0
859
934
  [0.10.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.10.0
860
935
  [0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
861
936
  [0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
data/dist/cable/index.js CHANGED
@@ -2,6 +2,15 @@ import { Controller } from '@hotwired/stimulus';
2
2
  import { createConsumer } from '@rails/actioncable';
3
3
 
4
4
  // src/cable/live_counter_controller.ts
5
+ function parseSubscriptionParams(raw) {
6
+ try {
7
+ const parsed = JSON.parse(raw);
8
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
9
+ return parsed;
10
+ } catch {
11
+ return {};
12
+ }
13
+ }
5
14
  var sharedConsumer = null;
6
15
  function setCableConsumer(consumer) {
7
16
  sharedConsumer = consumer;
@@ -10,41 +19,93 @@ function getCableConsumer() {
10
19
  if (!sharedConsumer) sharedConsumer = createConsumer();
11
20
  return sharedConsumer;
12
21
  }
22
+ var sharedSubscriptions = /* @__PURE__ */ new WeakMap();
23
+ function identifierOf(channel) {
24
+ return JSON.stringify(typeof channel === "string" ? { channel } : channel);
25
+ }
13
26
  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
- });
27
+ const consumer = getCableConsumer();
28
+ let registry = sharedSubscriptions.get(consumer);
29
+ if (!registry) {
30
+ registry = /* @__PURE__ */ new Map();
31
+ sharedSubscriptions.set(consumer, registry);
32
+ }
33
+ const identifier = identifierOf(channel);
34
+ const member = { mixin, catchUp: false };
35
+ let shared = registry.get(identifier);
36
+ if (!shared) {
37
+ shared = openSharedSubscription(consumer, channel);
38
+ registry.set(identifier, shared);
39
+ } else if (shared.rejected || shared.confirmed) {
40
+ const owed = shared;
41
+ member.catchUp = true;
42
+ queueMicrotask(() => {
43
+ if (!member.catchUp) return;
44
+ member.catchUp = false;
45
+ if (owed.rejected) mixin.rejected?.();
46
+ else mixin.connected?.();
47
+ });
48
+ }
49
+ shared.members.add(member);
50
+ const owner = shared;
51
+ const registered = registry;
52
+ let active = true;
37
53
  return {
38
- perform: (action, data) => subscription.perform(action, data),
39
- unsubscribe: () => subscription.unsubscribe(),
54
+ perform: (action, data) => owner.subscription.perform(action, data),
55
+ unsubscribe: () => {
56
+ if (!active) return;
57
+ active = false;
58
+ member.catchUp = false;
59
+ owner.members.delete(member);
60
+ if (owner.members.size === 0) {
61
+ registered.delete(identifier);
62
+ owner.subscription.unsubscribe();
63
+ }
64
+ },
40
65
  get confirmed() {
41
- return confirmed;
66
+ return owner.confirmed;
42
67
  },
43
68
  get rejected() {
44
- return rejected;
69
+ return owner.rejected;
45
70
  }
46
71
  };
47
72
  }
73
+ function openSharedSubscription(consumer, channel) {
74
+ const members = /* @__PURE__ */ new Set();
75
+ const fanOut = (deliver) => {
76
+ for (const member of [...members]) {
77
+ member.catchUp = false;
78
+ deliver(member.mixin);
79
+ }
80
+ };
81
+ const shared = {
82
+ members,
83
+ confirmed: false,
84
+ rejected: false,
85
+ subscription: consumer.subscriptions.create(channel, {
86
+ // A refusal is final: Action Cable never confirms a rejected subscription,
87
+ // so a late connected/disconnected (only possible from a misbehaving
88
+ // consumer double) must not reopen the gate `rejected` promised shut.
89
+ connected: () => {
90
+ if (shared.rejected) return;
91
+ shared.confirmed = true;
92
+ fanOut((mixin) => mixin.connected?.());
93
+ },
94
+ disconnected: () => {
95
+ if (shared.rejected) return;
96
+ shared.confirmed = false;
97
+ fanOut((mixin) => mixin.disconnected?.());
98
+ },
99
+ rejected: () => {
100
+ shared.confirmed = false;
101
+ shared.rejected = true;
102
+ fanOut((mixin) => mixin.rejected?.());
103
+ },
104
+ received: (data) => fanOut((mixin) => mixin.received?.(data))
105
+ })
106
+ };
107
+ return shared;
108
+ }
48
109
 
49
110
  // src/cable/live_counter_controller.ts
50
111
  var DISABLED_MARKER = "data-live-counter-disabled";
@@ -52,17 +113,28 @@ var LiveCounterController = class extends Controller {
52
113
  static targets = ["value", "trigger"];
53
114
  static values = {
54
115
  channel: { type: String, default: "" },
55
- params: { type: Object, default: {} },
116
+ params: { type: String, default: "" },
56
117
  id: { type: String, default: "" }
57
118
  };
58
119
  static actions = ["increment"];
59
120
  static events = ["change"];
121
+ /** Identifier parameters parsed once from their declaration, never in the hot path. */
122
+ #params = {};
60
123
  #subscription = null;
124
+ /**
125
+ * Re-parses the identifier parameters when the declaration changes.
126
+ *
127
+ * A malformed declaration falls back to no parameters, so the identifier keeps
128
+ * naming the channel instead of the subscription never being created at all.
129
+ */
130
+ paramsValueChanged() {
131
+ this.#params = parseSubscriptionParams(this.paramsValue);
132
+ }
61
133
  connect() {
62
134
  this.element.removeAttribute("data-live-counter-rejected");
63
135
  if (this.channelValue) {
64
136
  this.#subscription = createConfirmedSubscription(
65
- { channel: this.channelValue, ...this.paramsValue },
137
+ { channel: this.channelValue, ...this.#params },
66
138
  {
67
139
  connected: () => this.#syncTriggers(),
68
140
  // A drop closes the send window (the shared subscription tracks it)
@@ -240,13 +312,15 @@ var PresenceController = class extends Controller {
240
312
  static targets = ["count", "list", "template"];
241
313
  static values = {
242
314
  channel: { type: String, default: "" },
243
- params: { type: Object, default: {} },
315
+ params: { type: String, default: "" },
244
316
  id: { type: String, default: "" },
245
317
  name: { type: String, default: "" },
246
318
  heartbeat: { type: Number, default: 15e3 },
247
319
  timeout: { type: Number, default: 4e4 }
248
320
  };
249
321
  static events = ["join", "leave", "change"];
322
+ /** Identifier parameters parsed once from their declaration, never in the hot path. */
323
+ #params = {};
250
324
  #subscription = null;
251
325
  /** Present peers keyed by id (insertion order = join order). */
252
326
  #peers = /* @__PURE__ */ new Map();
@@ -256,13 +330,22 @@ var PresenceController = class extends Controller {
256
330
  #lastBeaconAt = 0;
257
331
  /** Pending trailing-edge convergence beacon (at most one queued). */
258
332
  #pendingBeacon = null;
333
+ /**
334
+ * Re-parses the identifier parameters when the declaration changes.
335
+ *
336
+ * A malformed declaration falls back to no parameters, so the identifier keeps
337
+ * naming the channel instead of the subscription never being created at all.
338
+ */
339
+ paramsValueChanged() {
340
+ this.#params = parseSubscriptionParams(this.paramsValue);
341
+ }
259
342
  connect() {
260
343
  this.#reset();
261
344
  this.element.removeAttribute("data-presence-rejected");
262
345
  if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
263
346
  if (!this.channelValue) return;
264
347
  this.#subscription = createConfirmedSubscription(
265
- { channel: this.channelValue, ...this.paramsValue },
348
+ { channel: this.channelValue, ...this.#params },
266
349
  {
267
350
  // The first beacon must wait for the confirmed subscription — a
268
351
  // perform() before that is silently dropped by Action Cable. Fires
@@ -441,25 +524,75 @@ var PresenceController = class extends Controller {
441
524
  }
442
525
  }
443
526
  };
444
- var TypingIndicatorController = class extends Controller {
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
+ var DEFAULT_TIMEOUT = 3e3;
547
+ var DEFAULT_THROTTLE = 2e3;
548
+ var fillTokens = (template, values) => template.replace(/%\{(name|names|count)\}/g, (match, token) => values[token] ?? match);
549
+ var TypingIndicatorController = class _TypingIndicatorController extends Controller {
445
550
  static targets = ["input", "status"];
446
551
  static values = {
447
552
  channel: { type: String, default: "" },
448
- params: { type: Object, default: {} },
553
+ params: { type: String, default: "" },
449
554
  name: { type: String, default: "" },
450
- timeout: { type: Number, default: 3e3 },
555
+ timeout: { type: Number, default: DEFAULT_TIMEOUT },
451
556
  // Must stay below `timeout`: the throttle is leading-edge only (no trailing
452
557
  // send), so a receiver's display survives continuous typing only while a
453
558
  // fresh signal lands within its timeout window.
454
- throttle: { type: Number, default: 2e3 }
559
+ throttle: { type: Number, default: DEFAULT_THROTTLE },
560
+ announceOneText: { type: String, default: "" },
561
+ announceManyText: { type: String, default: "" }
455
562
  };
456
563
  static events = ["change"];
564
+ /** Delay (ms) before one settled typer set is sent to the shared announcer. */
565
+ static #announceDelay = 200;
566
+ /** Identifier parameters parsed once from their declaration, never in the hot path. */
567
+ #params = {};
457
568
  #subscription = null;
458
569
  /** Names currently typing (other clients), each with its auto-clear timer id. */
459
570
  #typers = /* @__PURE__ */ new Map();
460
571
  #timers = new SafeTimeout();
461
572
  /** Epoch ms of the last broadcast, for leading-edge throttling. */
462
573
  #lastSentAt = 0;
574
+ /** The one outstanding announcement, so a newer set supersedes it. */
575
+ #announceId = null;
576
+ /**
577
+ * Re-parses the identifier parameters when the declaration changes.
578
+ *
579
+ * A malformed declaration falls back to no parameters, so the identifier keeps
580
+ * naming the channel instead of the subscription never being created at all.
581
+ */
582
+ paramsValueChanged() {
583
+ this.#params = parseSubscriptionParams(this.paramsValue);
584
+ }
585
+ /**
586
+ * Paints the current copy into a `status` target that arrived at runtime.
587
+ *
588
+ * No event: the set of typers did not change, only the element that displays it.
589
+ * Without this a region swapped in mid-conversation stays empty while `data-typing`
590
+ * still says someone is typing, leaving the state in the visual hook alone. An empty
591
+ * set needs no paint — a fresh region already shows it.
592
+ */
593
+ statusTargetConnected() {
594
+ if (this.#typers.size > 0) this.#paint();
595
+ }
463
596
  connect() {
464
597
  this.element.removeAttribute("data-typing");
465
598
  this.element.removeAttribute("data-typing-indicator-rejected");
@@ -467,7 +600,7 @@ var TypingIndicatorController = class extends Controller {
467
600
  this.element.addEventListener("input", this.#onInput);
468
601
  if (this.channelValue) {
469
602
  this.#subscription = createConfirmedSubscription(
470
- { channel: this.channelValue, ...this.paramsValue },
603
+ { channel: this.channelValue, ...this.#params },
471
604
  {
472
605
  // The server refused the subscription: the send gate stays shut for
473
606
  // good, and the hook lets the consumer's CSS reflect the dead stream.
@@ -484,6 +617,7 @@ var TypingIndicatorController = class extends Controller {
484
617
  this.#subscription?.unsubscribe();
485
618
  this.#subscription = null;
486
619
  this.#timers.clearAll();
620
+ this.#announceId = null;
487
621
  this.#typers.clear();
488
622
  this.element.removeAttribute("data-typing");
489
623
  this.element.removeAttribute("data-typing-indicator-rejected");
@@ -494,7 +628,7 @@ var TypingIndicatorController = class extends Controller {
494
628
  #onInput = () => {
495
629
  if (!this.#subscription?.confirmed) return;
496
630
  const now = Date.now();
497
- if (now - this.#lastSentAt < this.throttleValue) return;
631
+ if (now - this.#lastSentAt < this.#throttle) return;
498
632
  this.#lastSentAt = now;
499
633
  this.#subscription.perform("typing", { name: this.nameValue });
500
634
  };
@@ -511,25 +645,74 @@ var TypingIndicatorController = class extends Controller {
511
645
  const added = existing === void 0;
512
646
  this.#typers.set(
513
647
  name,
514
- this.#timers.set(() => this.#untrack(name), this.timeoutValue)
648
+ this.#timers.set(() => this.#untrack(name), this.#timeout)
515
649
  );
516
650
  if (added) this.#render();
517
651
  }
652
+ /**
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.
657
+ */
658
+ get #timeout() {
659
+ const declared = this.timeoutValue;
660
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_TIMEOUT;
661
+ }
662
+ /**
663
+ * The minimum gap between outgoing signals, in ms: a finite, non-negative number.
664
+ * Anything else names no interval, and the gate then settles the same way at every
665
+ * keystroke — `NaN` and a negative gap leave it open, so every keystroke broadcasts,
666
+ * while `Infinity` is never exceeded, so nothing is ever sent. Such a declaration
667
+ * falls back to the default.
668
+ */
669
+ get #throttle() {
670
+ const declared = this.throttleValue;
671
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THROTTLE;
672
+ }
518
673
  #untrack(name) {
519
674
  this.#typers.delete(name);
520
675
  this.#render();
521
676
  }
522
- /** Reflects the typer set onto the hook + live region and emits `change`. */
677
+ /** Reflects the typer set onto the display, announces it, and emits `change`. */
523
678
  #render() {
679
+ const names = this.#paint();
680
+ this.#announce(names);
681
+ this.dispatch("change", { detail: { names } });
682
+ }
683
+ /**
684
+ * Hands the settled typer set to the page's shared announcer.
685
+ *
686
+ * Debounced: a burst of arrivals is one announcement, not one per name. Only a
687
+ * non-empty set is announced — that typing stopped is not news worth interrupting
688
+ * a reader for, and the visible copy already clears. Wording comes from the
689
+ * consumer, and {@link announce} drops an empty message, so an undeclared template
690
+ * announces nothing at all.
691
+ */
692
+ #announce(names) {
693
+ if (this.#announceId !== null) this.#timers.clear(this.#announceId);
694
+ this.#announceId = null;
695
+ if (names.length === 0) return;
696
+ const message = names.length === 1 ? fillTemplate(this.announceOneTextValue, { name: names[0] ?? "" }) : fillTemplate(this.announceManyTextValue, {
697
+ names: names.join(", "),
698
+ count: names.length
699
+ });
700
+ this.#announceId = this.#timers.set(() => {
701
+ announce(message);
702
+ this.#announceId = null;
703
+ }, _TypingIndicatorController.#announceDelay);
704
+ }
705
+ /** Writes the current typer set onto the hook and the status slot. */
706
+ #paint() {
524
707
  const names = [...this.#typers.keys()];
525
708
  this.element.setAttribute("data-typing", names.length > 0 ? "true" : "false");
526
709
  if (this.hasStatusTarget) {
527
710
  this.statusTarget.textContent = this.#message(names);
528
711
  }
529
- this.dispatch("change", { detail: { names } });
712
+ return names;
530
713
  }
531
714
  /**
532
- * Builds the live-region copy. Localizable through `data-one` / `data-many`
715
+ * Builds the status copy. Localizable through `data-one` / `data-many`
533
716
  * templates on the status target (`%{name}` / `%{names}` / `%{count}`); terse
534
717
  * English is the fallback.
535
718
  */
@@ -539,10 +722,10 @@ var TypingIndicatorController = class extends Controller {
539
722
  if (names.length === 1) {
540
723
  const template2 = this.statusTarget.dataset.one;
541
724
  const name = names[0] ?? "";
542
- return template2 ? template2.replace("%{name}", name) : `${name} is typing\u2026`;
725
+ return template2 ? fillTokens(template2, { name }) : `${name} is typing\u2026`;
543
726
  }
544
727
  const template = this.statusTarget.dataset.many;
545
- return template ? template.replace("%{names}", joined).replace("%{count}", String(names.length)) : `${joined} are typing\u2026`;
728
+ return template ? fillTokens(template, { names: joined, count: String(names.length) }) : `${joined} are typing\u2026`;
546
729
  }
547
730
  };
548
731
 
@@ -64,6 +64,7 @@ var DetachGate = class _DetachGate {
64
64
  };
65
65
 
66
66
  // src/controllers/pointer_drag_controller.ts
67
+ var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
67
68
  var PointerDragController = class _PointerDragController extends Controller {
68
69
  static targets = ["handle"];
69
70
  static values = {
@@ -73,6 +74,7 @@ var PointerDragController = class _PointerDragController extends Controller {
73
74
  disabled: { type: Boolean, default: false },
74
75
  follow: { type: Boolean, default: false }
75
76
  };
77
+ static actions = ["reset"];
76
78
  static events = ["start", "move", "end", "cancel"];
77
79
  /** Marker attribute recording that this controller set the touch-action. */
78
80
  static #TOUCH_ACTION_MARKER = "data-pointer-drag-touch-action";
@@ -106,23 +108,49 @@ var PointerDragController = class _PointerDragController extends Controller {
106
108
  }
107
109
  this.#teardown();
108
110
  }
109
- /** Prepares handles added at runtime (touch-action + focusability). */
111
+ /**
112
+ * Returns the element to its origin: drops the committed follow offset and the
113
+ * inline `translate` that carries it.
114
+ *
115
+ * In follow mode the inline `translate` belongs to this controller, and the
116
+ * committed offset lives in a field the DOM cannot reach — so a consumer that
117
+ * wants the element back at the start, or that has written a position of its
118
+ * own, needs this to make the two agree again. An in-flight drag is cancelled
119
+ * first, or its deltas would land on top of the offset just cleared.
120
+ */
121
+ reset() {
122
+ const live = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
123
+ if (this.#pointer || this.#keyboard) this.#teardownSessions();
124
+ if (live) this.#dispatchCancel(live);
125
+ this.#followBase = { x: 0, y: 0 };
126
+ this.#applyFollow(0, 0);
127
+ }
110
128
  handleTargetConnected(handle) {
129
+ if (handle !== this.element) this.#restoreHandle(this.element);
111
130
  this.#prepareHandle(handle);
112
131
  }
113
132
  handleTargetDisconnected(handle) {
114
- if (!this.element.contains(handle)) {
115
- if (this.#pointer?.handle === handle) this.#endPointerSession();
116
- if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
117
- }
118
133
  this.#restoreHandle(handle);
134
+ if (this.element.contains(handle)) return;
135
+ const interrupted = this.#pointer?.handle === handle && this.#pointer.started ? this.#pointer.pointerType : this.#keyboard?.handle === handle ? "keyboard" : null;
136
+ if (this.#pointer?.handle === handle) this.#endPointerSession();
137
+ if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
138
+ if (!this.hasHandleTarget) this.#prepareHandle(this.element);
139
+ if (interrupted) this.#dispatchCancel(interrupted);
119
140
  }
120
- /** Re-derives the handles' touch-action when the axis changes. */
121
- axisValueChanged() {
141
+ /**
142
+ * Re-derives the handles' touch-action when the axis changes.
143
+ *
144
+ * Two layers, like the `tabindex` loan: the marker says the value was once
145
+ * ours, and the current inline value says it still is. A consumer that wrote
146
+ * its own `touch-action` after connect keeps it.
147
+ */
148
+ axisValueChanged(_value, previousValue) {
149
+ const lent = this.#touchActionFor(previousValue ?? this.axisValue);
122
150
  for (const handle of this.#handles()) {
123
- if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
124
- handle.style.touchAction = this.#touchActionForAxis();
125
- }
151
+ if (!handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) continue;
152
+ if (handle.style.touchAction !== lent) continue;
153
+ handle.style.touchAction = this.#touchActionForAxis();
126
154
  }
127
155
  }
128
156
  /** Cancels any in-flight session when the controller is disabled mid-drag. */
@@ -203,9 +231,10 @@ var PointerDragController = class _PointerDragController extends Controller {
203
231
  const handle = this.#handleFor(event.target);
204
232
  if (!handle) return;
205
233
  if (event.defaultPrevented) return;
234
+ if (event.isComposing) return;
235
+ if (this.#ownsNativeKeys(event.target, handle)) return;
206
236
  if (isReservedArrowChord(event)) return;
207
237
  if (event.key === "Escape") {
208
- if (event.isComposing) return;
209
238
  if (this.#pointer?.started) {
210
239
  const { pointerType } = this.#pointer;
211
240
  this.#teardownSessions();
@@ -314,6 +343,7 @@ var PointerDragController = class _PointerDragController extends Controller {
314
343
  const interrupted = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
315
344
  this.#teardownSessions();
316
345
  for (const handle of this.#handles()) this.#restoreHandle(handle);
346
+ this.#followReset();
317
347
  if (interrupted && this.element.isConnected) this.#dispatchCancel(interrupted);
318
348
  }
319
349
  /**
@@ -383,8 +413,12 @@ var PointerDragController = class _PointerDragController extends Controller {
383
413
  }
384
414
  /** `touch-action` that lets the page keep panning on the locked axis only. */
385
415
  #touchActionForAxis() {
386
- if (this.axisValue === "x") return "pan-y";
387
- if (this.axisValue === "y") return "pan-x";
416
+ return this.#touchActionFor(this.axisValue);
417
+ }
418
+ /** The `touch-action` an `axis` declaration lends, for any axis value. */
419
+ #touchActionFor(axis) {
420
+ if (axis === "x") return "pan-y";
421
+ if (axis === "y") return "pan-x";
388
422
  return "none";
389
423
  }
390
424
  /**
@@ -395,8 +429,10 @@ var PointerDragController = class _PointerDragController extends Controller {
395
429
  * are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
396
430
  */
397
431
  #prepareHandle(handle) {
398
- if (handle.style.touchAction === "" || handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
399
- handle.style.touchAction = this.#touchActionForAxis();
432
+ const lent = this.#touchActionForAxis();
433
+ const marked = handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
434
+ if (handle.style.touchAction === "" || marked && handle.style.touchAction === lent) {
435
+ handle.style.touchAction = lent;
400
436
  handle.setAttribute(_PointerDragController.#TOUCH_ACTION_MARKER, "true");
401
437
  }
402
438
  if (handle.tabIndex < 0 && !handle.hasAttribute("tabindex")) {
@@ -404,17 +440,27 @@ var PointerDragController = class _PointerDragController extends Controller {
404
440
  handle.setAttribute(_PointerDragController.#TABINDEX_MARKER, "true");
405
441
  }
406
442
  }
443
+ /** Whether the key belongs to a native control or editing surface in the handle. */
444
+ #ownsNativeKeys(target, handle) {
445
+ const owner = target?.closest(NATIVE_KEY_OWNERS) ?? null;
446
+ return owner !== null && owner !== handle && handle.contains(owner);
447
+ }
407
448
  /** Reverts the marker-owned touch-action + tabindex (authored values untouched). */
408
449
  #restoreHandle(handle) {
409
450
  if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
410
- handle.style.touchAction = "";
451
+ if (handle.style.touchAction === this.#touchActionForAxis()) {
452
+ handle.style.touchAction = "";
453
+ }
411
454
  handle.removeAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
412
455
  }
413
456
  if (handle.hasAttribute(_PointerDragController.#TABINDEX_MARKER)) {
414
- handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
415
- if (handle.getAttribute("tabindex") === "0" && document.activeElement !== handle) {
457
+ const ours = handle.getAttribute("tabindex") === "0";
458
+ if (ours && document.activeElement !== handle) {
416
459
  handle.removeAttribute("tabindex");
460
+ handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
461
+ return;
417
462
  }
463
+ if (!ours) handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
418
464
  }
419
465
  }
420
466
  };