69 lines
2.7 KiB
JavaScript
69 lines
2.7 KiB
JavaScript
import {useEffect,useRef} from 'react';
|
|
|
|
// Only the top modal remains interactive, including when it is portalled to body.
|
|
const modalStack=[];
|
|
const previousInert=new Map();
|
|
let isolationObserver=null;
|
|
function isolateTopModal() {
|
|
for(const [element,inert] of previousInert)element.inert=inert;
|
|
previousInert.clear();
|
|
const top=modalStack.at(-1);
|
|
if(!top?.isConnected)return;
|
|
for(let branch=top;branch.parentElement;branch=branch.parentElement){
|
|
const parent=branch.parentElement;
|
|
for(const sibling of parent.children){
|
|
if(sibling===branch)continue;
|
|
previousInert.set(sibling,sibling.inert);
|
|
sibling.inert=true;
|
|
}
|
|
if(parent===document.body)break;
|
|
}
|
|
}
|
|
|
|
function focusableElements(root) {
|
|
return [...root.querySelectorAll('button, a[href], input, select, textarea, summary, [tabindex]')].filter(el=>{
|
|
if(el.tabIndex<0 || el.matches(':disabled') || !el.getClientRects().length || el.closest('[inert]'))return false;
|
|
if(['hidden','collapse'].includes(getComputedStyle(el).visibility))return false;
|
|
// Closed details descendants can have rectangles but cannot receive focus.
|
|
for(let parent=el.parentElement;parent && parent!==root;parent=parent.parentElement){
|
|
if(parent.tagName==='DETAILS' && !parent.open && !parent.querySelector(':scope > summary')?.contains(el))return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function useModalFocus(open,onClose) {
|
|
const ref=useRef(null),close=useRef(onClose);
|
|
close.current=onClose;
|
|
useEffect(()=>{
|
|
if(!open || !ref.current)return;
|
|
const opener=document.activeElement,root=ref.current;
|
|
modalStack.push(root);
|
|
isolateTopModal();
|
|
if(!isolationObserver){
|
|
isolationObserver=new MutationObserver(isolateTopModal);
|
|
isolationObserver.observe(document.body,{childList:true,subtree:true});
|
|
}
|
|
(focusableElements(root)[0] || root).focus();
|
|
return ()=>{
|
|
const wasTop=modalStack.at(-1)===root;
|
|
const index=modalStack.indexOf(root);
|
|
if(index>=0)modalStack.splice(index,1);
|
|
isolateTopModal();
|
|
if(!modalStack.length){isolationObserver?.disconnect();isolationObserver=null;}
|
|
if(wasTop && opener?.isConnected && !opener.closest('[inert]'))opener.focus();
|
|
};
|
|
},[open]);
|
|
const onKeyDown=e=>{
|
|
const root=ref.current;
|
|
if(e.defaultPrevented || e.target.closest('[role="dialog"]')!==root)return;
|
|
if(e.key==='Escape'){e.preventDefault();e.stopPropagation();close.current();return;}
|
|
if(e.key!=='Tab')return;
|
|
const elements=focusableElements(root),first=elements[0],last=elements.at(-1);
|
|
if(!first){e.preventDefault();root.focus();}
|
|
else if(e.shiftKey && (document.activeElement===first || document.activeElement===root)){e.preventDefault();last.focus();}
|
|
else if(!e.shiftKey && document.activeElement===last){e.preventDefault();first.focus();}
|
|
};
|
|
return {ref,onKeyDown};
|
|
}
|