Serve delete, move and merge dialogs as htmx fragments
The server renders the dialogs (?dialog=delete|move) and answers a move that needs confirmation with the merge dialog itself, so the client no longer builds action URLs or interprets X-Merge-Available. Successful mutations send HX-Redirect, which hx.js turns into a history replace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+140
-136
@@ -1,120 +1,111 @@
|
||||
// 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 backdrop = null;
|
||||
var modal = null;
|
||||
var titleEl = null;
|
||||
var bodyEl = null;
|
||||
var cancelBtn = null;
|
||||
var confirmBtn = null;
|
||||
var footerEl = null;
|
||||
var prevFocus = null;
|
||||
var currentOpts = null;
|
||||
var onKeydown = null;
|
||||
var current = null; // { backdrop, prevFocus, onKeydown }
|
||||
var drag = null; // { modal, startX, startY, originX, originY }
|
||||
|
||||
function build() {
|
||||
backdrop = document.createElement('div');
|
||||
// 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';
|
||||
|
||||
modal = document.createElement('div');
|
||||
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';
|
||||
titleEl = document.createElement('span');
|
||||
var titleEl = document.createElement('span');
|
||||
titleEl.textContent = opts.title || '';
|
||||
header.appendChild(titleEl);
|
||||
|
||||
bodyEl = document.createElement('div');
|
||||
var bodyEl = document.createElement('div');
|
||||
bodyEl.className = 'panel-body';
|
||||
|
||||
footerEl = document.createElement('div');
|
||||
footerEl.className = 'panel-footer';
|
||||
|
||||
cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(bodyEl);
|
||||
modal.appendChild(footerEl);
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
backdrop.addEventListener('mousedown', function (e) {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
wireDrag(header);
|
||||
cancelBtn.addEventListener('click', close);
|
||||
confirmBtn.addEventListener('click', function () {
|
||||
if (confirmBtn.disabled) return;
|
||||
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
|
||||
currentOpts.confirm.onConfirm();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// wireDrag makes the modal draggable by `handle`. Dragging switches the
|
||||
// modal to fixed positioning so flexbox alignment doesn't fight us.
|
||||
function wireDrag(handle) {
|
||||
var dragging = false;
|
||||
var startX, startY, originX, originY;
|
||||
|
||||
function onDown(e) {
|
||||
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';
|
||||
dragging = true;
|
||||
startX = pt.clientX;
|
||||
startY = pt.clientY;
|
||||
originX = rect.left;
|
||||
originY = rect.top;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (!dragging) return;
|
||||
var pt = e.touches ? e.touches[0] : e;
|
||||
var dx = pt.clientX - startX;
|
||||
var dy = pt.clientY - startY;
|
||||
var w = modal.offsetWidth;
|
||||
var h = modal.offsetHeight;
|
||||
var maxX = window.innerWidth - w;
|
||||
var maxY = window.innerHeight - h;
|
||||
var x = Math.max(0, Math.min(maxX, originX + dx));
|
||||
var y = Math.max(0, Math.min(maxY, originY + dy));
|
||||
modal.style.left = x + 'px';
|
||||
modal.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function onUp() { dragging = false; }
|
||||
|
||||
handle.addEventListener('mousedown', onDown);
|
||||
handle.addEventListener('touchstart', onDown, { passive: false });
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('touchend', onUp);
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
if (backdrop && backdrop.parentNode) close();
|
||||
if (!backdrop) build();
|
||||
|
||||
currentOpts = opts;
|
||||
prevFocus = document.activeElement;
|
||||
|
||||
modal.classList.remove('is-dragged');
|
||||
modal.style.position = '';
|
||||
modal.style.left = '';
|
||||
modal.style.top = '';
|
||||
|
||||
titleEl.textContent = opts.title || '';
|
||||
|
||||
bodyEl.textContent = '';
|
||||
if (opts.body instanceof Node) {
|
||||
bodyEl.appendChild(opts.body);
|
||||
} else if (typeof opts.body === 'string') {
|
||||
@@ -124,14 +115,24 @@
|
||||
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', '');
|
||||
|
||||
footerEl.textContent = '';
|
||||
var footerEl = document.createElement('div');
|
||||
footerEl.className = 'panel-footer';
|
||||
if (opts.swapButtons) {
|
||||
footerEl.appendChild(confirmBtn);
|
||||
footerEl.appendChild(cancelBtn);
|
||||
@@ -140,7 +141,20 @@
|
||||
footerEl.appendChild(confirmBtn);
|
||||
}
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
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) {
|
||||
@@ -156,22 +170,6 @@
|
||||
}
|
||||
}, 0);
|
||||
|
||||
var enterConfirms = confirmOpts.enterConfirms !== false;
|
||||
onKeydown = function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && enterConfirms) {
|
||||
var tag = (e.target && e.target.tagName) || '';
|
||||
if (tag === 'TEXTAREA') return;
|
||||
e.preventDefault();
|
||||
if (!confirmBtn.disabled) confirmBtn.click();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
return {
|
||||
close: close,
|
||||
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
|
||||
@@ -179,20 +177,26 @@
|
||||
};
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!backdrop || !backdrop.parentNode) return;
|
||||
if (onKeydown) {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
onKeydown = null;
|
||||
// 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;
|
||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
||||
if (e.detail.target.id !== 'modal-host') return;
|
||||
hostPrevFocus = current ? detach(true) : document.activeElement;
|
||||
});
|
||||
document.addEventListener('htmx:afterSwap', function (e) {
|
||||
var host = e.detail.target;
|
||||
if (host.id !== 'modal-host') return;
|
||||
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();
|
||||
}
|
||||
backdrop.parentNode.removeChild(backdrop);
|
||||
currentOpts = null;
|
||||
var toRestore = prevFocus;
|
||||
prevFocus = null;
|
||||
if (toRestore && toRestore.focus) {
|
||||
try { toRestore.focus(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.openModal = open;
|
||||
window.closeModal = close;
|
||||
|
||||
Reference in New Issue
Block a user