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:
2026-04-22 15:22:26 +02:00
parent c14ff27c37
commit 811588290c
24 changed files with 1573 additions and 371 deletions

View File

@@ -1,7 +1,8 @@
import { initMultiSelect } from '/js/components/app-multiselect-init.js';
import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js';
import { initDetailDrawer } from '/js/components/app-detail-drawer.js';
import { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js';
import { escapeHtml, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js';
createListPageModule({
configId: 'address-book-index',
@@ -53,52 +54,111 @@ createListPageModule({
name: labels.name || 'Name',
sort: true,
formatter: (cell, row) => {
const nameValue = typeof cell === 'object' && cell !== null
? (cell.name ?? '')
: cell;
const avatarUuid = typeof cell === 'object' && cell !== null
? (cell.avatar_uuid ?? '')
: '';
const nameValue = cell?.name ?? '';
const email = cell?.email ?? '';
const avatarUuid = cell?.avatar_uuid ?? '';
const name = escapeHtml(nameValue || '-');
const uuid = row?.cells?.[uuidIndex]?.data;
const editUrl = uuid ? withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()) : '';
const nameHtml = editUrl
? `<a class="app-grid-link-cell" href="${escapeHtml(editUrl)}">${name}</a>`
: `<span>${name}</span>`;
const nameNode = editUrl
? `<a class="app-grid-link-cell addr-identity-name" href="${escapeHtml(editUrl)}" data-drawer-trigger>${name}</a>`
: `<span class="addr-identity-name">${name}</span>`;
const emailNode = email
? `<span class="addr-identity-email">${escapeHtml(email)}</span>`
: '';
let avatarNode;
if (!avatarUuid) {
const initials = escapeHtml(initialsForName(nameValue));
return gridjs.html(`<span class="grid-name-cell"><span class="grid-avatar grid-avatar-placeholder">${initials}</span>${nameHtml}</span>`);
avatarNode = `<span class="grid-avatar grid-avatar-placeholder addr-identity-avatar">${initials}</span>`;
} else {
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
avatarNode = `<a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar addr-identity-avatar" src="${src}" alt="" loading="lazy"></a>`;
}
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
return gridjs.html(`<span class="grid-name-cell"><a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a>${nameHtml}</span>`);
return gridjs.html(`<span class="addr-identity-cell">${avatarNode}<span class="addr-identity-text">${nameNode}${emailNode}</span></span>`);
},
},
{
name: labels.context || 'Tenant / Department',
sort: false,
formatter: (cell) => {
const tenant = escapeHtml(String(cell?.tenant || ''));
const department = escapeHtml(String(cell?.department || ''));
const extra = Number(cell?.extra || 0);
if (!tenant && !department && !extra) {
return gridjs.html('<span class="addr-context-cell addr-context-empty"></span>');
}
const parts = [];
if (tenant) {
parts.push(`<span class="addr-context-primary">${tenant}</span>`);
}
if (department) {
parts.push(`<span class="addr-context-separator" aria-hidden="true">·</span><span class="addr-context-secondary">${department}</span>`);
}
if (extra > 0) {
const extraLabel = escapeHtml(labels.extraAssignments || 'further assignments');
parts.push(`<span class="addr-context-extra" title="+${extra} ${extraLabel}">+${extra}</span>`);
}
return gridjs.html(`<span class="addr-context-cell">${parts.join('')}</span>`);
},
},
{
name: labels.phone || 'Phone',
sort: false,
formatter: (cell) => {
const number = String(cell || '').trim();
if (!number) {
return gridjs.html('<span class="addr-phone-empty"></span>');
}
const tel = number.replace(/[^+0-9]/g, '');
const safe = escapeHtml(number);
return gridjs.html(`<a class="addr-phone-cell" href="tel:${escapeHtml(tel)}">${safe}</a>`);
},
},
{
name: labels.actions || 'Actions',
sort: false,
formatter: (cell, row) => {
const uuid = row?.cells?.[uuidIndex]?.data;
if (!uuid) {
return gridjs.html('');
}
const viewUrl = escapeHtml(withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()));
const email = String(cell?.email || '').trim();
const openLabel = escapeHtml(labels.openDetails || 'Open details');
const mailLabel = escapeHtml(labels.sendEmail || 'Send email');
const actions = [
`<a class="addr-action-button" href="${viewUrl}" data-tooltip="${openLabel}" data-tooltip-pos="left" aria-label="${openLabel}"><i class="bi bi-box-arrow-up-right"></i></a>`,
];
if (email) {
const safeMail = escapeHtml(email);
actions.push(`<a class="addr-action-button" href="mailto:${safeMail}" data-tooltip="${mailLabel}" data-tooltip-pos="left" aria-label="${mailLabel}"><i class="bi bi-envelope"></i></a>`);
}
return gridjs.html(`<span class="addr-action-cell">${actions.join('')}</span>`);
},
},
{ name: labels.email || 'Email', sort: true },
{ name: labels.phone || 'Phone', sort: false },
{ name: labels.mobile || 'Mobile', sort: false },
{ name: labels.shortDial || 'Short dial', sort: false },
{ name: labels.tenants || 'Tenants', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: labels.departments || 'Departments', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: labels.roles || 'Roles', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
],
sortColumns: [null, 'display_name', 'email', null, null, null, null, null, null],
sortColumns: [null, 'display_name', null, null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
mapData: (data) => data.data.map((row) => [
row.uuid,
{
name: row.display_name,
avatar_uuid: row.has_avatar ? row.uuid : '',
},
row.email,
row.phone,
row.mobile,
row.short_dial,
row.tenants,
row.departments,
row.roles,
]),
mapData: (data) => data.data.map((row) => {
const phone = (row.phone && String(row.phone).trim()) || (row.mobile && String(row.mobile).trim()) || '';
return [
row.uuid,
{
name: row.display_name,
email: row.email || '',
avatar_uuid: row.has_avatar ? row.uuid : '',
},
{
tenant: row.primary_tenant_label || '',
department: row.primary_department_label || '',
extra: row.extra_assignments_count || 0,
},
phone,
{ email: row.email || '' },
];
}),
search: gridSearch,
filterSchema,
extraFilters: customFilters,
@@ -118,6 +178,13 @@ createListPageModule({
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#address-book-search'],
preserveFilterParamsOnReset: ['tenant'],
drawer: {
countExcludeParams: ['tenant'],
},
clearMetaOptions: {
preserveFilterParams: ['tenant'],
},
},
}) || {};
@@ -131,6 +198,23 @@ createListPageModule({
});
});
initDetailDrawer({
gridConfig,
triggerSelector: '[data-drawer-trigger]',
rowUuidAttr: 'uuid',
fetchUrl: (uuid) => new URL(`address-book/view-fragment/${uuid}`, appBase).toString(),
fullUrl: (uuid) => withCurrentListReturn(new URL(`address-book/view/${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 gridConfig;
},
});