import 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) } } }