useTrapFocus()

Set trappable focus within a modal find or dropdown menu with ease.

Why?

Trapping focus within expandable elements (modals, dropdown menus etc) can be a little tedious to tackle when you have many React components combined together in an application.

How?

Combined with any global state management system (I used Jotai), we can store the current focusable element in the global store, and react to the changeable state of that element. When the element changes, we re-inspect the focusable child-elements within and respond to "tab" keyboard and ensure that HTML elements outside the trapped element are not tabable.

type TrapFocusFnArgs = {
  element?: HTMLElement;
  trackPreviousFocusElement?: boolean;
};
export type UseTrapFocusReturn = {
  trapFocus: (argv: TrapFocusFnArgs) => void;
  releaseFocus: () => void;
  focusOnFirst: () => void;
  focusOnLast: () => void;
};

export const useTrapFocus = (): UseTrapFocusReturn => {
  const [
    { el, firstFocusableEl, lastFocusableEl, previousFocusedEl },
    setTrappedFocusEl,
  ] = useAtom(trappedFocusElAtom);

  const trapFocus = useCallback(
    ({ element, trackPreviousFocusElement = true }: TrapFocusFnArgs) => {
      const focusableEls = element?.querySelectorAll(focusablElementSelectors);
      const firstFocusableEl = focusableEls?.[0] as HTMLElement;
      const lastFocusableEl = focusableEls?.[
        focusableEls.length - 1
      ] as HTMLElement;

      setTrappedFocusEl({
        el: element,
        previousFocusedEl: trackPreviousFocusElement
          ? (document.activeElement as HTMLElement | null)
          : null,
        firstFocusableEl,
        lastFocusableEl,
      });
    },
    [setTrappedFocusEl]
  );

  const releaseFocus = useCallback(() => {
    trapFocus({});
    previousFocusedEl?.focus();
  }, [trapFocus, previousFocusedEl]);

  const keypressHandler = useCallback(
    (e: KeyboardEvent) => {
      if (!lastFocusableEl || !firstFocusableEl) {
        return;
      }
      if (e.key === "Tab") {
        if (e.shiftKey) {
          // Shift + Tab
          if (document.activeElement === firstFocusableEl) {
            lastFocusableEl.focus();
            e.preventDefault();
          }
          // Just tab key
        } else {
          if (document.activeElement === lastFocusableEl) {
            firstFocusableEl.focus();
            e.preventDefault();
          }
        }
      }
    },
    [lastFocusableEl, firstFocusableEl]
  );

  useEffect(() => {
    el?.addEventListener("keydown", keypressHandler);

    return () => {
      el?.removeEventListener("keydown", keypressHandler);
    };
  }, [keypressHandler, el]);

  const focusOnFirst = useCallback(() => {
    if (firstFocusableEl) {
      firstFocusableEl.focus();
    }
  }, [firstFocusableEl]);

  const focusOnLast = useCallback(() => {
    if (lastFocusableEl) {
      lastFocusableEl.focus();
    }
  }, [lastFocusableEl]);

  return { trapFocus, releaseFocus, focusOnFirst, focusOnLast };
};

Usage:

Assuming you're using a React Reference on the container HTML Element called elRef, you could use this useEffect to trap the focus. This works really well with React Portals.

useEffect(() => {
  const containerEl = elRef.current;

  if(!containerEl){
    return;
  }

  trapFocus({element: el});
  
}, [trapFocus]);
More projects