Last active
August 19, 2020 20:56
-
-
Save acodesmith/5239f7dabd953ea816bf13d9027bf4ca to your computer and use it in GitHub Desktop.
Focus Trap Hook - React TypeScript hook for trapping the focus.
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
import { useEffect, RefObject } from 'react'; | |
declare global { | |
interface Window { | |
focusTrap: { [key: string]: FocusTrap }; | |
} | |
} | |
interface FocusTrap { | |
focusable: HTMLElement[]; | |
nodeFocusedBeforeActivation: Element; | |
observer: MutationObserver; | |
parentElement: Element; | |
} | |
const updateGlobalInterface = ({ | |
parentElement, | |
focusable, | |
observer, | |
}: { | |
parentElement: Element; | |
focusable: HTMLElement[]; | |
observer?: MutationObserver | null; | |
}) => { | |
window.focusTrap[parentElement.id] = window.focusTrap[parentElement.id] || {}; | |
window.focusTrap[parentElement.id].parentElement = parentElement; | |
window.focusTrap[parentElement.id].focusable = focusable; | |
if (observer) { | |
window.focusTrap[parentElement.id].observer = observer; | |
} | |
}; | |
window.focusTrap = {}; | |
const getFocusableElements = (parentElement: HTMLElement): HTMLElement[] => { | |
const focusableElements = 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; | |
const elements = parentElement.querySelectorAll<HTMLElement>(focusableElements); | |
let focusable: HTMLElement[] = []; | |
[].forEach.call(elements, function (el) { | |
focusable.push(el); | |
}); | |
return focusable.filter((el: HTMLElement) => !(el as HTMLInputElement).disabled); | |
}; | |
/** | |
* Keep the focus inside of a element. For example if a user hits tab while a modal | |
* is open, only cycle through the modal focusable elements. | |
* | |
* Uses MutationObserver API to track changes to the DOM regardless of js library. | |
* | |
* Returns two functions: | |
* [ | |
* "start the focus trap", | |
* "end the focus trap" | |
* ] | |
* Exporting two functions to work well with useEffect. | |
*/ | |
export const focusTrap = (parentElement: HTMLElement, { debug }: { debug?: boolean } = {}) => { | |
const focusable = getFocusableElements(parentElement); | |
updateGlobalInterface({ parentElement, focusable }); | |
if (window.focusTrap[parentElement.id] && !window.focusTrap[parentElement.id].observer) { | |
const observer = new MutationObserver(() => { | |
updateGlobalInterface({ | |
parentElement, | |
focusable: getFocusableElements(parentElement), | |
}); | |
}); | |
observer.observe(parentElement, { attributes: true, childList: true, subtree: true }); | |
updateGlobalInterface({ parentElement, focusable, observer }); | |
} | |
const trackTabAndShift = (e: KeyboardEvent) => { | |
if (e.key.toLowerCase() !== 'tab' || e.keyCode !== 9) { | |
return; | |
} | |
const focusable = window.focusTrap[parentElement.id].focusable; | |
const lastElement = focusable[focusable.length - 1]; | |
const firstElement = focusable[0]; | |
if (e.shiftKey) { | |
if (document.activeElement === firstElement) { | |
lastElement.focus(); | |
e.preventDefault(); | |
} | |
} else { | |
if (document.activeElement === lastElement) { | |
firstElement.focus(); | |
e.preventDefault(); | |
} | |
} | |
}; | |
return [ | |
() => { | |
if (debug) { | |
console.debug(`Starting focus trap for id ${parentElement.id}`); | |
} | |
document.addEventListener('keydown', trackTabAndShift); | |
}, | |
() => { | |
if (debug) { | |
console.debug(`Ending focus trap for id ${parentElement.id}`); | |
} | |
document.removeEventListener('keydown', trackTabAndShift); | |
if (window.focusTrap[parentElement.id] && window.focusTrap[parentElement.id].observer) { | |
window.focusTrap[parentElement.id].observer.disconnect(); | |
} | |
delete window.focusTrap[parentElement.id]; | |
}, | |
]; | |
}; | |
export const useFocusTrap = ( | |
refElement: RefObject<HTMLElement>, | |
options: { | |
debug?: boolean; | |
isActive?: boolean; | |
trackStatus?: boolean; | |
refAccessor?: (ref: any) => RefObject<HTMLElement> | undefined; | |
}, | |
) => { | |
let { debug, trackStatus = false, isActive = false, refAccessor } = options; | |
isActive = trackStatus && isActive; | |
useEffect(() => { | |
let computedRefElement = refAccessor ? refAccessor(refElement) : refElement; | |
if (computedRefElement && computedRefElement.current && computedRefElement.current.id) { | |
const container = document.querySelector(`#${computedRefElement.current.id}`); | |
if (container) { | |
const [startFocusTrap, stopFocusTrap] = focusTrap(container as HTMLElement, { debug }); | |
// Ability to track an optional flag | |
// Used when a component has a show or hide toggle | |
// But the component does not "unmount" | |
if (trackStatus && isActive) { | |
startFocusTrap(); | |
} else if (trackStatus && !isActive) { | |
stopFocusTrap(); | |
} else if (!trackStatus) { | |
startFocusTrap(); | |
} | |
// Always return stopFocusTrap when de-registering the component; | |
return () => { | |
stopFocusTrap(); | |
}; | |
} | |
} else if (refElement.current && !refElement.current.id) { | |
console.error( | |
'useFocusTrap requires an valid id attribute for the provided container element. No id found!', | |
refElement.current, | |
); | |
} | |
}, [refElement, trackStatus, isActive, refAccessor, debug]); | |
}; |
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
// Basic | |
useFocusTrap(modalRef); | |
// Advanced - third party APIs (for example semantic ui) | |
useFocusTrap(thirdPartyRefWhichPointstoCustomAPINotAnElement, { | |
refAccessor: (ref: any) => { | |
if (ref.current) { | |
// return a nested ref or any other type of ref | |
return (ref.current as any).ref; | |
} | |
return undefined; | |
}, | |
}); | |
// If you need to track a active flag without removing unmounting the component. | |
// For example if a modal is hidden but still part of the DOM tree. | |
// It is bad practice to keep hidden content in the DOM tree - but certain libs do. | |
useFocusTrap(disclaimerRef, { | |
trackStatus: true, | |
isActive: isOpen, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment