stimeo-ui 0.1.0.pre.beta.1 → 0.1.0.pre.beta.2
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 +39 -0
- data/README.md +65 -5
- data/dist/cable/index.js +563 -0
- data/dist/controllers/combobox_controller.js +15 -0
- data/dist/controllers/count_up_controller.js +86 -0
- data/dist/controllers/intersection_controller.js +173 -0
- data/dist/controllers/lazy_frame_controller.js +57 -9
- data/dist/controllers/listbox_controller.js +13 -0
- data/dist/controllers/optimistic_controller.js +58 -0
- data/dist/controllers/pointer_drag_controller.js +406 -0
- data/dist/controllers/portal_controller.js +62 -6
- data/dist/controllers/reading_progress_controller.js +49 -0
- data/dist/controllers/roving_controller.js +1 -0
- data/dist/controllers/scrollspy_controller.js +65 -19
- data/dist/controllers/smart_sticky_header_controller.js +76 -0
- data/dist/controllers/sortable_controller.js +167 -0
- data/dist/controllers/sticky_observer_controller.js +60 -15
- data/dist/controllers/tabs_controller.js +1 -1
- data/dist/index.js +1124 -68
- data/lib/stimeo/ui/version.rb +3 -3
- metadata +11 -3
data/dist/cable/index.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
import { createConsumer } from '@rails/actioncable';
|
|
3
|
+
|
|
4
|
+
// src/cable/live_counter_controller.ts
|
|
5
|
+
var sharedConsumer = null;
|
|
6
|
+
function setCableConsumer(consumer) {
|
|
7
|
+
sharedConsumer = consumer;
|
|
8
|
+
}
|
|
9
|
+
function getCableConsumer() {
|
|
10
|
+
if (!sharedConsumer) sharedConsumer = createConsumer();
|
|
11
|
+
return sharedConsumer;
|
|
12
|
+
}
|
|
13
|
+
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
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
perform: (action, data) => subscription.perform(action, data),
|
|
39
|
+
unsubscribe: () => subscription.unsubscribe(),
|
|
40
|
+
get confirmed() {
|
|
41
|
+
return confirmed;
|
|
42
|
+
},
|
|
43
|
+
get rejected() {
|
|
44
|
+
return rejected;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/cable/live_counter_controller.ts
|
|
50
|
+
var DISABLED_MARKER = "data-live-counter-disabled";
|
|
51
|
+
var LiveCounterController = class extends Controller {
|
|
52
|
+
static targets = ["value", "trigger"];
|
|
53
|
+
static values = {
|
|
54
|
+
channel: { type: String, default: "" },
|
|
55
|
+
params: { type: Object, default: {} },
|
|
56
|
+
id: { type: String, default: "" }
|
|
57
|
+
};
|
|
58
|
+
static actions = ["increment"];
|
|
59
|
+
static events = ["change"];
|
|
60
|
+
#subscription = null;
|
|
61
|
+
connect() {
|
|
62
|
+
this.element.removeAttribute("data-live-counter-rejected");
|
|
63
|
+
if (this.channelValue) {
|
|
64
|
+
this.#subscription = createConfirmedSubscription(
|
|
65
|
+
{ channel: this.channelValue, ...this.paramsValue },
|
|
66
|
+
{
|
|
67
|
+
connected: () => this.#syncTriggers(),
|
|
68
|
+
// A drop closes the send window (the shared subscription tracks it)
|
|
69
|
+
// until Action Cable reconnects and re-confirms — an increment during
|
|
70
|
+
// the outage would bump the display while its perform() is silently
|
|
71
|
+
// discarded by the closed socket.
|
|
72
|
+
disconnected: () => this.#syncTriggers(),
|
|
73
|
+
// The server refused the subscription (auth, bad params): the gate
|
|
74
|
+
// stays shut for good, and the hook lets the consumer's CSS disable
|
|
75
|
+
// or hide the trigger instead of leaving a silently dead button.
|
|
76
|
+
rejected: () => {
|
|
77
|
+
this.element.setAttribute("data-live-counter-rejected", "true");
|
|
78
|
+
this.#syncTriggers();
|
|
79
|
+
},
|
|
80
|
+
received: (data) => this.#onReceived(data)
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
this.#syncTriggers();
|
|
85
|
+
}
|
|
86
|
+
disconnect() {
|
|
87
|
+
this.#subscription?.unsubscribe();
|
|
88
|
+
this.#subscription = null;
|
|
89
|
+
this.element.removeAttribute("data-live-counter-rejected");
|
|
90
|
+
}
|
|
91
|
+
/** Late-added triggers (e.g. via a Turbo Stream) pick up the current gate. */
|
|
92
|
+
triggerTargetConnected(target) {
|
|
93
|
+
this.#syncTrigger(target);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Optimistic local increment: bumps the display immediately, then asks the
|
|
97
|
+
* server to persist and broadcast. The step comes from the action param
|
|
98
|
+
* (`data-stimeo--live-counter-delta-param`), default 1. Bound via `data-action`.
|
|
99
|
+
*/
|
|
100
|
+
increment(event) {
|
|
101
|
+
if (this.#subscription && !this.#subscription.confirmed) return;
|
|
102
|
+
const raw = Number(event?.params?.delta ?? 1);
|
|
103
|
+
const delta = Number.isFinite(raw) ? raw : 1;
|
|
104
|
+
if (!this.#subscription || this.idValue !== "") {
|
|
105
|
+
this.#write(this.#current + delta);
|
|
106
|
+
}
|
|
107
|
+
this.#subscription?.perform("increment", { id: this.idValue, delta });
|
|
108
|
+
}
|
|
109
|
+
/** Reconciles a broadcast: absolute `count` wins; own-echo deltas are skipped. */
|
|
110
|
+
#onReceived(data) {
|
|
111
|
+
const message = data;
|
|
112
|
+
if (typeof message?.count === "number") {
|
|
113
|
+
this.#write(message.count);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (typeof message?.delta === "number") {
|
|
117
|
+
if (typeof message.by === "string" && message.by !== "" && message.by === this.idValue) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.#write(this.#current + message.delta);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** True while an increment would go through (channel-less counters always are). */
|
|
124
|
+
get #ready() {
|
|
125
|
+
return !this.#subscription || this.#subscription.confirmed;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Reflects the send gate onto the optional `trigger` targets as the real
|
|
129
|
+
* `disabled` attribute — the declarative alternative to styling off the
|
|
130
|
+
* `data-live-counter-rejected` hook (a disabled control is also skipped by
|
|
131
|
+
* keyboard focus and announced by AT, which CSS alone cannot do).
|
|
132
|
+
*/
|
|
133
|
+
#syncTriggers() {
|
|
134
|
+
for (const trigger of this.triggerTargets) this.#syncTrigger(trigger);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* `disabled` is a shared attribute: only disable what is currently enabled
|
|
138
|
+
* (marking it ours), and only lift a disabled carrying our marker — so an
|
|
139
|
+
* authored-disabled trigger ("disabled until valid", say) is never
|
|
140
|
+
* re-enabled by the gate. A marked disabled restored from a Turbo cache
|
|
141
|
+
* snapshot is recognized as ours and lifted once the gate opens.
|
|
142
|
+
*/
|
|
143
|
+
#syncTrigger(trigger) {
|
|
144
|
+
if (this.#ready) {
|
|
145
|
+
if (trigger.hasAttribute(DISABLED_MARKER)) {
|
|
146
|
+
trigger.removeAttribute("disabled");
|
|
147
|
+
trigger.removeAttribute(DISABLED_MARKER);
|
|
148
|
+
}
|
|
149
|
+
} else if (!trigger.hasAttribute("disabled")) {
|
|
150
|
+
trigger.setAttribute("disabled", "");
|
|
151
|
+
trigger.setAttribute(DISABLED_MARKER, "");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** The displayed element: the `value` target, else the controller element. */
|
|
155
|
+
get #display() {
|
|
156
|
+
return this.hasValueTarget ? this.valueTarget : this.element;
|
|
157
|
+
}
|
|
158
|
+
/** The current count, parsed from the DOM (the single source of truth). */
|
|
159
|
+
get #current() {
|
|
160
|
+
const parsed = Number.parseInt((this.#display.textContent ?? "").replace(/[^0-9-]/g, ""), 10);
|
|
161
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
162
|
+
}
|
|
163
|
+
#write(count) {
|
|
164
|
+
if (count === this.#current) return;
|
|
165
|
+
this.#display.textContent = String(count);
|
|
166
|
+
this.dispatch("change", { detail: { count } });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// src/utils/safe_timeout.ts
|
|
171
|
+
var TimerRegistry = class {
|
|
172
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
173
|
+
ids = /* @__PURE__ */ new Set();
|
|
174
|
+
/**
|
|
175
|
+
* Cancels a single tracked timer.
|
|
176
|
+
*
|
|
177
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
178
|
+
* registry), so callers can clear defensively without guarding.
|
|
179
|
+
*/
|
|
180
|
+
clear(id) {
|
|
181
|
+
if (this.ids.delete(id)) {
|
|
182
|
+
this.cancel(id);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
187
|
+
* to guarantee no timer outlives the element.
|
|
188
|
+
*/
|
|
189
|
+
clearAll() {
|
|
190
|
+
for (const id of this.ids) {
|
|
191
|
+
this.cancel(id);
|
|
192
|
+
}
|
|
193
|
+
this.ids.clear();
|
|
194
|
+
}
|
|
195
|
+
/** Number of timers currently tracked (pending). */
|
|
196
|
+
get size() {
|
|
197
|
+
return this.ids.size;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
201
|
+
/**
|
|
202
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
203
|
+
*
|
|
204
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
205
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
206
|
+
*/
|
|
207
|
+
set(callback, delay) {
|
|
208
|
+
const id = this.schedule(() => {
|
|
209
|
+
this.ids.delete(id);
|
|
210
|
+
callback();
|
|
211
|
+
}, delay);
|
|
212
|
+
this.ids.add(id);
|
|
213
|
+
return id;
|
|
214
|
+
}
|
|
215
|
+
schedule(callback, delay) {
|
|
216
|
+
return window.setTimeout(callback, delay);
|
|
217
|
+
}
|
|
218
|
+
cancel(id) {
|
|
219
|
+
window.clearTimeout(id);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
var SafeInterval = class extends TimerRegistry {
|
|
223
|
+
/** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
|
|
224
|
+
set(callback, delay) {
|
|
225
|
+
const id = this.schedule(callback, delay);
|
|
226
|
+
this.ids.add(id);
|
|
227
|
+
return id;
|
|
228
|
+
}
|
|
229
|
+
schedule(callback, delay) {
|
|
230
|
+
return window.setInterval(callback, delay);
|
|
231
|
+
}
|
|
232
|
+
cancel(id) {
|
|
233
|
+
window.clearInterval(id);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// src/cable/presence_controller.ts
|
|
238
|
+
var BEACON_THROTTLE_MS = 2e3;
|
|
239
|
+
var PresenceController = class extends Controller {
|
|
240
|
+
static targets = ["count", "list", "template"];
|
|
241
|
+
static values = {
|
|
242
|
+
channel: { type: String, default: "" },
|
|
243
|
+
params: { type: Object, default: {} },
|
|
244
|
+
id: { type: String, default: "" },
|
|
245
|
+
name: { type: String, default: "" },
|
|
246
|
+
heartbeat: { type: Number, default: 15e3 },
|
|
247
|
+
timeout: { type: Number, default: 4e4 }
|
|
248
|
+
};
|
|
249
|
+
static events = ["join", "leave", "change"];
|
|
250
|
+
#subscription = null;
|
|
251
|
+
/** Present peers keyed by id (insertion order = join order). */
|
|
252
|
+
#peers = /* @__PURE__ */ new Map();
|
|
253
|
+
#timers = new SafeTimeout();
|
|
254
|
+
#intervals = new SafeInterval();
|
|
255
|
+
/** Epoch ms of the last outgoing beacon, for the convergence throttle. */
|
|
256
|
+
#lastBeaconAt = 0;
|
|
257
|
+
/** Pending trailing-edge convergence beacon (at most one queued). */
|
|
258
|
+
#pendingBeacon = null;
|
|
259
|
+
connect() {
|
|
260
|
+
this.#reset();
|
|
261
|
+
this.element.removeAttribute("data-presence-rejected");
|
|
262
|
+
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(0);
|
|
263
|
+
if (!this.channelValue) return;
|
|
264
|
+
this.#subscription = createConfirmedSubscription(
|
|
265
|
+
{ channel: this.channelValue, ...this.paramsValue },
|
|
266
|
+
{
|
|
267
|
+
// The first beacon must wait for the confirmed subscription — a
|
|
268
|
+
// perform() before that is silently dropped by Action Cable. Fires
|
|
269
|
+
// again on every reconnect, so the roster self-heals after an outage.
|
|
270
|
+
connected: () => this.#beacon(true),
|
|
271
|
+
// The server refused the subscription: no beacon will ever go through,
|
|
272
|
+
// and the hook lets the consumer's CSS reflect the dead stream.
|
|
273
|
+
rejected: () => {
|
|
274
|
+
this.element.setAttribute("data-presence-rejected", "true");
|
|
275
|
+
},
|
|
276
|
+
received: (data) => this.#onReceived(data)
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
this.#intervals.set(() => this.#beacon(true), this.heartbeatValue);
|
|
280
|
+
window.addEventListener("pagehide", this.#onPageHide);
|
|
281
|
+
}
|
|
282
|
+
disconnect() {
|
|
283
|
+
window.removeEventListener("pagehide", this.#onPageHide);
|
|
284
|
+
this.#sendLeaveNotice();
|
|
285
|
+
this.#subscription?.unsubscribe();
|
|
286
|
+
this.#subscription = null;
|
|
287
|
+
this.#intervals.clearAll();
|
|
288
|
+
this.#reset();
|
|
289
|
+
this.element.removeAttribute("data-presence-rejected");
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Sends the best-effort leaving notice (skipped without an own `id`, and
|
|
293
|
+
* outside the confirmed window, where Action Cable would discard it anyway).
|
|
294
|
+
*/
|
|
295
|
+
#sendLeaveNotice() {
|
|
296
|
+
if (!this.#subscription?.confirmed || !this.idValue) return;
|
|
297
|
+
this.#subscription.perform("appear", { id: this.idValue, leaving: true });
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* `pagehide` covers the leaves `disconnect()` cannot see: closing the tab or
|
|
301
|
+
* a hard (non-Turbo) navigation destroys the page without running Stimulus
|
|
302
|
+
* teardown, so this listener is the only chance to announce them. Best-effort
|
|
303
|
+
* by nature (the socket may close before the frame flushes); peers' expiry
|
|
304
|
+
* timers stay the safety net. If the page enters the bfcache and is restored
|
|
305
|
+
* instead, the next heartbeat re-announces this client, so an over-eager
|
|
306
|
+
* leave self-heals.
|
|
307
|
+
*/
|
|
308
|
+
#onPageHide = () => {
|
|
309
|
+
this.#sendLeaveNotice();
|
|
310
|
+
};
|
|
311
|
+
/**
|
|
312
|
+
* Broadcasts this client's beacon (skipped without an own `id`). A throttled
|
|
313
|
+
* convergence answer is deferred to the trailing edge rather than dropped —
|
|
314
|
+
* otherwise a peer joining right after a heartbeat would not learn about
|
|
315
|
+
* this client until the next full heartbeat period.
|
|
316
|
+
*
|
|
317
|
+
* Gated on the confirmed subscription: before confirmation and during an
|
|
318
|
+
* outage Action Cable silently discards perform(), so a beacon sent then is
|
|
319
|
+
* pure waste — worse, it would burn `#lastBeaconAt` and delay the next real
|
|
320
|
+
* convergence answer by up to the throttle window. The heartbeat interval
|
|
321
|
+
* keeps ticking regardless; `connected` re-fires on reconfirm and
|
|
322
|
+
* force-beacons immediately, so a gated tick is never missed for long.
|
|
323
|
+
*/
|
|
324
|
+
#beacon(force) {
|
|
325
|
+
if (!this.#subscription?.confirmed || !this.idValue) return;
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
const wait = BEACON_THROTTLE_MS - (now - this.#lastBeaconAt);
|
|
328
|
+
if (!force && wait > 0) {
|
|
329
|
+
if (this.#pendingBeacon === null) {
|
|
330
|
+
this.#pendingBeacon = this.#timers.set(() => {
|
|
331
|
+
this.#pendingBeacon = null;
|
|
332
|
+
this.#beacon(true);
|
|
333
|
+
}, wait);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.#lastBeaconAt = now;
|
|
338
|
+
this.#subscription.perform("appear", { id: this.idValue, name: this.nameValue });
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Tracks a broadcast beacon: upserts the peer (restarting its expiry timer),
|
|
342
|
+
* removes it on a `leaving` notice, and re-announces this client when the
|
|
343
|
+
* peer was unknown (roster convergence — see the class doc).
|
|
344
|
+
*/
|
|
345
|
+
#onReceived(data) {
|
|
346
|
+
const beacon = data;
|
|
347
|
+
const id = beacon?.id;
|
|
348
|
+
if (typeof id !== "string" || id === "" || id === this.idValue) return;
|
|
349
|
+
if (beacon?.leaving === true) {
|
|
350
|
+
this.#drop(id);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const name = typeof beacon?.name === "string" ? beacon.name : "";
|
|
354
|
+
const existing = this.#peers.get(id);
|
|
355
|
+
if (existing !== void 0) this.#timers.clear(existing.timer);
|
|
356
|
+
const timer = this.#timers.set(() => this.#drop(id), this.timeoutValue);
|
|
357
|
+
this.#peers.set(id, { name, timer });
|
|
358
|
+
if (existing === void 0) {
|
|
359
|
+
this.#appendClone(id, name);
|
|
360
|
+
this.#render();
|
|
361
|
+
this.dispatch("join", { detail: { id, name } });
|
|
362
|
+
this.#beacon(false);
|
|
363
|
+
} else if (existing.name !== name) {
|
|
364
|
+
this.#updateClone(id, name);
|
|
365
|
+
this.#render();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Removes a peer (expiry or graceful leave) and reflects the change. */
|
|
369
|
+
#drop(id) {
|
|
370
|
+
const peer = this.#peers.get(id);
|
|
371
|
+
if (peer === void 0) return;
|
|
372
|
+
this.#timers.clear(peer.timer);
|
|
373
|
+
this.#peers.delete(id);
|
|
374
|
+
this.#removeClone(id);
|
|
375
|
+
this.#render();
|
|
376
|
+
this.dispatch("leave", { detail: { id } });
|
|
377
|
+
}
|
|
378
|
+
/** Reflects the roster onto the hooks + count target and emits `change`. */
|
|
379
|
+
#render() {
|
|
380
|
+
const users = [...this.#peers.entries()].map(([id, peer]) => ({ id, name: peer.name }));
|
|
381
|
+
this.element.setAttribute("data-present", users.length > 0 ? "true" : "false");
|
|
382
|
+
this.element.setAttribute("data-present-count", String(users.length));
|
|
383
|
+
if (this.hasCountTarget) this.countTarget.textContent = this.#countMessage(users.length);
|
|
384
|
+
this.dispatch("change", { detail: { users } });
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Builds the count copy. Localizable through `data-zero` / `data-one` /
|
|
388
|
+
* `data-other` templates on the count target (`%{count}`); the bare number is
|
|
389
|
+
* the fallback (copy-free, so nothing to localize by default).
|
|
390
|
+
*/
|
|
391
|
+
#countMessage(count) {
|
|
392
|
+
const templates = this.countTarget.dataset;
|
|
393
|
+
const template = (count === 0 ? templates.zero : count === 1 ? templates.one : templates.other) ?? templates.other;
|
|
394
|
+
return template ? template.replace("%{count}", String(count)) : String(count);
|
|
395
|
+
}
|
|
396
|
+
/** Appends one template clone for a newly present peer (list + template only). */
|
|
397
|
+
#appendClone(id, name) {
|
|
398
|
+
if (!this.hasListTarget || !this.hasTemplateTarget) return;
|
|
399
|
+
const clone = this.templateTarget.content.cloneNode(true);
|
|
400
|
+
const root = clone.firstElementChild;
|
|
401
|
+
if (!root) return;
|
|
402
|
+
root.setAttribute("data-presence-id", id);
|
|
403
|
+
this.#fillName(root, name);
|
|
404
|
+
this.listTarget.appendChild(clone);
|
|
405
|
+
}
|
|
406
|
+
#updateClone(id, name) {
|
|
407
|
+
const root = this.#cloneFor(id);
|
|
408
|
+
if (root) this.#fillName(root, name);
|
|
409
|
+
}
|
|
410
|
+
#removeClone(id) {
|
|
411
|
+
this.#cloneFor(id)?.remove();
|
|
412
|
+
}
|
|
413
|
+
#cloneFor(id) {
|
|
414
|
+
if (!this.hasListTarget) return null;
|
|
415
|
+
for (const child of this.listTarget.querySelectorAll("[data-presence-id]")) {
|
|
416
|
+
if (child.getAttribute("data-presence-id") === id) return child;
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
/** Writes the peer's name into the clone's `data-presence-name` slots. */
|
|
421
|
+
#fillName(root, name) {
|
|
422
|
+
const slots = root.querySelectorAll("[data-presence-name]");
|
|
423
|
+
for (const slot of slots) slot.textContent = name;
|
|
424
|
+
if (slots.length === 0 && root.hasAttribute("data-presence-name")) {
|
|
425
|
+
root.textContent = name;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Clears the transient roster state (connect reset + disconnect teardown). */
|
|
429
|
+
#reset() {
|
|
430
|
+
this.#timers.clearAll();
|
|
431
|
+
this.#pendingBeacon = null;
|
|
432
|
+
this.#peers.clear();
|
|
433
|
+
this.#lastBeaconAt = 0;
|
|
434
|
+
this.element.removeAttribute("data-present");
|
|
435
|
+
this.element.removeAttribute("data-present-count");
|
|
436
|
+
if (this.hasCountTarget) this.countTarget.textContent = "";
|
|
437
|
+
if (this.hasListTarget) {
|
|
438
|
+
for (const child of this.listTarget.querySelectorAll("[data-presence-id]")) {
|
|
439
|
+
child.remove();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
var TypingIndicatorController = class extends Controller {
|
|
445
|
+
static targets = ["input", "status"];
|
|
446
|
+
static values = {
|
|
447
|
+
channel: { type: String, default: "" },
|
|
448
|
+
params: { type: Object, default: {} },
|
|
449
|
+
name: { type: String, default: "" },
|
|
450
|
+
timeout: { type: Number, default: 3e3 },
|
|
451
|
+
// Must stay below `timeout`: the throttle is leading-edge only (no trailing
|
|
452
|
+
// send), so a receiver's display survives continuous typing only while a
|
|
453
|
+
// fresh signal lands within its timeout window.
|
|
454
|
+
throttle: { type: Number, default: 2e3 }
|
|
455
|
+
};
|
|
456
|
+
static events = ["change"];
|
|
457
|
+
#subscription = null;
|
|
458
|
+
/** Names currently typing (other clients), each with its auto-clear timer id. */
|
|
459
|
+
#typers = /* @__PURE__ */ new Map();
|
|
460
|
+
#timers = new SafeTimeout();
|
|
461
|
+
/** Epoch ms of the last broadcast, for leading-edge throttling. */
|
|
462
|
+
#lastSentAt = 0;
|
|
463
|
+
connect() {
|
|
464
|
+
this.element.removeAttribute("data-typing");
|
|
465
|
+
this.element.removeAttribute("data-typing-indicator-rejected");
|
|
466
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = "";
|
|
467
|
+
this.element.addEventListener("input", this.#onInput);
|
|
468
|
+
if (this.channelValue) {
|
|
469
|
+
this.#subscription = createConfirmedSubscription(
|
|
470
|
+
{ channel: this.channelValue, ...this.paramsValue },
|
|
471
|
+
{
|
|
472
|
+
// The server refused the subscription: the send gate stays shut for
|
|
473
|
+
// good, and the hook lets the consumer's CSS reflect the dead stream.
|
|
474
|
+
rejected: () => {
|
|
475
|
+
this.element.setAttribute("data-typing-indicator-rejected", "true");
|
|
476
|
+
},
|
|
477
|
+
received: (data) => this.#onReceived(data)
|
|
478
|
+
}
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
disconnect() {
|
|
483
|
+
this.element.removeEventListener("input", this.#onInput);
|
|
484
|
+
this.#subscription?.unsubscribe();
|
|
485
|
+
this.#subscription = null;
|
|
486
|
+
this.#timers.clearAll();
|
|
487
|
+
this.#typers.clear();
|
|
488
|
+
this.element.removeAttribute("data-typing");
|
|
489
|
+
this.element.removeAttribute("data-typing-indicator-rejected");
|
|
490
|
+
if (this.hasStatusTarget) this.statusTarget.textContent = "";
|
|
491
|
+
this.#lastSentAt = 0;
|
|
492
|
+
}
|
|
493
|
+
/** Throttled (leading-edge) broadcast of this client's typing signal. */
|
|
494
|
+
#onInput = () => {
|
|
495
|
+
if (!this.#subscription?.confirmed) return;
|
|
496
|
+
const now = Date.now();
|
|
497
|
+
if (now - this.#lastSentAt < this.throttleValue) return;
|
|
498
|
+
this.#lastSentAt = now;
|
|
499
|
+
this.#subscription.perform("typing", { name: this.nameValue });
|
|
500
|
+
};
|
|
501
|
+
/**
|
|
502
|
+
* Tracks a broadcast typer. The own echo is dropped (same `name`); every
|
|
503
|
+
* further signal from a name restarts its auto-clear timer, so the indicator
|
|
504
|
+
* survives continuous typing and clears `timeout` ms after the last signal.
|
|
505
|
+
*/
|
|
506
|
+
#onReceived(data) {
|
|
507
|
+
const name = data?.name;
|
|
508
|
+
if (typeof name !== "string" || name === "" || name === this.nameValue) return;
|
|
509
|
+
const existing = this.#typers.get(name);
|
|
510
|
+
if (existing !== void 0) this.#timers.clear(existing);
|
|
511
|
+
const added = existing === void 0;
|
|
512
|
+
this.#typers.set(
|
|
513
|
+
name,
|
|
514
|
+
this.#timers.set(() => this.#untrack(name), this.timeoutValue)
|
|
515
|
+
);
|
|
516
|
+
if (added) this.#render();
|
|
517
|
+
}
|
|
518
|
+
#untrack(name) {
|
|
519
|
+
this.#typers.delete(name);
|
|
520
|
+
this.#render();
|
|
521
|
+
}
|
|
522
|
+
/** Reflects the typer set onto the hook + live region and emits `change`. */
|
|
523
|
+
#render() {
|
|
524
|
+
const names = [...this.#typers.keys()];
|
|
525
|
+
this.element.setAttribute("data-typing", names.length > 0 ? "true" : "false");
|
|
526
|
+
if (this.hasStatusTarget) {
|
|
527
|
+
this.statusTarget.textContent = this.#message(names);
|
|
528
|
+
}
|
|
529
|
+
this.dispatch("change", { detail: { names } });
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Builds the live-region copy. Localizable through `data-one` / `data-many`
|
|
533
|
+
* templates on the status target (`%{name}` / `%{names}` / `%{count}`); terse
|
|
534
|
+
* English is the fallback.
|
|
535
|
+
*/
|
|
536
|
+
#message(names) {
|
|
537
|
+
if (names.length === 0) return "";
|
|
538
|
+
const joined = names.join(", ");
|
|
539
|
+
if (names.length === 1) {
|
|
540
|
+
const template2 = this.statusTarget.dataset.one;
|
|
541
|
+
const name = names[0] ?? "";
|
|
542
|
+
return template2 ? template2.replace("%{name}", name) : `${name} is typing\u2026`;
|
|
543
|
+
}
|
|
544
|
+
const template = this.statusTarget.dataset.many;
|
|
545
|
+
return template ? template.replace("%{names}", joined).replace("%{count}", String(names.length)) : `${joined} are typing\u2026`;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// src/cable/index.ts
|
|
550
|
+
var cableControllers = {
|
|
551
|
+
"stimeo--live-counter": LiveCounterController,
|
|
552
|
+
"stimeo--presence": PresenceController,
|
|
553
|
+
"stimeo--typing-indicator": TypingIndicatorController
|
|
554
|
+
};
|
|
555
|
+
function registerCable(application) {
|
|
556
|
+
for (const [identifier, controller] of Object.entries(cableControllers)) {
|
|
557
|
+
application.register(identifier, controller);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export { LiveCounterController, PresenceController, TypingIndicatorController, cableControllers, createConfirmedSubscription, getCableConsumer, registerCable, setCableConsumer };
|
|
562
|
+
//# sourceMappingURL=index.js.map
|
|
563
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/combobox_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/option_scroll.ts
|
|
6
|
+
function scrollOptionIntoView(list, option) {
|
|
7
|
+
if (list.scrollHeight <= list.clientHeight) return;
|
|
8
|
+
const listRect = list.getBoundingClientRect();
|
|
9
|
+
const optionRect = option.getBoundingClientRect();
|
|
10
|
+
if (optionRect.top < listRect.top) {
|
|
11
|
+
list.scrollTop -= listRect.top - optionRect.top;
|
|
12
|
+
} else if (optionRect.bottom > listRect.bottom) {
|
|
13
|
+
list.scrollTop += optionRect.bottom - listRect.bottom;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
// src/controllers/combobox_controller.ts
|
|
4
18
|
var ComboboxController = class extends Controller {
|
|
5
19
|
static targets = ["input", "list", "option"];
|
|
@@ -171,6 +185,7 @@ var ComboboxController = class extends Controller {
|
|
|
171
185
|
} else {
|
|
172
186
|
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
173
187
|
}
|
|
188
|
+
if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);
|
|
174
189
|
}
|
|
175
190
|
/** The options currently shown (not filtered out). */
|
|
176
191
|
#visibleOptions() {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/count_up_controller.ts
|
|
4
|
+
var CountUpController = class extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
duration: { type: Number, default: 1200 },
|
|
7
|
+
from: { type: Number, default: 0 },
|
|
8
|
+
once: { type: Boolean, default: true }
|
|
9
|
+
};
|
|
10
|
+
static actions = ["start"];
|
|
11
|
+
static events = ["end"];
|
|
12
|
+
#frame = null;
|
|
13
|
+
/** The authored final text, restored verbatim when the run settles. */
|
|
14
|
+
#finalText = "";
|
|
15
|
+
connect() {
|
|
16
|
+
if (this.element.hasAttribute("data-count-up-label")) {
|
|
17
|
+
this.element.textContent = this.element.getAttribute("aria-label") ?? this.element.textContent;
|
|
18
|
+
this.#restoreLabel();
|
|
19
|
+
this.element.setAttribute("data-count-up-done", "true");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
disconnect() {
|
|
23
|
+
if (this.#frame !== null) this.#settle();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Starts the animation (typically from `stimeo--intersection:enter` via
|
|
27
|
+
* `data-action`). No-ops while running, and after a finished run when `once`.
|
|
28
|
+
*/
|
|
29
|
+
start() {
|
|
30
|
+
if (this.#frame !== null) return;
|
|
31
|
+
if (this.onceValue && this.element.hasAttribute("data-count-up-done")) return;
|
|
32
|
+
this.#finalText = this.element.textContent ?? "";
|
|
33
|
+
const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, ""), 10);
|
|
34
|
+
if (Number.isNaN(target)) return;
|
|
35
|
+
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
|
36
|
+
this.element.setAttribute("data-count-up-done", "true");
|
|
37
|
+
this.dispatch("end", { detail: { value: target } });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const authored = this.element.getAttribute("aria-label");
|
|
41
|
+
if (authored !== null) {
|
|
42
|
+
this.element.setAttribute("data-count-up-original-label", authored);
|
|
43
|
+
}
|
|
44
|
+
this.element.setAttribute("data-count-up-label", "true");
|
|
45
|
+
this.element.setAttribute("aria-label", this.#finalText);
|
|
46
|
+
const started = performance.now();
|
|
47
|
+
const step = (now) => {
|
|
48
|
+
const t = Math.min((now - started) / this.durationValue, 1);
|
|
49
|
+
const eased = 1 - (1 - t) ** 3;
|
|
50
|
+
this.element.textContent = String(
|
|
51
|
+
Math.round(this.fromValue + (target - this.fromValue) * eased)
|
|
52
|
+
);
|
|
53
|
+
if (t < 1) {
|
|
54
|
+
this.#frame = requestAnimationFrame(step);
|
|
55
|
+
} else {
|
|
56
|
+
this.#settle();
|
|
57
|
+
this.dispatch("end", { detail: { value: target } });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
this.#frame = requestAnimationFrame(step);
|
|
61
|
+
}
|
|
62
|
+
/** Ends the run: cancels the frame and restores the authored presentation. */
|
|
63
|
+
#settle() {
|
|
64
|
+
if (this.#frame !== null) cancelAnimationFrame(this.#frame);
|
|
65
|
+
this.#frame = null;
|
|
66
|
+
this.element.textContent = this.#finalText;
|
|
67
|
+
this.#restoreLabel();
|
|
68
|
+
this.element.setAttribute("data-count-up-done", "true");
|
|
69
|
+
}
|
|
70
|
+
/** Releases the marker-owned aria-label, restoring any parked authored value. */
|
|
71
|
+
#restoreLabel() {
|
|
72
|
+
if (!this.element.hasAttribute("data-count-up-label")) return;
|
|
73
|
+
const original = this.element.getAttribute("data-count-up-original-label");
|
|
74
|
+
if (original !== null) {
|
|
75
|
+
this.element.setAttribute("aria-label", original);
|
|
76
|
+
this.element.removeAttribute("data-count-up-original-label");
|
|
77
|
+
} else {
|
|
78
|
+
this.element.removeAttribute("aria-label");
|
|
79
|
+
}
|
|
80
|
+
this.element.removeAttribute("data-count-up-label");
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export { CountUpController };
|
|
85
|
+
//# sourceMappingURL=count_up_controller.js.map
|
|
86
|
+
//# sourceMappingURL=count_up_controller.js.map
|