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.

Saturday, 12 June 2021

Javascript Javagony 99 Bottles of beer (yes please)!

I came across a post on reddit, Javagony for Javascript? this morning and thought I'd have to give it a go:

const printSong = bottles => {
  isFinite(1/(bottles -1)) && console.log(`${bottles.toString()} bottles of beer on the wall,\n${bottles.toString()} bottles of beer!\nTake on down,\nPass it around,`)
  isFinite(1/(bottles -1)) && bottles--
  console.log(`${bottles.toString()} bottles of beer on the wall,`)
  isFinite(1/(bottles -1)) && printSong(bottles)
  !isFinite(1/(bottles -1)) && console.log(`${bottles.toString()} bottle of beer on the wall,\n${bottles.toString()} bottle of beer!\nTake on down,\nPass it around,\nNo more bottles of beer on the wall!`)  
}

printSong(99)

I'll keep an eye on other puzzles - the Blackjack one will likely keep me busy too!

Saturday, 5 June 2021

Details/Summary elements and Lighthouse

Jumping like a jackrabbit on crystal meth

...was how an element was described as behaving upon being clicked.

This complaint was concerning an accordion element facilitated by Bootstrap4, so I decided to see what could be improved. I'm a holy terror for removing fancy geegaws with native elements as often as possible, evidenced by my deep and abiding respect for Description List (dl) (I know I'm not alone in that either, eh Wakkos?).

After checking to see just how well supported the details/summary paired elements were, I decided to give them a go (they're supported everywhere except IE11 - but we don't care).

That's an interesting point, though, isn't it? Just as Description Lists require matched pairs of Description Term (dt) and Description Details (dd) elements, the details must be provided with a summary element, which acts like a label. One wonders what would happen if we had one without the other...

A colleague raised concerns regarding accessibility and the likely impact on Cumulative Layout Shift (CLS). With a few minor changes to the markup (and a tiny bit of JS to update those changes), I reasoned that it would be more than satisfactory regarding accessibility. I also figured that CLS would not be an issue as clicking on the summary element within the details element would be a deliberate choice and thus not subject to the dictates of CLS. I set to work on a couple of glitches and then ran them through Lighthouse.

The Bootstrap4 accordion version did well with a performance score of 96, accessibility at 96, best practice at 93 and SEO at 91. The summary/details version scored with a performance score of 99, accessibility at 97, best practice at 93 and SEO at 91. No changes for best practice or SEO scores but a 3 point increase in performance and 1 point increase for accessibility. Nothing major to write home about, but better than a kick in the teeth.

Those figures are pretty decent indicators that the summary/details pair may well be the way to go. Quite apart from the Lighthouse figures, the HTML is slightly smaller using the summary/details pairs, measuring in at 4.8 kB - the Bootstrap 4 version measures in at 7.3 kB, but that doesn't include the Bootstrap Bundle and jQuery (Bootstrap 5 doesn't require jQuery).

I wonder what the impact would be using Bootstrap5, especially as it no longer requires jQuery?

I also got to thinking about inlining the JS and came up with this (using Bootstrap5 and no external JS):

<details class="card">
  <summary class="card-header"
           aria-expanded="false"
           tabindex="0"
           role="button"
           onclick="((e) => {
             const summary = e.target.closest('summary')
             const details = e.target.closest('details')
             summary.setAttribute('aria-expanded', !details.hasAttribute('open'))
           })(arguments[0])">
    <h2 class="h5 mb-0">
      Will Royal Mail need a signature?
    </h2>
  </summary>
  <div class="card-body">
    Our courier is no longer asking recipients to sign for items delivered on or after Saturday 14 March 2020. Please click here to find out more.
  </div>
</details>

Which can be shrunk further to:

<details class="card">
  <summary class="card-header"
           aria-expanded="false"
           tabindex="0"
           role="button"
           onclick="(e=>e.target.closest('summary').setAttribute('aria-expanded', !e.target.closest('details').hasAttribute('open')))(arguments[0])">
    <h2 class="h5 mb-0">
      Will Royal Mail need a signature?
    </h2>
  </summary>
  <div class="card-body">
    Our courier is no longer asking recipients to sign for items delivered on or after Saturday 14 March 2020. Please click here to find out more.
  </div>
</details>

Not pretty, but it works a treat. It also has exactly the same Lighthouse scores as the other, but is slightly larger in terms of the HTML, weighing in at at 6.6 kB.

Wednesday, 2 June 2021

Walking for NASS

As you likely already know, AS is an odd illness because the primary way of alleviating it involves exercise. Exercising your aching joints can seem a nonsensical way of preventing increased stiffness later, but it seems to work a treat. Asking for a Blue Badge assessment does, I can assure you, lead to some quizzical looks. Sure, I can walk, and sure, I try to park as far away as possible from where I'm going, but - when I'm walking to my final destination once I get out of the car - I look pretty disabled; hobbling along like someone who needs some help.

Last year, my friend and colleague Claire Ashton started up a walking challenge to keep people engaged in physical exercise during the lockdown. She's written about the process on LinkedIn, and I'd recommend you read that article if you want to set up a challenge yourself. I signed up, and I think I did pretty well, primarily because I walk the dogs at least twice a day (if it's too hot, we knock off the lunchtime walk and instead do two long walks, one early morning and the other once the sun goes down). I also spent weekends walking, a lot! Even to the extent that someone stopped to enquire to check if I were OK.

After completing the walking challenge at work, I wanted to carry on. Claire and I followed a link from a colleague, joined her team, and signed up for the walk 1,000 miles challenge. That coincided with the Walk with us, Walk for AS fundraiser, so I adapted the template to 1,000 miles rather than 250, and away I went. I hit 1,000 miles at the end of April this year and achieved my funding target of £250.00 a few days ago. I've now signed up for the Pacific Crest Trail Virtual Challenge, which is a further 2,485.5 miles, and as of today, I'm 15.16% of the way there (I know because I wrote a Bookmarklet). Hopefully, I'll get there by the end of the year; at least once it gets a bit cooler, it'll be OK to take the dogs for long walks at lunchtime.

I still walk like I'm about three times older than I am (admittedly, that'd put me at near to 150 - but you perhaps know what I mean), and the tendonitis I've got in my ankle means that most steps are not in the least bit comfortable, but it does keep me active! And messing about with charting my progress, and working out percentages, has kept my mind engaged while I'm not walking. I've kept involved while walking with listening to audiobooks via audible and encouraging my youngest dog to drop the latest terrible thing that she's decided to eat. Every so often, I even manage to have someone else to walk with, now that restrictions are easing.

I hope NASS do the same next year; I'll certainly sign up again!