103 lines
2.5 KiB
TypeScript
103 lines
2.5 KiB
TypeScript
import { useEffect, useRef, type ReactNode } from 'react';
|
|
|
|
interface ModalPanelProps {
|
|
children: ReactNode;
|
|
className: string;
|
|
labelledBy: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ModalPanel({
|
|
children,
|
|
className,
|
|
labelledBy,
|
|
onClose,
|
|
}: ModalPanelProps) {
|
|
const panel = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
const returnFocus =
|
|
document.activeElement instanceof HTMLElement
|
|
? document.activeElement
|
|
: null;
|
|
const dialog = panel.current;
|
|
const focusable = () =>
|
|
dialog
|
|
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
|
(element) => !element.hasAttribute('disabled')
|
|
)
|
|
: [];
|
|
|
|
(focusable()[0] ?? dialog)?.focus();
|
|
const previousOverflow = document.documentElement.style.overflow;
|
|
document.documentElement.style.overflow = 'hidden';
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
onClose();
|
|
return;
|
|
}
|
|
if (event.key !== 'Tab') return;
|
|
|
|
const elements = focusable();
|
|
const first = elements[0];
|
|
const last = elements.at(-1);
|
|
if (!first || !last) {
|
|
event.preventDefault();
|
|
dialog?.focus();
|
|
return;
|
|
}
|
|
|
|
const active = document.activeElement;
|
|
if (!dialog?.contains(active)) {
|
|
event.preventDefault();
|
|
(event.shiftKey ? last : first).focus();
|
|
} else if (event.shiftKey && active === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && active === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
document.documentElement.style.overflow = previousOverflow;
|
|
if (returnFocus?.isConnected) returnFocus.focus();
|
|
};
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div
|
|
className="dialog-backdrop"
|
|
role="presentation"
|
|
onPointerDown={(event) =>
|
|
event.target === event.currentTarget && onClose()
|
|
}
|
|
>
|
|
<section
|
|
ref={panel}
|
|
className={className}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={labelledBy}
|
|
tabIndex={-1}
|
|
>
|
|
{children}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const FOCUSABLE_SELECTOR = [
|
|
'a[href]',
|
|
'button:not([disabled])',
|
|
'input:not([disabled])',
|
|
'select:not([disabled])',
|
|
'textarea:not([disabled])',
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
].join(',');
|