useTrapFocus(๐ฏ)
26 October 2020ย ย โขย ย 1 min read
Trap user โบ
WCAG is growing for each day. Today we're going to target tabbing and focus order.
A common mistake with modals is that developers doesn't trap the focus order. So when a user opens a modal and starts tabbing it's not guaranteed that the focus is within the modal.
In this blog post I'll showcase a hook I've created to take care of this.
Showcase ๐ผ
Want to try it? Just toggle the modal below and start tabbing.
The hook ๐ฃ
For those who haven't figured it out yet, this only works on devices with a keyboard.
I'm using my useEventListener() hook to listen for the tab key, and using the tabbable package to get all tabbable DOM nodes within a containing node.
import {
useEffect,
useCallback,
useState,
useMemo,
useRef,
MutableRefObject
} from 'react';
import { tabbable, FocusableElement } from 'tabbable';
import { useEventListener } from './hooks';
type Node = HTMLDivElement | null;
interface UseTrapFocus {
includeContainer?: boolean;
initialFocus?: 'container' | Node;
returnFocus?: boolean;
updateNodes?: boolean;
}
export const useTrapFocus = (
options?: UseTrapFocus
): MutableRefObject<Node> => {
const node = useRef<Node>(null);
const {
includeContainer,
initialFocus,
returnFocus,
updateNodes
} = useMemo<UseTrapFocus>(
() => ({
includeContainer: false,
initialFocus: null,
returnFocus: true,
updateNodes: false,
...options
}),
[options]
);
const [tabbableNodes, setTabbableNodes] = useState<FocusableElement[]>([]);
const previousFocusedNode = useRef<Node>(document.activeElement as Node);
const setInitialFocus = useCallback(() => {
if (initialFocus === 'container') {
node.current?.focus();
} else {
initialFocus?.focus();
}
}, [initialFocus]);
const updateTabbableNodes = useCallback(() => {
const { current } = node;
if (current) {
const getTabbableNodes = tabbable(current, { includeContainer });
setTabbableNodes(getTabbableNodes);
return getTabbableNodes;
}
return [];
}, [includeContainer]);
useEffect(() => {
updateTabbableNodes();
if (node.current) setInitialFocus();
}, [setInitialFocus, updateTabbableNodes]);
useEffect(() => {
return () => {
const { current } = previousFocusedNode;
if (current && returnFocus) current.focus();
};
}, [returnFocus]);
const handleKeydown = useCallback(
(event) => {
const { key, keyCode, shiftKey } = event;
let getTabbableNodes = tabbableNodes;
if (updateNodes) getTabbableNodes = updateTabbableNodes();
if ((key === 'Tab' || keyCode === 9) && getTabbableNodes.length) {
const firstNode = getTabbableNodes[0];
const lastNode = getTabbableNodes[getTabbableNodes.length - 1];
const { activeElement } = document;
if (!getTabbableNodes.includes(activeElement as FocusableElement)) {
event.preventDefault();
shiftKey ? lastNode.focus() : firstNode.focus();
}
if (shiftKey && activeElement === firstNode) {
event.preventDefault();
lastNode.focus();
}
if (!shiftKey && activeElement === lastNode) {
event.preventDefault();
firstNode.focus();
}
}
},
[tabbableNodes, updateNodes, updateTabbableNodes]
);
useEventListener({
type: 'keydown',
listener: handleKeydown
});
return node;
};
Usage
Here you can see a stripped version of the showcase.
import React, { useRef } from 'react';
import { useTrapFocus } from './hooks';
const Component = () => {
const initialFocusRef = useRef(null);
const trapRef = useTrapFocus({
// Incudes container (trapRef) in the tabbable nodes
includeContainer: true,
// Can also be set as 'container' which will focus trapRef
initialFocus: initialFocusRef.current,
// Return focus to the element that had focus before trapped
returnFocus: true,
// Update nodes on each tab, can be useful if tabbable nodes
// is rendered dynamic in some way
updateNodes: false
});
return (
<div ref={trapRef} tabIndex={0}>
Lorem ipsum{' '}
<button type="button" ref={initialFocusRef}>
dolor
</button>
</div>
);
};
export default Component;
The end ๐ฏ
I hope you found this helpful.