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.
@@ -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