All files / src/components/ui use-modal-a11y.ts

96.82% Statements 61/63
91.66% Branches 33/36
100% Functions 12/12
100% Lines 55/55

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167                                                            38x                     14x   42x                   38x 38x     219x 218x 218x   219x 219x 219x 219x 219x 219x 219x 218x                                                   1485x 1485x       1485x 1223x   168x 5x 5x   517x 517x                     1485x 762x 220x 220x   762x   762x 220x 220x         1485x 762x 219x     1485x 163x 14x 14x   14x 14x     1x 1x 1x     13x 13x 13x   13x 4x 4x 9x 5x 5x       1485x    
import { useCallback, useEffect, useRef } from "react";
 
/**
 * The modal contract the portal's overlays were missing.
 *
 * `drawer-dialog.tsx` and `mobile-full-page.tsx` both paint a surface over the
 * whole viewport, and neither trapped focus: with the drawer open, Tab walked
 * straight out of it into the dashboard chrome underneath — which a screen
 * reader and a keyboard user cannot see is behind an overlay. Neither restored
 * focus on close, and neither locked the background scroll, so closing a drawer
 * dropped focus on `<body>` and iOS rubber-banded the page behind it.
 *
 * One hook rather than two implementations, because "the drawers are not
 * focus-trapped" was one bug in two files, and the next overlay should get the
 * behaviour for free.
 *
 * What it does while `open`:
 *   - moves focus to the surface itself, which the caller gives `tabIndex={-1}`,
 *     so the dialog's own name and role are what gets announced
 *   - traps Tab / Shift+Tab inside it, wrapping at both ends
 *   - closes on `Escape`
 *   - locks `document.body` scroll, reference-counted so nested overlays and
 *     React's StrictMode double-invoke cannot leave the page unscrollable
 *   - restores focus to whatever was focused before it opened
 *
 * It deliberately does NOT render anything or own the markup: ARIA
 * (`role="dialog"`, `aria-modal`, `aria-labelledby`) stays at the call site,
 * where the title lives.
 */
 
const FOCUSABLE_SELECTOR = [
	"a[href]",
	"button:not([disabled])",
	"input:not([disabled]):not([type='hidden'])",
	"select:not([disabled])",
	"textarea:not([disabled])",
	"[tabindex]:not([tabindex='-1'])",
].join(",");
 
/** Visible, focusable descendants in tab order. */
function focusableWithin(container: HTMLElement): HTMLElement[] {
	return Array.from(
		container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
	).filter((element) => element.getAttribute("aria-hidden") !== "true");
}
 
/**
 * Scroll-lock reference count.
 *
 * Module scope on purpose: two overlays open at once (the project wizard opens
 * a confirm) must not have the inner one's cleanup unlock the page while the
 * outer one is still up.
 */
let scrollLockCount = 0;
let scrollLockPrevious = "";
 
function lockBodyScroll(): () => void {
	if (scrollLockCount === 0) {
		scrollLockPrevious = document.body.style.overflow;
		document.body.style.overflow = "hidden";
	}
	scrollLockCount++;
	let released = false;
	return () => {
		Iif (released) return;
		released = true;
		scrollLockCount--;
		if (scrollLockCount === 0) {
			document.body.style.overflow = scrollLockPrevious;
		}
	};
}
 
export interface ModalA11yOptions {
	/** Whether the surface is on screen. */
	open: boolean;
	/** Called on `Escape`. Omit to make the surface non-dismissable by key. */
	onClose?: () => void;
	/** Set false for a surface that must not lock the page scroll. */
	lockScroll?: boolean;
}
 
export interface ModalA11y {
	/** Attach to the modal surface element. */
	surfaceRef: React.RefObject<HTMLDivElement | null>;
	/** Attach as the surface's `onKeyDown` to trap Tab inside it. */
	onKeyDown: (event: React.KeyboardEvent) => void;
}
 
export function useModalA11y({
	open,
	onClose,
	lockScroll = true,
}: ModalA11yOptions): ModalA11y {
	const surfaceRef = useRef<HTMLDivElement | null>(null);
	const restoreFocusTo = useRef<HTMLElement | null>(null);
 
	// Escape. A separate effect from focus so a changing `onClose` identity
	// does not re-run the focus move and yank focus back off a field.
	useEffect(() => {
		if (!open || !onClose) return;
		function handleKeyDown(event: KeyboardEvent) {
			if (event.key !== "Escape") return;
			event.stopPropagation();
			onClose?.();
		}
		document.addEventListener("keydown", handleKeyDown);
		return () => document.removeEventListener("keydown", handleKeyDown);
	}, [open, onClose]);
 
	// Focus in on open, back out on close.
	//
	// Focus lands on the SURFACE, not on its first control. The surface is the
	// element carrying `role="dialog"` and `aria-labelledby`, so a screen
	// reader announces "dialog, Add lead" — focusing the header's close button
	// instead would announce "Close, button" and the title would never be read.
	// Tab from there reaches the first control, because the surface precedes
	// its own children in tab order.
	useEffect(() => {
		if (!open) return;
		const previous = document.activeElement;
		restoreFocusTo.current = previous instanceof HTMLElement ? previous : null;
 
		surfaceRef.current?.focus();
 
		return () => {
			restoreFocusTo.current?.focus();
			restoreFocusTo.current = null;
		};
	}, [open]);
 
	// Background scroll lock.
	useEffect(() => {
		if (!open || !lockScroll) return;
		return lockBodyScroll();
	}, [open, lockScroll]);
 
	const onKeyDown = useCallback((event: React.KeyboardEvent) => {
		if (event.key !== "Tab") return;
		const surface = surfaceRef.current;
		Iif (!surface) return;
 
		const focusable = focusableWithin(surface);
		if (focusable.length === 0) {
			// Nothing to tab to: keep focus on the surface rather than letting
			// it escape to the page underneath.
			event.preventDefault();
			surface.focus();
			return;
		}
 
		const first = focusable[0] as HTMLElement;
		const last = focusable[focusable.length - 1] as HTMLElement;
		const active = document.activeElement;
 
		if (event.shiftKey && (active === first || active === surface)) {
			event.preventDefault();
			last.focus();
		} else if (!event.shiftKey && active === last) {
			event.preventDefault();
			first.focus();
		}
	}, []);
 
	return { surfaceRef, onKeyDown };
}