🛠️ Carousel: Building a library

Hey ,

I'm thrilled to help you learn JavaScript. Unfortunately, you've landed on a page where you cannot access with your current purchase.

Please upgrade (use this link) access this content.

I'm super eager to help you learn more!

🛠️ Carousel: Building a library

We’ll start refactoring the Carousel by creating a carousel.js file. Make sure you link this carousel.js file to main.js.

<script type="module" src="js/main.js"></script>
import './carousel.js'

Multiple carousels can be used on the same page. Here’s one example (which I helped build a long time ago).

If we want to support multiple carousel instances, we need to find these 8 variables for each carousel that appears on the page.

const carousel = document.querySelector('.carousel')
const previousButton = carousel.querySelector('.previous-button')
const nextButton = carousel.querySelector('.next-button')
const contents = carousel.querySelector('.carousel__contents')
const slides = [...carousel.querySelectorAll('.carousel__slide')]
const dotsContainer = createDots(slides)
const dots = [...dotsContainer.children]
const liveregion = carousel.querySelector('[role="status"]')

Did you notice the last 7 variables all depend on carousel? We can easily create multiple instances with a forEach loop.

const carousels = [...document.querySelectorAll('.carousel')]

carousels.forEach(carousel => {
  const previousButton = carousel.querySelector('.previous-button')
  const nextButton = carousel.querySelector('.next-button')
  const contents = carousel.querySelector('.carousel__contents')
  const slides = [...carousel.querySelectorAll('.carousel__slide')]
  const dotsContainer = createDots(slides)
  const dots = [...dotsContainer.children]
  const liveregion = carousel.querySelector('[role="status"]')

  // All functions here
  // All event listeners here
})

Of course, we should check whether a carousel is present in the first place. We’ll only execute the code if carousels are present (so there won’t be errors).

const carousels = [...document.querySelectorAll('.carousel')]

if (carousels.length > 0)  {
  // Do stuff
}

At this point, the carousel should still function as normal.

The Carousel’s code is really hard to read at this point. Functions are all over the place, and the code doesn’t seem to have any structure.

We can make this code easier to read by using Object Oriented Programming. We will use Factory Functions for this component.

My favourite way of structuring components with Object Oriented Programming (with the Factory Function Flavour) is in the following format:

function ComponentName () {
  // Declare Private Variables
  // Declare component state (if required)

  const component = {
    // Declare methods
    // Declare event listeners
  }

  // Initialize the component
  // Add event listeners here as necessary

  // Return the component
  return component
}

Creating the Blueprint

We’ll start by creating a Carousel function to hold create carousels.

function Carousel () {
  // Do stuff
}

We can then create each carousel instance by calling Carousel.

if (carousels.length > 0) {
  carousels.forEach(carousel => {
    Carousel(carousel)
  })
}

That’s all you need to do for an Automatic Library.

Manual Libraries

If you’re creating a Manual Library, you can export Carousel. Then create the instances inside main.js.

export default function Carousel () {
  // Do stuff
}

We can then create each carousel instance by calling Carousel.

// main.js
const carousels = [...document.querySelectorAll('.carousel')]

if (carousels.length > 0) {
  carousels.forEach(carousel => {
    Carousel(carousel)
  })
}

We rely on users to create the HTML for the Carousel. This means we need them to pass in the carousel element. We’ll name this variable carouselElement to make it clear for us.

// carousel.js
export default function Carousel (carouselElement) {
  // ...
}

We then derive the rest of the variables we need from this carouselElement.

export default function Carousel (carouselElement) {
  const contents = carouselElement.querySelector('.carousel__contents')
  const slides = [...carouselElement.querySelectorAll('.carousel__slide')]
  const previousButton = carouselElement.querySelector('.previous-button')
  const nextButton = carouselElement.querySelector('.next-button')
  const liveregion = carouselElement.querySelector('[role="status"]')
  const dotsContainer = createDots(slides)
  const dots = [...dotsContainer.children]
}

Notice that dotsContainer needs a createDots function. We can copy-paste createDots into carousel.js.

// carousel.js
export default function Carousel (carouselElement) {/* ... */}

function createDots (slides) {
  const dotsContainer = document.createElement('div')
  dotsContainer.classList.add('carousel__dots')
  dotsContainer.setAttribute('aria-hidden', true)

  slides.forEach(slide => {
    const dot = document.createElement('button')
    dot.classList.add('carousel__dot')
    dot.setAttribute('tabindex', -1)

    if (slide.classList.contains('is-selected')) {
      dot.classList.add('is-selected')
    }

    dotsContainer.appendChild(dot)
  })
  return dotsContainer
}

After we create dotsContainer, we need to add it to the carousel.

export default function Carousel (carouselElement) {
  // ...
  const dotsContainer = createDots(slides)
  const dots = [...dotsContainer.children]

  carouselElement.appendChild(dotsContainer)
}

We also need to set the width and position of each slide.

export default function Carousel (carouselElement) {
  // ...
  carouselElement.appendChild(dotsContainer)
  setSlidePositions(slides)
}

