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:
170
web/js/core/app-focus-trap.js
Normal file
170
web/js/core/app-focus-trap.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
82
web/js/core/app-fragment-init.js
Normal file
82
web/js/core/app-fragment-init.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Initializes standard UI components inside a dynamically-loaded HTML fragment
|
||||
* (e.g. detail drawer body). Mirrors the subset of app-init.js components that
|
||||
* make sense inside content fragments — layout/topbar/global components are
|
||||
* intentionally excluded since those live in the shell, not the fragment.
|
||||
*
|
||||
* Consumers:
|
||||
* import { initFragmentContent } from '/js/core/app-fragment-init.js';
|
||||
* initFragmentContent(contentEl);
|
||||
*
|
||||
* Each initializer is best-effort: failures are logged but do not abort the
|
||||
* remaining initializers so one broken component cannot silently break all
|
||||
* interactive elements in the fragment.
|
||||
*/
|
||||
|
||||
import { initTabs } from '../components/app-tabs.js';
|
||||
import { initConfirmActions } from '../components/app-confirm-actions.js';
|
||||
import { initLookupFields } from '../components/app-lookup-field.js';
|
||||
import { initAutoSubmit } from '../components/app-auto-submit.js';
|
||||
import { initFileUpload } from '../components/app-file-upload.js';
|
||||
|
||||
const INITIALIZERS = [
|
||||
{
|
||||
name: 'tabs',
|
||||
selector: '[data-app-component="tabs"]',
|
||||
run: (root) => initTabs(root),
|
||||
},
|
||||
{
|
||||
name: 'confirm-actions',
|
||||
selector: '[data-confirm]',
|
||||
run: (root) => initConfirmActions(root),
|
||||
},
|
||||
{
|
||||
name: 'lookup-field',
|
||||
selector: '[data-lookup-field]',
|
||||
run: (root) => initLookupFields(root),
|
||||
},
|
||||
{
|
||||
name: 'auto-submit',
|
||||
selector: '[data-auto-submit]',
|
||||
run: (root) => initAutoSubmit(root),
|
||||
},
|
||||
{
|
||||
name: 'file-upload',
|
||||
selector: '[data-app-component="file-upload"]',
|
||||
run: (root) => initFileUpload(root),
|
||||
},
|
||||
];
|
||||
|
||||
const refreshFsLightboxSafe = () => {
|
||||
if (typeof window !== 'undefined' && typeof window.refreshFsLightbox === 'function') {
|
||||
try {
|
||||
window.refreshFsLightbox();
|
||||
} catch (err) {
|
||||
console.warn('[fragment-init] refreshFsLightbox failed', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize all known components within the given fragment root.
|
||||
*
|
||||
* @param {HTMLElement} contentEl — the container holding freshly-injected HTML
|
||||
*/
|
||||
export function initFragmentContent(contentEl) {
|
||||
if (!(contentEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { name, selector, run } of INITIALIZERS) {
|
||||
if (!contentEl.querySelector(selector)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
run(contentEl);
|
||||
} catch (err) {
|
||||
console.warn(`[fragment-init] ${name} failed`, err);
|
||||
}
|
||||
}
|
||||
|
||||
refreshFsLightboxSafe();
|
||||
}
|
||||
@@ -124,6 +124,14 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionExpiredError extends Error {
|
||||
constructor(redirectUrl = '') {
|
||||
super('Session expired');
|
||||
this.name = 'SessionExpiredError';
|
||||
this.redirectUrl = redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const emitHttpTelemetry = (error, source) => {
|
||||
if (!(error instanceof HttpError)) {
|
||||
return;
|
||||
@@ -251,6 +259,62 @@ export const postForm = (url, data, options = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getHtml = async (url, options = {}) => {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
const headers = mergeHeaders({
|
||||
'Accept': 'text/html',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
}, options.headers || null);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(normalizedUrl, {
|
||||
...options,
|
||||
method: 'GET',
|
||||
credentials: options.credentials || 'same-origin',
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') { throw error; }
|
||||
const httpError = new HttpError({
|
||||
status: 0,
|
||||
method: 'GET',
|
||||
url: normalizedUrl,
|
||||
message: 'Network error',
|
||||
cause: error,
|
||||
});
|
||||
emitHttpTelemetry(httpError, 'http.network');
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const httpError = new HttpError({
|
||||
status: response.status,
|
||||
method: 'GET',
|
||||
url: normalizedUrl,
|
||||
message: response.statusText || 'Request failed',
|
||||
});
|
||||
emitHttpTelemetry(httpError, 'http.response');
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
// Session-expiry detection: fetch follows 302 redirects transparently, so we
|
||||
// land on the login page with a 200 response. The final URL path diverges
|
||||
// from the requested one — that's the signal.
|
||||
try {
|
||||
const requested = new URL(normalizedUrl).pathname;
|
||||
const landed = new URL(response.url || normalizedUrl).pathname;
|
||||
if (landed !== requested && /\/login(\/|$)/.test(landed)) {
|
||||
throw new SessionExpiredError(response.url || '');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) { throw err; }
|
||||
// URL parsing error — ignore, let caller process the body
|
||||
}
|
||||
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const postJson = (url, payload, options = {}) => {
|
||||
const headers = mergeHeaders({
|
||||
...DEFAULT_HEADERS,
|
||||
|
||||
Reference in New Issue
Block a user