fix: resolve modal stacking, animation, and close cleanup bugs

- Set backdrop z-index:0 and content z-index:1 to fix backdrop covering
  modal content within the stacking context
- Force reflow between removing hidden and adding open class so CSS
  opacity transition fires correctly
- Add transitionend listener + setTimeout fallback to restore hidden
  class after close animation completes
- Override flex:1 on modal footer buttons to prevent full-width stretch
- Add min-width to modal size variants for consistent sizing
- Add 23 tests covering open/close lifecycle, keyboard, and destroy

Amit Haridas
This commit is contained in:
2026-03-25 22:20:34 +05:30
parent 5911a7501b
commit adc8dabda1
3 changed files with 314 additions and 3 deletions
+10 -1
View File
@@ -14,7 +14,7 @@
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: var(--z-modal, 200);
z-index: 0;
cursor: pointer;
}
@@ -46,6 +46,9 @@
* ============================================ */
.modal-content {
position: relative;
z-index: 1;
width: 100%;
background: hsl(var(--background, 0 0% 100%));
border-radius: var(--radius-lg, 0.5rem);
box-shadow: var(--shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1));
@@ -139,6 +142,9 @@
.modal-footer .btn {
min-width: 80px;
/* styles-modern.css sets flex:1 on .btn-primary/.btn-secondary globally
* which causes footer buttons to stretch to full width. Override here. */
flex: none;
}
/* ============================================
@@ -208,15 +214,18 @@
.modal-content.small {
max-width: 400px;
min-width: 320px;
}
.modal-content.large {
max-width: 800px;
min-width: 560px;
}
.modal-content.full {
max-width: 95vw;
max-height: 95vh;
min-width: min(95vw, 700px);
}
/* ============================================
+24 -2
View File
@@ -124,8 +124,12 @@ class ModalManager {
// Track open modals
ModalManager.#openModals.push(this);
// Show modal (remove hidden, add open)
// Show modal: remove hidden first, force a reflow so the browser
// records opacity:0 as the start state, then add 'open' to trigger
// the CSS transition. Without the reflow, both class changes are
// batched into one style recalculation and the transition is skipped.
this.#modal.classList.remove('hidden');
void this.#modal.offsetHeight; // Force reflow — do not remove
this.#modal.classList.add('open');
this.#isOpen = true;
@@ -165,10 +169,28 @@ class ModalManager {
ModalManager.#openModals.splice(index, 1);
}
// Hide modal
// Start hide transition
this.#modal.classList.remove('open');
this.#isOpen = false;
// Re-add 'hidden' after the CSS transition completes so the modal
// is fully removed from rendering (display:none), not just invisible.
// We use both transitionend and a setTimeout fallback because
// transitionend never fires when prefers-reduced-motion disables transitions.
let hidden = false;
const addHidden = () => {
if (hidden || this.#isOpen) return;
hidden = true;
this.#modal.removeEventListener('transitionend', onTransitionEnd);
this.#modal.classList.add('hidden');
};
const onTransitionEnd = (e) => {
if (e.target !== this.#modal) return;
addHidden();
};
this.#modal.addEventListener('transitionend', onTransitionEnd);
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
// Restore body scroll if no modals open
if (ModalManager.#openModals.length === 0) {
document.body.style.overflow = '';