Files
datascape/assets/modal.js
T
luxickandClaude Opus 5.5 aa90a04f91 Boost page navigation with htmx
Links and GET forms to wiki pages swap the next page into .shell instead
of loading a new document: the server answers boosted requests and
history restores with a partial (title, page actions out-of-band, shell
content). The header stays, browsers without cross-document view
transitions (Firefox) no longer flash between pages, and the swap runs
as a same-document view transition. Files, the editor, internal
endpoints and POST forms still navigate normally.

Back/Forward refetch pages (no snapshots that could predate a move or
delete); the tree rail keeps its scroll position and the content column
returns to where it was. Mutation redirects swap their result in the
same way. Page scripts that assumed a fresh document now also run after
a swap (toc, contents, companion, scroll-sync, search actions) or
register once (fitness).

The e2e suite now also runs in Firefox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:29:08 +02:00

208 lines
8.0 KiB
JavaScript

// One modal at a time, from either of two sources sharing the same chrome
// (backdrop click, Escape, drag by the header, focus restore):
// - openModal(opts) builds a modal from JS;
// - server-rendered dialogs (assets/page/dialogs.html) that htmx swaps into
// #modal-host. Their forms post themselves; a [data-modal-close] element
// closes them.
(function () {
var current = null; // { backdrop, prevFocus, onKeydown }
var drag = null; // { modal, startX, startY, originX, originY }
// show mounts backdrop (whose child is the .modal) into parent and makes
// it the current modal. onKeydown handles keys beyond Escape.
function show(backdrop, parent, prevFocus, onKeydown) {
var modal = backdrop.querySelector('.modal');
var header = modal.querySelector('.panel-header');
function onDown(e) { startDrag(e, modal); }
header.addEventListener('mousedown', onDown);
header.addEventListener('touchstart', onDown, { passive: false });
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close();
});
backdrop.addEventListener('click', function (e) {
if (e.target.closest('[data-modal-close]')) close();
});
var keydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (onKeydown) onKeydown(e);
};
document.addEventListener('keydown', keydown);
current = { backdrop: backdrop, prevFocus: prevFocus, onKeydown: keydown };
if (backdrop.parentNode !== parent) parent.appendChild(backdrop);
}
// Dragging switches the modal to fixed positioning so flexbox alignment
// doesn't fight us.
function startDrag(e, modal) {
if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect();
modal.classList.add('is-dragged');
modal.style.position = 'fixed';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top };
e.preventDefault();
}
function onMove(e) {
if (!drag) return;
var pt = e.touches ? e.touches[0] : e;
var m = drag.modal;
var maxX = window.innerWidth - m.offsetWidth;
var maxY = window.innerHeight - m.offsetHeight;
m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px';
m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px';
}
function onUp() { drag = null; }
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
// detach tears down the current modal and returns the element that had
// focus before it opened. keepDOM leaves the backdrop in place for a swap
// about to replace it: htmx must still find the element that issued the
// request in the document.
function detach(keepDOM) {
if (!current) return null;
var c = current;
current = null;
drag = null;
document.removeEventListener('keydown', c.onKeydown);
if (!keepDOM && c.backdrop.parentNode) c.backdrop.parentNode.removeChild(c.backdrop);
return c.prevFocus;
}
function close() {
var toRestore = detach();
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
function open(opts) {
var prevFocus = current ? detach() : document.activeElement;
var backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
var modal = document.createElement('div');
modal.className = 'modal panel panel-floating';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
var header = document.createElement('div');
header.className = 'panel-header';
var titleEl = document.createElement('span');
titleEl.textContent = opts.title || '';
header.appendChild(titleEl);
var bodyEl = document.createElement('div');
bodyEl.className = 'panel-body';
if (opts.body instanceof Node) {
bodyEl.appendChild(opts.body);
} else if (typeof opts.body === 'string') {
bodyEl.textContent = opts.body;
}
var confirmOpts = opts.confirm || {};
var cancelOpts = opts.cancel || {};
var confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.textContent = confirmOpts.label || 'OK';
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (confirmOpts.onConfirm) confirmOpts.onConfirm();
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = cancelOpts.label || 'CANCEL';
cancelBtn.className = 'btn';
cancelBtn.setAttribute('data-modal-close', '');
var footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
if (opts.swapButtons) {
footerEl.appendChild(confirmBtn);
footerEl.appendChild(cancelBtn);
} else {
footerEl.appendChild(cancelBtn);
footerEl.appendChild(confirmBtn);
}
modal.appendChild(header);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
var enterConfirms = confirmOpts.enterConfirms !== false;
show(backdrop, document.body, prevFocus, function (e) {
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
});
setTimeout(function () {
if (cancelOpts.autofocus) {
cancelBtn.focus();
return;
}
var firstInput = bodyEl.querySelector('input, textarea, select');
if (firstInput) {
firstInput.focus();
if (firstInput.select) firstInput.select();
} else {
confirmBtn.focus();
}
}, 0);
return {
close: close,
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
confirmButton: confirmBtn
};
}
// Server-rendered dialogs. A swap into #modal-host replaces whatever modal
// is open (e.g. the merge step replacing the move dialog); focus returns
// to where it was before the first one opened.
var hostPrevFocus = null;
// History restores swap without event details; they never target the host.
function isHost(e) {
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
}
document.addEventListener('htmx:beforeSwap', function (e) {
if (!isHost(e)) return;
hostPrevFocus = current ? detach(true) : document.activeElement;
});
document.addEventListener('htmx:afterSwap', function (e) {
if (!isHost(e)) return;
var host = e.detail.target;
var backdrop = host.querySelector('.modal-backdrop');
if (!backdrop) return;
show(backdrop, host, hostPrevFocus);
var f = backdrop.querySelector('[autofocus]') || backdrop.querySelector('input:not([type=hidden])');
if (f) {
f.focus();
if (f.select) f.select();
}
});
window.openModal = open;
window.closeModal = close;
})();