Files
breadcrumb-the-shire/web/js/core/app-focus-trap.js
fs 811588290c feat(core): detail drawer + address book list redesign
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>
2026-04-22 15:22:26 +02:00

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));
}
},
};
}