diff --git a/src/components/onboarding/provisioning-status.tsx b/src/components/onboarding/provisioning-status.tsx index ec703fd..6af854c 100644 --- a/src/components/onboarding/provisioning-status.tsx +++ b/src/components/onboarding/provisioning-status.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations, useFormatter } from "next-intl"; import { Card } from "@/components/ui/card"; +import { Modal } from "@/components/ui/modal"; import { StatusBadge } from "@/components/ui/status-badge"; import { formatDateTime, formatRelative } from "@/lib/format"; @@ -250,43 +251,38 @@ export function ProvisioningStatus({ requestId, canAct }: Props) { {confirmCancel && ( -
{ - if (e.target === e.currentTarget) setConfirmCancel(false); - }} + setConfirmCancel(false)} + ariaLabel={t("cancelConfirmRequestTitle")} > -
-

- {t("cancelConfirmRequestTitle")} -

-

- {t("cancelConfirmRequestDescription")} -

-
- - -
+

+ {t("cancelConfirmRequestTitle")} +

+

+ {t("cancelConfirmRequestDescription")} +

+
+ +
-
+
)} ); diff --git a/src/components/tenants/subscription-toggle.tsx b/src/components/tenants/subscription-toggle.tsx index 0661115..e1624ab 100644 --- a/src/components/tenants/subscription-toggle.tsx +++ b/src/components/tenants/subscription-toggle.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import { Modal } from "@/components/ui/modal"; interface Props { tenantName: string; @@ -102,55 +103,50 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) { )} {confirmOpen && ( -
{ - if (e.target === e.currentTarget) setConfirmOpen(false); - }} + setConfirmOpen(false)} + ariaLabel={t("cancelConfirmTitle")} > -
-

- {t("cancelConfirmTitle")} -

-

- {t("cancelConfirmDescription")} -

-
    -
  • {t("cancelConfirmBullet1")}
  • -
  • {t("cancelConfirmBullet2")}
  • -
  • {t("cancelConfirmBullet3")}
  • -
+

+ {t("cancelConfirmTitle")} +

+

+ {t("cancelConfirmDescription")} +

+
    +
  • {t("cancelConfirmBullet1")}
  • +
  • {t("cancelConfirmBullet2")}
  • +
  • {t("cancelConfirmBullet3")}
  • +
- {error && ( -
- {error} -
- )} - -
- - + {error && ( +
+ {error}
+ )} + +
+ +
-
+ )}
); diff --git a/src/components/ui/modal.tsx b/src/components/ui/modal.tsx new file mode 100644 index 0000000..b83af70 --- /dev/null +++ b/src/components/ui/modal.tsx @@ -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( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {children} +
+
, + document.body + ); +}