/** * Focus-trap + body-scroll-lock utilities for modal-style overlays * (filter drawer, detail drawer, dialogs). * * Usage: * const trap = createFocusTrap(panelEl); * trap.activate(triggerEl); // remembers trigger for focus-return * trap.deactivate(); // restores focus to trigger * * const lock = createScrollLock(); * lock.lock(); * lock.unlock(); */ const FOCUSABLE_SELECTORS = [ 'a[href]', 'area[href]', 'input:not([type="hidden"]):not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); const getFocusable = (panel) => { if (!(panel instanceof HTMLElement)) { return []; } return Array.from(panel.querySelectorAll(FOCUSABLE_SELECTORS)).filter((el) => { if (el.getAttribute('aria-hidden') === 'true') { return false; } if (el instanceof HTMLElement && el.hidden) { return false; } return true; }); }; export function createFocusTrap(panel) { if (!(panel instanceof HTMLElement)) { return { activate() {}, deactivate() {}, focusFirst() {}, }; } if (!panel.hasAttribute('tabindex')) { panel.setAttribute('tabindex', '-1'); } let active = false; let lastTrigger = null; const focusFirst = () => { const target = getFocusable(panel)[0] || panel; if (target && typeof target.focus === 'function') { target.focus(); } }; const onKeyDown = (event) => { if (!active) { return; } if (event.key !== 'Tab') { return; } const focusable = getFocusable(panel); if (!focusable.length) { event.preventDefault(); panel.focus(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const current = document.activeElement; if (event.shiftKey) { if (current === first || !panel.contains(current)) { event.preventDefault(); last.focus(); } return; } if (current === last) { event.preventDefault(); first.focus(); } }; document.addEventListener('keydown', onKeyDown); return { activate(trigger = null) { if (active) { return; } active = true; lastTrigger = trigger && typeof trigger.focus === 'function' ? trigger : (document.activeElement instanceof HTMLElement ? document.activeElement : null); requestAnimationFrame(() => focusFirst()); }, deactivate() { if (!active) { return; } active = false; if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) { lastTrigger.focus(); } lastTrigger = null; }, focusFirst, destroy() { active = false; lastTrigger = null; document.removeEventListener('keydown', onKeyDown); }, }; } /** * Body scroll-lock with position:fixed strategy. Reference-counted so multiple * overlays cooperate: the lock is only released when the last caller unlocks. */ const SCROLL_LOCK_ATTR = 'data-app-scroll-lock-count'; export function createScrollLock() { let locked = false; return { lock() { if (locked) { return; } locked = true; const body = document.body; if (!body) { return; } const current = Number(body.getAttribute(SCROLL_LOCK_ATTR) || '0') || 0; const next = current + 1; body.setAttribute(SCROLL_LOCK_ATTR, String(next)); if (current === 0) { const y = window.scrollY || window.pageYOffset || 0; body.dataset.appScrollLockY = String(y); body.style.position = 'fixed'; body.style.top = `-${y}px`; body.style.left = '0'; body.style.right = '0'; body.style.width = '100%'; } }, unlock() { if (!locked) { return; } locked = false; const body = document.body; if (!body) { return; } const current = Number(body.getAttribute(SCROLL_LOCK_ATTR) || '0') || 0; const next = Math.max(0, current - 1); if (next === 0) { body.removeAttribute(SCROLL_LOCK_ATTR); const y = Number(body.dataset.appScrollLockY || '0') || 0; body.style.position = ''; body.style.top = ''; body.style.left = ''; body.style.right = ''; body.style.width = ''; delete body.dataset.appScrollLockY; window.scrollTo(0, y); } else { body.setAttribute(SCROLL_LOCK_ATTR, String(next)); } }, }; }