tuff popup transition
This commit is contained in:
parent
6eeafe2fa6
commit
6f9c486774
2 changed files with 86 additions and 2 deletions
|
|
@ -109,6 +109,7 @@ function toast(message, kind) {
|
||||||
// import mode -> prefix picker happens within milliseconds), so showModal
|
// import mode -> prefix picker happens within milliseconds), so showModal
|
||||||
// cancels any pending teardown and clears the closing state.
|
// cancels any pending teardown and clears the closing state.
|
||||||
let modalTeardownTimer = null
|
let modalTeardownTimer = null
|
||||||
|
let modalSwapTimer = null
|
||||||
|
|
||||||
function showModal(html) {
|
function showModal(html) {
|
||||||
if (modalTeardownTimer) {
|
if (modalTeardownTimer) {
|
||||||
|
|
@ -118,12 +119,55 @@ function showModal(html) {
|
||||||
const overlay = qs('#modal-overlay')
|
const overlay = qs('#modal-overlay')
|
||||||
const modal = qs('#modal')
|
const modal = qs('#modal')
|
||||||
modal.classList.remove('closing')
|
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'
|
overlay.style.display = 'flex'
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show')))
|
requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show')))
|
||||||
return qs('#modal')
|
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() {
|
function closeModal() {
|
||||||
const overlay = qs('#modal-overlay')
|
const overlay = qs('#modal-overlay')
|
||||||
const modal = qs('#modal')
|
const modal = qs('#modal')
|
||||||
|
|
@ -133,8 +177,13 @@ function closeModal() {
|
||||||
if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
|
if (modalTeardownTimer) clearTimeout(modalTeardownTimer)
|
||||||
modalTeardownTimer = setTimeout(() => {
|
modalTeardownTimer = setTimeout(() => {
|
||||||
modalTeardownTimer = null
|
modalTeardownTimer = null
|
||||||
|
if (modalSwapTimer) {
|
||||||
|
clearTimeout(modalSwapTimer)
|
||||||
|
modalSwapTimer = null
|
||||||
|
}
|
||||||
overlay.style.display = 'none'
|
overlay.style.display = 'none'
|
||||||
modal.classList.remove('closing')
|
modal.classList.remove('closing', 'swapping', 'resizing')
|
||||||
|
modal.style.height = ''
|
||||||
modal.innerHTML = ''
|
modal.innerHTML = ''
|
||||||
}, 160)
|
}, 160)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -603,6 +603,7 @@ select {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.6);
|
transform: scale(0.6);
|
||||||
transition: all 0.2s var(--spring);
|
transition: all 0.2s var(--spring);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.modal-overlay.show .modal {
|
.modal-overlay.show .modal {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -613,6 +614,40 @@ select {
|
||||||
transform: scale(0.8);
|
transform: scale(0.8);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
/* --- animated content swap (import mode -> prefix picker etc.) -------------- */
|
||||||
|
/* During the morph the box keeps a fixed pixel height and glides to the new
|
||||||
|
content's height; overflow is clipped ONLY then (a permanent clip would
|
||||||
|
cut off open custom-select dropdowns). */
|
||||||
|
.modal.resizing {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.22s var(--spring);
|
||||||
|
will-change: height;
|
||||||
|
}
|
||||||
|
/* Opaque snapshot of the OLD content laid over the new one; fades out while
|
||||||
|
the box glides -> content dissolves from old to new (like switching tabs). */
|
||||||
|
.modal .modal-ghost {
|
||||||
|
position: absolute;
|
||||||
|
inset: 1.4rem 1.6rem; /* overlay the box's own padding area */
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--panel-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem; /* mirror the modal's layout so the snapshot aligns 1:1 */
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.modal.swapping .modal-ghost {
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
/* new content fades in under the fading-out ghost -> real crossfade */
|
||||||
|
.modal.swapping > :not(.modal-ghost) {
|
||||||
|
animation: modal-content-in 0.2s ease;
|
||||||
|
}
|
||||||
|
@keyframes modal-content-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
.modal h2 {
|
.modal h2 {
|
||||||
font-family: 'Nunito Black', 'Nunito', sans-serif;
|
font-family: 'Nunito Black', 'Nunito', sans-serif;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue