šŸ› ļø Carousel: Adding keyboard interaction

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: Adding keyboard interaction

Users can Tab through every element of the carousel. By this definition, our carousel is already keyboard accessible.

Tabbing through all elements.

Even though users can Tab through a carousel, they wonā€™t know it unless they see a focus style.

Showing a focus style

Each slide has a focus style. We donā€™t see the focus because the focus got hidden by overflow: hidden.

/* This is the culprit */
.carousel__contents-container {
  overflow: hidden;
}

We can see the focus if we removed overflow: hidden from .carousel__contents-container.

Removed overflow hidden property to show the default focus style.

However, we cannot remove overflow: hidden from .carousel__contents-container. Thatā€™s the property we used to hide other slides. We need overflow: hidden to remain.

We need another way to show focus on each slide. There are two choices:

  1. Use an inset box shadow on each focused slide
  2. Add a box shadow to .carousel__contents-container when children elements get focus

Both methods work. Weā€™ll go with the second one because the focus looks way better when we change slides.

We can detect when children element gets focus with :focus-within. Weā€™ll use this pseudo-class to display the focus.

.carousel__contents-container:focus-within {
  box-shadow: 0 0 0 0.5rem lightskyblue;
}
Modified focus style.

Imagine using a website with this carousel. You need to get past the carousel with a keyboard. To get past the carousel, you need to hit Tab 9 times.

This is a frustrating experience. (And the frustration increases with the number of slides).

It takes 9 tabs to get through a carousel with 3 slides

The best way to allow users to move past the carousel is to remove the ability to Tab through all slides, buttons, and dots. If we do this, users can get through the carousel in 2 Tabs.

Allows the user to tab through the carousel in 2 tabs.

To do this, we have to set tabindex to -1 for the previous and next buttons.

<section class="carousel">
  <button class="carousel__button previous-button" hidden tabindex="-1"> ...</button>
  <div class="carousel__contents-container"> ... </div>
  <button class="carousel__button next-button" tabindex="-1"> ... </button>
</section>

We also need to set tabindex for slides to -1.

But since we want users to Tab into the current slide, we will NOT add tabindex="-1" to the current slide.

<ul class="carousel__contents">
  <li class="carousel__slide is-selected">
    <a href="#"> ... </a>
  </li>
  <li class="carousel__slide">
    <a href="#" tabindex="-1"> ... </a>
  </li>
  <li class="carousel__slide">
    <a href="#" tabindex="-1"> ... </a>
  </li>
</ul>

Finally, we set tabindex for each dot to -1.

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

Switching slides with arrow keys

We destroyed keyboard accessibility when we prevent users from Tabbing into the slides. We need to provide users an alternate way to access the slides.

The best way is to let them switch slides with arrow keys. They can:

  1. Go to the next slide with ā†’
  2. Go to the previous slide with ā†

First, we need to listen for arrow keys.

The best way to listen for arrow keys normally is to add the event listeners on focusable elements (the <a> tags for the carousel).

slides.forEach(slide => {
  const a = slide.querySelector('a')
  a.addEventListener('keydown', event => {
    console.log(event.key)
  })
})

But, in this case, we can use the event delegation pattern on a parent element like .carousel__contents.

We can do this because each <a> tag covers the same area as .carousel__contents. Users will not be able to click on a space between <a> and .carousel__contents.

contents.addEventListener('keydown', event => {
  // ...
})

We want to switch slides if the user presses the Left or Right arrow keys. The simplest way to do this is:

  1. Click nextButton if user presses ā†’
  2. Click previousButton if user presses ā†
contents.addEventListener('keydown', event => {
  const { key } = event
  if (key === 'ArrowLeft') previousButton.click()
  if (key === 'ArrowRight') nextButton.click()
})
Switching slides with arrow keys.

Focusing on the correct slide

Users should be able to interact with each slide. If they press Enter when the second slide is shown, they should navigate to the link the second slide points to.

Letā€™s add real links to each slide to test this out.

<ul class="carousel__contents">
  <li class="carousel__slide is-selected">
    <a href="https://en.wikipedia.org/wiki/1"> ... </a>
  </li>
  <li class="carousel__slide">
    <a href="https://simple.wikipedia.org/wiki/Universe" ...tabindex="-1"> ... </a>
  </li>
  <li class="carousel__slide">
    <a href="https://learnjavascript.today" tabindex="-1"> ... </a>
  </li>
</ul>

Try going to the third slide with ā†’. Press Enter when you get to the third slide. Youā€™ll notice you get directed to the first slideā€™s link.

This is wrong. You should get directed to the third slideā€™s link.

Carousels always link to the first slide

This happens because we only changed the slides visually. Focus still remains on the first slideā€™s <a> element.

For keyboard accessibility to work properly, we need to focus on the correct slideā€™s <a> too.

nextButton.addEventListener('click', event => {
  // ...
  // Focus on selected slide's anchor tag
  const link = slides[nextSlideIndex].querySelector('a')
  link.focus()
})

Unfortunately, the carousel breaks after adding the focus method.

Try clicking the right button (or hitting ā†’ once). You should see the second slide. But youā€™ll see the third slide.

This happens because the focus method forces the browser to scroll to the focused element (both vertically and horizontally).

We can prevent automatic scrolling by setting preventScroll to true.

nextButton.addEventListener('click', event => {
  // ...
  // Focus on selected slide's anchor tag
  const link = slides[nextSlideIndex].querySelector('a')
  link.focus({ preventScroll: true })
})
Carousel fixed. Hitting Enter on a slide goes to the correct page too.

