1
0

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

@@ -0,0 +1,132 @@
@layer components {
/* Detail drawer — slide-in overlay panel for row detail view. */
html.app-detail-drawer-open {
overflow: hidden;
overscroll-behavior: none;
}
.app-detail-drawer {
position: fixed;
inset: 0;
z-index: 2050;
display: flex;
justify-content: flex-end;
pointer-events: none;
}
.app-detail-drawer[hidden] {
display: none;
}
.app-detail-drawer-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.3);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
opacity: 0;
transition: opacity 0.18s ease;
pointer-events: auto;
}
.app-detail-drawer.is-open .app-detail-drawer-backdrop {
opacity: 1;
}
.app-detail-drawer-panel {
position: relative;
display: flex;
flex-direction: column;
width: min(720px, 100vw);
height: 100%;
background: var(--app-card-background-color, #fff);
border-left: 1px solid var(--app-muted-border-color, rgba(148, 163, 184, 0.35));
box-shadow: -12px 0 30px rgba(15, 23, 42, 0.18);
transform: translateX(100%);
transition: transform 0.22s ease;
pointer-events: auto;
outline: none;
}
.app-detail-drawer.is-open .app-detail-drawer-panel {
transform: translateX(0);
}
.app-detail-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--app-muted-border-color, rgba(148, 163, 184, 0.25));
background: var(--app-card-background-color, #fff);
position: sticky;
top: 0;
z-index: 1;
}
.app-detail-drawer-stepper,
.app-detail-drawer-actions {
display: inline-flex;
gap: 0.25rem;
align-items: center;
}
.app-detail-drawer-stepper[hidden] {
display: none;
}
.app-detail-drawer-header button,
.app-detail-drawer-header a {
margin-bottom: 0;
}
.app-detail-drawer-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 1rem 1.25rem 2rem;
-webkit-overflow-scrolling: touch;
}
.app-detail-drawer-loading,
.app-detail-drawer-error {
padding: 2rem 1rem;
text-align: center;
color: var(--app-color-muted, #6b7280);
}
.app-detail-drawer-error {
color: var(--app-color-danger, #b91c1c);
}
@media (max-width: 900px) {
.app-detail-drawer-panel {
width: 100vw;
border-left: none;
}
}
/* Inside the drawer, force single-column layout for any .app-details-container
regardless of viewport width (the grid rule in app-details.css triggers on
viewport ≥968px, which is wrong for a 720px drawer). Any page that uses the
standard details container gets stacked main + aside automatically. */
.app-detail-drawer .app-details-container,
.app-detail-drawer .app-details-container:has(> aside) {
display: block;
grid-template-columns: none;
min-height: 0;
}
.app-detail-drawer .app-details-container > section {
width: 100%;
}
.app-detail-drawer .app-details-container > aside {
border-left: none;
border-top: 1px solid var(--app-border);
min-height: 0;
padding: calc(var(--app-spacing) * 1.5) 0 0;
margin-top: calc(var(--app-spacing) * 1.5);
}
}

View File

@@ -36,6 +36,7 @@
@import url("components/app-tabs.css");
@import url("components/app-list-toolbar.css");
@import url("components/app-filter-drawer.css");
@import url("components/app-detail-drawer.css");
@import url("components/app-active-filter-chips.css");
@import url("components/app-confirm-dialog.css");
@import url("components/app-breadcrumb.css");

View 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;
}

View File

@@ -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);

View 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));
}
},
};
}

View 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();
}

View File

@@ -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,