We need to use setSlidePositions here, so go ahead and copy-paste setSlidePositions into carousel.js.

// carousel.js
export default function Carousel (carouselElement) {/* ... */}
function createDots (slides) {/* ... */}

function setSlidePositions (slides) {
  const slideWidth = slides[0].getBoundingClientRect().width
  slides.forEach((slide, index) => {
    slide.style.left = slideWidth * index + 'px'
  })
}

Next, we want to add event listeners.

Adding Event Listeners

Since we only have one variation for this carousel, we can add the event listeners directly inside Carousel.

export default function Carousel (carouselElement) {
  // Declare variables
  // Initialize Carousel

  // Add event listeners
}

We’ll add the event listeners one by one.

Next Button Event Listener

We will start with the nextButton's event listener. Go ahead and copy-paste everything we wrote into Carousel.

export default function Carousel (carouselElement) {
  // ...

  nextButton.addEventListener('click', event => {
    const currentSlideIndex = getCurrentSlideIndex()
    const nextSlideIndex = currentSlideIndex + 1
    switchSlide(currentSlideIndex, nextSlideIndex)
  })
}

We can see that this event listener needs getCurrentSlideIndex and switchSlide. We’ll work through them one by one.

Here’s what we wrote for getCurrentSlideIndex previously. If you examine the code closely, you’ll see we need contents and slides variables to work.

const getCurrentSlideIndex = _ => {
  const currentSlide = contents.querySelector('.is-selected')
  return slides.findIndex(slide => slide === currentSlide)
}

Since each Carousel instance has their own contents and slides, it make sense for us to create getCurrentSlideIndex inside Carousel. If we do this, contents and slides will be available in the lexical scope.

I prefer to put functions like getCurrentSlideIndex as methods to the component. The additional indentation makes it easy to understand the structure at a glance.

export default function Carousel (carouselElement) {
  // Declare variables

  const carousel = {
    getCurrentSlideIndex () {
      const currentSlide = contents.querySelector('.is-selected')
      return slides.findIndex(slide => slide === currentSlide)
    }
  }

  // Initialize Carousel
  // Add event listeners
}

We can use getCurrentSlideIndex like this. Notice I used the carousel object instead of this so we won’t have to deal with contexts where the value of this changes to something else.

export default function Carousel (carouselElement) {
  // ...

  nextButton.addEventListener('click', event => {
    const currentSlideIndex = carousel.getCurrentSlideIndex()
    // ...
  })
}

We can do the same thing with switchSlide.

export default function Carousel (carouselElement) {
  // Declare variables

  const carousel = {
    getCurrentSlideIndex () {
      const currentSlide = contents.querySelector('.is-selected')
      return slides.findIndex(slide => slide === currentSlide)
    },

    switchSlide (currentSlideIndex, targetSlideIndex) {
      // Copy-paste the code we wrote here
    }
  }

  // Initialize Carousel
  // Add event listeners
}

Here’s how we use switchSlide.

export default function Carousel (carouselElement) {
  // ...

  nextButton.addEventListener('click', event => {
    // ...
    carousel.switchSlide(currentSlideIndex, nextSlideIndex)
  })
}

Reorganising the event listener

When you look at nextButton's event listener, do you intuitively know what it does? Probably not. You have to read the code and figure out it changes to the next slide.

We can make this process easier to understand by creating a showNextSlide callback for this event listener. We’ll put this callback inside carousel.

export default function Carousel (carouselElement) {
  // Declare variables

  const carousel = {
    // Methods ...

    // Listeners
    showNextSlide () {
      const currentSlideIndex = carousel.getCurrentSlideIndex()
      const nextSlideIndex = currentSlideIndex + 1
      carousel.switchSlide(currentSlideIndex, nextSlideIndex)
    }
  }

  // Initialize Carousel
  // Add event listeners
}

We can then use showNextSlide like this:

export default function Carousel (carouselElement) {
  // ...
  nextButton.addEventListener('click', carousel.showNextSlide)
}

Previous button’s event listener

We can use the same process above to create the previous button’s event listener.

First, we will copy-paste the event listener into Carousel.

export default function Carousel (carouselElement) {
  // ...
  previousButton.addEventListener('click', event => {
    const currentSlideIndex = getCurrentSlideIndex()
    const previousSlideIndex = currentSlideIndex - 1
    switchSlide(currentSlideIndex, previousSlideIndex)
  })
}

We’ll update the function to use carousel.getCurrentSlideIndex and carousel.switchSlide.

export default function Carousel (carouselElement) {
  // ...
  previousButton.addEventListener('click', event => {
    const currentSlideIndex = carousel.getCurrentSlideIndex()
    const previousSlideIndex = currentSlideIndex - 1
    carousel.switchSlide(currentSlideIndex, previousSlideIndex)
  })
}

Finally, we will create a showPreviousSlide callback for this event listener. It makes the code easier to read.

