forked from fa/breadcrumb-the-shire
The drawer chrome used to be built in JS via a 50-line innerHTML string and needed every caller to plumb through a labels object sourced from per-page PHP config. That diverged from the codebase convention — the confirm dialog, search dialog and session warning are all PHP partials mounted once in templates/default.phtml. The drawer now follows the same pattern: templates/partials/app-detail-drawer.phtml renders the markup with t() labels, default.phtml mounts it inside the logged-in shell, and the JS only attaches behavior via the [data-detail-drawer] selector. Knock-on cleanup: ensureDrawerElement and resolveLabels disappear from the JS, all three initDetailDrawer callers (admin/users, address book, helpdesk debitor) drop their labels parameter, and the matching dead 'drawerClose'/'drawerPrev'/… entries leave the per-page PHP grid configs. The drawer also gains a clean fallback when the partial is missing (console warn + null return), so logged-out edges can't crash. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
259 lines
8.7 KiB
JavaScript
259 lines
8.7 KiB
JavaScript
/**
|
|
* 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.
|
|
*
|
|
* The drawer chrome is rendered server-side by templates/partials/app-detail-drawer.phtml
|
|
* (mounted once in the logged-in shell). This module only attaches behavior
|
|
* via the [data-detail-drawer] selector and never injects markup.
|
|
*/
|
|
|
|
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');
|
|
|
|
/** 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,
|
|
} = options;
|
|
|
|
if (typeof fetchUrl !== 'function') {
|
|
console.warn('[detail-drawer] fetchUrl required');
|
|
return null;
|
|
}
|
|
|
|
const root = document.querySelector('[data-detail-drawer]');
|
|
if (!(root instanceof HTMLElement)) {
|
|
console.warn('[detail-drawer] [data-detail-drawer] not in DOM — partial not mounted?');
|
|
return null;
|
|
}
|
|
if (root[STATE_KEY]) { return root[STATE_KEY]; }
|
|
|
|
const effectiveRowProvider = typeof rowProvider === 'function'
|
|
? rowProvider
|
|
: makeGridRowProvider(gridConfig, rowUuidAttr);
|
|
const stepperEnabled = typeof effectiveRowProvider === 'function';
|
|
|
|
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;
|
|
}
|