Monday, 5 July 2021

Replacing Slick Slider with keen-slider

"Simply a drop in replacement" was what I was told... It so wasn't, but it was a fantastic opportunity to mess about with a new library and get it working like another one; learning something new is always worthwhile, especially if it's better for the business. I wanted to estimate three weeks but then plumped for two instead - fixing the bugs meant that it was at least three, and maybe even a little more.

We used Slick Slider for carousels, but there were questions about the amount of time the JS took to run. We read an article by Javier Villanueva titled Javascript sliders will kill your website performance. He was particularly dismissive of Slick Slider while being less dismissive of Keen Slider, despite keen-slider lacking some of Slick Sliders built-in features. While reading his article, I also found several resources suggesting that we should avoid carousels like the plague:

I'd suggest reading those articles and following any pertinent links to decide whether or not you want to use carousels, but do that before you implement carousels on your site! If you've already implemented them, and your internal clients are still keen on them (despite being asked to read the links above), then this post details my experiences of moving most - if not all - of our Slick Slider carousels to keen-slider. Be prepared though, I decided to go all ES6 classy - and it's all the better for being that way too!

I decided to use classes because Slick Slider did an awful lot for us to place items. Let me elaborate. If we had two items and the Slick Slider was set to display three, then the two items would be shrunk and inserted from left to right at the correct width, a third of the container's width. That meant a gap on the right was 33.33% wide, and that seemed to be the accepted way of doing it. I changed this and aligned the items in the centre of the container but at the correct width. This change caused a bug to be raised, ah well. It's worth noting that by default, a keen-slider will make the items fit the container.

So anyway, we needed to read some extra data, provided as data attributes, for some carousels. We also needed to be aware that not all carousels were present on page load and that some would be inserted or removed at various points during the customer's journey through the site. Some carousels would only be carousels on mobile devices - that was fun. And to top all that off, we needed to be aware of rotation changes on mobile devices.

Anyway, this is our base class:

class KeenCarousel {
    constructor(element){
        this.element = element
    }
    createButton(text) {
        const button = Helpers.createAndPopulate('button', 'Previous', {
            'class': `${text === 'Previous' ? 'slick-prev': 'slick-next'} slick-arrow`,
            'aria-label': text,
            'type': 'button'
        })
        button.addEventListener('click', () => (text === 'Previous') ? this.slider.prev() : this.slider.next())
        return button
    }
    createButtonHandler(text) {
        const button = Helpers.createAndPopulate('button', 'Previous', {
            'class': `${text === 'Previous' ? 'slick-prev': 'slick-next'} slick-arrow`,
            'aria-label': text,
            'type': 'button'
        })
        button.addEventListener('click', () => (text === 'Previous') ? this.handleChange('prev') : this.handleChange('next'))
        return button
    }
    createButtonListener(text) {
        const button = Helpers.createAndPopulate('button', 'Previous', {
            'class': `${text === 'Previous' ? 'slick-prev': 'slick-next'} slick-arrow`,
            'aria-label': text,
            'type': 'button'
        })
        button.addEventListener('mouseover', () => this.autoplay(false))
        button.addEventListener('mouseout', () => this.autoplay(true))
        button.addEventListener('click', () => (text === 'Previous') ? this.slider.prev() : this.slider.next())
        return button
    }
    createDots(instance) {
        const dots_ul = Helpers.createAndPopulate('ul', null, {
            'class': 'slick-dots',
            'role': 'tablist'
        })
        this.element.parentNode.insertBefore(dots_ul, this.element.nextSibling)
        ;[...this.element.querySelectorAll(this.options.slides)].forEach(function (_, index, arr) {
            const dot_li = Helpers.createAndPopulate('li', null, {
                'role': 'presentation'
            })
            if(!index){
                dot_li.classList.add('slick-active')
            }
            dots_ul.append(dot_li)
            const button = Helpers.createAndPopulate('button', index + 1, {
                'type': 'button',
                'role': 'tab',
                'aria-label': `${index + 1} of ${arr.length}`,
                'tabindex': !index ? '0' : '-1'
            })
            if(!index){
                button.setAttribute('aria-selected', 'true')
            }
            button.addEventListener('click', function(){
                instance.moveToSlide(index)
            })
            dot_li.append(button)
        })
    }
    updateDots(instance) {
        const slide = instance.details().relativeSlide
        const ul = this.element.parentNode.querySelector('.slick-dots')
        if(ul){
            ;[...ul.querySelectorAll('li')].forEach((li, index) => {
                index === slide
                    ? li.classList.add('slick-active')
                    : li.classList.remove('slick-active')
                const button = li.querySelector('button')
                button.setAttribute('aria-selected', index === slide)
                button.setAttribute('tabindex', index === slide ? '-1' : '0' )
            })
        }
    }
    getTarget(breakpoints) {
        const target = window.innerWidth
        return breakpoints.find(breakpoint => {
            if(breakpoint.min && breakpoint.max) {
                if(target >= breakpoint.min && target <= breakpoint.max){
                    return true
                }
            } else {
                if(breakpoint.min){
                    if(target >= breakpoint.min){
                        return true
                    } 
                } else {
                    if(target <= breakpoint.max) {
                        return true
                    }        
                }
            }
        }).slides
    }
    buildBreakpoints(breakpoints) {
        return breakpoints.reduce((acc, breakpoint) => {
            if(breakpoint.min && breakpoint.max){
                acc[`(min-width: ${breakpoint.min}px) and (max-width: ${breakpoint.max}px)`] = {
                    'slidesPerView': breakpoint.slides
                }
            } else {
                if(breakpoint.min) {
                    acc[`(min-width: ${breakpoint.min}px)`] = {
                        'slidesPerView': breakpoint.slides          
                    }
                } else {
                    acc[`(max-width: ${breakpoint.max}px)`] = {
                        'slidesPerView': breakpoint.slides          
                    }
                }
            }
            return acc
        }, {})
    }
}

