tuff popup transition

This commit is contained in:
olcxja 2026-09-30 19:02:41 +02:00
commit 6f9c486774
2 changed files with 86 additions and 2 deletions

View file

@ -109,6 +109,7 @@ function toast(message, kind) {
// import mode -> prefix picker happens within milliseconds), so showModal
// cancels any pending teardown and clears the closing state.
let modalTeardownTimer = null
let modalSwapTimer = null
function showModal(html) {
if (modalTeardownTimer) {
@ -118,12 +119,55 @@ function showModal(html) {
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
modal.classList.remove('closing')
qs('#modal').innerHTML = html
// Opening over an already-visible modal (import mode -> prefix picker):
// morph instead of close-then-open - the box glides to the new height
// while old content dissolves into the new one.
const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim()
if (swapping) swapModalContent(modal, html)
else modal.innerHTML = html
overlay.style.display = 'flex'
requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show')))
return qs('#modal')
}
// Animated content swap while the modal stays open. The new HTML is put in
// SYNCHRONOUSLY (callers wire their buttons right after showModal returns);
// an opaque snapshot of the old content is laid on top and fades out while
// the box animates to the new content's height. Same feel as switching tabs.
function swapModalContent(modal, html) {
if (modalSwapTimer) {
clearTimeout(modalSwapTimer)
modalSwapTimer = null
}
const prevGhost = modal.querySelector('.modal-ghost')
if (prevGhost) prevGhost.remove() // rapid double swap: snapshot clean state
modal.classList.remove('swapping')
const oldH = modal.offsetHeight
const ghost = document.createElement('div')
ghost.className = 'modal-ghost'
ghost.innerHTML = modal.innerHTML
modal.classList.add('resizing') // clip content while the box glides
modal.style.height = oldH + 'px'
modal.innerHTML = html
// measure the new natural height without an intermediate paint
modal.style.height = 'auto'
const newH = modal.offsetHeight
modal.style.height = oldH + 'px'
void modal.offsetHeight // flush styles so the height transition starts here
modal.appendChild(ghost)
void ghost.offsetHeight // commit the ghost's initial opacity:1 so the fade transition runs
requestAnimationFrame(() => {
modal.style.height = newH + 'px'
modal.classList.add('swapping') // fades the ghost out over the new content
modalSwapTimer = setTimeout(() => {
modalSwapTimer = null
ghost.remove()
modal.classList.remove('swapping', 'resizing')
modal.style.height = '' // back to auto (dropdowns may overflow again)
}, 240)
})
}
function closeModal() {
const overlay = qs('#modal-overlay')
const modal = qs('#modal')
@ -133,8 +177,13 @@ function closeModal() {
if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
modalTeardownTimer = setTimeout(() => {
modalTeardownTimer = null
if (modalSwapTimer) {
clearTimeout(modalSwapTimer)
modalSwapTimer = null
}
overlay.style.display = 'none'
modal.classList.remove('closing')
modal.classList.remove('closing', 'swapping', 'resizing')
modal.style.height = ''
modal.innerHTML = ''
}, 160)
}