171 lines
4.4 KiB
JavaScript
171 lines
4.4 KiB
JavaScript
|
|
/**
|
||
|
|
* 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));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|