Skip to content

Instantly share code, notes, and snippets.

@downzer0
Last active December 22, 2021 20:10
Show Gist options
  • Save downzer0/45c3a577551cf3e1fefe862ce63b59ee to your computer and use it in GitHub Desktop.
Save downzer0/45c3a577551cf3e1fefe862ce63b59ee to your computer and use it in GitHub Desktop.
Accessibility trap focus plugin
/**
* This helper utility is meant to be used with the flyout navigation or modals.
* It traps focus inside the opened or overlaying component per WCAG spec.
*
* Some trap focus examples only focus on Tab and Shift+Tab, but many screen readers
* browse differently, in caret mode, with arrow keys. As a result, these traditional
* trap focus methods are insufficient. We need to hide everything outside the overlay
* from screen readers and make it not focusable. This means applying 'aria-hidden'
* and 'tabindex=-1' to all elements not within the modal. Once the modal is closed
* we need to remove these attributes.
*
* However since some elements have these by default we need to replace them, rather
* than remove them, so we store existing attributes (if any).
*
* Menus and modals should have a mechanism to close. A bonus is using the Esc.
*/
const trapFocus = (props: TrapFocusProps) => {
const { element, trigger, isOpen } = props;
const modalElements = Array.from(
(element as HTMLDivElement).querySelectorAll('*')
).concat(element as HTMLDivElement);
const nonModalElements = Array.from(document.querySelectorAll('body *'))
.filter((elem) => !modalElements.includes(elem))
.filter((elem) => elem.id !== '__next')
.filter((elem) => elem.id !== 'header');
if (isOpen) {
nonModalElements.forEach((e) => {
const storedTabindex = e.getAttribute('tabindex') || false;
const storedAriaHidden = e.getAttribute('aria-hidden') || false;
if (storedTabindex) e.setAttribute('data-tabindex', storedTabindex);
if (storedAriaHidden)
e.setAttribute('data-aria-hidden', storedAriaHidden);
e.setAttribute('tabindex', '-1');
e.setAttribute('aria-hidden', 'true');
e.setAttribute('data-hidden-from-modal', 'true');
});
element?.focus(); // huzzah
} else {
nonModalElements.forEach((e) => {
const storedTabindex = e.getAttribute('data-tabindex') || false;
const storedAriaHidden = e.getAttribute('data-aria-hidden') || false;
if (storedTabindex) {
e.setAttribute('tabindex', storedTabindex);
e.removeAttribute('data-tabindex');
} else {
e.removeAttribute('tabindex');
}
if (storedAriaHidden) {
e.setAttribute('aria-hidden', storedAriaHidden);
e.removeAttribute('data-aria-hidden');
} else {
e.removeAttribute('aria-hidden');
}
e.removeAttribute('data-hidden-from-modal');
});
trigger?.focus(); // huzzah
}
};
export type TrapFocusProps = {
/** The panel being shown */
element: HTMLDivElement | null;
/** HTML control (button) that opens this panel */
trigger: HTMLButtonElement | null;
/** Open or closed status of the panel */
isOpen: boolean; // modal open or closed
};
export default trapFocus;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment