Fix modal popup

This commit is contained in:
2026-05-01 16:56:33 +02:00
parent f308c84325
commit 9a1ab44f15
3 changed files with 161 additions and 80 deletions

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
@@ -250,15 +251,11 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
</div> </div>
{confirmCancel && ( {confirmCancel && (
<div <Modal
role="dialog" open={confirmCancel}
aria-modal="true" onClose={() => setConfirmCancel(false)}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" ariaLabel={t("cancelConfirmRequestTitle")}
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmCancel(false);
}}
> >
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2"> <h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmRequestTitle")} {t("cancelConfirmRequestTitle")}
</h3> </h3>
@@ -285,8 +282,7 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
: t("cancelRequestConfirm")} : t("cancelRequestConfirm")}
</button> </button>
</div> </div>
</div> </Modal>
</div>
)} )}
</Card> </Card>
); );

View File

@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Modal } from "@/components/ui/modal";
interface Props { interface Props {
tenantName: string; tenantName: string;
@@ -102,15 +103,11 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
)} )}
{confirmOpen && ( {confirmOpen && (
<div <Modal
role="dialog" open={confirmOpen}
aria-modal="true" onClose={() => setConfirmOpen(false)}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" ariaLabel={t("cancelConfirmTitle")}
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmOpen(false);
}}
> >
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2"> <h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmTitle")} {t("cancelConfirmTitle")}
</h3> </h3>
@@ -149,8 +146,7 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
: t("cancelSubscriptionConfirm")} : t("cancelSubscriptionConfirm")}
</button> </button>
</div> </div>
</div> </Modal>
</div>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface Props {
open: boolean;
/** Called when user clicks the backdrop or presses Escape. */
onClose: () => void;
children: React.ReactNode;
/**
* ARIA label fallback when no labelled element exists inside.
* Optional; if you have a heading inside the modal with id, set
* `aria-labelledby` on a wrapper instead.
*/
ariaLabel?: string;
}
/**
* Portal-based modal.
*
* Why a portal
* ------------
* `position: fixed` becomes positioned relative to a transformed
* ancestor's containing block, not the viewport, when ANY ancestor
* has a `transform`, `perspective`, or `filter` applied. Our
* `animate-in` utility sets `transform: translateY(0)` on a lot of
* dashboard/tenant-detail containers (because of the fade-up
* animation, which uses `animation-fill-mode: both` to keep the
* transform on after the animation finishes). That broke modals
* rendered as in-place children — they centred to the panel they
* lived in, not to the page.
*
* Rendering at `document.body` via `createPortal` escapes every
* containing-block ancestor and gives us true viewport coordinates.
*
* UX details
* ----------
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
* when the click target IS the backdrop, not the panel inside.)
* - Escape key triggers `onClose`. Standard modal expectation.
* - `body` overflow is locked while open so background content
* doesn't scroll behind the modal.
* - Renders nothing on first paint server-side, then mounts on
* client. `useEffect` gating ensures `document.body` is available;
* without it Next.js SSR would throw on `document` reference.
*/
export function Modal({ open, onClose, children, ariaLabel }: Props) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return;
// Lock background scroll. Restore on unmount/close.
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open]);
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
{children}
</div>
</div>,
document.body
);
}