import { as, clampNumber, nextTick, wrapNumber } from '@newthink/utilities' import type { DirectiveCallback } from 'alpinejs' import { childrenSansPassthrough } from './childrenSansPassthrough' import { $slider, type SliderCTX } from './SliderCTX' import { nearestIndex, scrollEdges } from './utils/geometry' import { isIMGElement } from './utils/isIMGElement' export const SliderRoot: DirectiveCallback = ( el, { expression }, { evaluate, cleanup, Alpine }, ) => { const cleanups: (() => void)[] = [] cleanup(() => cleanups.forEach((cleanup) => cleanup())) const opts = Object.assign( { scrollToDepth: 0, intoView: false, centerVertically: false, autoScroll: 0, autoInitialize: false, animated: false, }, expression ? evaluate(expression) : {}, ) const ctx = Alpine.reactive({ focusedIndex: 0, focusedSize: 0, initialized: Boolean(opts.autoInitialize), touched: false, paused: false, atStart: true, atEnd: false, pause() { this.paused = true return this }, unpause() { this.paused = false return this }, components: new Map<'gallery' | 'thumbnails', HTMLElement>(), autoScrollPercentage: 1, init() { if (opts.autoScroll) { const autoScroller = new Timer(opts.autoScroll) .onTick((completion) => { this.autoScrollPercentage = completion }) .onFinish(() => { if (this.touched) return autoScroller.abort() if (this.paused) return const gallery = this.components.get('gallery') if (!gallery) return this.scrollTo( wrapNumber( this.focusedIndex + 1, childrenSansPassthrough(gallery).length, ), ) }) cleanups.push(() => autoScroller.abort()) } }, onLoad(target: HTMLElement) { if (this.initialized || (isIMGElement(target) && !target.complete)) return this.focusedIndex = 0 this.focusedSize = this.itemAtIndex(0)?.clientHeight || 0 const gallery = this.components.get('gallery') if (gallery) gallery.scrollLeft = 0 this.initialized = true this.init() this.updateEdges() }, handleIntersect(el: HTMLElement, intersecting = true) { if (!this.initialized) return const gallery = this.components.get('gallery') if (intersecting && (!gallery || opts.animated)) { // In animated mode the gallery doesn't scroll (items are stacked and // faded in place), so the scrollTo-driven index is authoritative. this.focusedIndex = this.indexOfItem(el) ?? 0 this.focusedSize = el.clientHeight return } this.updateFocusedIndex() this.updateEdges() }, updateFocusedIndex() { const gallery = this.components.get('gallery') if (!gallery) return const children = childrenSansPassthrough(gallery) const padStart = parseFloat(getComputedStyle(gallery).scrollPaddingLeft) || 0 const offsets = children.map( (child) => as(HTMLElement, child)?.offsetLeft ?? 0, ) const index = nearestIndex(offsets, gallery.scrollLeft + padStart) this.focusedIndex = index this.focusedSize = as(HTMLElement, children[index])?.clientHeight ?? el.clientHeight }, itemAtIndex(idx: number): Element | undefined { const gallery = this.components.get('gallery') if (!gallery) return const children = childrenSansPassthrough(gallery) let item: Element = children[clampNumber(idx, children.length - 1)] if (item === undefined) return for (let i = 0; i < opts.scrollToDepth; i++) item = item?.firstElementChild ?? item if (item?.closest('template')) return this.itemAtIndex(idx + 1) return item }, indexOfItem(el: Element) { const gallery = this.components.get('gallery') if (!gallery) return const children = childrenSansPassthrough(gallery) for (let i = 0; i < children.length; i++) { if (children[i].contains(el)) return i } }, async scrollTo( target: number | Element, offset = 0, userInitiated = false, instant = false, ) { const gallery = this.components.get('gallery') if (!gallery) return const el = target instanceof Element ? target : this.itemAtIndex(target) if (!el) return if (userInitiated) this.touched = true const idx = typeof target === 'number' ? target : (this.indexOfItem(el) ?? this.focusedIndex) if (!this.initialized) { this.initialized = true await nextTick() } if (instant || opts.animated) this.focusedIndex = idx else this.focusedIndex += Math.sign(idx - this.focusedIndex) if (!opts.animated) { // hack to break out of broken scrolling 💩 window.scrollBy({ top: 0.5, }) window.scrollBy({ top: -0.5, }) if (opts.intoView) el.scrollIntoView({ behavior: 'smooth', block: opts.centerVertically ? 'center' : 'nearest', inline: 'center', }) else { if (instant) gallery.style.scrollBehavior = 'auto' gallery.scrollLeft = target === 0 ? 0 : (as(HTMLElement, el)?.offsetLeft ?? 0) - offset // @ts-ignore: Setting to `null` removes any element specific css, reseting to the cascade if (instant) gallery.style.scrollBehavior = null } } const thumbnails = this.components.get('thumbnails') if (!thumbnails) return const thumb = as(HTMLElement, thumbnails.children[idx]) if (!thumb) return thumbnails.scrollTo({ top: thumb.offsetTop - (thumbnails.clientHeight - thumb.clientHeight) / 2, left: thumb.offsetLeft - (thumbnails.clientWidth - thumb.clientWidth) / 2, behavior: 'smooth', }) }, // Reflect whether the gallery is scrolled to its start / end. See // scrollEdges: index-based edges are wrong when several cards are visible. updateEdges() { const gallery = this.components.get('gallery') if (!gallery || opts.animated) return const { atStart, atEnd } = scrollEdges( gallery.scrollLeft, gallery.scrollWidth, gallery.clientWidth, ) this.atStart = atStart this.atEnd = atEnd }, get isFirst() { if (opts.animated) return this.focusedIndex === 0 return this.atStart }, get isLast() { const gallery = this.components.get('gallery') if (!gallery) return true if (opts.animated) return this.focusedIndex === childrenSansPassthrough(gallery).length - 1 return this.atEnd }, get itemCount() { const gallery = this.components.get('gallery') if (!gallery) return 0 return childrenSansPassthrough(gallery).length }, next(element: HTMLElement) { const step = this.getStepInfo(element) this.scrollTo(Math.max(0, this.focusedIndex + step), 10, true) }, prev(element: HTMLElement) { const step = this.getStepInfo(element) this.scrollTo(Math.max(0, this.focusedIndex - step), 10, true) }, getStepInfo(el: HTMLElement) { const styles = getComputedStyle(el) let step = parseInt(styles.getPropertyValue('--perpage') || '1') const arrowStep = styles.getPropertyValue('--arrow-step').trim() const gallery = this.components.get('gallery') if (gallery && arrowStep === 'view') { const first = as(HTMLElement, this.itemAtIndex(0)) const second = as(HTMLElement, this.itemAtIndex(1)) const stride = first && second && first !== second ? Math.abs(second.offsetLeft - first.offsetLeft) : 0 if (stride > 0) step = Math.max(1, Math.floor(gallery.clientWidth / stride)) } return step }, } satisfies SliderCTX) Alpine.addScopeToNode(el, { [$slider]: ctx }) if (opts.autoInitialize) ctx.init() } class Timer extends EventTarget { private duration: number = 0 private currentTime: number = 0 private lastTimestep: number = 0 private controller: AbortController = new AbortController() get percentage() { return this.currentTime / this.duration } constructor(durationMs: number) { super() this.duration = durationMs this.lastTimestep = performance.now() requestAnimationFrame(this.ticker) } ticker = (timestamp: number) => { if (this.controller.signal.aborted) return const tick = timestamp - this.lastTimestep this.lastTimestep = timestamp this.currentTime += tick this.dispatchEvent(new Event('tick')) if (this.currentTime > this.duration) { this.dispatchEvent(new Event('just-finished')) this.currentTime %= this.duration } requestAnimationFrame(this.ticker) } abort() { this.controller.abort() } onTick(cb: (completion: number) => void): this { this.addEventListener('tick', () => cb(this.percentage), { signal: this.controller.signal, }) return this } onFinish(cb: () => void): this { this.addEventListener('just-finished', cb, { signal: this.controller.signal, }) return this } static fromSeconds(seconds: number) { return new Timer(seconds * 1000) } }