/** * 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 = `
`; 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; }