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 +4 -4
- data/CHANGELOG.md +145 -0
- data/dist/cable/index.js +249 -45
- data/dist/controllers/count_up_controller.js +74 -35
- data/dist/controllers/intersection_controller.js +55 -8
- data/dist/controllers/lazy_frame_controller.js +81 -19
- data/dist/controllers/pointer_drag_controller.js +68 -18
- data/dist/controllers/portal_controller.js +32 -11
- data/dist/controllers/preview_guard_controller.js +180 -20
- data/dist/controllers/reading_progress_controller.js +177 -6
- data/dist/controllers/roving_controller.js +127 -14
- data/dist/controllers/scrollspy_controller.js +13 -2
- data/dist/controllers/smart_sticky_header_controller.js +52 -10
- data/dist/controllers/sortable_controller.js +241 -67
- data/dist/controllers/stick_to_bottom_controller.js +149 -28
- data/dist/controllers/sticky_observer_controller.js +13 -2
- data/dist/index.js +934 -285
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f691ab87c2a0b5092226d8d319ebff3c7b6d8a658b8a62c42017b7dc3e50807
|
|
4
|
+
data.tar.gz: e2aebaed7d717a580cbd9af42ae6149e7cda083fbbdd3465488525fdac8ee01e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
15
|
-
let
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
rejected
|
|
33
|
-
mixin.
|
|
34
|
-
}
|
|
35
|
-
|
|
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: () =>
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
574
|
+
params: { type: String, default: "" },
|
|
449
575
|
name: { type: String, default: "" },
|
|
450
|
-
timeout: { type: Number, default:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
733
|
+
return names;
|
|
530
734
|
}
|
|
531
735
|
/**
|
|
532
|
-
* Builds the
|
|
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
|
|
746
|
+
return template2 ? fillTokens(template2, { name }) : `${name} is typing\u2026`;
|
|
543
747
|
}
|
|
544
748
|
const template = this.statusTarget.dataset.many;
|
|
545
|
-
return template ? template
|
|
749
|
+
return template ? fillTokens(template, { names: joined, count: String(names.length) }) : `${joined} are typing\u2026`;
|
|
546
750
|
}
|
|
547
751
|
};
|
|
548
752
|
|