Remember to add the focus code to previousButton's and dotsContainer's event listeners as well.

previousButton.addEventListener('click', event => {
  // ...
  // Focus on selected slide's anchor tag
  const link = slides[previousSlideIndex].querySelector('a')
  link.focus({ preventScroll: true })
})
dotsContainer.addEventListener('click', event => {
  // ...
  // Focus on selected slide's anchor tag
  const link = slides[targetSlideIndex].querySelector('a')
  link.focus({ preventScroll: true })
})

Unfortunately, preventScroll is not supported on Safari and Edge yet.

preventScroll is not supported on Safari and Edge.

We need a fallback.

Fallback for preventScroll

When the browsers scrolls to the focused element, theyā€™ll scroll the nearest scrollable ancestor element. In this case, the scrollable element is .contents__track-container.

You can detect the scroll by adding a transitionend event listener.

window.addEventListener('transitionend', event => {
  console.log(contents.parentElement.scrollLeft)
})
Logs the scrolled amount in the console.

We want to prevent this scrolling behavior from happening. To do this, we need to set .contents__track-container scroll back to 0 before the transition happens.

We need to use a setTimeout callback to do this.

nextButton.addEventListener('click', event => {
  // ...

  const link = slides[nextSlideIndex].querySelector('a')
  link.focus({ preventScroll: true })

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

// Repeat fallback for previousButton and dotContainer event listeners

Safari and Edge should work fine now.

Fallback for preventScroll works on Safari.

Subsequent focus

Letā€™s say a user Tabs out of the carousel from the second slide. A while later, the Tab back into the carousel. When they do this, we want focus to be on the second slide.

We can do this by swapping the tabindex values when we change slides.

nextButton.addEventListener('click', event => {
  // ...
  // Focus on selected slide's anchor tag
  const nextLink = slides[nextSlideIndex].querySelector('a')
  nextLink.focus({ preventScroll: true })

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

  // Roving tabindex
  const currentLink = slides[currentSlideIndex].querySelector('a')
  currentLink.setAttribute('tabindex', '-1')
  nextLink.removeAttribute('tabindex')
})
previousButton.addEventListener('click', event => {
  // ...
  // Focus on selected slide's anchor tag
  const previousLink = slides[previousSlideIndex].querySelector('a')
  previousLink.focus({ preventScroll: true })

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

  // Roving tabindex
  const currentLink = slides[currentSlideIndex].querySelector('a')
  currentLink.setAttribute('tabindex', '-1')
  previousLink.removeAttribute('tabindex')
})
dotsContainer.addEventListener('click', event => {
  // ...
  // Focus on selected slide's anchor tag
  const targetLink = slides[targetSlideIndex].querySelector('a')
  targetLink.focus({preventScroll: true})

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

  // Roving tabindex
  const currentLink = slides[currentSlideIndex].querySelector('a')
  currentLink.setAttribute('tabindex', '-1')
  targetLink.removeAttribute('tabindex')
})

Refactoring

We did these 5 things in nextButton's, previousButton's, and dotContainer's event listeners:

  1. Switch to the correct slide
  2. Highlight dot
  3. Show/hide arrow buttons
  4. Focus on the slideā€™s link
  5. Corrected each slideā€™s tabindex attribute

Since we need to do all five things each time we switch a slide, we might as well group them into switchSlide.

const switchSlide = (currentSlideIndex, targetSlideIndex) => {
  const currentSlide = slides[currentSlideIndex]
  const targetSlide = slides[targetSlideIndex]

  // Switches to the correct slide
  const destination = getComputedStyle(targetSlide).left
  contents.style.transform = `translateX(-${destination})`
  currentSlide.classList.remove('is-selected')
  targetSlide.classList.add('is-selected')

  // Highlights the correct dot
  const currentDot = dots[currentSlideIndex]
  const targetDot = dots[targetSlideIndex]
  currentDot.classList.remove('is-selected')
  targetDot.classList.add('is-selected')

  // Show/hide arrow buttons accordingly
  if (targetSlideIndex === 0) {
    previousButton.setAttribute('hidden', true)
    nextButton.removeAttribute('hidden')
  } else if (targetSlideIndex === dots.length - 1) {
    previousButton.removeAttribute('hidden')
    nextButton.setAttribute('hidden', true)
  } else {
    previousButton.removeAttribute('hidden')
    nextButton.removeAttribute('hidden')
  }

  // Focus on selected slide's anchor tag
  const currentLink = slides[currentSlideIndex].querySelector('a')
  const targetLink = slides[targetSlideIndex].querySelector('a')
  targetLink.focus({ preventScroll: true })

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

  // Roving tabindex
  currentLink.setAttribute('tabindex', '-1')
  targetLink.removeAttribute('tabindex')
}

If we do this, we can simplify our event listeners use only switchSlide.

nextButton.addEventListener('click', event => {
  const currentSlideIndex = getCurrentSlideIndex()
  const nextSlideIndex = currentSlideIndex + 1
  switchSlide(currentSlideIndex, nextSlideIndex)
})
previousButton.addEventListener('click', event => {
  const currentSlideIndex = getCurrentSlideIndex()
  const previousSlideIndex = currentSlideIndex - 1
  switchSlide(currentSlideIndex, previousSlideIndex)
})
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)
})

We can also delete highlightDot and showHideArrowButtons.

Thatā€™s it!