// 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; })();