@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.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/docs/guides/upgrade.md +14 -0
- package/docs/reference/portable-presentation.md +9 -0
- package/docs/reference/ui-primitives.md +128 -0
- package/fixtures/templates/local-library.v1.json +3 -1
- package/package.json +3 -1
- package/src/ui/primitives/browser.mjs +6 -0
- package/src/ui/primitives/carousel.mjs +627 -0
- package/src/ui/primitives/disclosure.mjs +50 -0
- package/src/ui/primitives/index.mjs +7 -0
- package/src/ui/primitives/motion.mjs +50 -0
- package/src/ui/primitives/render.mjs +22 -0
- package/src/ui/primitives/scroll-lock.mjs +47 -0
- package/src/ui/primitives/state.mjs +153 -0
- package/src/ui/primitives/styles.mjs +127 -0
- package/templates/external-project-workspace/pack-lifecycle.json +3 -1
|
@@ -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
|
+
}
|