Last active
December 22, 2021 20:10
-
-
Save downzer0/45c3a577551cf3e1fefe862ce63b59ee to your computer and use it in GitHub Desktop.
Accessibility trap focus plugin
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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