🛠️ Popover: Keyboard

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!

🛠️ Popover: Keyboard

We can open a popover with Enter and Space keys since the trigger is a button.

Opens the popover with the space key and closes it with the enter key.

But this is not enough to consider popover as accessible.

Why?

A popover over can contain focusable elements. For the popover to be truly accessible, we need to allow users to Tab into (and out of) the popover.

Tabs into and out of a popover

Preparations

We will add a form to the right popover for this lesson. Here’s the HTML. You can find the styles in the starter files.

<div id="pop-3" class="popover" data-position="right">
  <h2>Heya, form!</h2>
  <div>
    <label for="name">Name</label>
    <input type="email" name="email" id="email" />
  </div>
  <div>
    <label for="message">Message</label>
    <textarea name="message" id="message"></textarea>
  </div>
  <div>
    <button type="submit">Send</button>
  </div>
</div>

Tabbing into the popover

If the popover is open, we want to let users Tab into the first focusable element. This means we need to listen for a keydown event.

Since the popover triggers can be placed anywhere in the DOM, we should listen to the keydown event on the document.

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

We’re only interested if the user presses the Tab key. We can check if the user pressed the Tab key with event.key.

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

We don’t want to do anything if the user presses Shift + Tab.

document.addEventListener('keydown', event => {
  // ...
  if (event.shiftKey) return
})

Next, we want to check if the Tab key originates from a trigger. We’ll end the function with an early return if Tab doesn’t originate from a trigger.

document.addEventListener('keydown', event => {
  // ...
  const popoverTrigger = event.target.closest('.popover-trigger')
  if (!popoverTrigger) return
})

Next, we need to find the popover to Tab into. We can find the popover with getPopover.

document.addEventListener('keydown', event => {
  // ...
  const popover = getPopover(popoverTrigger)
})

We only want to Tab into the popover if it is open. If the popover is open, it should not have the hidden attribute.

document.addEventListener('keydown', event => {
  // ...
  const shouldTabIntoPopover = !popover.hasAttribute('hidden')

  if (shouldTabIntoPopover) {
    // Tabs into popover
  }
})

We want to Tab to the first focusable element in the popover. Possible focusable elements are:

  1. Links
  2. Buttons
  3. Form fields (like input and textarea)
  4. Elements with tabindex set to 0
  5. Elements with tabindex set to 1

We’re only interested in elements a user can Tab into, so we don’t want elements from (5).

We can use querySelectorAll with a set of selectors to find all possible focusable elements:

document.addEventListener('keydown', event => {
  // ...
  if (shouldTabIntoPopover) {
    const focusables = [...popover.querySelectorAll(
      'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
    )]
  }
})

The first focusable element is the first item in focusables. We can use the focus method to focus on the first focusable element.

document.addEventListener('keydown', event => {
  // ...
  if (shouldTabIntoPopover) {
    const focusables = [...popover.querySelectorAll(
      'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
    )]
    focusables[0].focus()
  }
})

The Tab key’s default behavior activates after the focus method. We need to prevent this default behavior so it doesn’t shift focus to the second focusable element.

document.addEventListener('keydown', event => {
  // ...
  if (shouldTabIntoPopover) {
    event.preventDefault()
    // ...
    focusables[0].focus()
  }
})
Opens the popover with space. Then tabs into the first focusable element.

What if there are no focusable elements in the popover?

We can only Tab into the popover if there are focusable elements in the popover. We can adjust shouldTabIntoPopover to account for this.

document.addEventListener('keydown', event => {
  // ...
  const popover = getPopover(popoverTrigger)
  const focusables = [...popover.querySelectorAll(
    'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
  )]
  const shouldTabIntoPopover = !popover.hasAttribute('hidden') && focusables.length !== 0

  if (shouldTabIntoPopover) {
    event.preventDefault()
    focusables[0].focus()
  }
})
Tab does not focus on the popover if there are no focusable elements in the popover.

Tabbing out of the popover

We need to let users Tab out of the popover as well.

  1. Shift + Tab on first focusable element: Goes back to trigger
  2. Tab on last focusable element: Go to next focusable element (from the trigger)

Shift + Tab on first focusable element

First, we’ll listen to all popovers with the event delegation pattern.

document.addEventListener('keydown', event => {
  const popover = event.target.closest('.popover')
  if (!popover) return
}

We will only do things if the user presses Tab.

document.addEventListener('keydown', event => {
  // ...
  if (event.key !== 'Tab') return
})

Next, we need to find the popover’s trigger. We can get the trigger by finding an element with a data-target attribute that matches the popover’s id attribute.

document.addEventListener('keydown', event => {
  // ...
  const popoverTrigger = document.querySelector(`.popover-trigger[data-target="${popover.id}"]`)
})

If the user presses Shift + Tab on the first focusable element, we want to return the user back to the trigger.

document.addEventListener('keydown', event => {
  // ...
  const focusables = [...popover.querySelectorAll(
      'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
    )]

  if (event.shiftKey && event.target === focusables[0]) {
	return popoverTrigger.focus()
  }
})

The default Shift + Tab behavior activates after the focus method. We need to prevent this behavior. Otherwise, we’ll go to the focusable element before the popover trigger.

document.addEventListener('keydown', event => {
  // ...

  if (event.shiftKey && event.target === focusables[0]) {
    event.preventDefault()
	return popoverTrigger.focus()
  }
})
Shift + Tab out of the popover.

Tab on last focusable element

If the user press Tab on the last focusable element (without the Shift key), we want to focus on the next focusable element after the trigger.

document.addEventListener('keydown', event => {
  // ...
  if (!event.shiftKey && event.target === focusables[focusables.length - 1]) {
    // Focus on next element after trigger
  }
})

The easiest way to do this is to:

  1. Focus on the trigger with the focus method
  2. Let the default Tab behaviour do the trick
document.addEventListener('keydown', event => {
  // ...
  if (!event.shiftKey && event.target === focusables[focusables.length - 1]) {
    return popoverTrigger.focus()
  }
})
Tabs out of the popover.

Closing the popover

It’s a common practice for keyboard users to close things with the Escape key. We want to let them use Escape to close the popover as well.

First, we’ll listen to a keydown event on the document. This lets us manage all popovers at once.

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

We only want to do something if:

  1. The user presses Escape
  2. The event originates from a popover
document.addEventListener('keydown', event => {
  const { key } = event
  if (key !== 'Escape') return

  const popover = event.target.closest('.popover')
  if (!popover) return
})

If the user presses Escape, we want to close the popover.

document.addEventListener('keydown', event => {
  // ...
  popover.setAttribute('hidden', true)
})

We also want to focus on its trigger.

document.addEventListener('keydown', event => {
  // ...
  popover.setAttribute('hidden', true)
  const popoverTrigger = document.querySelector(`.popover-trigger[data-target="${popover.id}"]`)
  popoverTrigger.focus()
})
Closes the popover with the Escape key.

That’s it!