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>
208 lines
8.0 KiB
JavaScript
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;
|
|
})();
|