You'll see that I used some utility methods which were in a Helper class, so I should share those here too:

class Helpers {
    static createElement(element) {
        return document.createElement(element)
    }
    static setText (element, text) {
        return element.appendChild(document.createTextNode(text))
    }
    static createAndPopulate(element, text = null, attributes = null, styles = null) {
        const el = this.createElement(element)
        text && Helpers.setText(el, text)
        attributes && Helpers.setAttributes(el, attributes)
        styles && Helpers.setStyles(el, styles)
        return el
    }
    static setAttributes(element, attributes) {
        for (const attr in attributes) {
            if(attributes.hasOwnProperty(attr)) {
                // converts camelCase to hyphen-separated, lowercase, string
                // e.g. "dataId" becomes "data-id"
                const attribute = attr.split(/(?=[A-Z])/).join('-').toLowerCase()
                element.setAttribute(attribute, attributes[attr])
            }
        }
    }
    static setStyles(element, declarations) {
        for (const prop in declarations) {
            if(declarations.hasOwnProperty(prop)){
                const property = prop.split(/(?=[A-Z])/).join('-').toLowerCase()
                element.style[property] = declarations[prop]
            }
        }
    }
    static removeElements(elms) { 
        elms.forEach(el => el.remove())
    }
}

I've used these in multiple projects, and I'm quite happy with their performance. The fun things to note in the KeenCarousel class are those methods associated with breakpoints. We're mobile-first, so we need to be aware of screen sizes and different resolutions. We set our breakpoints at initialisation like this:

this.breakpoints = [
    {
        min: 1440,
        slides: 4
    },{
        max: 1439,
        min: 810,
        slides: 3
    },{
        max: 809,
        min: 750,
        slides: 2
    },{
        max: 749,
        slides: 1
    }
]

Then provide them to Keen like this:

this.options = {
    loop: true,
    duration: 300,
    slides,
    breakpoints: this.buildBreakpoints(this.breakpoints),
}

Those options semi-map to the options Slick Slider accepts; for instance, loop is equivalent to infinite. Some things don't work in the same way, and that's why we have so much more code. It's going to be laborious to go through all the child classes, so we'll only look at a few here; they all inherit from KeenCarousel, though.

