🛠️ Datepicker: Tabbing in and out

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!

🛠️ Datepicker: Tabbing in and out

We need to take care of many things to make the Datepicker keyboard accessible. They are:

  1. Showing the Datepicker
  2. Tabbing into/out of the Datepicker
  3. Keyboard shortcuts inside the Datepicker

We’ll work on the first two things in this lesson.

Showing the Datepicker

We showed the Datepicker when a user clicks on the <input>. But we also want to show the Datepicker when the user Tabs into the <input>. We can do this by changing the click event to focus.

This is okay because a click creates focus on the <input>.

input.addEventListener('focus', event => {
  datepicker.removeAttribute('hidden')
})

Tabbing into/out of the Datepicker

We want to allow users to Tab into the Datepicker. It shouldn’t matter if there are any focusable elements between the <input> and the Datepicker.

Before we do this, let’s put a link between the <input> and the Datepicker. This will allow us to test the shifting of focus properly.

<form action="#" autocomplete="off">
  <div class="input">
    <label for="date">Select Date:</label>
    <input type="text" id="date" placeholder="DD/MM/YYYY" />
    <a href="#">Here's a link!</a>
  </div>
</form>

We want to intercept the browser’s default behaviour when the user presses the Tab key. But we don’t want to do anything if they press Shift + Tab.

input.addEventListener('keydown', event => {
  const { key } = event
  if (key !== 'Tab') return
  if (event.shiftKey) return
  event.preventDefault()
})

If they press Tab, we want to focus on the first focusable element in the Datepicker. This would be the previous month’s button.

input.addEventListener('keydown', event => {
  // ...
  const focusablesInDatepicker = datepicker.querySelectorAll('button')
  const firstFocusableElement = focusablesInDatepicker[0]
  firstFocusableElement.focus()
})

Retaining “focus” on the input

Notice the input becomes blueish-grey when you Tab into the Datepicker? This signifies the focus on the input was lost. (Which is true, because focus is now on the previous month’s button).

But the user is using the Datepicker (which is linked to the input). They expect the Datepicker to be part of the input. So the input should still be “focused”.

We can retain the focused state (the white background) by adding a custom attribute called data-state. We’ll set data-state to focus. (You can use a class if you prefer).

// Shows the Datepicker
input.addEventListener('focus', event => {
  datepicker.removeAttribute('hidden')
  input.dataset.state = 'focus'
})

The necessary CSS to activate this state has already been done for you.

If the user closes the Datepicker, we want to remove “focus” from the input. To do this, we can delete the data-state attribute.

// Hides the Datepicker
document.addEventListener('click', event => {
  if (event.target.closest('.datepicker')) return
  if (event.target.closest('input') === input) return
  datepicker.setAttribute('hidden', true)
  delete input.dataset.state
})

Tabbing out of the Datepicker

It would be a chore for users to Tab through all date buttons before getting out of the Datepicker.

A better way is to let users Tab into ONE date button. Then, their next Tab takes them out of the Datepicker. (We can let them navigate through the rest of the dates with arrow keys later).

To do this, we need to set all button’s (except the first) tabindex to -1.

const createDateGridHTML = date => {
  // ...
  for (let day = 1; day <= daysInMonth; day++) {
    if (day === 1) button.style.setProperty('--firstDayOfMonth', firstDayOfMonth + 1)
    if (day !== 1) button.setAttribute('tabindex', '-1')
    // ...
  }
}

When a user clicks on a button, we want to allow users to Tab back into the button. We can do this by changing up the tabindex attributes.

dategrid.addEventListener('click', event => {
  // ...
  // Highlights the selected button (and corrects tabindex)
  buttons.forEach(button => {
    button.classList.remove('is-selected')
    button.setAttribute('tabindex', '-1')
  })
  button.classList.add('is-selected')
  button.removeAttribute('tabindex')
  // ...
})

When a user Tabs out of the Datepicker, we want to direct the user to the next focusable element (after the input). In this case, the focusable element is the link we placed in the HTML.

To do this, we need to listen to a keydown event.

datepicker.addEventListener('keydown', event => {
  const { key } = event
  // ...
})

To focus on the link, we need to:

  1. Get all keyboard focusable elements from the DOM
  2. Find the index of the <input>
  3. Focus on the next element after the input

We can get all keyboard focusable elements from the DOM with getFocusableElements from the popover’s code. (This is how you reuse code! 😃).

const getFocusableElements = (element = document) => {
  return [...element.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
}

datepicker.addEventListener('keydown', event => {
  // ...
  const focusableElements = getFocusableElements()
})

Next, we find the index of the input.

datepicker.addEventListener('keydown', event => {
  // ...
  const focusableElements = getFocusableElements()
  const index = focusableElements.findIndex(element => element === input)
})

And we focus on the next element after the input. Remember to prevent the default Tab behaviour here. Otherwise you’ll focus on the 2nd element after the input.

datepicker.addEventListener('keydown', event => {
  // ...
  event.preventDefault()
  focusableElements[index + 1].focus()
})

After the user Tabs out of the Datepicker, we want to close the Datepicker. We can do it by setting the hidden attribute. We also need to remove the data-state custom attribute from the <input>

datepicker.addEventListener('keydown', event => {
  // ...
  datepicker.setAttribute('hidden', true)
  delete input.dataset.state
})