// Action: Focus Trap export function focusTrap(node, enabled) { const elemWhitelist = 'a[href]:not([tabindex="-1"]), button:not([tabindex="-1"]), input:not([tabindex="-1"]), textarea:not([tabindex="-1"]), select:not([tabindex="-1"]), details:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])'; let elemFirst; let elemLast; // When the first element is selected, shift+tab pressed, jump to the last selectable item. function onFirstElemKeydown(e) { if (e.shiftKey && e.code === 'Tab') { e.preventDefault(); elemLast.focus(); } } // When the last item selected, tab pressed, jump to the first selectable item. function onLastElemKeydown(e) { if (!e.shiftKey && e.code === 'Tab') { e.preventDefault(); elemFirst.focus(); } } const onScanElements = (fromObserver) => { if (enabled === false) return; // Gather all focusable elements const focusableElems = Array.from(node.querySelectorAll(elemWhitelist)); if (focusableElems.length) { // Set first/last focusable elements elemFirst = focusableElems[0]; elemLast = focusableElems[focusableElems.length - 1]; // Auto-focus first focusable element only when not called from observer if (!fromObserver) elemFirst.focus(); // Listen for keydown on first & last element elemFirst.addEventListener('keydown', onFirstElemKeydown); elemLast.addEventListener('keydown', onLastElemKeydown); } }; onScanElements(false); function onCleanUp() { if (elemFirst) elemFirst.removeEventListener('keydown', onFirstElemKeydown); if (elemLast) elemLast.removeEventListener('keydown', onLastElemKeydown); } // When children of node are changed (added or removed) const onObservationChange = (mutationRecords, observer) => { if (mutationRecords.length) { onCleanUp(); onScanElements(true); } return observer; }; const observer = new MutationObserver(onObservationChange); observer.observe(node, { childList: true, subtree: true }); // Lifecycle return { update(newArgs) { enabled = newArgs; newArgs ? onScanElements(false) : onCleanUp(); }, destroy() { onCleanUp(); observer.disconnect(); } }; }