Skip to content

Instantly share code, notes, and snippets.

@acodesmith
Last active August 19, 2020 20:56
Show Gist options
  • Save acodesmith/5239f7dabd953ea816bf13d9027bf4ca to your computer and use it in GitHub Desktop.
Save acodesmith/5239f7dabd953ea816bf13d9027bf4ca to your computer and use it in GitHub Desktop.
Focus Trap Hook - React TypeScript hook for trapping the focus.
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]);
};
// 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