class PromotionCarousel extends KeenCarousel{
    constructor(element, parent){
        super(element, element)
        const slides = '.slick-carousel-item'
        this.children = this.element.querySelectorAll(slides)
        this.slidesNumber = this.children.length
        window.addEventListener('resize', () => this.checkWidth())
        this.breakpoints = [
            {
                min: 980,
                slides: 4
            },{
                max: 979,
                min: 540,
                slides: 3
            },{
                max: 539,
                slides: 2
            }
        ]
        this.options = {
            loop: true,
            slides,
            breakpoints: this.buildBreakpoints(this.breakpoints),
        }
        this.resizeTimer = null
        this.checkWidth()
    }
    handleChange(direction) {
        const relativeSlide = this.slider.details().relativeSlide 
        const slidesPerView = this.slider.options().slidesPerView
        if(direction === 'prev'){
            if(this.slidesNumber >= (slidesPerView * 2)){
                this.slider.moveToSlideRelative(relativeSlide - slidesPerView, true)
            }else{
                this.slider.prev()
            }
        }else{
            if(this.slidesNumber >= (slidesPerView * 2)){
                this.slider.moveToSlideRelative(relativeSlide + slidesPerView, true)
            }else{
                this.slider.next()
            }
        }
    }
    checkWidth() {
        if (this.resizeTimer){
            clearTimeout(this.resizeTimer);
        } 
        this.resizeTimer = setTimeout(() => {
            const breakpointSlide = this.getTarget(this.breakpoints)
            if(this.slidesNumber > breakpointSlide) {
                if(!this.slider){
                    this.slider = new KeenSlider(this.element, this.options)
                    this.element.parentNode.insertBefore(this.createButtonHandler('Previous'), this.element.parentNode.firstChild)
                    this.element.parentNode.append(this.createButtonHandler('Next'))
                }
            }else{
                this.slider && this.slider.destroy()
                Helpers.removeElements(this.element.parentNode.querySelectorAll('.slick-arrow'))
                this.slider = null
                setTimeout(() => {
                    this.children.forEach(slide => {
                        slide.setAttribute('style', `width: ${(100 / breakpointSlide).toFixed(2)}%`);
                    })
                }, this.options.duration * 2)
            }
            this.resizeTimer = null
            this.element.parentNode.classList.remove('opacity-0')
        }, 100)
    }
}

This class is the last one I've worked on, so it's fresh in my mind. It's also worth bearing in mind that the carousel is contained within a _holder div with a small amount of padding to the left and right to hold the arrows which the user can click on to move through the carousel - though dragging works by default, which is nice.

We've added functionality to that movement: if more than twice as many children can be displayed (according to the breakpoints), then the carousel moves the same number of items as the breakpoint for the carousel. Meaning that if there are three items in the carousel, and we're only showing two, then clicking on the arrow moves the carousel one item along. Should there be four or more items, then it moves two items at a time. I like this mechanism, and the logic is in the handleChange method. We use the parent class's createButtonHandler method to create those buttons, and they're attached to the parent _holder element rather than to the carousel itself. We don't initialise the carousel within the constructor method. Instead, we call the checkWidth method - we also add an event listener to the window, which will call checkWidth. checkWidth is the method where most of the heavy lifting occurs.

Upon developing this carousel, I noticed that it was firing multiple times. We make sure to do a sort of debounce using a setTimeout - it's a little bit of a hack, but it works a treat and saves using an external library which - in all likelihood - would do something similar but hide it from us.

After getting the number of items to be displayed, we check it against the number of children elements - which we set during the construction of the class. We know that the items all have a class of slick-carousel-item, so we use a querySelectorAll to get them all from the element (we might need them later) and store their number in the classes slidesNumber property.

If the slidesNumber is higher than the current breakpoint slide number, and we don't currently have a slider in play, we initialise the keen-slider and store it in our slider property. If we don't need to kick off a keen-slider, we destroy the slider if it exists, remove its buttons, and tweak the items within a setTimeout (see, it was a good idea to store them when we initialised the carousel) so that they display appropriately. The carousel element is a horizontal flex-box with overflow hidden, so items automatically float to the left.

We carry on listening, though and, should the resize event occur again, we'll run checkWidth all over again. Neat eh?

On our product display page, we have several images. We have the primary image associated with the product and any other pictures or resources (such as videos). The main carousel only shows one item and another carousel, directly under it, contains thumbnails of that image and any other images or links. Clicking on the thumbnails moves the primary carousel to the appropriate image. As such, one acts as the ParentCarousel; the other is the ChildCarousel. In terms of their relationship, the parent invokes the child but then listens to it; the child only exists to direct the parent and - by default - shows four images.

Let's look at the parent:

