refactor(ui): move detail drawer chrome to a server-rendered partial

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>
This commit is contained in:
2026-04-25 19:06:06 +02:00
parent b205ba1312
commit 4d1bd13a3b
9 changed files with 69 additions and 107 deletions

View File

@@ -2,6 +2,10 @@
* 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';
@@ -10,66 +14,6 @@ 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 role="button" class="secondary outline small app-detail-drawer-full" data-detail-drawer-full
aria-label="${labels.openFull}">
<i class="bi bi-box-arrow-up-right" aria-hidden="true"></i>
<span>${labels.openFull}</span>
</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; }
@@ -96,7 +40,6 @@ export function initDetailDrawer(options = {}) {
fullUrl,
hashPrefix = 'detail',
onContentLoaded = null,
labels: labelOverrides = {},
} = options;
if (typeof fetchUrl !== 'function') {
@@ -104,15 +47,18 @@ export function initDetailDrawer(options = {}) {
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 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]');

View File

@@ -199,14 +199,6 @@ createListPageModule({
fetchUrl: (uuid) => new URL(`admin/users/view-fragment/${uuid}`, appBase).toString(),
fullUrl: (uuid) => withCurrentListReturn(new URL(`admin/users/edit/${uuid}`, appBase).toString()),
hashPrefix: 'user',
labels: {
close: labels.drawerClose || 'Close',
previous: labels.drawerPrev || 'Previous',
next: labels.drawerNext || 'Next',
openFull: labels.drawerOpenFull || 'Open full page',
loading: labels.drawerLoading || 'Loading',
error: labels.drawerError || 'Failed to load',
},
});
return usersGridConfig;