9.9 kB · 451 lines · Created 4mo ago
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451import tinkerbell from 'tinkerbell'import rosin from 'rosin'import srraf from 'srraf'
function ease (t, b, c, d) { return (t === d) ? b + c : c * (-Math.pow(2, -10 * t / d) + 1) + b}
export default function noodle (slider, opts = {}) { opts = Object.assign({ index: 0, a11y: true, setHeight: true }, opts)
/** * Hoisted variables */ const frame = 1000 / 60 let width = slider.offsetWidth let prevIndex = 0 let index = opts.index let slidesCount = 0 let position = 0 let delta = 0 let t = Date.now() let velo = 0 let totalTravel = 0
let ticking = false let dragging = false
let tick = null let dragger = null let resizer = null
let active = false let suspended = false // library disabled let destroyed = false // user disabled
const evs = {}
function on (ev, cb) { evs[ev] = (evs[ev] || []).concat(cb) return () => { evs[ev].splice(evs[ev].indexOf(cb), 1) } }
function emit (ev, data) { if (suspended) return (evs[ev] || []).map(cb => cb(data)) }
/** * Contains slides */ const track = document.createElement('div') track.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; bottom: 0; `
function clamp (i) { return Math.min(Math.max(i, 0), (slidesCount - 1)) }
/** * TODO * * If slide width were to change on resize, it'll need * to re-calc the offsets as it does here. */ function mount () { for (let i = slider.children.length - 1; i > -1; i--) { const slide = slider.children[i]
slide.setAttribute('tabindex', '1')
track.insertBefore(slide, track.children[0])
slidesCount++ }
slider.appendChild(track) slider.setAttribute('tabindex', '0')
position = getPosition(index) track.style.transform = `translateX(${position}px)`
// disable image drag const imgs = slider.getElementsByTagName('img') for (let i = 0; i < imgs.length; i++) { imgs[i].onmousedown = e => e.preventDefault() // imgs[i].ontouchstart = e => e.preventDefault() }
reflow() }
/** * Get's total slide width in comparison to the width of * the slider parent. If this is < 0, it means the slides * are less wide than the parent, and the slider can be * suspended. If it's > 0, we need to slide */ function getTotalTravel () { const parent = active ? track : slider
let w = width * -1
for (let i = 0; i < parent.children.length; i++) { w += parent.children[i].clientWidth }
return w }
/** * Reset slide position within the slider. Happens on resize. */ function reflow () { let offset = 0
for (let i = 0; i < track.children.length; i++) { if (i > 0) { offset += (i / i) * (track.children[i - 1].offsetWidth / width) * 100 }
const slide = track.children[i] slide.style.position = 'absolute' slide.style.top = 0 slide.style.left = offset + '%' }
if (opts.setHeight && track.children[index]) slider.style.height = track.children[index].clientHeight + 'px' }
/** * Update dimensions and call reflow. Also manages * whether or not the slider should be active, * based on the totalTravel */ function resize () { width = slider.offsetWidth
totalTravel = getTotalTravel()
if (totalTravel <= 0 && active) { destroy() } else if (totalTravel > 0 && !active) { init() }
if (active && !suspended) { reflow() selectByIndex(true) // skip focus, will jump page or slider otherwise } }
/** * Called after each cell selection, * or when sliding is interrupted by another action. */ function reset () { tick = typeof tick === 'function' ? tick() : cancelAnimationFrame(tick) ticking = false delta = 0 velo = 0 }
function focusActiveSlide () { if (!opts.a11y) return
for (let i = 0; i < track.children.length; i++) { if (i === index) { track.children[i].setAttribute('tabindex', '0') track.children[i].focus() } else { track.children[i].setAttribute('tabindex', '1') } } }
/** * Set active class */ function setActiveSlide () { for (let i = 0; i < track.children.length; i++) { track.children[i].classList[i === index ? 'add' : 'remove']('is-selected') }
if (opts.setHeight && track.children[index]) slider.style.height = track.children[index].clientHeight + 'px' }
/** * Get slide[index] position, either prevIndex or index */ function getPosition (ind) { let travel = 0
for (let i = 0; i < ind; i++) { travel += track.children[i].offsetWidth }
return Math.min(travel, totalTravel) * -1 }
/** * Slide to slide at provided index */ function selectByIndex (skipFocus) { ticking = true
const nextSlideWidth = track.children[index].offsetWidth const prev = position // basically, getPosition(prevIndex) const next = getPosition(index)
/** * Prevent from traveling beyond the last slide */ if (Math.abs(next) > totalTravel) return reset()
setActiveSlide()
tick = tinkerbell(prev, next, 1000, ease)(v => { track.style.transform = `translateX(${v}px)` position = v }, () => { !skipFocus && focusActiveSlide() reset() prevIndex !== index && emit('settle', index) }) }
/** * Calculates which slide the swipe will come to rest on, * accounting for momentum calculated in release() */ function whichByDistance (del, slidesPast = 0, dir) { const requested = index + (slidesPast * dir * -1) const i = clamp(requested) const threshold = 0.15 const currSlideWidth = track.children[i].offsetWidth
if (requested > (slidesCount - 1) || requested < 0) { return i } else if (del > currSlideWidth) { return whichByDistance(del - currSlideWidth, slidesPast + 1, dir) } else if (del > (currSlideWidth * threshold)) { return clamp(i - dir) } else if (del < ((currSlideWidth * threshold) * -1)) { return clamp(i - dir) } else { return index } }
function select (i) { i = clamp(i)
if (index !== i) { prevIndex = index index = i
emit('select', index)
reset() selectByIndex() } }
/** * End flick action and calculate slider resting position, * then select that slide */ function release (pos, e) { dragging = false
slider.classList.remove('is-dragging')
t = e.timeStamp
let v = Math.abs(velo)
// starting position for tween position = position + delta
// estimate resting position let x = 0 while (v > 0.1) { v *= 1 - 0.15 x += v }
prevIndex = index index = whichByDistance(Math.abs(delta) + x, 0, delta < 0 ? -1 : 1)
emit('select', index)
selectByIndex() }
/** * Tracks swipe action */ function move ({ x }, e) { dragging = true slider.classList.add('is-dragging') const elapsed = e.timeStamp - t || 1 // in ms, must be > 0 velo = (x - delta) / (elapsed * frame) t = e.timeStamp delta = x track.style.transform = `translateX(${position + delta}px)` }
function start (pos, e) { if (ticking && tick) reset() }
function keypress ({ keyCode }) { if (slider === document.activeElement || slider.contains(document.activeElement)) { if (keyCode === 37) select(index - 1) if (keyCode === 39) select(index + 1) } }
function destroy () { if (suspended || destroyed) return
/** * Execute all at once */ dragger.destroy()
window.removeEventListener('keydown', keypress)
for (let i = track.children.length - 1; i > -1; i--) { const slide = track.children[i]
slider.insertBefore(slide, slider.children[0]) slide.style.position = '' slide.style.top = '' slide.style.left = ''
slide.removeAttribute('tabindex') slide.classList.remove('is-selected') }
const imgs = slider.getElementsByTagName('img') for (let i = 0; i < imgs.length; i++) { imgs[i].removeEventListener('load', reflow) }
slider.removeAttribute('tabindex') slider.removeChild(track) slider.style.height = ''
slider.classList.remove('is-active')
active = false suspended = true
slidesCount = 0 prevIndex = 0 index = 0 slidesCount = 0 position = 0
emit('destroy') }
function init () { if (active) return
totalTravel = getTotalTravel()
if (totalTravel > 0) { mount() setActiveSlide()
dragger = rosin(slider) dragger.on('mousedown', start) dragger.on('dragLeft', move) dragger.on('dragRight', move) dragger.on('mouseup', release)
window.addEventListener('keydown', keypress)
const imgs = slider.getElementsByTagName('img') for (let i = 0; i < imgs.length; i++) { imgs[i].addEventListener('load', reflow) }
suspended = false active = true
slider.classList.add('is-active')
emit('init') } else { suspended = true } }
/** * Need this to run even if suspended, so that it * can re-init if needed */ resizer = srraf(({ vw, pvw }) => { if (destroyed) return if (vw !==pvw) resize() })
/** * Go go go */ init()
return { on, resize, select, init () { init() destroyed = false }, destroy () { destroy() destroyed = true }, get active () { return active }, get suspended () { return suspended }, get destroyed () { return destroyed }, get index () { return index }, prev () { select(index - 1) }, next () { select(index + 1) } }}