class ParentCarousel extends KeenCarousel {
    constructor(element, passedOptions = {}, rejig = false){
        super(element, element)
        this.passedOptions = passedOptions
        const slides = '.parent-element'
        this.children = this.element.querySelectorAll(slides)
        this.rejig = rejig
        this.hasButtons = false
        this.options = {
            loop: this.children.length > 1,
            duration: 1000,
            slides,
            controls: true,
            created: () => this.element.parentNode.classList.remove('opacity-0'),
            mounted: () => setTimeout(() => this.slider.resize(), 3000),
            afterChange: element => {
                ;[...this.element.querySelectorAll('iframe')].forEach(
                    iframe => iframe.contentWindow.postMessage(JSON.stringify({ 
                        event: "command",
                        func: "stopVideo",
                        args: "" }), '*')
                )
                const slideIdx = element.details().relativeSlide
                const slide = this.children[slideIdx]
                const img = slide.querySelector('.parent-carousel_image')
                if(img && !slide.classList.contains('zoomable')){
                    slide.classList.add('zoomable')
                    $(slide).zoom({
                        url: $(slide).data('enlarge-img'),
                        on: 'click',
                        magnify: 0.75,
                        touch: true
                    })
                }
            }
        }
        ;[...this.element.querySelectorAll('iframe')].forEach(iframe => {
            const src = decodeURIComponent(iframe.src)
            iframe.src = `${src}${!!~src.indexOf('?') ? '&enablejsapi=1' : '?enablejsapi=1'}`
        })
        this.slider = new KeenSlider(element, this.options)
        this.child = document.querySelector(this.passedOptions.asNavFor)
        if(this.child){
            this.child = new ChildCarousel(this.child, this)
        }
        if(this.rejig){
            Helpers.removeElements(this.element.parentNode.querySelectorAll(".slick-arrow"))
        }
        if(this.children.length > 1){
            this.element.parentNode.insertBefore(this.createButtonHandler('Previous'), this.element.parentNode.firstChild)
            this.element.parentNode.append(this.createButtonHandler('Next'))
        }
        this.enableZoom()
    }
    handleChange(direction) {
        if(direction === 'prev'){
            this.slider.prev()
        }else{
            this.slider.next()
        }
    }
}

It's quite similar to the previous carousel except for adding the zoomable class and triggering some jQuery to elements just coming into view - this saves us loads as larger images are only downloaded when required. It also does some fancy stuff with YouTube links which might already have query parameters. If there are already query parameters, we append the new parameter with an ampersand (&); if not, we add it with a question mark (?). That particular issue took a good couple of hours trying to figure out why YouTube videos didn't stop when they were out of view on the carousel. We also have some logic on the backend that links the two carousels - the parent has a data attribute of data-as-nav-for that links to a thumbnail carousels class attribute. We need to check it's there before invoking it:

class ChildCarousel extends KeenCarousel{
    constructor(element, parent){
        super(element, element)
        const slides = '.child-element'
        this.children = this.element.querySelectorAll(slides)
        const slidesPerView = 4
        this.parent = parent
        this.options = {
            loop: true,
            slidesPerView,
            slides,
            mounted: () => this.element.parentNode.classList.remove('opacity-0'),
        }
        if(this.children.length > 3){
            this.slider = new KeenSlider(this.element, this.options)
            if(this.children.length > 3){
                this.element.parentNode.insertBefore(this.createButtonHandler('Previous'), this.element.parentNode.firstChild)
                this.element.parentNode.append(this.createButtonHandler('Next'))
            }
        }else{
            this.element.classList.add('justify-content-center')
            ;[...this.children].forEach((child) => {
                child.classList.add('w-33')
            })
            this.element.parentNode.classList.remove('opacity-0')
        }
        this.handleChildClicks()
    }
    handleChildClicks() {
        ;[...this.children].forEach((child, index) => {
            child.addEventListener('click', e => {
                this.parent.slider.moveToSlideRelative(index, true)
            })
        })
    }
    handleChange(direction) {
        if(direction === 'prev'){
            this.slider.prev()
        }else{
            this.slider.next()
        }
    }
}

Again, we do clever stuff about whether or not we invoke the child carousel's slick-slider. We also add event listeners to the child elements in handleChildClicks, which tells the parent to moveToSlideRelative.

Carousels are marked up with the class of opacity-0, which sets the element to have an opacity of 0. We remove the class once the carousel is initialised (whether or not it's a keen-slider). We sometimes also add an initialised class to ensure we don't re-initialise existing carousels. The opacity-0 class prevents Cummulative Layout Shift - the elements are there; they're invisible! I like this way of doing things - we hide them until they're ready.

These are just three of the carousel classes we use; there are another nine, some of which have dots underneath (which also act as navigation between the elements). Those carousels were fun, and I dare say you can see evidence of them in the keenCarousel classes createDots method.

Replacing Slick Slider with keen-slider was a mammoth task but thoroughly enjoyable. I pondered turning them into components like I considered doing for my interview, but I don't think we're ready for that just yet; perhaps the next time I have to look into this, I'll do just that, though.

No comments:

Post a Comment