Fix modal popup
This commit is contained in:
@@ -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,43 +251,38 @@ 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>
|
<p className="text-sm text-text-secondary mb-5">
|
||||||
<p className="text-sm text-text-secondary mb-5">
|
{t("cancelConfirmRequestDescription")}
|
||||||
{t("cancelConfirmRequestDescription")}
|
</p>
|
||||||
</p>
|
<div className="flex justify-end gap-2">
|
||||||
<div className="flex justify-end gap-2">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setConfirmCancel(false)}
|
||||||
onClick={() => setConfirmCancel(false)}
|
disabled={actionPending}
|
||||||
disabled={actionPending}
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
>
|
||||||
>
|
{tCommon("cancel")}
|
||||||
{tCommon("cancel")}
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={handleCancel}
|
||||||
onClick={handleCancel}
|
disabled={actionPending}
|
||||||
disabled={actionPending}
|
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||||
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
|
>
|
||||||
>
|
{actionPending
|
||||||
{actionPending
|
? tCommon("loading")
|
||||||
? tCommon("loading")
|
: t("cancelRequestConfirm")}
|
||||||
: t("cancelRequestConfirm")}
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,55 +103,50 @@ 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>
|
<p className="text-sm text-text-secondary mb-3">
|
||||||
<p className="text-sm text-text-secondary mb-3">
|
{t("cancelConfirmDescription")}
|
||||||
{t("cancelConfirmDescription")}
|
</p>
|
||||||
</p>
|
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
|
||||||
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
|
<li>{t("cancelConfirmBullet1")}</li>
|
||||||
<li>{t("cancelConfirmBullet1")}</li>
|
<li>{t("cancelConfirmBullet2")}</li>
|
||||||
<li>{t("cancelConfirmBullet2")}</li>
|
<li>{t("cancelConfirmBullet3")}</li>
|
||||||
<li>{t("cancelConfirmBullet3")}</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmOpen(false)}
|
|
||||||
disabled={submitting}
|
|
||||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{tCommon("cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleSuspend(true)}
|
|
||||||
disabled={submitting}
|
|
||||||
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submitting
|
|
||||||
? tCommon("loading")
|
|
||||||
: t("cancelSubscriptionConfirm")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmOpen(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSuspend(true)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? tCommon("loading")
|
||||||
|
: t("cancelSubscriptionConfirm")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
89
src/components/ui/modal.tsx
Normal file
89
src/components/ui/modal.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user