Hard forked TypeScript version of Siema forked sqwk variation which applies passive listeners and various other fixes.
Created
January 21, 2022 12:09
-
-
Save panoply/2ac2e49d118d94fae9a1b1889e992a43 to your computer and use it in GitHub Desktop.
Siema TypeScript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { assign, from } from 'utils/native'; | |
export interface SiemaOptions { | |
selector: HTMLElement|string; | |
duration: number; | |
easing: string; | |
perPage: number|{[width: number]: number}; | |
startIndex: number; | |
draggable: boolean; | |
multipleDrag: boolean; | |
threshold: number; | |
loop: boolean; | |
rtl: boolean; | |
onInit?: () => void; | |
onChange?: () => void; | |
} | |
export class Siema { | |
/** Config used for this instace of Siema */ | |
public config: SiemaOptions; | |
/** Variable to track if mouse is pressed */ | |
private pointerDown: boolean; | |
/** Variable to track mouse track */ | |
private drag: { | |
startX: number; | |
endX: number; | |
startY: number; | |
letItGo: boolean; | |
preventClick: boolean; | |
}; | |
/** Reference to the Siema element */ | |
public selector: HTMLElement; | |
/** The width of the Siema element */ | |
private selectorWidth: number; | |
/** How many items to show per page */ | |
private perPage: number; | |
/** Variable to keep track of the actual items */ | |
private innerElements: Element[]; | |
/** Zero based index of the current slide */ | |
public currentSlide: number; | |
/** Variable to keeo track of wether to use standard or webkit compaotible transforms */ | |
private transformProperty: 'transform' | 'webkitTransform'; | |
/** Reference to the the sliding wrapper for all items */ | |
private sliderFrame: HTMLDivElement; | |
/** | |
* Create a Siema. | |
*/ | |
constructor (options: Partial<SiemaOptions>) { | |
// Merge defaults with user's settings | |
this.config = assign({ | |
selector: '.siema', | |
duration: 200, | |
easing: 'ease-out', | |
perPage: 1, | |
startIndex: 0, | |
draggable: true, | |
multipleDrag: true, | |
threshold: 20, | |
loop: false, | |
rtl: false, | |
onInit: Function, | |
onChange: Function | |
}, options); | |
// Resolve selector's type | |
this.selector = typeof this.config.selector === 'string' | |
? document.querySelector(this.config.selector) | |
: this.config.selector; | |
// Early throw if selector doesn't exists | |
if (this.selector === null) throw new Error('Something is wrong with your selector'); | |
// update perPage number dependable of user value | |
this.resolveSlidesNumber(); | |
// Create global references | |
this.transformProperty = typeof document.documentElement.style === 'string' | |
? 'transform' | |
: 'webkitTransform'; | |
this.selectorWidth = this.selector.offsetWidth; | |
this.innerElements = from(this.selector.children); | |
this.currentSlide = this.config.loop | |
? this.config.startIndex % this.innerElements.length | |
: Math.max( | |
0, | |
Math.min( | |
this.config.startIndex, | |
this.innerElements.length - this.perPage | |
) | |
); | |
// Build markup and apply required styling to elements | |
this.init(); | |
} | |
/** | |
* Builds the markup and attaches listeners to required events. | |
*/ | |
private init (): void { | |
this.attachEvents(); | |
// hide everything out of selector's boundaries | |
this.selector.style.overflow = 'hidden'; | |
// rtl or ltr | |
this.selector.style.direction = this.config.rtl ? 'rtl' : 'ltr'; | |
// build a frame and slide to a currentSlide | |
this.buildSliderFrame(); | |
this.config.onInit.call(this); | |
} | |
/** | |
* Attaches listeners to required events. | |
*/ | |
private attachEvents (): void { | |
// Resize element on window resize | |
window.addEventListener('resize', this.resizeHandler); | |
// If element is draggable / swipable, add event handlers | |
if (this.config.draggable) { | |
// Keep track pointer hold and dragging distance | |
this.pointerDown = false; | |
this.drag = { | |
startX: 0, | |
endX: 0, | |
startY: 0, | |
letItGo: null, | |
preventClick: false | |
}; | |
// Touch events | |
this.selector.addEventListener('touchstart', this.touchstartHandler, { | |
passive: true | |
}); | |
this.selector.addEventListener('touchend', this.touchendHandler, { | |
passive: true | |
}); | |
this.selector.addEventListener('touchmove', this.touchmoveHandler, { | |
passive: false | |
}); | |
// Mouse events | |
this.selector.addEventListener('mousedown', this.mousedownHandler, { | |
passive: false | |
}); | |
this.selector.addEventListener('mouseup', this.mouseupHandler, { | |
passive: false | |
}); | |
this.selector.addEventListener('mouseleave', this.mouseleaveHandler, { | |
passive: true | |
}); | |
this.selector.addEventListener('mousemove', this.mousemoveHandler, { | |
passive: false | |
}); | |
// Click | |
this.selector.addEventListener('click', this.clickHandler); | |
} | |
} | |
/** | |
* Detaches listeners from required events. | |
*/ | |
private detachEvents (): void { | |
window.removeEventListener('resize', this.resizeHandler); | |
this.selector.removeEventListener('touchstart', this.touchstartHandler); | |
this.selector.removeEventListener('touchend', this.touchendHandler); | |
this.selector.removeEventListener('touchmove', this.touchmoveHandler); | |
this.selector.removeEventListener('mousedown', this.mousedownHandler); | |
this.selector.removeEventListener('mouseup', this.mouseupHandler); | |
this.selector.removeEventListener('mouseleave', this.mouseleaveHandler); | |
this.selector.removeEventListener('mousemove', this.mousemoveHandler); | |
this.selector.removeEventListener('click', this.clickHandler); | |
} | |
/** | |
* Build a sliderFrame and slide to a current item. | |
*/ | |
private buildSliderFrame (): void { | |
const widthItem = this.selectorWidth / this.perPage; | |
const itemsToBuild = this.config.loop | |
? this.innerElements.length + (2 * this.perPage) | |
: this.innerElements.length; | |
// Create frame and apply styling | |
this.sliderFrame = document.createElement('div'); | |
this.sliderFrame.style.width = `${widthItem * itemsToBuild}px`; | |
this.enableTransition(); | |
if (this.config.draggable) this.selector.style.cursor = '-webkit-grab'; | |
// Create a document fragment to put slides into it | |
const docFragment = document.createDocumentFragment(); | |
// Loop through the slides, add styling and add them to document fragment | |
if (this.config.loop) { | |
let slide = this.innerElements.length - this.perPage; | |
for (; slide < this.innerElements.length; slide++) { | |
const element = this.buildSliderFrameItem( | |
this.innerElements[slide].cloneNode(true) as HTMLElement | |
); | |
docFragment.appendChild(element); | |
} | |
} | |
for (let i = 0; i < this.innerElements.length; i++) { | |
const element = this.buildSliderFrameItem(this.innerElements[i] as HTMLElement); | |
docFragment.appendChild(element); | |
} | |
if (this.config.loop) { | |
for (let i = 0; i < this.perPage; i++) { | |
const element = this.buildSliderFrameItem( | |
this.innerElements[i].cloneNode(true) as HTMLElement | |
); | |
docFragment.appendChild(element); | |
} | |
} | |
// Add fragment to the frame | |
this.sliderFrame.appendChild(docFragment); | |
// Clear selector (just in case something is there) and insert a frame | |
this.selector.innerHTML = ''; | |
this.selector.appendChild(this.sliderFrame); | |
// Go to currently active slide after initial build | |
this.slideToCurrent(); | |
} | |
private buildSliderFrameItem (elm: HTMLElement): HTMLDivElement { | |
const container = document.createElement('div'); | |
container.style.cssFloat = this.config.rtl ? 'right' : 'left'; | |
container.style.width = `${this.config.loop | |
? 100 / (this.innerElements.length + (this.perPage * 2)) | |
: 100 / (this.innerElements.length)}%`; | |
container.appendChild(elm); | |
return container; | |
} | |
/** | |
* Determinates slides number accordingly to clients viewport. | |
*/ | |
private resolveSlidesNumber (): void { | |
if (typeof this.config.perPage === 'number') { | |
this.perPage = this.config.perPage; | |
} else { | |
this.perPage = 1; | |
for (const key in this.config.perPage) { | |
if (window.innerWidth >= this.config.perPage[key]) { | |
this.perPage = this.config.perPage[key]; | |
} | |
} | |
} | |
} | |
/** | |
* Go to previous slide. | |
*/ | |
public prev (howManySlides = 1, callback?: () => void): void { | |
// early return when there is nothing to slide | |
if (this.innerElements.length <= this.perPage) return; | |
const beforeChange = this.currentSlide; | |
if (this.config.loop) { | |
const isNewIndexClone = this.currentSlide - howManySlides < 0; | |
if (isNewIndexClone) { | |
this.disableTransition(); | |
const mirrorSlideIndex = this.currentSlide + this.innerElements.length; | |
const mirrorSlideIndexOffset = this.perPage; | |
const moveTo = mirrorSlideIndex + mirrorSlideIndexOffset; | |
const offset = ( | |
this.config.rtl | |
? 1 | |
: -1 | |
) * moveTo * (this.selectorWidth / this.perPage); | |
const dragDistance = this.config.draggable | |
? this.drag.endX - this.drag.startX | |
: 0; | |
this.sliderFrame.style[this.transformProperty] = ( | |
'translate3d(' + offset + dragDistance + 'px, 0, 0)' | |
); | |
this.currentSlide = mirrorSlideIndex - howManySlides; | |
} else { | |
this.currentSlide = this.currentSlide - howManySlides; | |
} | |
} else { | |
this.currentSlide = Math.max(this.currentSlide - howManySlides, 0); | |
} | |
if (beforeChange !== this.currentSlide) { | |
this.slideToCurrent(this.config.loop); | |
this.config.onChange.call(this); | |
if (callback) callback.call(this); | |
} | |
} | |
/** | |
* Go to next slide. | |
*/ | |
public next (howManySlides = 1, callback?: () => void): void { | |
// early return when there is nothing to slide | |
if (this.innerElements.length <= this.perPage) return; | |
const beforeChange = this.currentSlide; | |
if (this.config.loop) { | |
const isNewIndexClone = this.currentSlide + | |
howManySlides > this.innerElements.length - this.perPage; | |
if (isNewIndexClone) { | |
this.disableTransition(); | |
const mirrorSlideIndex = this.currentSlide - this.innerElements.length; | |
const mirrorSlideIndexOffset = this.perPage; | |
const moveTo = mirrorSlideIndex + mirrorSlideIndexOffset; | |
const offset = ( | |
this.config.rtl ? 1 : -1 | |
) * moveTo * ( | |
this.selectorWidth / this.perPage | |
); | |
const dragDistance = this.config.draggable | |
? this.drag.endX - this.drag.startX | |
: 0; | |
this.sliderFrame.style[this.transformProperty] = ( | |
'translate3d(' + offset + dragDistance + 'px, 0, 0)' | |
); | |
this.currentSlide = mirrorSlideIndex + howManySlides; | |
} else { | |
this.currentSlide = this.currentSlide + howManySlides; | |
} | |
} else { | |
this.currentSlide = Math.min( | |
this.currentSlide + howManySlides, | |
this.innerElements.length - this.perPage | |
); | |
} | |
if (beforeChange !== this.currentSlide) { | |
this.slideToCurrent(this.config.loop); | |
this.config.onChange.call(this); | |
if (callback) callback.call(this); | |
} | |
} | |
/** | |
* Disable transition on sliderFrame. | |
*/ | |
private disableTransition (): void { | |
this.sliderFrame.style.transition = ( | |
'all 0ms ' + this.config.easing | |
); | |
} | |
/** | |
* Enable transition on sliderFrame. | |
*/ | |
private enableTransition (): void { | |
this.sliderFrame.style.transition = ( | |
'all ' + this.config.duration + 'ms ' + this.config.easing | |
); | |
} | |
/** | |
* Go to slide with particular index | |
*/ | |
public goTo (index: number, callback?: () => void): void { | |
if (this.innerElements.length <= this.perPage) return; | |
const beforeChange = this.currentSlide; | |
this.currentSlide = this.config.loop | |
? index % this.innerElements.length | |
: Math.min(Math.max(index, 0), this.innerElements.length - this.perPage); | |
if (beforeChange !== this.currentSlide) { | |
this.slideToCurrent(); | |
this.config.onChange.call(this); | |
if (callback) callback.call(this); | |
} | |
} | |
/** | |
* Moves sliders frame to position of currently active slide | |
*/ | |
public slideToCurrent (enableTransition?: boolean): void { | |
const currentSlide = this.config.loop | |
? this.currentSlide + this.perPage | |
: this.currentSlide; | |
const offset = ( | |
this.config.rtl ? 1 : -1 | |
) * currentSlide * ( | |
this.selectorWidth / this.perPage | |
); | |
if (enableTransition) { | |
// This one is tricky, I know but this is a perfect explanation: | |
// https://youtu.be/cCOL7MC4Pl0 | |
requestAnimationFrame(() => { | |
requestAnimationFrame(() => { | |
this.enableTransition(); | |
this.sliderFrame.style.transform = 'translate3d(' + offset + 'px, 0, 0)'; | |
}); | |
}); | |
} else { | |
this.sliderFrame.style[this.transformProperty] = 'translate3d(' + offset + 'px, 0, 0)'; | |
} | |
} | |
/** | |
* Recalculate drag /swipe event and reposition the frame of a slider | |
*/ | |
private updateAfterDrag (): void { | |
const movement = (this.config.rtl ? -1 : 1) * (this.drag.endX - this.drag.startX); | |
const movementDistance = Math.abs(movement); | |
const howManySliderToSlide = this.config.multipleDrag | |
? Math.ceil(movementDistance / (this.selectorWidth / this.perPage)) | |
: 1; | |
const slideToNegativeClone = ( | |
movement > 0 && | |
this.currentSlide - howManySliderToSlide < 0 | |
); | |
const slideToPositiveClone = ( | |
movement < 0 && | |
this.currentSlide + howManySliderToSlide > this.innerElements.length - this.perPage | |
); | |
if ( | |
movement > 0 && | |
movementDistance > this.config.threshold && | |
this.innerElements.length > this.perPage | |
) { | |
this.prev(howManySliderToSlide); | |
} else if ( | |
movement < 0 && | |
movementDistance > this.config.threshold && | |
this.innerElements.length > this.perPage | |
) { | |
this.next(howManySliderToSlide); | |
} | |
this.slideToCurrent(slideToNegativeClone || slideToPositiveClone); | |
} | |
/** | |
* When window resizes, resize slider components as well | |
*/ | |
private resizeHandler = (): void => { | |
// update perPage number dependable of user value | |
this.resolveSlidesNumber(); | |
// relcalculate currentSlide | |
// prevent hiding items when browser width increases | |
if (this.currentSlide + this.perPage > this.innerElements.length) { | |
this.currentSlide = this.innerElements.length <= this.perPage ? 0 : this.innerElements.length - this.perPage; | |
} | |
this.selectorWidth = this.selector.offsetWidth; | |
this.buildSliderFrame(); | |
} | |
/** | |
* Clear drag after touchend and mouseup event | |
*/ | |
private clearDrag (): void { | |
this.drag.startX = 0; | |
this.drag.endX = 0; | |
this.drag.startY = 0; | |
this.drag.letItGo = null; | |
this.drag.preventClick = false; | |
} | |
/** | |
* touchstart event handler | |
*/ | |
private touchstartHandler = (e: TouchEvent): void => { | |
// Prevent dragging / swiping on inputs, selects and textareas | |
const ignoreSiema = [ 'TEXTAREA', 'OPTION', 'INPUT', 'SELECT' ].indexOf((e.target as HTMLElement).nodeName) !== -1; | |
if (ignoreSiema) return; | |
e.stopPropagation(); | |
this.pointerDown = true; | |
this.drag.startX = e.touches[0].pageX; | |
this.drag.startY = e.touches[0].pageY; | |
} | |
/** | |
* touchend event handler | |
*/ | |
private touchendHandler = (e: TouchEvent): void => { | |
e.stopPropagation(); | |
this.pointerDown = false; | |
this.enableTransition(); | |
if (this.drag.endX) this.updateAfterDrag(); | |
this.clearDrag(); | |
} | |
/** | |
* touchmove event handler | |
*/ | |
private touchmoveHandler = (e: TouchEvent): void => { | |
e.stopPropagation(); | |
if (this.drag.letItGo === null) { | |
this.drag.letItGo = Math.abs( | |
this.drag.startY - e.touches[0].pageY | |
) < Math.abs(this.drag.startX - e.touches[0].pageX); | |
} | |
if (this.pointerDown && this.drag.letItGo) { | |
e.preventDefault(); | |
this.drag.endX = e.touches[0].pageX; | |
this.sliderFrame.style.transition = 'all 0ms ' + this.config.easing; | |
const currentSlide = this.config.loop | |
? this.currentSlide + this.perPage | |
: this.currentSlide; | |
const dragOffset = (this.drag.endX - this.drag.startX); | |
const currentOffset = currentSlide * (this.selectorWidth / this.perPage); | |
const offset = this.config.rtl | |
? currentOffset + dragOffset | |
: currentOffset - dragOffset; | |
this.sliderFrame.style[this.transformProperty] = ( | |
'translate3d(' + ((this.config.rtl ? 1 : -1) * offset) + 'px, 0, 0)' | |
); | |
} | |
} | |
/** | |
* mousedown event handler | |
*/ | |
private mousedownHandler = (e: MouseEvent): void => { | |
// Prevent dragging / swiping on inputs, selects and textareas | |
const ignoreSiema = [ | |
'TEXTAREA', | |
'OPTION', | |
'INPUT', | |
'SELECT' | |
].indexOf((e.target as HTMLElement).nodeName) !== -1; | |
if (ignoreSiema) return; | |
e.preventDefault(); | |
e.stopPropagation(); | |
this.pointerDown = true; | |
this.drag.startX = e.pageX; | |
this.drag.preventClick = this.insideAnchor(e.target); | |
} | |
/** | |
* mouseup event handler | |
*/ | |
private mouseupHandler = (e: MouseEvent): void => { | |
e.stopPropagation(); | |
e.preventDefault(); | |
this.pointerDown = false; | |
this.selector.style.cursor = '-webkit-grab'; | |
this.enableTransition(); | |
if (this.drag.endX) this.updateAfterDrag(); | |
this.clearDrag(); | |
} | |
private insideAnchor (elem: any) { | |
let value = false; | |
do { value = elem.nodeName === 'A'; } while (!value && (elem = elem.parentNode)); | |
return value; | |
} | |
/** | |
* mousemove event handler | |
*/ | |
private mousemoveHandler = (e: MouseEvent): void => { | |
e.preventDefault(); | |
if (this.pointerDown) { | |
this.drag.endX = e.pageX; | |
this.selector.style.cursor = '-webkit-grabbing'; | |
this.sliderFrame.style.transition = 'all 0ms ' + this.config.easing; | |
const currentSlide = this.config.loop | |
? this.currentSlide + this.perPage | |
: this.currentSlide; | |
const currentOffset = currentSlide * (this.selectorWidth / this.perPage); | |
const dragOffset = (this.drag.endX - this.drag.startX); | |
const offset = this.config.rtl | |
? currentOffset + dragOffset | |
: currentOffset - dragOffset; | |
this.sliderFrame.style[this.transformProperty] = ( | |
'translate3d(' + ((this.config.rtl ? 1 : -1) * offset) + 'px, 0, 0)' | |
); | |
} | |
} | |
/** | |
* mouseleave event handler | |
*/ | |
private mouseleaveHandler = (e: MouseEvent): void => { | |
if (this.pointerDown) { | |
this.pointerDown = false; | |
this.selector.style.cursor = '-webkit-grab'; | |
this.drag.endX = e.pageX; | |
this.drag.preventClick = false; | |
this.enableTransition(); | |
this.updateAfterDrag(); | |
this.clearDrag(); | |
} | |
} | |
/** | |
* click event handler | |
*/ | |
private clickHandler = (e: Event): void => { | |
// if the dragged element is a link | |
// prevent browsers from folowing the link | |
if (this.drag.preventClick) e.preventDefault(); | |
this.drag.preventClick = false; | |
} | |
/** | |
* Remove item from carousel. | |
*/ | |
public remove (index: number, callback?: () => void): void { | |
if (index < 0 || index >= this.innerElements.length) { | |
throw new Error('Item to remove doesn\'t exist'); | |
} | |
// Shift sliderFrame back by one item when: | |
// 1. Item with lower index than currenSlide is removed. | |
// 2. Last item is removed. | |
const lowerIndex = index < this.currentSlide; | |
const lastItem = this.currentSlide + this.perPage - 1 === index; | |
if (lowerIndex || lastItem) this.currentSlide--; | |
this.innerElements.splice(index, 1); | |
// build a frame and slide to a currentSlide | |
this.buildSliderFrame(); | |
if (callback) callback.call(this); | |
} | |
/** | |
* Insert item to carousel at particular index. | |
*/ | |
public insert (item: HTMLElement, index: number, callback?: () => void): void { | |
if (index < 0 || index > this.innerElements.length + 1) { | |
throw new Error('Unable to inset it at this index 😭'); | |
} | |
if (this.innerElements.indexOf(item) !== -1) { | |
throw new Error('The same item in a carousel? Really? Nope 😭'); | |
} | |
// Avoid shifting content | |
const shouldItShift = index <= this.currentSlide && this.innerElements.length; | |
this.currentSlide = shouldItShift ? this.currentSlide + 1 : this.currentSlide; | |
this.innerElements.splice(index, 0, item); | |
// build a frame and slide to a currentSlide | |
this.buildSliderFrame(); | |
if (callback) callback.call(this); | |
} | |
/** | |
* Prepernd item to carousel. | |
*/ | |
public prepend (item: HTMLElement, callback?: () => void): void { | |
this.insert(item, 0); | |
if (callback) callback.call(this); | |
} | |
/** | |
* Append item to carousel. | |
*/ | |
public append (item: HTMLElement, callback?: () => void): void { | |
this.insert(item, this.innerElements.length + 1); | |
if (callback) callback.call(this); | |
} | |
/** | |
* Removes listeners and optionally restores to initial markup | |
*/ | |
public destroy (restoreMarkup = false, callback?: () => void): void { | |
this.detachEvents(); | |
this.selector.style.cursor = 'auto'; | |
if (restoreMarkup) { | |
const slides = document.createDocumentFragment(); | |
for (let i = 0; i < this.innerElements.length; i++) { | |
slides.appendChild(this.innerElements[i]); | |
} | |
this.selector.innerHTML = ''; | |
this.selector.appendChild(slides); | |
this.selector.removeAttribute('style'); | |
} | |
if (callback) callback.call(this); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment