@mnstry/atelier 0.2.0-alpha.16 → 0.2.0-alpha.18

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,627 @@
1
+ /**
2
+ * Carousel enhancer (browser). Progressive enhancement over plain HTML: without
3
+ * script every slide is a native horizontal scroller (track) or a stacked list
4
+ * (fade). See docs/reference/ui-primitives.md for the markup contract.
5
+ *
6
+ * Track carousels scroll natively (CSS scroll snap), so touch, trackpad, wheel
7
+ * and keyboard keep the platform's physics. Script adds seamless loops over
8
+ * inert clones, mouse drag (with momentum for multi-card strips), a draggable
9
+ * scroll bar, arrows, pages or thumbnails, a live status, height that follows
10
+ * the current slide, receding neighbours, a still row when every card fits,
11
+ * and discrete or smooth autoplay with a pause control.
12
+ */
13
+ import {
14
+ arrowStep, autoplayConfig, dragIntent, driftStep, glideStep, homeIndex, loopSets, loopThumbGeometry, nearestCopy, rampFactor, releaseVelocity, resumeDelay,
15
+ pageSize, recede, rowFit, scrubPosition, slideWindow, swipeStep, thumbGeometry, wrap,
16
+ } from './state.mjs'
17
+ import { animationAllowed, onMotionChange } from './motion.mjs'
18
+
19
+ const q = (root, selectors) => root.querySelector(selectors)
20
+ const qa = (root, selectors) => [...root.querySelectorAll(selectors)]
21
+
22
+ // Move keyboard focus before its slide becomes inert or hidden.
23
+ function focusAvailable(candidates, reveal = false) {
24
+ const target = candidates.find(element => element && !element.matches(':disabled') && !element.closest('[inert], [hidden]') && element.getClientRects().length && getComputedStyle(element).visibility !== 'hidden')
25
+ target?.focus({ preventScroll: !reveal })
26
+ return !!target && document.activeElement === target
27
+ }
28
+ function revealControlFocus(controls) {
29
+ const focused = document.activeElement
30
+ if (focused instanceof HTMLElement && controls?.contains(focused) && focused.getClientRects().length) focused.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' })
31
+ }
32
+ function preserveSlideFocus(slides, shown, control) {
33
+ if (!slides.some(slide => slide !== shown && slide.contains(document.activeElement))) return
34
+ const controls = control?.closest('[data-controls]')
35
+ focusAvailable([control, ...(controls ? controls.querySelectorAll('button') : [])])
36
+ }
37
+
38
+ /** Enhance every `[data-carousel]` under `root`. Safe to call more than once. */
39
+ export function enhanceCarousels(root = document) {
40
+ return qa(root, '[data-carousel]').map(enhanceCarousel).filter(Boolean)
41
+ }
42
+
43
+ export function enhanceCarousel(root) {
44
+ if (root.dataset.ready !== undefined) return null
45
+ const track = q(root, '[data-carousel-track], .carousel__track')
46
+ if (!track) return null
47
+ // Slides are the track's children; `data-slide` narrows that when a track holds other elements.
48
+ const children = [...track.children].filter(element => !element.hasAttribute('data-clone'))
49
+ const slides = children.some(element => element.hasAttribute('data-slide')) ? children.filter(element => element.hasAttribute('data-slide')) : children
50
+ const parts = {
51
+ root, track, slides, n: slides.length,
52
+ label: root.getAttribute('aria-label') ?? 'Carousel',
53
+ viewport: q(root, '[data-carousel-viewport], .carousel__viewport') || track,
54
+ controls: q(root, '[data-controls]'),
55
+ back: q(root, '[data-prev]'),
56
+ next: q(root, '[data-next]'),
57
+ pages: qa(root, '[data-page]'),
58
+ status: q(root, '[data-status]'),
59
+ count: q(root, '[data-count]'),
60
+ scrollbar: q(root, '[data-scrollbar]'),
61
+ toggle: q(root, '[data-autoplay-toggle]'),
62
+ autoplay: autoplayConfig({ mode: root.dataset.autoplay, interval: root.dataset.interval, speed: root.dataset.speed }),
63
+ }
64
+ if (parts.n < 2) { if (parts.controls) parts.controls.hidden = true; root.dataset.ready = ''; return null }
65
+ slides.forEach((slide, i) => {
66
+ slide.setAttribute('role', 'group')
67
+ slide.setAttribute('aria-roledescription', 'slide')
68
+ if (!slide.hasAttribute('aria-label')) slide.setAttribute('aria-label', `${i + 1} of ${parts.n}`)
69
+ })
70
+ root.querySelectorAll('img, a').forEach(element => element.setAttribute('draggable', 'false'))
71
+ // Thumbnail pages without their own image show the slide's image.
72
+ parts.pages.forEach((page, i) => {
73
+ if (!page.hasAttribute('data-thumb') || page.querySelector('img')) return
74
+ const img = slides[i]?.querySelector('img')
75
+ const set = () => img?.currentSrc && page.style.setProperty('--thumb', `url("${img.currentSrc}")`)
76
+ img?.complete ? set() : img?.addEventListener('load', set, { once: true })
77
+ })
78
+ return root.dataset.carousel === 'fade' ? initFade(parts) : initTrack(parts)
79
+ }
80
+
81
+ /* ------------------------------------------------------------------ autoplay */
82
+
83
+ function createAutoplay({ root, toggle, autoplay }, { advance, startDrift, stopDrift, canRun }) {
84
+ const reasons = new Set()
85
+ const pause = resumeDelay(root.dataset.resume)
86
+ const timers = new Map()
87
+ let userPaused = root.dataset.autoplayPaused !== undefined
88
+ let onScreen = true
89
+ let timer = 0
90
+ const enabled = () => autoplay.mode !== 'off' && animationAllowed() && canRun()
91
+ const running = () => enabled() && !userPaused && !reasons.size && onScreen
92
+ const stop = () => { clearTimeout(timer); timer = 0; stopDrift() }
93
+ const start = () => {
94
+ stop()
95
+ if (!running()) return
96
+ if (autoplay.mode === 'discrete') timer = setTimeout(() => { advance(); start() }, autoplay.interval)
97
+ else startDrift()
98
+ }
99
+ const sync = () => {
100
+ root.dataset.autoplayState = enabled() ? (userPaused ? 'paused' : reasons.size ? 'held' : 'playing') : 'off'
101
+ if (!toggle) return
102
+ toggle.hidden = !enabled()
103
+ toggle.setAttribute('aria-label', userPaused ? 'Play automatic scrolling' : 'Pause automatic scrolling')
104
+ toggle.dataset.state = userPaused ? 'paused' : 'playing'
105
+ }
106
+ // A later hold for the same reason cancels a pending release.
107
+ const hold = reason => { clearTimeout(timers.get(reason)); timers.delete(reason); reasons.add(reason); stop(); sync() }
108
+ const release = (reason, delay = 0) => {
109
+ clearTimeout(timers.get(reason))
110
+ const go = () => { timers.delete(reason); reasons.delete(reason); start(); sync() }
111
+ if (delay) timers.set(reason, setTimeout(go, delay)); else go()
112
+ }
113
+ if (autoplay.mode !== 'off') {
114
+ // Autoplay keeps running under the pointer. It holds for a press (any click,
115
+ // tap or drag), keyboard focus, a wheel, an expanded slide, a hidden tab or
116
+ // off screen, and resumes gently `data-resume` ms (default 2000) after the
117
+ // reader lets go.
118
+ toggle?.addEventListener('click', () => { userPaused = !userPaused; start(); sync() })
119
+ root.addEventListener('pointerdown', event => { if (!toggle?.contains(event.target)) hold('press') })
120
+ const lift = () => release('press', pause)
121
+ root.addEventListener('pointerup', lift)
122
+ root.addEventListener('pointercancel', lift)
123
+ root.addEventListener('focusin', event => { if (!toggle?.contains(event.target) && event.target.matches?.(':focus-visible')) hold('focus') })
124
+ root.addEventListener('focusout', event => { if (!root.contains(event.relatedTarget)) release('focus', pause) })
125
+ let wheelTimer = 0
126
+ root.addEventListener('wheel', () => { hold('wheel'); clearTimeout(wheelTimer); wheelTimer = setTimeout(() => release('wheel'), pause) }, { passive: true })
127
+ onMotionChange(() => { start(); sync() })
128
+ if ('IntersectionObserver' in window) new IntersectionObserver(entries => { onScreen = entries.some(entry => entry.isIntersecting); start(); sync() }, { threshold: 0.25 }).observe(root)
129
+ }
130
+ return { start, stop, hold, release, sync, pause, restart: () => { if (autoplay.mode === 'discrete') start() }, smooth: autoplay.mode === 'smooth', active: running }
131
+ }
132
+
133
+ /* ------------------------------------------------------------- scroll bar */
134
+
135
+ function bindScrollbar({ root, scrollbar }, { geometry, scrubTo, begin, end }) {
136
+ if (!scrollbar) return () => {}
137
+ // Two thumb segments: in a loop, the part of the window past the end is drawn
138
+ // again from the start, so the thumb leaves on the right and re-enters on the left.
139
+ const thumbs = [...scrollbar.querySelectorAll(':scope > span, [data-scrollbar-thumb]')]
140
+ if (!thumbs.length) return () => {}
141
+ while (thumbs.length < 2) { const extra = document.createElement('span'); scrollbar.append(extra); thumbs.push(extra) }
142
+ scrollbar.addEventListener('pointerdown', event => {
143
+ if (event.button > 0) return
144
+ event.preventDefault()
145
+ const rail = scrollbar.getBoundingClientRect()
146
+ const box = thumbs[0].getBoundingClientRect()
147
+ const width = Math.min(box.width, rail.width)
148
+ const onThumb = event.clientX >= box.left && event.clientX <= Math.min(box.right, rail.right)
149
+ const grab = onThumb ? event.clientX - box.left : width / 2
150
+ scrollbar.setPointerCapture(event.pointerId)
151
+ root.dataset.scrubbing = ''
152
+ begin()
153
+ const move = x => scrubTo(scrubPosition(x, rail.left, rail.width, width, grab, geometry().range))
154
+ move(event.clientX)
155
+ const onMove = moveEvent => move(moveEvent.clientX)
156
+ const onUp = () => { scrollbar.removeEventListener('pointermove', onMove); delete root.dataset.scrubbing; end() }
157
+ scrollbar.addEventListener('pointermove', onMove)
158
+ scrollbar.addEventListener('pointerup', onUp, { once: true })
159
+ scrollbar.addEventListener('pointercancel', onUp, { once: true })
160
+ })
161
+ return () => {
162
+ const { position, range, viewport, period } = geometry()
163
+ // In a loop the thumb spans the part of the period not covered by the scroll range, so it
164
+ // reaches the right end exactly when the last slide does, and wraps only beyond that.
165
+ const { width, left } = period ? loopThumbGeometry(position, period, period - range) : thumbGeometry(position, range, viewport)
166
+ thumbs[0].style.width = thumbs[1].style.width = `${width * 100}%`
167
+ thumbs[0].style.left = `${left * 100}%`
168
+ thumbs[1].style.left = `${(left - 1) * 100}%`
169
+ thumbs[1].hidden = !period
170
+ scrollbar.hidden = range <= 1
171
+ }
172
+ }
173
+
174
+ /* ------------------------------------------------------------------ track */
175
+
176
+ function initTrack(parts) {
177
+ const { root, viewport, track, slides, n, label, controls, back, next, pages, status, count } = parts
178
+ const loop = root.hasAttribute('data-loop')
179
+ const peek = root.hasAttribute('data-peek') || root.classList.contains('carousel--peek')
180
+ // select="current": one slide is current and the rest are inert (slideshows).
181
+ // select="visible": every card on screen stays interactive (product strips).
182
+ const visibleMode = root.dataset.select === 'visible'
183
+ const pageStep = (root.dataset.step || (visibleMode ? 'page' : 'item')) === 'page'
184
+ // fit="current": the strip is as tall as the slide in view, blending between heights as they pass.
185
+ const fitCurrent = root.dataset.fit === 'current'
186
+ const rowMin = Number(root.dataset.rowMin) || 400
187
+ let sets = 0
188
+ let items = slides
189
+ let step = 0, current = -1, target = null, announce = false, row = false, timer = 0, realigned = false
190
+ let home = 0, intent = false, drifting = false, gliding = 0, scrubbing = false
191
+ // free: the reader left the strip between cards (a scrub, a throw, a centred slide); nothing pulls it back.
192
+ let free = false
193
+ let heights = [], padY = 0, padTop = 0
194
+ let drag = null, suppressClick = false
195
+
196
+ const buildClones = perSide => {
197
+ track.querySelectorAll('[data-clone]').forEach(clone => clone.remove())
198
+ sets = loop ? loopSets(n, perSide) : 0
199
+ if (sets) {
200
+ const clone = slide => {
201
+ const copy = slide.cloneNode(true)
202
+ copy.removeAttribute('id'); copy.querySelectorAll('[id]').forEach(element => element.removeAttribute('id'))
203
+ copy.dataset.clone = ''; copy.inert = true; copy.setAttribute('aria-hidden', 'true')
204
+ return copy
205
+ }
206
+ const copies = () => Array.from({ length: sets }, () => slides.map(clone)).flat()
207
+ track.prepend(...copies()); track.append(...copies())
208
+ }
209
+ items = [...track.children]
210
+ }
211
+ const first = () => sets * n
212
+ const position = () => (step ? viewport.scrollLeft / step : 0)
213
+ const scrollToIndex = (index, smooth) => viewport.scrollTo({ left: index * step, behavior: smooth && animationAllowed() ? 'smooth' : 'instant' })
214
+ const perPage = () => (pageStep ? arrowStep(pageSize(viewport.clientWidth, step), n, loop) : 1)
215
+ const lastIndex = () => (step ? Math.max(0, Math.round((viewport.scrollWidth - viewport.clientWidth) / step)) : 0)
216
+
217
+ const fitHeight = pos => {
218
+ if (!fitCurrent || row || !heights.length) return
219
+ const a = Math.floor(pos), h = i => heights[((i % n) + n) % n]
220
+ viewport.style.height = `${h(a) + (h(a + 1) - h(a)) * (pos - a) + padY}px`
221
+ }
222
+ const placeArrows = () => {
223
+ const middle = fitCurrent && heights.length ? padTop + Math.min(...heights) / 2 : viewport.offsetHeight / 2
224
+ root.style.setProperty('--mid', `${viewport.offsetTop + middle}px`)
225
+ }
226
+ const measureHeights = () => {
227
+ if (!fitCurrent) return
228
+ const style = getComputedStyle(viewport)
229
+ padTop = parseFloat(style.paddingTop); padY = padTop + parseFloat(style.paddingBottom)
230
+ heights = slides.map(slide => slide.offsetHeight)
231
+ }
232
+
233
+ // The scroll position at which the last real slide sits at the viewport's inner end edge
234
+ // (measured, so side insets and gaps are respected), relative to the start of the loop.
235
+ const loopEnd = () => {
236
+ const last = slides.at(-1)
237
+ const box = viewport.getBoundingClientRect(), edge = parseFloat(getComputedStyle(viewport).scrollPaddingRight) || 0
238
+ const end = viewport.scrollLeft + last.getBoundingClientRect().right - (box.right - edge)
239
+ return Math.max(0, wrap(end - first() * step, n * step) || 0)
240
+ }
241
+ const geometry = () => {
242
+ if (sets) {
243
+ const period = n * step
244
+ return { position: wrap(viewport.scrollLeft - first() * step, period), range: loopEnd(), viewport: viewport.clientWidth, period }
245
+ }
246
+ return { position: viewport.scrollLeft, range: Math.max(0, viewport.scrollWidth - viewport.clientWidth), viewport: viewport.clientWidth }
247
+ }
248
+ const paintScrollbar = bindScrollbar(parts, {
249
+ geometry,
250
+ scrubTo: offset => { viewport.scrollLeft = (sets ? first() * step : 0) + offset },
251
+ begin: () => { stopGlide(); scrubbing = true; intent = true; viewport.style.scrollSnapType = 'none'; player.hold('scrub') },
252
+ end: () => { scrubbing = false; free = true; settleSoon(); player.release('scrub', player.pause) },
253
+ })
254
+
255
+ const paint = () => {
256
+ if (row || !step) return
257
+ const pos = position(), c = Math.max(0, Math.min(items.length - 1, Math.round(pos)))
258
+ if (peek) items.forEach((item, i) => item.style.setProperty('--d', recede(pos, i).toFixed(3)))
259
+ fitHeight(pos)
260
+ paintScrollbar()
261
+ if (c === current) return
262
+ current = c
263
+ if (!loop && back && next) { back.disabled = c <= 0; next.disabled = c >= lastIndex() }
264
+ if (!visibleMode) preserveSlideFocus(items, items[c], pages[c % n])
265
+ items.forEach((item, i) => {
266
+ const shown = i === c
267
+ item.classList.toggle('is-current', shown)
268
+ if (!visibleMode && !item.hasAttribute('data-clone')) { item.inert = !shown; item.toggleAttribute('aria-hidden', !shown) }
269
+ })
270
+ pages.forEach((page, i) => page.setAttribute('aria-current', String(i === c % n)))
271
+ if (count) count.textContent = `${(c % n) + 1} / ${n}`
272
+ }
273
+
274
+ // Keep a looping strip inside the real slides by moving whole periods over identical clones (invisible).
275
+ const loopBase = () => first() * step
276
+ const normalizeTo = position => (sets && step ? loopBase() + wrap(position - loopBase(), n * step) : position)
277
+ const normalize = () => {
278
+ const position = viewport.scrollLeft, wrapped = normalizeTo(position)
279
+ if (Math.abs(wrapped - position) > 0.5) viewport.scrollTo({ left: wrapped, behavior: 'instant' })
280
+ }
281
+
282
+ // Once still: step back onto the real slide showing the same content (identical, so invisible).
283
+ const settle = () => {
284
+ if (row || !step || drag || gliding || scrubbing) return
285
+ if (drifting) return
286
+ // Drifting strips and free positions stay exactly where the reader left them.
287
+ if (player.smooth || free) {
288
+ normalize()
289
+ home = sets ? wrap(Math.round(position()) - first(), n) : Math.round(position())
290
+ intent = false; target = null; realigned = false
291
+ paint()
292
+ return
293
+ }
294
+ if (!intent) {
295
+ if (!player.smooth) viewport.style.removeProperty('scroll-snap-type')
296
+ const goal = (first() + home) * step
297
+ if (Math.abs(viewport.scrollLeft - Math.min(goal, viewport.scrollWidth - viewport.clientWidth)) > 1) scrollToIndex(first() + home, false)
298
+ paint(); return
299
+ }
300
+ const c = Math.round(position())
301
+ if (!player.smooth && !realigned && Math.abs(viewport.scrollLeft - c * step) > 2 && c < lastIndex()) {
302
+ realigned = true; viewport.style.removeProperty('scroll-snap-type'); scrollToIndex(c, true)
303
+ if (!animationAllowed()) settle()
304
+ return
305
+ }
306
+ realigned = false
307
+ if (sets && (c < first() || c >= first() + n)) viewport.scrollTo({ left: viewport.scrollLeft + (homeIndex(c, n, sets) - c) * step, behavior: 'instant' })
308
+ home = ((Math.round(position()) % n) + n) % n
309
+ if (!sets) home = Math.round(position())
310
+ intent = false; target = null
311
+ if (!player.smooth) viewport.style.removeProperty('scroll-snap-type')
312
+ paint()
313
+ if (announce && status) { status.textContent = `${label}: ${(current % n) + 1} of ${n}`; announce = false }
314
+ }
315
+ const settleSoon = () => { if (!('onscrollend' in window)) { clearTimeout(timer); timer = setTimeout(settle, 140) } else requestAnimationFrame(() => settle()) }
316
+
317
+ const go = (delta, { user = true } = {}) => {
318
+ if (row || !step) return
319
+ stopGlide()
320
+ intent = true
321
+ free = false
322
+ let base = target ?? Math.round(position())
323
+ if (sets) {
324
+ const equivalent = homeIndex(base, n, sets)
325
+ if (equivalent !== base) { viewport.scrollTo({ left: viewport.scrollLeft + (equivalent - base) * step, behavior: 'instant' }); base = equivalent }
326
+ } else if (loop && delta > 0 && base >= lastIndex()) {
327
+ delta = -base // a loop too short for clones still wraps
328
+ } else if (loop && delta < 0 && base <= 0) {
329
+ delta = lastIndex()
330
+ }
331
+ const to = sets ? base + delta : Math.max(0, Math.min(lastIndex(), base + delta))
332
+ target = to; announce = user
333
+ scrollToIndex(to, true)
334
+ }
335
+ const goTo = index => go(sets ? nearestCopy(target ?? Math.round(position()), index, n) : index - (target ?? Math.round(position())))
336
+
337
+ const measure = () => {
338
+ const focusedSlide = slides.findIndex(slide => slide.contains(document.activeElement))
339
+ const keep = row && focusedSlide >= 0 ? focusedSlide : home
340
+ home = keep
341
+ const controlFocused = controls?.contains(document.activeElement)
342
+ const focusedPage = pages.findIndex(page => page.contains(document.activeElement))
343
+ root.style.removeProperty('--slide-fit'); delete root.dataset.row
344
+ const gap = parseFloat(getComputedStyle(track).columnGap) || 0
345
+ const fit = peek ? rowFit(n, slides[0].offsetWidth, gap, root.clientWidth, rowMin) : 0
346
+ row = fit > 0
347
+ viewport.style.removeProperty('height')
348
+ if (row) {
349
+ root.dataset.row = ''; root.style.setProperty('--slide-fit', `${fit}px`)
350
+ slides.forEach(slide => { slide.inert = false; slide.removeAttribute('aria-hidden'); slide.classList.remove('is-current'); slide.style.removeProperty('--d') })
351
+ if (controlFocused) {
352
+ const shown = slides[focusedPage >= 0 ? focusedPage : keep]
353
+ if (!focusAvailable([...shown.querySelectorAll('a[href], button, [tabindex]:not([tabindex="-1"])')], true)) { root.tabIndex = -1; root.focus() }
354
+ }
355
+ if (controls) controls.hidden = true
356
+ current = -1
357
+ return
358
+ }
359
+ // Fractional widths matter: offsetLeft rounds, and the error grows per card.
360
+ const width = parseFloat(getComputedStyle(slides[0]).width)
361
+ step = width + gap
362
+ root.style.setProperty('--slide-px', `${width}px`)
363
+ const fitsAll = !sets && viewport.scrollWidth - viewport.clientWidth <= 1
364
+ if (loop) {
365
+ const needed = pageSize(viewport.clientWidth, step) + (peek ? 4 : 1)
366
+ if (!sets || loopSets(n, needed) !== sets) buildClones(needed)
367
+ // A loop that cannot fill the screen with one set falls back to plain wrapping arrows.
368
+ if (n * step < viewport.clientWidth) { track.querySelectorAll('[data-clone]').forEach(clone => clone.remove()); sets = 0; items = slides }
369
+ }
370
+ root.dataset.overflow = String(!(fitsAll || (!sets && viewport.scrollWidth - viewport.clientWidth <= 1)))
371
+ if (controls) controls.hidden = root.dataset.overflow === 'false'
372
+ measureHeights()
373
+ current = -1; target = null
374
+ scrollToIndex(first() + keep, false)
375
+ paint()
376
+ placeArrows()
377
+ revealControlFocus(controls)
378
+ player.sync()
379
+ }
380
+
381
+ // Momentum for multi-card strips; slideshows step one slide per swipe instead.
382
+ // A drifting strip coasts longer and stops where it stops; others settle on a card.
383
+ function stopGlide() { if (gliding) { cancelAnimationFrame(gliding); gliding = 0; player.release('glide', player.pause) } }
384
+ const glide = velocity => {
385
+ const coast = player.smooth
386
+ if (!animationAllowed() || Math.abs(velocity) < 0.05) { if (coast) free = true; settle(); return }
387
+ player.hold('glide')
388
+ let last = performance.now(), pos = viewport.scrollLeft
389
+ const frame = now => {
390
+ const next = glideStep(pos, velocity, now - last, coast ? 0.975 : 0.94)
391
+ last = now; velocity = next.velocity
392
+ pos = normalizeTo(next.position)
393
+ viewport.scrollLeft = pos
394
+ const atEdge = !sets && (pos <= 0 || pos >= viewport.scrollWidth - viewport.clientWidth)
395
+ if (Math.abs(velocity) > (coast ? 0.01 : 0.03) && !atEdge) { gliding = requestAnimationFrame(frame); return }
396
+ gliding = 0
397
+ if (coast) free = true
398
+ settle()
399
+ player.release('glide', player.pause)
400
+ }
401
+ gliding = requestAnimationFrame(frame)
402
+ }
403
+
404
+ // Smooth autoplay keeps its own sub-pixel position so slow speeds never stall on rounding.
405
+ let driftFrame = 0
406
+ const player = createAutoplay(parts, {
407
+ advance: () => go(perPage(), { user: false }),
408
+ canRun: () => !row && root.dataset.overflow !== 'false',
409
+ startDrift: () => {
410
+ drifting = true
411
+ viewport.style.scrollSnapType = 'none'
412
+ let pos = viewport.scrollLeft, last = 0, began = 0
413
+ const frame = now => {
414
+ if (!began) began = now
415
+ if (last) {
416
+ // Ease back in after a pause instead of jumping to full speed.
417
+ const speed = parts.autoplay.speed * rampFactor(now - began, 1200)
418
+ if (sets) pos = first() * step + driftStep(pos - first() * step, speed, now - last, n * step)
419
+ else pos = driftStep(pos, speed, now - last, 0, viewport.scrollWidth - viewport.clientWidth)
420
+ viewport.scrollLeft = pos
421
+ }
422
+ last = now
423
+ driftFrame = requestAnimationFrame(frame)
424
+ }
425
+ driftFrame = requestAnimationFrame(frame)
426
+ },
427
+ stopDrift: () => {
428
+ if (!driftFrame) return
429
+ cancelAnimationFrame(driftFrame); driftFrame = 0; drifting = false
430
+ free = true
431
+ home = ((Math.round(position()) - first()) % n + n) % n
432
+ },
433
+ })
434
+ if (player.smooth) root.dataset.drift = ''
435
+
436
+ // Something expanded inside a slide (a quick view, a details panel): centre that slide and hold
437
+ // autoplay until everything inside the strip is closed again.
438
+ const expanded = () => slides.some(slide => slide.querySelector('details[open]'))
439
+ root.addEventListener('toggle', event => {
440
+ const details = event.target
441
+ if (!(details instanceof HTMLDetailsElement) || !track.contains(details) || details.closest('[data-clone]')) return
442
+ if (details.open) {
443
+ player.hold('expanded')
444
+ stopGlide()
445
+ const slide = slides.find(item => item.contains(details))
446
+ if (!slide || row || !step) return
447
+ free = true
448
+ viewport.style.scrollSnapType = 'none'
449
+ const box = viewport.getBoundingClientRect(), card = slide.getBoundingClientRect()
450
+ viewport.scrollBy({ left: card.left + card.width / 2 - (box.left + box.width / 2), behavior: animationAllowed() ? 'smooth' : 'instant' })
451
+ } else if (!expanded()) {
452
+ player.release('expanded', player.pause)
453
+ }
454
+ }, true)
455
+
456
+ viewport.addEventListener('scroll', () => {
457
+ requestAnimationFrame(paint)
458
+ if (!('onscrollend' in window)) { clearTimeout(timer); timer = setTimeout(settle, 140) }
459
+ }, { passive: true })
460
+ viewport.addEventListener('scrollend', settle)
461
+ const user = () => { intent = true; stopGlide() }
462
+ for (const type of ['pointerdown', 'touchstart', 'wheel', 'keydown']) viewport.addEventListener(type, user, { passive: true })
463
+
464
+ back?.addEventListener('click', () => { go(-perPage()); player.restart() })
465
+ next?.addEventListener('click', () => { go(perPage()); player.restart() })
466
+ pages.forEach((page, i) => page.addEventListener('click', () => { goTo(i); player.restart() }))
467
+ root.addEventListener('keydown', event => {
468
+ if (row || event.altKey || event.ctrlKey || event.metaKey) return
469
+ if (event.target.closest?.('input, select, textarea, [contenteditable]')) return
470
+ const keys = { ArrowLeft: () => go(-1), ArrowRight: () => go(1), Home: () => goTo(0), End: () => goTo(n - 1) }
471
+ if (!keys[event.key]) return
472
+ event.preventDefault(); keys[event.key]()
473
+ })
474
+
475
+ // Mouse drag (touch and trackpads already scroll natively): follow the pointer, then settle.
476
+ root.addEventListener('pointerdown', event => {
477
+ if (row || event.pointerType !== 'mouse' || event.button !== 0 || event.target.closest('button, [data-scrollbar], input, select, textarea')) return
478
+ if (!viewport.contains(event.target)) return
479
+ stopGlide()
480
+ drag = { id: event.pointerId, x: event.clientX, y: event.clientY, t: event.timeStamp, left: viewport.scrollLeft, state: 'pending', samples: [{ x: event.clientX, t: event.timeStamp }] }
481
+ suppressClick = false
482
+ })
483
+ root.addEventListener('pointermove', event => {
484
+ if (!drag || event.pointerId !== drag.id) return
485
+ const dx = event.clientX - drag.x, dy = event.clientY - drag.y
486
+ if (drag.state === 'pending') {
487
+ drag.state = dragIntent(dx, dy)
488
+ if (drag.state === 'vertical') { drag = null; return }
489
+ if (drag.state === 'horizontal') {
490
+ root.setPointerCapture(event.pointerId); root.dataset.dragging = ''
491
+ viewport.style.scrollSnapType = 'none'; intent = true; player.hold('drag')
492
+ }
493
+ }
494
+ if (drag.state === 'horizontal') {
495
+ let to = drag.left - dx
496
+ const wrapped = normalizeTo(to)
497
+ drag.left += wrapped - to; to = wrapped
498
+ viewport.scrollLeft = to
499
+ drag.samples.push({ x: event.clientX, t: event.timeStamp })
500
+ if (drag.samples.length > 6) drag.samples.shift()
501
+ }
502
+ })
503
+ const release = (event, cancelled) => {
504
+ if (!drag || event.pointerId !== drag.id) return
505
+ const { x, y, t, left, state, samples } = drag
506
+ drag = null; delete root.dataset.dragging
507
+ if (state !== 'horizontal') return
508
+ suppressClick = true
509
+ free = false
510
+ if (visibleMode || player.smooth) {
511
+ const a = samples[0], b = samples.at(-1)
512
+ glide(cancelled ? 0 : releaseVelocity(b.x - a.x, b.t - a.t))
513
+ player.release('drag', player.pause)
514
+ return
515
+ }
516
+ player.release('drag', player.pause)
517
+ const start = Math.round(left / step), near = Math.round(position())
518
+ const flick = cancelled ? 0 : swipeStep(event.clientX - x, event.clientY - y, event.timeStamp - t)
519
+ let to = near === start ? start + flick : near
520
+ if (!sets) to = Math.max(0, Math.min(lastIndex(), to))
521
+ announce = to !== start; target = to
522
+ scrollToIndex(to, true)
523
+ if (Math.abs(viewport.scrollLeft - to * step) < 1) settle()
524
+ }
525
+ root.addEventListener('pointerup', event => release(event, false))
526
+ root.addEventListener('pointercancel', event => release(event, true))
527
+ root.addEventListener('dragstart', event => event.preventDefault())
528
+ root.addEventListener('click', event => {
529
+ if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false; return }
530
+ // A click on a receding neighbour brings it to the centre instead of following its link.
531
+ if (visibleMode || !peek || row || !step || event.detail === 0 || !viewport.contains(event.target)) return
532
+ const box = viewport.getBoundingClientRect()
533
+ const offset = Math.round((event.clientX - (box.left + box.width / 2)) / step)
534
+ if (offset) { event.preventDefault(); event.stopPropagation(); go(offset) }
535
+ }, true)
536
+
537
+ let lastWidth = 0
538
+ new ResizeObserver(() => requestAnimationFrame(() => { const w = root.clientWidth; if (Math.abs(w - lastWidth) > 0.5) { lastWidth = w; measure() } })).observe(root)
539
+ if (fitCurrent) {
540
+ const watch = new ResizeObserver(() => requestAnimationFrame(() => { if (row || !step) return; measureHeights(); fitHeight(position()); placeArrows() }))
541
+ slides.forEach(slide => watch.observe(slide))
542
+ }
543
+ root.dataset.ready = ''
544
+ measure()
545
+ player.start()
546
+ player.sync()
547
+ return { root, go, goTo, pause: () => player.hold('api'), play: () => player.release('api') }
548
+ }
549
+
550
+ /* ------------------------------------------------------------------- fade */
551
+
552
+ function initFade(parts) {
553
+ const { root, viewport, slides, n, label, controls, back, next, pages, status, count } = parts
554
+ const loop = root.hasAttribute('data-loop')
555
+ const fit = root.dataset.fit === 'current'
556
+ let active = 0
557
+ const fitHeight = () => { if (fit) viewport.style.height = `${slides[active].offsetHeight}px` }
558
+ const placeArrows = () => {
559
+ const height = fit ? Math.min(...slides.map(slide => slide.offsetHeight)) : viewport.offsetHeight
560
+ root.style.setProperty('--mid', `${viewport.offsetTop + height / 2}px`)
561
+ }
562
+ let lastWidth = root.clientWidth
563
+ const watch = new ResizeObserver(() => requestAnimationFrame(() => {
564
+ const width = root.clientWidth, resized = Math.abs(width - lastWidth) > 0.5
565
+ lastWidth = width
566
+ fitHeight(); placeArrows()
567
+ if (resized) revealControlFocus(controls)
568
+ }))
569
+ watch.observe(root); slides.forEach(slide => watch.observe(slide))
570
+ const render = announce => {
571
+ const state = slideWindow(active, n, 1, loop)
572
+ active = state.start
573
+ if (back) back.disabled = state.previousDisabled
574
+ if (next) next.disabled = state.nextDisabled
575
+ preserveSlideFocus(slides, slides[active], pages[active])
576
+ slides.forEach((slide, i) => { const shown = i === active; slide.classList.toggle('is-current', shown); slide.inert = !shown })
577
+ pages.forEach((page, i) => page.setAttribute('aria-current', String(i === active)))
578
+ if (count) count.textContent = `${active + 1} / ${n}`
579
+ fitHeight()
580
+ if (announce && status) status.textContent = `${label}: ${active + 1} of ${n}`
581
+ root.dispatchEvent(new CustomEvent('carousel:change', { detail: { index: active } }))
582
+ }
583
+ const go = (targetIndex, announce = true) => { active = loop ? ((targetIndex % n) + n) % n : Math.max(0, Math.min(n - 1, targetIndex)); render(announce) }
584
+ const player = createAutoplay(parts, { advance: () => go(active + 1, false), canRun: () => true, startDrift: () => {}, stopDrift: () => {} })
585
+
586
+ back?.addEventListener('click', () => { go(active - 1); player.restart() })
587
+ next?.addEventListener('click', () => { go(active + 1); player.restart() })
588
+ pages.forEach((page, i) => page.addEventListener('click', () => { go(i); player.restart() }))
589
+ root.addEventListener('keydown', event => {
590
+ if (event.altKey || event.ctrlKey || event.metaKey) return
591
+ const keys = { ArrowLeft: () => go(active - 1), ArrowRight: () => go(active + 1), Home: () => go(0), End: () => go(n - 1) }
592
+ if (!keys[event.key]) return
593
+ event.preventDefault(); keys[event.key]()
594
+ })
595
+
596
+ // Swipes: a horizontal gesture changes slide; a vertical one stays a page scroll.
597
+ let drag = null, suppressClick = false
598
+ root.addEventListener('pointerdown', event => {
599
+ if (!event.isPrimary || event.button > 0 || event.target.closest('button')) return
600
+ drag = { id: event.pointerId, x: event.clientX, y: event.clientY, t: event.timeStamp, state: 'pending' }
601
+ suppressClick = false
602
+ })
603
+ root.addEventListener('pointermove', event => {
604
+ if (!drag || event.pointerId !== drag.id || drag.state !== 'pending') return
605
+ drag.state = dragIntent(event.clientX - drag.x, event.clientY - drag.y)
606
+ if (drag.state === 'vertical') drag = null
607
+ else if (drag.state === 'horizontal') { root.setPointerCapture(event.pointerId); root.dataset.dragging = '' }
608
+ })
609
+ const release = (event, cancelled) => {
610
+ if (!drag || event.pointerId !== drag.id) return
611
+ const horizontal = drag.state === 'horizontal', delta = cancelled ? 0 : swipeStep(event.clientX - drag.x, event.clientY - drag.y, event.timeStamp - drag.t)
612
+ drag = null; delete root.dataset.dragging
613
+ if (!horizontal) return
614
+ suppressClick = true
615
+ if (delta) { go(active + delta); player.restart() }
616
+ }
617
+ root.addEventListener('pointerup', event => release(event, false))
618
+ root.addEventListener('pointercancel', event => release(event, true))
619
+ root.addEventListener('click', event => { if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false } }, true)
620
+
621
+ if (controls) controls.hidden = false
622
+ root.dataset.ready = ''
623
+ render(false)
624
+ placeArrows()
625
+ player.start(); player.sync()
626
+ return { root, go, pause: () => player.hold('api'), play: () => player.release('api') }
627
+ }