Friday 23 July 2021

Email: border-radius and box-shadow

I love working with designers; they constantly challenge me to develop new designs to implement. They have their lovely pixel-based programs and design beautiful things, which I then convert into different formats. This conversion is often relatively easy, but recently I had to create an email with the content within a rounded container, a rounded container with a drop shadow.

Let me tell you, that was a pain! I did a fair bit of research and found excellent ways of generating rounded corners (not least my own from back in the day). AE Writer has an article on drop shadow for HTML email, which sort of confirmed my thoughts on needing to use tables. Alejandro Vargas has an article up on Medium about HTML email rounded corners. I spent a fair few hours over last weekend taking a screengrab of a container into The Gimp and playing with contrast to generate the appropriate data format for a chunk of JS to generate the appropriate nested divs within a table.

Given this table row:

<tr data-row="5"
    data-description="6->3-fe->2-fd->2-fc->1-fb->1-fd->ff">

This code:

(()=>{

  const 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]
      }
    }
  }



  document.querySelectorAll('tr').forEach(tr => {
    if(tr.dataset.description){
      const description = tr.dataset.description.split('->')
      let target = tr
      const td = document.createElement('td')
      td.setAttribute('align', 'center')
      setStyles(td, {
        paddingLeft: `${description[0]}px`,
        paddingRight:`${description[0]}px`,
      })
      if(!tr.dataset.main){
        setStyles(td, {
          height:'1px',
          lineHeight:'1px',
          fontSize:'1px'
        })
      }
      target.appendChild(td)
      target = td
      description.shift()
      for(let i = 0; i < description.length; i++){
        const parts = description[i].split('-')
        const div = document.createElement('div')
        setStyles(div, {
          display:'block',
          paddingLeft:'0px',
          paddingRight:'0px',
          backgroundColor:`#${parts[0].repeat(3)}`,
          width: '100% !important',
          minWidth: 'initial !important',
        })
        if(parts.length !== 1){
          setStyles(div, {
            paddingLeft: `${parts[0]}px`,
            paddingRight:`${parts[0]}px`,
            backgroundColor:`#${parts[1].repeat(3)}`,
          })
        }else{
          setStyles(div, {
            backgroundColor:`#${parts[0].repeat(3)}`,
          })
        }
        if(!tr.dataset.main){
          setStyles(div, {
            height:'1px',
            lineHeight:'1px',
            fontSize:'1px'
          })
        }
        target.appendChild(div)
        target = div
      }
    }
  })
})()

Would generate this markup:

<tr data-row="5"
    data-description="6->3-fe->2-fd->2-fc->1-fb->1-fd->ff">
  <td align="center"
      style="padding-left: 6px; padding-right: 6px; height: 1px; line-height: 1px; font-size: 1px;">
    <div style="display: block; padding-left: 3px; padding-right: 3px; background-color: rgb(254, 254, 254); height: 1px; line-height: 1px; font-size: 1px;">
      <div style="display: block; padding-left: 2px; padding-right: 2px; background-color: rgb(253, 253, 253); height: 1px; line-height: 1px; font-size: 1px;">
        <div style="display: block; padding-left: 2px; padding-right: 2px; background-color: rgb(252, 252, 252); height: 1px; line-height: 1px; font-size: 1px;">
          <div style="display: block; padding-left: 1px; padding-right: 1px; background-color: rgb(251, 251, 251); height: 1px; line-height: 1px; font-size: 1px;">
            <div style="display: block; padding-left: 1px; padding-right: 1px; background-color: rgb(253, 253, 253); height: 1px; line-height: 1px; font-size: 1px;">
              <div style="display: block; padding-left: 0px; padding-right: 0px; background-color: rgb(255, 255, 255); height: 1px; line-height: 1px; font-size: 1px;">
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </td>
</tr>

That whole manual process was boring, though, so I decided to automate the process. Especially as, knowing designers, I just knew that the box-shadow or border-radius would need to change in the future.

I knew that libraries for generating images from DOM elements were available, so I tried a couple. html2canvas wasn't quite what I was looking for, but dom-to-image worked a treat!

I decided to take an incremental approach and started by copying the dom to a png image format and placing that png within a canvas element of the same size as the element. This process is the code within the Immediately Invoked Function Expression (IIFE) at the bottom of the file. One thing to take note of is the onload function. I ran into all sorts of issues with the subsequent scripts failing until I clocked that the img wasn't loaded when I tried to manipulate it. Once we've set drawn the image atop the canvas, we add some data attributes using the getDimension function - I wouldn't've bothered with this except WebStorm kept complaining about the amount of repeated code I had.

trim, invoked at the end of the IIFE, strips out the remaining white space around the image, leaving us with an image that has only grey-scale colours surrounding it (except at the corners). It trims the rows and columns which contain only white colour values by referencing the values from getDimension. getDimension was clever and checked the values from iterating over the data from getImageData, if any value was not 255 then we had something what was not pure white. The array from getImageData should be chunked into sub-arrays of four as each lump of four values represent the RGBA value from a single pixel.

Once we have a trimmed image, we can build the values that equate with the data attribute we had in the original implementation. I created a simple class for this as a simple array wouldn't work here as I needed more than just the array of values; I needed to know which was the repeating row, so we had a placeholder for the actual content.

We chunk the data into sub-arrays of four and grab the hex colour value from each chunk. If the preceding HEX value is identical, the preceding classes incidence count is incremented; if not, it's added to the row array. If the row is not identical to the preceding row, then it's added to the rows as a Row object; if it is identical then the preceding Row has it's main value changed to true - this will be our placeholder.

We then build our table using the array of Row objects (rows) using code that is very similar to the one above but that is ever so slightly more nuanced and places a placeholder table within the main row. Nice eh? I'm quite pleased with it.

Tuesday 13 July 2021

Rhino SFRA

Being a fan of JavaScript and now working with SFRA, colleagues told me that we're using Rhino under the hood rather than NodeJS (as I'd assumed from looking at the controllers). Further, from doing a little research, I found out that we're using Rhino 1.7R5. As such, we're a little limited in terms of the JS we can use; proper ES5.

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.