stimeo-ui 0.7.0 → 0.9.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.
@@ -20,6 +20,76 @@ function isReservedArrowChord(event, allow = []) {
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
22
 
23
+ // src/utils/attribute_lease.ts
24
+ var AttributeLease = class {
25
+ #attribute;
26
+ #records = /* @__PURE__ */ new Map();
27
+ /** @param attribute - The attribute whose temporary values this lease owns. */
28
+ constructor(attribute) {
29
+ this.#attribute = attribute;
30
+ }
31
+ /** Writes or removes the leased attribute while preserving its authored value. */
32
+ write(element, value) {
33
+ const existing = this.#records.get(element);
34
+ if (existing) {
35
+ existing.written = value;
36
+ } else {
37
+ this.#records.set(element, {
38
+ original: element.getAttribute(this.#attribute),
39
+ written: value
40
+ });
41
+ }
42
+ this.#reflect(element, value);
43
+ }
44
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
45
+ return(element) {
46
+ const record = this.#records.get(element);
47
+ if (!record) return;
48
+ this.#records.delete(element);
49
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
50
+ if (stillOwned) this.#reflect(element, record.original);
51
+ }
52
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
53
+ #reflect(element, value) {
54
+ if (element.getAttribute(this.#attribute) === value) return;
55
+ if (value === null) element.removeAttribute(this.#attribute);
56
+ else element.setAttribute(this.#attribute, value);
57
+ }
58
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
59
+ returnAll() {
60
+ for (const element of Array.from(this.#records.keys())) this.return(element);
61
+ }
62
+ };
63
+
64
+ // src/utils/before_cache_reset.ts
65
+ var BeforeCacheReset = class _BeforeCacheReset {
66
+ /** Every subscribed instance, iterated by the one shared document listener. */
67
+ static #subscribers = /* @__PURE__ */ new Set();
68
+ /** The shared listener; installed while at least one instance is subscribed. */
69
+ static #onBeforeCache = () => {
70
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
71
+ };
72
+ #rewind;
73
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
74
+ constructor(rewind) {
75
+ this.#rewind = rewind;
76
+ }
77
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
78
+ activate() {
79
+ const first = _BeforeCacheReset.#subscribers.size === 0;
80
+ _BeforeCacheReset.#subscribers.add(this);
81
+ if (first) {
82
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
83
+ }
84
+ }
85
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
86
+ deactivate() {
87
+ _BeforeCacheReset.#subscribers.delete(this);
88
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
89
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
90
+ }
91
+ };
92
+
23
93
  // src/utils/microtask_coalescer.ts
24
94
  var MicrotaskCoalescer = class {
25
95
  #run;
@@ -53,6 +123,11 @@ var MicrotaskCoalescer = class {
53
123
  }
54
124
  };
55
125
 
126
+ // src/utils/reduced_motion.ts
127
+ function prefersReducedMotion() {
128
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
129
+ }
130
+
56
131
  // src/utils/roving_tabindex.ts
57
132
  var RovingTabindex = class {
58
133
  /** Returns the current ordered item elements; called on every operation. */
@@ -139,11 +214,13 @@ var SafeInterval = class extends TimerRegistry {
139
214
  };
140
215
 
141
216
  // src/controllers/carousel_controller.ts
217
+ var DEFAULT_INTERVAL = 5e3;
218
+ var OBSERVED_ATTRIBUTES = ["aria-selected", "data-state", "hidden"];
142
219
  var CarouselController = class extends Controller {
143
220
  static targets = ["slide", "viewport", "prev", "next", "picker", "playToggle"];
144
221
  static values = {
145
222
  autoplay: { type: Boolean, default: false },
146
- interval: { type: Number, default: 5e3 },
223
+ interval: { type: Number, default: DEFAULT_INTERVAL },
147
224
  loop: { type: Boolean, default: true }
148
225
  };
149
226
  static actions = [
@@ -158,37 +235,107 @@ var CarouselController = class extends Controller {
158
235
  static events = ["change", "pause", "play", "reconcile"];
159
236
  #roving = new RovingTabindex(() => this.pickerTargets);
160
237
  #reconcileTargets = new MicrotaskCoalescer(() => this.#reconcileTargetSet());
238
+ #intervals = new SafeInterval();
239
+ /** Unreachable step controls and the toggle of a carousel that cannot rotate. */
240
+ #ariaDisabled = new AttributeLease("aria-disabled");
241
+ /** The slide container's live-region politeness, which tracks the rotation. */
242
+ #ariaLive = new AttributeLease("aria-live");
243
+ /** Pairs with {@link CarouselController.#ariaLive}: only the changed slide is read. */
244
+ #ariaAtomic = new AttributeLease("aria-atomic");
245
+ #beforeCache = new BeforeCacheReset(() => this.#returnLeases());
246
+ /**
247
+ * Events an authored action binding already took. The delegated listener runs
248
+ * later — it sits on the controller element, above every control — so it can
249
+ * consume the mark and stand down, letting the two wirings coexist without
250
+ * handling one interaction twice.
251
+ */
252
+ #handledEvents = /* @__PURE__ */ new WeakSet();
161
253
  /**
162
254
  * Whether `connect()` has run. Scheduling is already inert outside that window
163
- * ({@link MicrotaskCoalescer}), so this only gates the Tab stop a picker
164
- * present at mount authored for itself.
255
+ * ({@link MicrotaskCoalescer}), so this gates the Value callbacks Stimulus
256
+ * delivers ahead of `connect()`.
165
257
  */
166
258
  #connected = false;
167
- #intervals = new SafeInterval();
168
259
  /** Index of the visible slide. */
169
260
  #index = 0;
170
- /** User intent to autoplay (toggled by the play button / focus hard-stop). */
171
- #playing = false;
172
- /** Pointer is hovering the carousel: a temporary, auto-resuming suspension. */
261
+ /** The visible slide element, which identifies it across a changing target set. */
262
+ #activeSlide = null;
263
+ /** Slide count at the last resolved state, so a changed total is reportable. */
264
+ #total = 0;
265
+ /** Pointer rests on the carousel: a suspension that lifts on `mouseleave`. */
173
266
  #pointerPaused = false;
267
+ /** Focus is inside the carousel: a suspension that lifts when it leaves. */
268
+ #focusPaused = false;
269
+ /** The tab is in the background: a suspension that lifts when it returns. */
270
+ #hiddenPaused = false;
174
271
  /** Id of the live autoplay interval, or null when stopped. */
175
272
  #timerId = null;
273
+ /** Delay the live interval was armed with, so an `interval` change re-arms it. */
274
+ #timerInterval = 0;
275
+ /** Follows state-attribute rewrites on retained slides and pickers. */
276
+ #observer = null;
277
+ /** Keeps a hidden tab from advancing behind the user's back. */
278
+ #onVisibilityChange = () => {
279
+ this.#hiddenPaused = document.visibilityState === "hidden";
280
+ this.#syncTimer();
281
+ };
176
282
  /**
177
- * Renders the initial slide and starts autoplay when requested.
283
+ * Renders the initial slide, wires the delegated listeners, and starts autoplay
284
+ * when requested.
178
285
  *
179
- * `findIndex` makes the authored pre-selection first-wins when several pickers
180
- * are marked; `#render` then writes an explicit value onto every picker.
286
+ * Every suspension is re-derived from the environment rather than carried, so an
287
+ * in-page move which Stimulus delivers to the *same* controller instance as
288
+ * `disconnect()` then `connect()` — cannot strand the carousel in a suspension
289
+ * whose lifting event will never arrive. The attribute observer starts after the
290
+ * first render so the controller's own opening writes are not fed back to it.
181
291
  */
182
292
  connect() {
183
- const preselected = this.pickerTargets.findIndex(
184
- (picker) => picker.getAttribute("aria-selected") === "true"
185
- );
186
- this.#index = preselected === -1 ? 0 : preselected;
187
- this.#playing = this.#initialPlaying();
293
+ this.#pointerPaused = this.element.matches(":hover");
294
+ this.#focusPaused = this.element.contains(document.activeElement);
295
+ this.#hiddenPaused = document.visibilityState === "hidden";
296
+ if (this.autoplayValue && prefersReducedMotion()) this.autoplayValue = false;
297
+ this.#index = this.#resolveIndex();
188
298
  this.#render({ focus: false });
299
+ this.#total = this.slideTargets.length;
189
300
  this.#syncTimer();
190
301
  this.#connected = true;
191
302
  this.#reconcileTargets.activate();
303
+ this.#beforeCache.activate();
304
+ this.element.addEventListener("click", this.#onClick);
305
+ this.element.addEventListener("keydown", this.#onKeydown);
306
+ this.element.addEventListener("focusin", this.#onFocusin);
307
+ this.element.addEventListener("focusout", this.#onFocusout);
308
+ this.element.addEventListener("mouseenter", this.#onMouseenter);
309
+ this.element.addEventListener("mouseleave", this.#onMouseleave);
310
+ document.addEventListener("visibilitychange", this.#onVisibilityChange);
311
+ this.#observer = new MutationObserver(this.#onAttributeMutations);
312
+ this.#observer.observe(this.element, {
313
+ subtree: true,
314
+ attributes: true,
315
+ attributeFilter: OBSERVED_ATTRIBUTES
316
+ });
317
+ }
318
+ /** Releases every listener and observer, returns the leased ARIA, drops the suspensions. */
319
+ disconnect() {
320
+ this.#connected = false;
321
+ this.element.removeEventListener("click", this.#onClick);
322
+ this.element.removeEventListener("keydown", this.#onKeydown);
323
+ this.element.removeEventListener("focusin", this.#onFocusin);
324
+ this.element.removeEventListener("focusout", this.#onFocusout);
325
+ this.element.removeEventListener("mouseenter", this.#onMouseenter);
326
+ this.element.removeEventListener("mouseleave", this.#onMouseleave);
327
+ document.removeEventListener("visibilitychange", this.#onVisibilityChange);
328
+ this.#observer?.disconnect();
329
+ this.#observer = null;
330
+ this.#reconcileTargets.cancel();
331
+ this.#intervals.clearAll();
332
+ this.#timerId = null;
333
+ this.#pointerPaused = false;
334
+ this.#focusPaused = false;
335
+ this.#hiddenPaused = false;
336
+ this.#activeSlide = null;
337
+ this.#returnLeases();
338
+ this.#beforeCache.deactivate();
192
339
  }
193
340
  /**
194
341
  * Re-establishes the single selected picker when one is added after connect.
@@ -200,7 +347,6 @@ var CarouselController = class extends Controller {
200
347
  * the selection.
201
348
  */
202
349
  pickerTargetConnected(picker) {
203
- if (!this.#connected) return;
204
350
  picker.tabIndex = -1;
205
351
  this.#reconcileTargets.schedule();
206
352
  }
@@ -216,74 +362,173 @@ var CarouselController = class extends Controller {
216
362
  slideTargetDisconnected() {
217
363
  this.#reconcileTargets.schedule();
218
364
  }
219
- /**
220
- * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
221
- * source of truth **when present**, so a Turbo Drive cache restore / morph that
222
- * re-runs `connect()` against existing DOM does not silently resume autoplay the
223
- * user had stopped (e.g. by focusing into the carousel). Only when no toggle
224
- * carries `aria-pressed` does it fall back to the declarative `autoplay` value.
225
- */
226
- #initialPlaying() {
227
- if (this.hasPlayToggleTarget && this.playToggleTarget.hasAttribute("aria-pressed")) {
228
- return this.playToggleTarget.getAttribute("aria-pressed") === "true";
229
- }
230
- return this.autoplayValue;
365
+ /** Follows a rotation intent the page changed at runtime. */
366
+ autoplayValueChanged() {
367
+ if (this.#connected) this.#syncTimer();
231
368
  }
232
- /** Clears the autoplay interval so it never fires after teardown. */
233
- disconnect() {
234
- this.#connected = false;
235
- this.#reconcileTargets.cancel();
236
- this.#intervals.clearAll();
237
- this.#timerId = null;
369
+ /** Re-arms the live interval at the new delay without reporting a state change. */
370
+ intervalValueChanged() {
371
+ if (this.#connected) this.#syncTimer();
238
372
  }
239
- /** Advances to the next slide. Bound via `data-action`. */
240
- next() {
373
+ /** Re-publishes the step controls and re-evaluates the non-looping end. */
374
+ loopValueChanged() {
375
+ if (!this.#connected) return;
376
+ this.#render({ focus: false });
377
+ this.#syncTimer();
378
+ }
379
+ /** Advances to the next slide. Delegated; `data-action` wiring is optional. */
380
+ next(event) {
381
+ if (event?.defaultPrevented) return;
382
+ this.#markHandled(event);
241
383
  this.#select(this.#step(1), { focus: false });
242
384
  }
243
- /** Returns to the previous slide. Bound via `data-action`. */
244
- prev() {
385
+ /** Returns to the previous slide. Delegated; `data-action` wiring is optional. */
386
+ prev(event) {
387
+ if (event?.defaultPrevented) return;
388
+ this.#markHandled(event);
245
389
  this.#select(this.#step(-1), { focus: false });
246
390
  }
247
391
  /** Jumps to the slide whose picker was activated (click / Enter / Space). */
248
392
  goto(event) {
249
- const target = event.currentTarget;
250
- const index = this.pickerTargets.indexOf(target);
393
+ if (event.defaultPrevented) return;
394
+ this.#markHandled(event);
395
+ const index = this.#pickerIndexFor(event.currentTarget);
251
396
  if (index !== -1) this.#select(index, { focus: false });
252
397
  }
253
- /** Toggles autoplay on the user's explicit request and syncs the timer. */
254
- togglePlay() {
255
- this.#playing = !this.#playing;
256
- this.#syncTimer();
398
+ /**
399
+ * Flips the rotation intent on the user's explicit request.
400
+ *
401
+ * The intent is written back to the `autoplay` Value, which is where every other
402
+ * path reads it from. A carousel with nothing to advance to has no intent to
403
+ * flip: the toggle is marked `aria-disabled` and does nothing.
404
+ */
405
+ togglePlay(event) {
406
+ if (event?.defaultPrevented) return;
407
+ this.#markHandled(event);
408
+ this.#togglePlay();
257
409
  }
258
410
  /**
259
- * Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes
260
- * on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so
261
- * it cannot resume without an explicit play (WCAG 2.2.2).
411
+ * Suspends rotation. A `focus`-family event records that focus is inside the
412
+ * carousel; anything else hover, or a bare programmatic call records the
413
+ * pointer suspension. Both lift through the matching {@link resume}, and
414
+ * neither touches the rotation intent.
262
415
  */
263
416
  pause(event) {
264
- if (event?.type.startsWith("focus")) {
265
- this.#playing = false;
266
- } else {
267
- this.#pointerPaused = true;
268
- }
417
+ this.#markHandled(event);
418
+ if (isFocusEvent(event)) this.#focusPaused = true;
419
+ else this.#pointerPaused = true;
269
420
  this.#syncTimer();
270
421
  }
271
422
  /**
272
- * Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still
273
- * on. A `focusout` does nothing here: the focus pause was a hard stop, so the
274
- * user must press play to restart.
423
+ * Lifts the matching suspension. A `focusout` whose `relatedTarget` is still
424
+ * inside the carousel is focus moving between its own controls, which leaves the
425
+ * focus suspension in place releasing it there would stop and restart the
426
+ * interval on every Tab press.
275
427
  */
276
428
  resume(event) {
277
- if (event?.type.startsWith("focus")) return;
278
- this.#pointerPaused = false;
429
+ this.#markHandled(event);
430
+ if (isFocusEvent(event)) {
431
+ if (this.#focusStaysInside(event)) return;
432
+ this.#focusPaused = false;
433
+ } else {
434
+ this.#pointerPaused = false;
435
+ }
279
436
  this.#syncTimer();
280
437
  }
281
- /** Picker roving: arrows move focus only; Home/End activate first/last slide. */
438
+ /** Picker roving for authored bindings; the delegated path is `#onKeydown`. */
282
439
  onPickerKeydown(event) {
440
+ const index = this.#pickerIndexFor(event.currentTarget);
441
+ if (index === -1) return;
442
+ this.#markHandled(event);
443
+ this.#handlePickerKeydown(event, index);
444
+ }
445
+ /** Delegated activation for pickers and step controls without authored actions. */
446
+ #onClick = (event) => this.#delegate(event, () => {
447
+ const index = this.#pickerIndexFor(event.target);
448
+ if (index !== -1) {
449
+ this.#select(index, { focus: false });
450
+ } else if (hits(this.nextTargets, event.target)) {
451
+ this.#select(this.#step(1), { focus: false });
452
+ } else if (hits(this.prevTargets, event.target)) {
453
+ this.#select(this.#step(-1), { focus: false });
454
+ } else if (hits(this.playToggleTargets, event.target)) {
455
+ this.#togglePlay();
456
+ }
457
+ });
458
+ /** Delegated picker roving for pickers without authored actions. */
459
+ #onKeydown = (event) => this.#delegate(event, () => {
460
+ const index = this.#pickerIndexFor(event.target);
461
+ if (index !== -1) this.#handlePickerKeydown(event, index);
462
+ });
463
+ /** Focus arriving anywhere inside suspends the rotation. */
464
+ #onFocusin = (event) => this.#delegate(event, () => {
465
+ this.#focusPaused = true;
466
+ this.#syncTimer();
467
+ });
468
+ /** Focus genuinely leaving lifts the suspension; moves between own controls do not. */
469
+ #onFocusout = (event) => this.#delegate(event, () => {
470
+ if (this.#focusStaysInside(event)) return;
471
+ this.#focusPaused = false;
472
+ this.#syncTimer();
473
+ });
474
+ /** Pointer entry suspends the rotation. */
475
+ #onMouseenter = (event) => this.#delegate(event, () => {
476
+ this.#pointerPaused = true;
477
+ this.#syncTimer();
478
+ });
479
+ /** Pointer exit lifts the suspension. */
480
+ #onMouseleave = (event) => this.#delegate(event, () => {
481
+ this.#pointerPaused = false;
482
+ this.#syncTimer();
483
+ });
484
+ /**
485
+ * Schedules one reconciliation when a state attribute is rewritten in place —
486
+ * the only shape of change no target callback reports.
487
+ *
488
+ * The filter is the `attributeFilter` alone: those three attributes belong to
489
+ * the slides and pickers, and a coalesced pass over an unrelated one costs a
490
+ * repaint that writes nothing. Narrowing further here would add a branch no
491
+ * test could distinguish.
492
+ */
493
+ #onAttributeMutations = () => {
494
+ this.#reconcileTargets.schedule();
495
+ };
496
+ /**
497
+ * Records that an action binding took this event.
498
+ *
499
+ * An authored binding sits on the control, so it runs while the event is still
500
+ * below the controller element and always precedes the delegated listener.
501
+ * Marking is the one signal that keeps the two wirings from both acting.
502
+ */
503
+ #markHandled(event) {
504
+ if (event) this.#handledEvents.add(event);
505
+ }
506
+ /** Runs a delegated handler unless an action binding, or a descendant, took the event. */
507
+ #delegate(event, run) {
508
+ if (this.#handledEvents.delete(event) || event.defaultPrevented) return;
509
+ run();
510
+ }
511
+ /** Position of the picker that is or contains `node`, or `-1` when none does. */
512
+ #pickerIndexFor(node) {
513
+ return this.pickerTargets.findIndex(
514
+ (picker) => node instanceof Node && (picker === node || picker.contains(node))
515
+ );
516
+ }
517
+ /** Whether a focus transition lands on another control of this same carousel. */
518
+ #focusStaysInside(event) {
519
+ const next = event.relatedTarget;
520
+ return next instanceof Node && this.element.contains(next);
521
+ }
522
+ /** Flips the rotation intent, unless there is nothing to rotate to. */
523
+ #togglePlay() {
524
+ if (!this.#canAutoplay()) return;
525
+ this.autoplayValue = !this.autoplayValue;
526
+ this.#syncTimer();
527
+ }
528
+ /** Applies the APG picker key map: arrows, Home, and End all move focus only. */
529
+ #handlePickerKeydown(event, current) {
283
530
  if (event.defaultPrevented) return;
284
531
  if (isReservedArrowChord(event)) return;
285
- const current = this.pickerTargets.indexOf(event.currentTarget);
286
- if (current === -1) return;
287
532
  const length = this.pickerTargets.length;
288
533
  switch (event.key) {
289
534
  case "ArrowRight":
@@ -295,24 +540,44 @@ var CarouselController = class extends Controller {
295
540
  this.#roving.setActive(rovingMove(current, length, step), { focus: true });
296
541
  return;
297
542
  }
543
+ // A chorded Home/End is a document-level shortcut (`Control+Home` scrolls
544
+ // the page) that belongs to the browser, exactly as a chorded arrow does.
298
545
  case "Home":
546
+ if (hasModifier(event)) return;
299
547
  event.preventDefault();
300
- this.#select(0, { focus: true });
548
+ this.#roving.setActive(0, { focus: true });
301
549
  return;
302
550
  case "End":
551
+ if (hasModifier(event)) return;
303
552
  event.preventDefault();
304
- this.#select(length - 1, { focus: true });
553
+ this.#roving.setActive(length - 1, { focus: true });
305
554
  return;
306
555
  }
307
556
  }
308
- /** Resolves the index one step away from the current one, honoring `loop`. */
557
+ /**
558
+ * Resolves the index one step away from the current one, honoring `loop`.
559
+ *
560
+ * Bounding is {@link CarouselController.#clampToSlides}'s job alone, so an
561
+ * empty set is allowed to fall out of the arithmetic here rather than being
562
+ * special-cased in two places that could disagree.
563
+ */
309
564
  #step(delta) {
310
565
  const total = this.slideTargets.length;
311
- if (total === 0) return 0;
312
566
  const next = this.#index + delta;
313
567
  if (this.loopValue) return (next + total) % total;
314
568
  return Math.min(total - 1, Math.max(0, next));
315
569
  }
570
+ /**
571
+ * Confines an index to the slide range, so no index can hide every slide.
572
+ *
573
+ * The single place an index is bounded: an empty set collapses to the first
574
+ * position, which is also where a non-numeric step from that empty set lands.
575
+ */
576
+ #clampToSlides(index) {
577
+ const last = this.slideTargets.length - 1;
578
+ if (last < 0) return 0;
579
+ return Math.min(last, Math.max(0, index));
580
+ }
316
581
  /**
317
582
  * Changes the active slide, updates state hooks, and emits `change` — but only
318
583
  * when the index actually changes, so a `next`/`prev` clamped at the end (or an
@@ -320,73 +585,159 @@ var CarouselController = class extends Controller {
320
585
  * (matching the "emit on real change" policy of flash/masonry/bulk-select).
321
586
  */
322
587
  #select(index, { focus }) {
323
- const changed = index !== this.#index;
324
- this.#index = index;
588
+ const target = this.#clampToSlides(index);
589
+ const changed = target !== this.#index;
590
+ this.#index = target;
325
591
  this.#render({ focus });
326
592
  this.#syncTimer();
327
- if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
593
+ if (changed) {
594
+ this.dispatch("change", { detail: { index: target, total: this.slideTargets.length } });
595
+ }
328
596
  }
329
597
  /**
330
- * Reflects `this.#index` onto slides and pickers (state hooks + roving).
598
+ * Reflects `this.#index` onto the slides, the pickers, and the step controls.
599
+ *
600
+ * Every observed attribute is written only when its value actually changes: the
601
+ * same writes are watched by {@link CarouselController.#onAttributeMutations},
602
+ * and an unconditional rewrite would feed the controller its own output.
331
603
  *
332
604
  * @stimeoRenderRoot
333
605
  */
334
606
  #render({ focus }) {
607
+ this.#activeSlide = this.slideTargets[this.#index] ?? null;
335
608
  this.slideTargets.forEach((slide, i) => {
336
609
  const active = i === this.#index;
337
- slide.setAttribute("data-state", active ? "active" : "inactive");
338
- slide.hidden = !active;
610
+ setAttributeIfChanged(slide, "data-state", active ? "active" : "inactive");
611
+ if (slide.hidden !== !active) slide.hidden = !active;
612
+ slide.toggleAttribute("inert", !active);
339
613
  });
340
614
  const pickerIndex = Math.min(this.#index, this.pickerTargets.length - 1);
341
615
  this.pickerTargets.forEach((picker, i) => {
342
- picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
616
+ setAttributeIfChanged(picker, "aria-selected", i === pickerIndex ? "true" : "false");
343
617
  });
344
618
  this.#roving.setActive(pickerIndex, { focus });
619
+ this.#syncStepControls();
620
+ }
621
+ /** Marks the step control a non-looping carousel has no slide left to reach. */
622
+ #syncStepControls() {
623
+ const last = this.slideTargets.length - 1;
624
+ const atStart = !this.loopValue && this.#index <= 0;
625
+ const atEnd = !this.loopValue && this.#index >= last;
626
+ for (const button of this.prevTargets) {
627
+ this.#ariaDisabled.write(button, atStart ? "true" : null);
628
+ }
629
+ for (const button of this.nextTargets) {
630
+ this.#ariaDisabled.write(button, atEnd ? "true" : null);
631
+ }
345
632
  }
346
- /** Keeps the live active slide when possible and otherwise selects the nearest survivor. */
347
- #reconcileTargetSet() {
348
- const activeSlide = this.slideTargets.findIndex(
349
- (slide) => slide.getAttribute("data-state") === "active"
350
- );
351
- const selectedPicker = this.pickerTargets.findIndex(
633
+ /**
634
+ * Resolves which slide is current from the strongest evidence available.
635
+ *
636
+ * A single `data-state="active"` is unambiguous and wins, which is what restores
637
+ * the visible slide after a Turbo cache restore even with no pickers present.
638
+ * Competing claims — a slide inserted already marked active — are settled by the
639
+ * element the last render actually showed, so a newcomer never displaces what the
640
+ * reader is looking at. With no claim at all the authored picker selection
641
+ * decides, first in DOM order; failing that the previous position is kept.
642
+ */
643
+ #resolveIndex() {
644
+ const slides = this.slideTargets;
645
+ const claims = [];
646
+ slides.forEach((slide, index) => {
647
+ if (slide.getAttribute("data-state") === "active") claims.push(index);
648
+ });
649
+ if (claims.length > 1 && this.#activeSlide !== null) {
650
+ const live = slides.indexOf(this.#activeSlide);
651
+ if (claims.includes(live)) return live;
652
+ }
653
+ const claimed = claims[0];
654
+ if (claimed !== void 0) return claimed;
655
+ const selected = this.pickerTargets.findIndex(
352
656
  (picker) => picker.getAttribute("aria-selected") === "true"
353
657
  );
354
- const lastSlide = this.slideTargets.length - 1;
355
- const candidate = activeSlide !== -1 ? activeSlide : selectedPicker !== -1 ? selectedPicker : this.#index;
356
- const previous = this.#index;
357
- this.#index = lastSlide < 0 ? 0 : Math.min(lastSlide, Math.max(0, candidate));
658
+ return this.#clampToSlides(selected === -1 ? this.#index : selected);
659
+ }
660
+ /** Re-resolves the active slide after the target set changed and reports the move. */
661
+ #reconcileTargetSet() {
662
+ const previousIndex = this.#index;
663
+ const previousTotal = this.#total;
664
+ this.#index = this.#resolveIndex();
358
665
  this.#render({ focus: false });
666
+ this.#total = this.slideTargets.length;
359
667
  this.#syncTimer();
360
- if (this.#index !== previous) {
361
- this.dispatch("reconcile", {
362
- detail: { index: this.#index, total: this.slideTargets.length }
363
- });
668
+ if (this.#index !== previousIndex || this.#total !== previousTotal) {
669
+ this.dispatch("reconcile", { detail: { index: this.#index, total: this.#total } });
364
670
  }
365
671
  }
672
+ /** Whether autoplay has anywhere left to advance to. */
673
+ #canAutoplay() {
674
+ const total = this.slideTargets.length;
675
+ if (total <= 1) return false;
676
+ return this.loopValue || this.#index < total - 1;
677
+ }
678
+ /** The advance delay; a non-finite or non-positive declaration falls back. */
679
+ get #interval() {
680
+ const declared = this.intervalValue;
681
+ return Number.isFinite(declared) && declared > 0 ? declared : DEFAULT_INTERVAL;
682
+ }
366
683
  /**
367
- * Drives the autoplay interval toward the desired state. Autoplay should run
368
- * only when the user wants it (`playing`), the pointer is not hovering, and more
369
- * than one slide exists. Transitions emit `play`/`pause` and keep the toggle's
370
- * `aria-pressed` in sync.
684
+ * Drives the autoplay interval toward the desired state and publishes it.
685
+ *
686
+ * Rotation runs when the intent is on, nothing suspends it, and a slide is left
687
+ * to advance to. A carousel that has run out normalizes the intent to `false`, so
688
+ * `aria-pressed` never claims a rotation that cannot happen. Crossing the
689
+ * run/stop boundary emits `play`/`pause`; re-arming at a new `interval` is the
690
+ * same state and stays silent.
371
691
  */
372
692
  #syncTimer() {
373
- if (!this.loopValue && this.#index >= this.slideTargets.length - 1) {
374
- this.#playing = false;
375
- }
376
- const shouldRun = this.#playing && !this.#pointerPaused && this.slideTargets.length > 1;
377
- if (shouldRun && this.#timerId === null) {
378
- this.#timerId = this.#intervals.set(() => this.next(), this.intervalValue);
379
- this.dispatch("play");
380
- } else if (!shouldRun && this.#timerId !== null) {
693
+ const canAutoplay = this.#canAutoplay();
694
+ if (this.autoplayValue && !canAutoplay) this.autoplayValue = false;
695
+ const shouldRun = this.autoplayValue && canAutoplay && !this.#pointerPaused && !this.#focusPaused && !this.#hiddenPaused;
696
+ const wasRunning = this.#timerId !== null;
697
+ const interval = this.#interval;
698
+ if (this.#timerId !== null && (!shouldRun || interval !== this.#timerInterval)) {
381
699
  this.#intervals.clear(this.#timerId);
382
700
  this.#timerId = null;
383
- this.dispatch("pause");
384
701
  }
385
- if (this.hasPlayToggleTarget) {
386
- this.playToggleTarget.setAttribute("aria-pressed", this.#playing ? "true" : "false");
702
+ if (shouldRun && this.#timerId === null) {
703
+ this.#timerInterval = interval;
704
+ this.#timerId = this.#intervals.set(() => this.next(), interval);
705
+ }
706
+ setAttributeIfChanged(this.element, "data-state", shouldRun ? "playing" : "paused");
707
+ if (shouldRun !== wasRunning) {
708
+ if (shouldRun) this.dispatch("play");
709
+ else this.dispatch("pause");
710
+ }
711
+ for (const toggle of this.playToggleTargets) {
712
+ toggle.setAttribute("aria-pressed", this.autoplayValue ? "true" : "false");
713
+ this.#ariaDisabled.write(toggle, canAutoplay ? null : "true");
387
714
  }
715
+ if (this.hasViewportTarget) {
716
+ this.#ariaLive.write(this.viewportTarget, shouldRun ? "off" : "polite");
717
+ this.#ariaAtomic.write(this.viewportTarget, "false");
718
+ }
719
+ }
720
+ /** Hands every leased attribute back to the value the consumer authored. */
721
+ #returnLeases() {
722
+ this.#ariaDisabled.returnAll();
723
+ this.#ariaLive.returnAll();
724
+ this.#ariaAtomic.returnAll();
388
725
  }
389
726
  };
727
+ function isFocusEvent(event) {
728
+ return event?.type.startsWith("focus") === true;
729
+ }
730
+ function hasModifier(event) {
731
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
732
+ }
733
+ function hits(elements, node) {
734
+ return elements.some(
735
+ (element) => node instanceof Node && (element === node || element.contains(node))
736
+ );
737
+ }
738
+ function setAttributeIfChanged(element, name, value) {
739
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value);
740
+ }
390
741
 
391
742
  export { CarouselController };
392
743
  //# sourceMappingURL=carousel_controller.js.map