Introduces a reusable core detail-drawer primitive that slides in from the right and loads any view via a `*-fragment(none).phtml` endpoint. Bundles the address book list overhaul that is its first consumer. Core additions: - `app-detail-drawer.js` — generic drawer with stepper, focus trap, body scroll-lock, URL-hash deep-linking, session-expiry detection - `app-fragment-init.js` — auto-wires tabs/lookups/confirm/file-upload/ fslightbox inside injected HTML; consumers do not re-initialize components - `app-focus-trap.js` — shared focus-trap + refcounted scroll-lock, used by both filter-drawer and detail-drawer - `getHtml()` in `app-http.js` + `SessionExpiredError`; drawer reloads the page on auth redirect instead of rendering the login form in the panel - `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer` consumer ships matching `*-fragment($id).php` + `*-fragment(none).phtml` Address book list: - Grid collapses from 9 columns to 4 (identity / context / phone / actions) with a two-line identity cell (avatar + name + email) - Tenant register tabs above the grid using the `app-list-tabs` partial; tenant filter wired via hidden toolbar field so grid.js forwards it on every data fetch - Profile body extracted to a shared partial so the full-page view and the new drawer fragment share the same markup - New i18n keys for the drawer/list labels Also refactors `app-filter-drawer` to reuse the shared focus-trap and scroll-lock instead of maintaining its own copy, and documents the detail-drawer convention in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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));
|
|
}
|
|
},
|
|
};
|
|
}
|