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>
This commit is contained in:
312
web/js/components/app-detail-drawer.js
Normal file
312
web/js/components/app-detail-drawer.js
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Reusable detail drawer — slides in from the right, loads a fragment URL,
|
||||
* supports deep-link via URL hash, and can step through rows on the current
|
||||
* grid page.
|
||||
*/
|
||||
|
||||
import { getHtml, SessionExpiredError } from '../core/app-http.js';
|
||||
import { initFragmentContent } from '../core/app-fragment-init.js';
|
||||
import { createFocusTrap, createScrollLock } from '../core/app-focus-trap.js';
|
||||
|
||||
const STATE_KEY = Symbol('detail-drawer-state');
|
||||
|
||||
function resolveLabels(labels = {}) {
|
||||
return {
|
||||
close: labels.close || 'Close',
|
||||
previous: labels.previous || 'Previous',
|
||||
next: labels.next || 'Next',
|
||||
openFull: labels.openFull || 'Open full page',
|
||||
loading: labels.loading || 'Loading',
|
||||
error: labels.error || 'Failed to load',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDrawerElement(labels) {
|
||||
let root = document.querySelector('[data-detail-drawer]');
|
||||
if (root instanceof HTMLElement) {
|
||||
return root;
|
||||
}
|
||||
root = document.createElement('div');
|
||||
root.className = 'app-detail-drawer';
|
||||
root.setAttribute('data-detail-drawer', '');
|
||||
root.setAttribute('role', 'dialog');
|
||||
root.setAttribute('aria-modal', 'true');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
root.hidden = true;
|
||||
root.innerHTML = `
|
||||
<div class="app-detail-drawer-backdrop" data-detail-drawer-backdrop></div>
|
||||
<aside class="app-detail-drawer-panel" data-detail-drawer-panel tabindex="-1">
|
||||
<header class="app-detail-drawer-header">
|
||||
<div class="app-detail-drawer-stepper">
|
||||
<button type="button" class="transparent icon-button" data-detail-drawer-prev
|
||||
aria-label="${labels.previous}" data-tooltip="${labels.previous}" data-tooltip-pos="bottom">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<button type="button" class="transparent icon-button" data-detail-drawer-next
|
||||
aria-label="${labels.next}" data-tooltip="${labels.next}" data-tooltip-pos="bottom">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-detail-drawer-actions">
|
||||
<a class="transparent icon-button" data-detail-drawer-full
|
||||
target="_blank" rel="noopener"
|
||||
aria-label="${labels.openFull}" data-tooltip="${labels.openFull}" data-tooltip-pos="bottom">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
<button type="button" class="transparent icon-button" data-detail-drawer-close
|
||||
aria-label="${labels.close}" data-tooltip="${labels.close}" data-tooltip-pos="bottom">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="app-detail-drawer-body" data-detail-drawer-body>
|
||||
<div class="app-detail-drawer-loading" data-detail-drawer-loading hidden>${labels.loading}</div>
|
||||
<div class="app-detail-drawer-error" data-detail-drawer-error hidden>${labels.error}</div>
|
||||
<div class="app-detail-drawer-content" data-detail-drawer-content></div>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Default row-uuid discovery: scrapes the visible Grid.js rows on the page. */
|
||||
function makeGridRowProvider(gridConfig, rowUuidAttr) {
|
||||
if (!gridConfig) { return null; }
|
||||
return () => {
|
||||
const wrapper = gridConfig?.wrapper || document;
|
||||
const rows = wrapper.querySelectorAll(`.gridjs-tr[data-${rowUuidAttr}]`);
|
||||
const uuids = [];
|
||||
rows.forEach((row) => {
|
||||
const uuid = row.getAttribute(`data-${rowUuidAttr}`);
|
||||
if (uuid) { uuids.push(uuid); }
|
||||
});
|
||||
return uuids;
|
||||
};
|
||||
}
|
||||
|
||||
export function initDetailDrawer(options = {}) {
|
||||
const {
|
||||
gridConfig = null,
|
||||
triggerSelector = '[data-drawer-trigger]',
|
||||
rowSelector = '.gridjs-tr',
|
||||
rowUuidAttr = 'uuid',
|
||||
rowProvider = null,
|
||||
fetchUrl,
|
||||
fullUrl,
|
||||
hashPrefix = 'detail',
|
||||
onContentLoaded = null,
|
||||
labels: labelOverrides = {},
|
||||
} = options;
|
||||
|
||||
if (typeof fetchUrl !== 'function') {
|
||||
console.warn('[detail-drawer] fetchUrl required');
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveRowProvider = typeof rowProvider === 'function'
|
||||
? rowProvider
|
||||
: makeGridRowProvider(gridConfig, rowUuidAttr);
|
||||
const stepperEnabled = typeof effectiveRowProvider === 'function';
|
||||
|
||||
const labels = resolveLabels(labelOverrides);
|
||||
const root = ensureDrawerElement(labels);
|
||||
if (root[STATE_KEY]) { return root[STATE_KEY]; }
|
||||
|
||||
const panel = root.querySelector('[data-detail-drawer-panel]');
|
||||
const backdrop = root.querySelector('[data-detail-drawer-backdrop]');
|
||||
const closeButton = root.querySelector('[data-detail-drawer-close]');
|
||||
const prevButton = root.querySelector('[data-detail-drawer-prev]');
|
||||
const nextButton = root.querySelector('[data-detail-drawer-next]');
|
||||
const stepperContainer = root.querySelector('.app-detail-drawer-stepper');
|
||||
const fullLink = root.querySelector('[data-detail-drawer-full]');
|
||||
const contentEl = root.querySelector('[data-detail-drawer-content]');
|
||||
const loadingEl = root.querySelector('[data-detail-drawer-loading]');
|
||||
const errorEl = root.querySelector('[data-detail-drawer-error]');
|
||||
|
||||
if (stepperContainer instanceof HTMLElement) {
|
||||
stepperContainer.hidden = !stepperEnabled;
|
||||
}
|
||||
|
||||
const hashPattern = new RegExp(`^#${hashPrefix}/([^/?#]+)`);
|
||||
const focusTrap = createFocusTrap(panel);
|
||||
const scrollLock = createScrollLock();
|
||||
let lastTrigger = null;
|
||||
|
||||
const api = {
|
||||
currentUuid: null,
|
||||
isOpen: false,
|
||||
open(uuid, opts = {}) {
|
||||
const pushHash = opts.pushHash !== false;
|
||||
if (!uuid) { return; }
|
||||
const wasOpen = api.isOpen;
|
||||
api.currentUuid = String(uuid);
|
||||
root.hidden = false;
|
||||
root.setAttribute('aria-hidden', 'false');
|
||||
root.classList.add('is-open');
|
||||
document.documentElement.classList.add('app-detail-drawer-open');
|
||||
api.isOpen = true;
|
||||
if (!wasOpen) {
|
||||
lastTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
scrollLock.lock();
|
||||
focusTrap.activate(lastTrigger);
|
||||
}
|
||||
if (typeof fullUrl === 'function') {
|
||||
fullLink.href = fullUrl(uuid);
|
||||
}
|
||||
if (pushHash) {
|
||||
const targetHash = `#${hashPrefix}/${uuid}`;
|
||||
if (window.location.hash !== targetHash) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search + targetHash);
|
||||
}
|
||||
}
|
||||
updateStepper();
|
||||
loadContent(uuid);
|
||||
},
|
||||
close(opts = {}) {
|
||||
const clearHash = opts.clearHash !== false;
|
||||
if (!api.isOpen) { return; }
|
||||
api.isOpen = false;
|
||||
api.currentUuid = null;
|
||||
root.classList.remove('is-open');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
root.hidden = true;
|
||||
document.documentElement.classList.remove('app-detail-drawer-open');
|
||||
focusTrap.deactivate();
|
||||
scrollLock.unlock();
|
||||
lastTrigger = null;
|
||||
contentEl.innerHTML = '';
|
||||
if (clearHash && hashPattern.test(window.location.hash)) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
},
|
||||
openNext() { step(+1); },
|
||||
openPrev() { step(-1); },
|
||||
};
|
||||
|
||||
function getRowUuids() {
|
||||
if (!stepperEnabled) { return []; }
|
||||
try {
|
||||
const result = effectiveRowProvider();
|
||||
return Array.isArray(result) ? result.filter(Boolean).map(String) : [];
|
||||
} catch (err) {
|
||||
console.warn('[detail-drawer] rowProvider failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function step(direction) {
|
||||
if (!api.currentUuid || !stepperEnabled) { return; }
|
||||
const uuids = getRowUuids();
|
||||
const idx = uuids.indexOf(api.currentUuid);
|
||||
if (idx < 0) { return; }
|
||||
const nextIdx = idx + direction;
|
||||
if (nextIdx < 0 || nextIdx >= uuids.length) { return; }
|
||||
api.open(uuids[nextIdx]);
|
||||
}
|
||||
|
||||
function updateStepper() {
|
||||
if (!stepperEnabled) {
|
||||
prevButton.disabled = true;
|
||||
nextButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
const uuids = getRowUuids();
|
||||
const idx = uuids.indexOf(api.currentUuid);
|
||||
prevButton.disabled = idx <= 0;
|
||||
nextButton.disabled = idx < 0 || idx >= uuids.length - 1;
|
||||
}
|
||||
|
||||
async function loadContent(uuid) {
|
||||
contentEl.innerHTML = '';
|
||||
errorEl.hidden = true;
|
||||
loadingEl.hidden = false;
|
||||
const url = fetchUrl(uuid);
|
||||
try {
|
||||
const html = await getHtml(url);
|
||||
if (!api.isOpen || api.currentUuid !== String(uuid)) { return; }
|
||||
contentEl.innerHTML = html;
|
||||
contentEl.querySelectorAll('script').forEach((script) => {
|
||||
const replacement = document.createElement('script');
|
||||
[...script.attributes].forEach((attr) => replacement.setAttribute(attr.name, attr.value));
|
||||
replacement.textContent = script.textContent;
|
||||
script.parentNode.replaceChild(replacement, script);
|
||||
});
|
||||
loadingEl.hidden = true;
|
||||
initFragmentContent(contentEl);
|
||||
if (typeof onContentLoaded === 'function') {
|
||||
try {
|
||||
onContentLoaded(contentEl, uuid);
|
||||
} catch (hookErr) {
|
||||
console.warn('[detail-drawer] onContentLoaded failed', hookErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) {
|
||||
api.close({ clearHash: true });
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
loadingEl.hidden = true;
|
||||
errorEl.hidden = false;
|
||||
console.warn('[detail-drawer] load failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest(triggerSelector);
|
||||
if (!trigger) { return; }
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) {
|
||||
return;
|
||||
}
|
||||
const row = trigger.closest(rowSelector);
|
||||
const uuid = row?.getAttribute(`data-${rowUuidAttr}`) || trigger.getAttribute(`data-${rowUuidAttr}`);
|
||||
if (!uuid) { return; }
|
||||
event.preventDefault();
|
||||
api.open(uuid);
|
||||
});
|
||||
|
||||
closeButton.addEventListener('click', () => api.close());
|
||||
backdrop.addEventListener('click', () => api.close());
|
||||
prevButton.addEventListener('click', () => api.openPrev());
|
||||
nextButton.addEventListener('click', () => api.openNext());
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (!api.isOpen) { return; }
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
api.close();
|
||||
return;
|
||||
}
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const tag = event.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === 'j' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
api.openNext();
|
||||
} else if (event.key === 'k' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
api.openPrev();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const match = hashPattern.exec(window.location.hash);
|
||||
if (match) {
|
||||
api.open(match[1], { pushHash: false });
|
||||
} else if (api.isOpen) {
|
||||
api.close({ clearHash: false });
|
||||
}
|
||||
});
|
||||
|
||||
const initialMatch = hashPattern.exec(window.location.hash);
|
||||
if (initialMatch) {
|
||||
requestAnimationFrame(() => api.open(initialMatch[1], { pushHash: false }));
|
||||
}
|
||||
|
||||
root[STATE_KEY] = api;
|
||||
return api;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
* Slide-in filter drawer with focus trap, scroll lock, and apply/reset/discard lifecycle.
|
||||
*/
|
||||
import { warnOnce, resolveHost } from '../core/app-dom.js';
|
||||
import { createFocusTrap, createScrollLock } from '../core/app-focus-trap.js';
|
||||
|
||||
export function initFilterDrawer(options = {}) {
|
||||
const {
|
||||
@@ -46,8 +47,9 @@ export function initFilterDrawer(options = {}) {
|
||||
};
|
||||
|
||||
let isOpen = false;
|
||||
let lockedScrollY = 0;
|
||||
let lastTrigger = null;
|
||||
const focusTrap = createFocusTrap(panel);
|
||||
const scrollLock = createScrollLock();
|
||||
|
||||
const ensureDrawerInBody = () => {
|
||||
if (!document.body || !drawer.isConnected || drawer.parentNode === document.body) {
|
||||
@@ -83,61 +85,9 @@ export function initFilterDrawer(options = {}) {
|
||||
panel.setAttribute('tabindex', '-1');
|
||||
}
|
||||
|
||||
const lockPageScroll = () => {
|
||||
const body = document.body;
|
||||
if (body.dataset.filterDrawerLock === '1') {
|
||||
return;
|
||||
}
|
||||
lockedScrollY = window.scrollY || window.pageYOffset || 0;
|
||||
body.dataset.filterDrawerLock = '1';
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${lockedScrollY}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
};
|
||||
|
||||
const unlockPageScroll = () => {
|
||||
const body = document.body;
|
||||
if (body.dataset.filterDrawerLock !== '1') {
|
||||
return;
|
||||
}
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
body.style.left = '';
|
||||
body.style.right = '';
|
||||
body.style.width = '';
|
||||
delete body.dataset.filterDrawerLock;
|
||||
window.scrollTo(0, lockedScrollY);
|
||||
};
|
||||
|
||||
const getFocusableElements = () => {
|
||||
const selectors = [
|
||||
'a[href]',
|
||||
'area[href]',
|
||||
'input:not([type="hidden"]):not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'button:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
];
|
||||
return Array.from(panel.querySelectorAll(selectors.join(','))).filter((element) => {
|
||||
if (element.getAttribute('aria-hidden') === 'true') {
|
||||
return false;
|
||||
}
|
||||
if (element instanceof HTMLElement && element.hidden) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const focusFirstField = () => {
|
||||
const focusable = getFocusableElements()[0] || panel;
|
||||
if (focusable && typeof focusable.focus === 'function') {
|
||||
focusable.focus();
|
||||
}
|
||||
};
|
||||
const lockPageScroll = () => scrollLock.lock();
|
||||
const unlockPageScroll = () => scrollLock.unlock();
|
||||
cleanupFns.push(() => focusTrap.destroy());
|
||||
|
||||
const setOpen = (nextOpen) => {
|
||||
if (nextOpen) {
|
||||
@@ -151,7 +101,9 @@ export function initFilterDrawer(options = {}) {
|
||||
|
||||
if (nextOpen) {
|
||||
lockPageScroll();
|
||||
focusTrap.activate(lastTrigger);
|
||||
} else {
|
||||
focusTrap.deactivate();
|
||||
unlockPageScroll();
|
||||
}
|
||||
|
||||
@@ -160,9 +112,6 @@ export function initFilterDrawer(options = {}) {
|
||||
});
|
||||
|
||||
if (!nextOpen) {
|
||||
if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) {
|
||||
lastTrigger.focus();
|
||||
}
|
||||
lastTrigger = null;
|
||||
if (typeof onClose === 'function') {
|
||||
onClose();
|
||||
@@ -173,7 +122,6 @@ export function initFilterDrawer(options = {}) {
|
||||
if (typeof onOpen === 'function') {
|
||||
onOpen();
|
||||
}
|
||||
focusFirstField();
|
||||
};
|
||||
|
||||
const normalizeForcedClose = () => {
|
||||
@@ -181,6 +129,7 @@ export function initFilterDrawer(options = {}) {
|
||||
return;
|
||||
}
|
||||
isOpen = false;
|
||||
focusTrap.deactivate();
|
||||
unlockPageScroll();
|
||||
document.body.classList.remove('filter-drawer-open');
|
||||
openButtons.forEach((button) => {
|
||||
@@ -267,32 +216,6 @@ export function initFilterDrawer(options = {}) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close('discard');
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') {return;}
|
||||
|
||||
const focusable = getFocusableElements();
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (active === first || !panel.contains(active)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
bind(document, 'keydown', onKeyDown);
|
||||
|
||||
Reference in New Issue
Block a user