export default function Carousel (carouselElement) {
  // Declare variables

  const carousel = {
    // Methods ...

    // Listeners
    showNextSlide () { /* ... */ },

    showPreviousSlide () {
      const currentSlideIndex = carousel.getCurrentSlideIndex()()
      const previousSlideIndex = currentSlideIndex - 1
      carousel.switchSlide(currentSlideIndex, previousSlideIndex)
    },
  }

  // Initialize Carousel
  // Add event listeners
}

Dots Event Listener

Next, we have an event listener that handles a click on each dot. Here’s the code we wrote previously:

dotsContainer.addEventListener('click', event => {
  const dot = event.target.closest('button')
  if (!dot) return

  const currentSlideIndex = getCurrentSlideIndex()
  const targetSlideIndex = dots.findIndex(d => d === dot)
  switchSlide(currentSlideIndex, targetSlideIndex)
})

You know the drill at this point.

  1. We will copy-paste this code into Carousel.
  2. Update this code to use carousel.getCurrentSlideIndex and carousel.switchSlide
  3. Create a named callback function. We’ll call this callback handleClicksOnDots.
export default function Carousel (carouselElement) {
  // Declare variables

  const carousel = {
    // Methods ...

    // Listeners
    showNextSlide () { /* ... */ },
    showPreviousSlide () { /* ... */ },

    handleClicksOnDots (event) {
      const dot = event.target.closest('button')
      if (!dot) return

      const currentSlideIndex = carousel.getCurrentSlideIndex()
      const targetSlideIndex = dots.findIndex(d => d === dot)
      carousel.switchSlide(currentSlideIndex, targetSlideIndex)
    },
  }

  // ...
  dotsContainer.addEventListener('click', carousel.handleClicksOnDots)
}

Handle Left and Right arrow keys

We have one last event listener where we allowed users to switch slides with left and right arrow keys. Here’s what we wrote previously:

contents.addEventListener('keydown', event => {
  const { key } = event
  if (key !== 'ArrowLeft' && key !== 'ArrowRight') return

  const currentSlideIndex = getCurrentSlideIndex()
  let targetSlideIndex

  if (key === 'ArrowLeft') targetSlideIndex = currentSlideIndex - 1
  if (key === 'ArrowRight') targetSlideIndex = currentSlideIndex + 1

  if (targetSlideIndex < 0) targetSlideIndex = 0
  if (targetSlideIndex > slides.length - 1) targetSlideIndex = slides.length - 1

  switchSlide(currentSlideIndex, targetSlideIndex)

  // Focus on the correct slide
  const targetLink = slides[targetSlideIndex].querySelector('a')
  targetLink.focus({ preventScroll: true })

  // Fallback for preventScroll
  setTimeout(() => {
    contents.parentElement.scrollLeft = 0
  }, 0)
})

Same drill applies.

export default function Carousel (carouselElement) {
  // Declare variables

  const carousel = {
    // Methods ...

    // Listeners
    showNextSlide () { /* ... */ },
    showPreviousSlide () { /* ... */ },
    handleClicksOnDots (event) { /* ... */ },

    handleKeydown (event) {
      const { key } = event
      if (key !== 'ArrowLeft' && key !== 'ArrowRight') return

      const currentSlideIndex = carousel.getCurrentSlideIndex()
      let targetSlideIndex

      if (key === 'ArrowLeft') targetSlideIndex = currentSlideIndex - 1
      if (key === 'ArrowRight') targetSlideIndex = currentSlideIndex + 1

      if (targetSlideIndex < 0) targetSlideIndex = 0
      if (targetSlideIndex > slides.length - 1) targetSlideIndex = slides.length - 1

      carousel.switchSlide(currentSlideIndex, targetSlideIndex)

      // Focus on the correct slide
      const targetLink = slides[targetSlideIndex].querySelector('a')
      targetLink.focus({ preventScroll: true })

      // Fallback for preventScroll
      setTimeout(() => {
        contents.parentElement.scrollLeft = 0
      }, 0)
    }
  }

  // ...
  contents.addEventListener('keydown', carousel.handleKeydown)
}

We’re almost done. There’s only one more improvement to make.

Using a Getter function

Did you notice that getCurrentSlideIndex is a function?

export default function Carousel (carouselElement) {
  // ...

  const carousel = {
    getCurrentSlideIndex () {
      const currentSlide = contents.querySelector('.is-selected')
      return slides.findIndex(slide => slide === currentSlide)
    }

    // ...
  }

  // ...
}

Since we put getCurrentSlideIndex into an object. And since we’re using it to get a value, we can use a Getter function instead.

export default function Carousel (carouselElement) {
  // ...

  const carousel = {
    get currentSlideIndex () {
      const currentSlide = contents.querySelector('.is-selected')
      return slides.findIndex(slide => slide === currentSlide)
    }

    // ...
  }

  // ...
}

This lets us get the current slide index with the dot notation.

// Before
const currentSlideIndex = carousel.getCurrentSlideIndex()

// After
const currentSlideIndex = carousel.currentSlideIndex

That’s it!