useModalDialog.ts
1,715 bytes
| 1 | import { useEffect, useRef } from "react"; |
|---|---|
| 2 | |
| 3 | const FOCUSABLE = "button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex='-1'])"; |
| 4 | |
| 5 | export function useModalDialog(onClose: () => void) { |
| 6 | const dialogRef = useRef<HTMLElement>(null); |
| 7 | const onCloseRef = useRef(onClose); |
| 8 | |
| 9 | useEffect(() => { |
| 10 | onCloseRef.current = onClose; |
| 11 | }, [onClose]); |
| 12 | |
| 13 | useEffect(() => { |
| 14 | const previousOverflow = document.body.style.overflow; |
| 15 | const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; |
| 16 | document.body.style.overflow = "hidden"; |
| 17 | dialogRef.current?.querySelector<HTMLElement>("[data-initial-focus], " + FOCUSABLE)?.focus(); |
| 18 | |
| 19 | function handleKeyDown(event: KeyboardEvent) { |
| 20 | if (event.key === "Escape") { |
| 21 | event.preventDefault(); |
| 22 | onCloseRef.current(); |
| 23 | return; |
| 24 | } |
| 25 | if (event.key !== "Tab" || !dialogRef.current) return; |
| 26 | const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)); |
| 27 | if (!focusable.length) return; |
| 28 | const first = focusable[0]; |
| 29 | const last = focusable[focusable.length - 1]; |
| 30 | if (event.shiftKey && document.activeElement === first) { |
| 31 | event.preventDefault(); |
| 32 | last.focus(); |
| 33 | } else if (!event.shiftKey && document.activeElement === last) { |
| 34 | event.preventDefault(); |
| 35 | first.focus(); |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | document.addEventListener("keydown", handleKeyDown); |
| 40 | return () => { |
| 41 | document.removeEventListener("keydown", handleKeyDown); |
| 42 | document.body.style.overflow = previousOverflow; |
| 43 | previousFocus?.focus(); |
| 44 | }; |
| 45 | }, []); |
| 46 | |
| 47 | return dialogRef; |
| 48 | } |
| 49 | |