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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/data_grid_controller.js +150 -24
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +485 -114
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +2764 -923
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -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:
|
|
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
|
|
164
|
-
*
|
|
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
|
-
/**
|
|
171
|
-
#
|
|
172
|
-
/**
|
|
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
|
|
283
|
+
* Renders the initial slide, wires the delegated listeners, and starts autoplay
|
|
284
|
+
* when requested.
|
|
178
285
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this
|
|
187
|
-
this.#
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
/**
|
|
233
|
-
|
|
234
|
-
this.#connected
|
|
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
|
-
/**
|
|
240
|
-
|
|
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.
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
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.#
|
|
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.#
|
|
553
|
+
this.#roving.setActive(length - 1, { focus: true });
|
|
305
554
|
return;
|
|
306
555
|
}
|
|
307
556
|
}
|
|
308
|
-
/**
|
|
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
|
|
324
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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 !==
|
|
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
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
386
|
-
this
|
|
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
|