🛠️ 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'
Supporting multiple Carousel instances
Multiple carousels can be used on the same page. Here’s one example (which I helped build a long time ago).
Your browser doesn't support embedded videos. Watch the video here instead.
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.
Your browser doesn't support embedded videos. Watch the video here instead.
Refactoring the Carousel
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)
})
}
Initializing the 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.
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)
}
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.
We will copy-paste this code into Carousel.
Update this code to use carousel.getCurrentSlideIndex
and carousel.switchSlide
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!