Files
breadcrumb-the-shire/web/js/components/app-detail-drawer.js
2026-04-24 22:43:37 +02:00

313 lines
11 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.
*/
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 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; }
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;
}