🛠️ Popover: Keyboard
We can open a popover with Enter
and Space
keys since the trigger is a button.
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.
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:
Links
Buttons
Form fields (like input
and textarea
)
Elements with tabindex
set to 0
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()
}
})
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()
}
})
Tabbing out of the popover
We need to let users Tab
out of the popover as well.
Shift
+ Tab
on first focusable element: Goes back to trigger
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()
}
})
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:
Focus on the trigger with the focus
method
Let the default Tab behaviour do the trick
document.addEventListener('keydown', event => {
// ...
if (!event.shiftKey && event.target === focusables[focusables.length - 1]) {
return popoverTrigger.focus()
}
})
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:
The user presses Escape
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()
})
That’s it!