201 lines
8.1 KiB
JavaScript
201 lines
8.1 KiB
JavaScript
/* Brain of Reese — document modal (phase 26, task 02).
|
|
*
|
|
* "New documents should open in an almost-fullscreen modal, not in a new
|
|
* page" (TODO.md L4). This module is the SINGLE owner of the modal (the
|
|
* way header.js owns the shared header): the chat page (app.js source
|
|
* chips) and the Sources page (sources.js table links) each import
|
|
* openDocumentModal(...) from here — one implementation, no duplicate
|
|
* module instance, no direct <script> tag (esbuild inlines it into the
|
|
* page bundle, same single-evaluation design as header.js).
|
|
*
|
|
* Contract:
|
|
* • openDocumentModal(source, path, triggerEl) — shows the #doc-modal
|
|
* overlay (the phase-26 task-01 skeleton) in its loading state,
|
|
* moves focus into #doc-modal-content (tabindex="-1" — programmatic
|
|
* focus target), fetches GET /api/documents/content (the SAME
|
|
* stateless endpoint the /document.html page uses — identical
|
|
* percent-encoding), and renders through document.js's shared
|
|
* renderDocument: md/markdown → .doc-md via the escape-first
|
|
* renderer, other formats → <pre class="doc-raw"> via textContent.
|
|
* A !ok / network failure renders a short "document not found" line
|
|
* in the content area.
|
|
* • closeDocumentModal() — hides the overlay, removes the Escape/Tab
|
|
* capture, and restores focus to the element that opened the modal
|
|
* (best-effort: the trigger may have been removed from the DOM, e.g.
|
|
* "New chat" clearing the list while the modal is open).
|
|
* • Close affordances (modal UX standard): #doc-modal-close button,
|
|
* backdrop click, and the Escape key (captured on document, so it
|
|
* works no matter where focus is). While open, Tab / Shift+Tab are
|
|
* trapped inside the panel.
|
|
* • #doc-modal-open ("Full page") is the escape hatch to the
|
|
* dedicated viewer: its href is set on open to the same
|
|
* /document.html?source=…&path=… URL the chip/table link carries,
|
|
* so the no-JS / direct-link page is always one click away.
|
|
*
|
|
* Import safety: the top level only binds controls that exist on the
|
|
* page (a missing element is a no-op — the header.js pattern). document
|
|
* .js is imported for renderDocument alone; its /document.html-specific
|
|
* init is guarded there, so importing this module on the chat/sources
|
|
* pages has no side effects beyond binding the modal itself.
|
|
*/
|
|
|
|
import { renderDocument } from "./document.js";
|
|
|
|
const modalEl = document.querySelector("#doc-modal");
|
|
const backdropEl = document.querySelector("#doc-modal-backdrop");
|
|
const panelEl = document.querySelector("#doc-modal-panel");
|
|
const titleEl = document.querySelector("#doc-modal-title");
|
|
const metaEl = document.querySelector("#doc-modal-meta");
|
|
const contentEl = document.querySelector("#doc-modal-content");
|
|
const openEl = document.querySelector("#doc-modal-open");
|
|
const closeEl = document.querySelector("#doc-modal-close");
|
|
const descEl = document.querySelector("#doc-modal-desc");
|
|
|
|
/* The element that opened the modal — closeDocumentModal restores focus
|
|
* to it (best-effort, see the header note). */
|
|
let triggerEl = null;
|
|
/* Monotonic fetch sequence: a close or a re-open for another document
|
|
* must not render a stale response into the modal (rapid
|
|
* open → close → open). */
|
|
let fetchSeq = 0;
|
|
|
|
/* Same encoding the chips and the Sources table links use — both query
|
|
* values percent-encoded (real paths contain slashes, sometimes spaces). */
|
|
function contentUrl(source, path) {
|
|
return "/api/documents/content?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
|
}
|
|
|
|
function fullPageUrl(source, path) {
|
|
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
|
}
|
|
|
|
function statusLine(text) {
|
|
const p = document.createElement("p");
|
|
p.className = "doc-modal-loading";
|
|
p.setAttribute("role", "status");
|
|
p.textContent = text;
|
|
return p;
|
|
}
|
|
|
|
function setDesc(text) {
|
|
if (descEl) descEl.textContent = text; // the #doc-modal-desc role=status announcer
|
|
}
|
|
|
|
function showLoading() {
|
|
if (titleEl) titleEl.textContent = "Loading…";
|
|
if (metaEl) metaEl.replaceChildren();
|
|
if (contentEl) contentEl.replaceChildren(statusLine("Loading document…"));
|
|
setDesc("Document content is loading.");
|
|
}
|
|
|
|
function renderNotFound(path) {
|
|
if (titleEl) titleEl.textContent = "Document not found";
|
|
if (metaEl) metaEl.replaceChildren();
|
|
if (contentEl) {
|
|
contentEl.replaceChildren(
|
|
statusLine(`“${path}” was not found — it may have been removed in a re-import.`),
|
|
);
|
|
}
|
|
setDesc("Document not found.");
|
|
}
|
|
|
|
/* Focus trap while the modal is open: Tab / Shift+Tab cycle within the
|
|
* panel (the visible controls are "Full page" + Close; the content
|
|
* target is programmatic-focus only, tabindex="-1"). */
|
|
const FOCUSABLE =
|
|
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
|
|
function onModalKeydown(e) {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
closeDocumentModal();
|
|
return;
|
|
}
|
|
if (e.key !== "Tab" || !panelEl) return;
|
|
const focusable = Array.from(panelEl.querySelectorAll(FOCUSABLE)).filter(
|
|
(el) => el.getClientRects().length > 0,
|
|
);
|
|
if (!focusable.length) {
|
|
e.preventDefault();
|
|
if (contentEl) contentEl.focus({ preventScroll: true });
|
|
return;
|
|
}
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
const active = document.activeElement;
|
|
const inside = active !== null && panelEl.contains(active);
|
|
if (e.shiftKey) {
|
|
if (!inside || active === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
}
|
|
} else if (!inside || active === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
|
|
/* Show the modal for (source, path) and fetch + render the document.
|
|
* `trigger` (the chip / table link that was clicked) is remembered so
|
|
* closeDocumentModal can return focus to it. */
|
|
export function openDocumentModal(source, path, trigger = null) {
|
|
if (!modalEl || !titleEl || !metaEl || !contentEl) {
|
|
// No modal skeleton on this page (should not happen for the pages
|
|
// that import this module) — fall back to the dedicated viewer
|
|
// instead of swallowing the click.
|
|
window.open(fullPageUrl(source, path), "_blank", "noopener");
|
|
return;
|
|
}
|
|
triggerEl = trigger && typeof trigger.focus === "function" ? trigger : null;
|
|
modalEl.hidden = false; // .doc-modal[hidden] { display:none } — visible now
|
|
showLoading();
|
|
if (openEl) {
|
|
openEl.href = fullPageUrl(source, path); // the "Full page" escape hatch
|
|
openEl.hidden = false;
|
|
}
|
|
document.addEventListener("keydown", onModalKeydown, true); // Escape + Tab trap
|
|
contentEl.focus({ preventScroll: true }); // a11y: focus moves INTO the dialog
|
|
|
|
const seq = ++fetchSeq;
|
|
fetch(contentUrl(source, path))
|
|
.then(async (r) => {
|
|
if (seq !== fetchSeq) return; // stale — closed or re-opened meanwhile
|
|
if (!r.ok) {
|
|
renderNotFound(path);
|
|
return;
|
|
}
|
|
const doc = await r.json();
|
|
if (seq !== fetchSeq) return;
|
|
renderDocument(doc, { titleEl, metaEl, contentEl });
|
|
setDesc("Document loaded.");
|
|
})
|
|
.catch(() => {
|
|
if (seq === fetchSeq) renderNotFound(path);
|
|
});
|
|
}
|
|
|
|
/* Hide the modal, drop the Escape/Tab capture, and restore focus to the
|
|
* triggering control (best-effort — it may have been removed from the
|
|
* DOM since the modal opened). */
|
|
export function closeDocumentModal() {
|
|
if (!modalEl || modalEl.hidden) return;
|
|
fetchSeq += 1; // any in-flight fetch is stale now
|
|
modalEl.hidden = true;
|
|
document.removeEventListener("keydown", onModalKeydown, true);
|
|
if (openEl) {
|
|
openEl.hidden = true;
|
|
openEl.removeAttribute("href");
|
|
}
|
|
if (triggerEl && typeof triggerEl.focus === "function" && document.contains(triggerEl)) {
|
|
triggerEl.focus({ preventScroll: true });
|
|
}
|
|
triggerEl = null;
|
|
}
|
|
|
|
/* Top-level bindings — only on pages that carry the modal skeleton
|
|
* (a missing element is a no-op, the header.js pattern). The Escape /
|
|
* Tab capture is per-open (added/removed in open/close), so it never
|
|
* leaks into the page behind a closed modal. */
|
|
if (closeEl) closeEl.addEventListener("click", closeDocumentModal);
|
|
if (backdropEl) backdropEl.addEventListener("click", closeDocumentModal);
|