From fea7cdbf3102766ea4de2a3b3747e343743f7f46 Mon Sep 17 00:00:00 2001 From: paufau Date: Mon, 7 Sep 2026 21:49:01 +0400 Subject: [PATCH] (feat): Trap web a11y focus --- eslint.config.js | 2 + src/FocusBracket.tsx | 7 +++ src/ModalView.web.tsx | 9 +++ src/focusHelpers.ts | 113 +++++++++++++++++++++++++++++++++++ src/hooks/useFocusTrap.ts | 45 ++++++++++++++ src/hooks/useRestoreFocus.ts | 17 ++++++ 6 files changed, 193 insertions(+) create mode 100644 src/FocusBracket.tsx create mode 100644 src/focusHelpers.ts create mode 100644 src/hooks/useFocusTrap.ts create mode 100644 src/hooks/useRestoreFocus.ts diff --git a/eslint.config.js b/eslint.config.js index fd0c9ec..655d701 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -31,6 +31,8 @@ module.exports = [ require: 'readonly', console: 'readonly', KeyboardEvent: 'readonly', + HTMLElement: 'readonly', + Element: 'readonly', }, }, plugins: { diff --git a/src/FocusBracket.tsx b/src/FocusBracket.tsx new file mode 100644 index 0000000..32dc4ea --- /dev/null +++ b/src/FocusBracket.tsx @@ -0,0 +1,7 @@ +import type { FC } from 'react'; + +// Invisible + focusable sentinels placed on each side of the dialog +// Tabbing past either edge lands on a bracket (outside the trap) and gets bounced back in +export const FocusBracket: FC = () => ( +
+); diff --git a/src/ModalView.web.tsx b/src/ModalView.web.tsx index d685919..808fcd1 100644 --- a/src/ModalView.web.tsx +++ b/src/ModalView.web.tsx @@ -4,6 +4,8 @@ import type { FC } from 'react'; import { createPortal } from 'react-dom'; import { StyleSheet, View, Pressable } from 'react-native'; +import { FocusBracket } from './FocusBracket'; +import { useFocusTrap } from './hooks/useFocusTrap'; import { useModalStack } from './hooks/useModalStack'; import type { ModalViewProps } from './types'; @@ -40,6 +42,8 @@ export const ModalView: FC = ({ const currentModalId = modalId ?? reactId; const { isTopmost } = useModalStack(currentModalId); + const contentRef = useFocusTrap(isTopmost); + const [isOpen, setIsOpen] = useState(false); useEffect(() => { @@ -96,13 +100,18 @@ export const ModalView: FC = ({ )} + {children} + , document.body, ); diff --git a/src/focusHelpers.ts b/src/focusHelpers.ts new file mode 100644 index 0000000..6514e24 --- /dev/null +++ b/src/focusHelpers.ts @@ -0,0 +1,113 @@ +export const maybeGetElement = (node: unknown): HTMLElement | null => + node instanceof HTMLElement ? node : null; + +export const getModalRoot = (element: HTMLElement): HTMLElement => { + let root = element; + + while (root.parentElement && root.parentElement !== document.body) { + root = root.parentElement; + } + + return root; +}; + +export const inertBackground = (modalRoot: Element): (() => void) => { + const backgrounded = Array.from(document.body.children).filter( + node => node !== modalRoot && !node.hasAttribute('inert'), + ); + + backgrounded.forEach(node => node.setAttribute('inert', '')); + + return () => { + backgrounded.forEach(node => node.removeAttribute('inert')); + }; +}; + +export const onDocumentFocus = (handler: () => void): (() => void) => { + document.addEventListener('focus', handler, true); + + return () => { + document.removeEventListener('focus', handler, true); + }; +}; + +const attemptFocus = (element: HTMLElement): boolean => { + try { + element.focus(); + } catch { + // .focus() can throw in rare cases (e.g. a detached node) + } + + return document.activeElement === element; +}; + +export const focusFirstDescendant = (element: HTMLElement): boolean => { + const { children } = element; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + + if ( + child instanceof HTMLElement && + (attemptFocus(child) || focusFirstDescendant(child)) + ) { + return true; + } + } + + return false; +}; + +export const focusLastDescendant = (element: HTMLElement): boolean => { + const { children } = element; + + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + + if ( + child instanceof HTMLElement && + (attemptFocus(child) || focusLastDescendant(child)) + ) { + return true; + } + } + + return false; +}; + +export const createFocusTrap = ( + getModalContent: () => HTMLElement | null, +): (() => void) => { + let trapInProgress = false; + let lastFocused: Element | null = null; + + return () => { + const modalContent = getModalContent(); + + if (!modalContent || trapInProgress) { + return; + } + + trapInProgress = true; + + try { + const activeElement = document.activeElement; + + if (activeElement && !modalContent.contains(activeElement)) { + let hasFocused = focusFirstDescendant(modalContent); + + if (lastFocused === document.activeElement) { + hasFocused = focusLastDescendant(modalContent); + } + + if (!hasFocused) { + modalContent.focus(); + } + } + } finally { + trapInProgress = false; + } + + lastFocused = document.activeElement; + }; +}; diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts new file mode 100644 index 0000000..1de83ef --- /dev/null +++ b/src/hooks/useFocusTrap.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; +import type { View } from 'react-native'; + +import { + createFocusTrap, + getModalRoot, + inertBackground, + maybeGetElement, + onDocumentFocus, +} from '../focusHelpers'; +import { useRestoreFocus } from './useRestoreFocus'; + +export function useFocusTrap(isTopmost: boolean) { + const contentRef = useRef(null); + + useRestoreFocus(); + + useEffect(() => { + if (!isTopmost) { + return; + } + + const modalContent = maybeGetElement(contentRef.current); + + if (!modalContent) { + return; + } + + return inertBackground(getModalRoot(modalContent)); + }, [isTopmost]); + + useEffect(() => { + if (!isTopmost) { + return; + } + + const trapFocus = createFocusTrap(() => maybeGetElement(contentRef.current)); + + trapFocus(); + + return onDocumentFocus(trapFocus); + }, [isTopmost]); + + return contentRef; +} diff --git a/src/hooks/useRestoreFocus.ts b/src/hooks/useRestoreFocus.ts new file mode 100644 index 0000000..5cf9603 --- /dev/null +++ b/src/hooks/useRestoreFocus.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; + +import { maybeGetElement } from '../focusHelpers'; + +export function useRestoreFocus() { + useEffect(() => { + const previouslyFocused = maybeGetElement(document.activeElement); + + return () => { + Promise.resolve().then(() => { + if (previouslyFocused && document.contains(previouslyFocused)) { + previouslyFocused.focus(); + } + }); + }; + }, []); +}