Files
clinicpro/.claude/prompt/fix-modal-portal-positioning.md

7.9 KiB

رفع افتادن مودال‌ها به پایین صفحه (portal برای همه مودال‌ها)

پروژه

clinicpro (پنل ادمین React — assets/admin/)

زمینه

بعضی مودال‌ها هنگام باز شدن وسط صفحه نمی‌افتند و «خیلی پایین» یا آفست ظاهر می‌شوند. نمونه‌اش مودال کراپ عکس در صفحه‌ی دکتر بود که با انتقال به createPortal(document.body) حل شد.

مشکل / هدف

کلاس مشترک .overlay از position: fixed; inset: 0 استفاده می‌کند (تعریف در assets/admin/styles.css:613). طبق مشخصات CSS، وقتی یک عنصر position: fixed داخل ancestorـی رندر شود که یکی از این خواص را دارد، دیگر نسبت به viewport نیست بلکه نسبت به همان ancestor محاسبه می‌شود:

  • transform (حتی transform: translateZ(0) یا انیمیشن‌های pop/slidein که transform دارند)
  • filter / backdrop-filter
  • perspective, will-change: transform, contain: paint/layout

در نتیجه هر مودالی که .overlay را inline در عمق درخت کامپوننت (داخل کارت‌ها/کانتینرهای transform‌دار) رندر کند، پایین/آفست می‌افتد.

راه‌حل استاندارد پروژه: رندر مودال از طریق createPortal(..., document.body) تا از زنجیره‌ی ancestorها خارج شود. کامپوننت‌های components/ui/Modal.tsx و components/ImageCropModal.tsx همین کار را می‌کنند و درست‌اند — الگوی مرجع همین‌هاست.

هدف: همه‌ی مودال‌های .overlay که هنوز portal ندارند به portal منتقل شوند تا این مشکل در «هر جایی که مودال باز می‌شود» حل شود.

فایل‌های مرتبط

فایل وضعیت نقش
assets/admin/components/ui/Modal.tsx portal دارد الگوی مرجع — تغییر نده
assets/admin/components/ImageCropModal.tsx portal دارد الگوی مرجع — تغییر نده
assets/admin/components/ui/ConfirmDialog.tsx بدون portal پرکاربردترین؛ اولویت اول
assets/admin/components/ui/InviteDoctorModal.tsx بدون portal مودال دعوت دکتر
assets/admin/components/layout/Topbar.tsx بدون portal overlay (خط ~75)
assets/admin/pages/UsersPage.tsx بدون portal مودال inline (خط ~104)
assets/admin/pages/ClinicsPage.tsx بدون portal مودال افزودن (خط ~247)
assets/admin/pages/PreRegistrationsPage.tsx بدون portal مودال رد کردن (خط ~207)
assets/admin/pages/ClinicDetailPage.tsx یک مورد بدون portal (خط ~354) بقیه‌ی overlayهایش portal دارند
assets/admin/styles.css مرجع تعریف .overlay (خط 613) — تغییر نده

وضعیت فعلی

نمونه‌ی ConfirmDialog.tsx (بدون portal — همین الگو در بقیه هم هست):

import React from 'react';
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';

export default function ConfirmDialog({ open, /* ... */ onConfirm, onCancel }: Props) {
  if (!open) return null;

  return (
    <div className="overlay" onClick={onCancel}>
      <div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
        {/* ... */}
      </div>
    </div>
  );
}

.overlay (styles.css:613) — درست است، دست نزن:

.overlay {
  position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 20px;
  background: rgba(8,13,22,.5); backdrop-filter: blur(4px); ...
}

وظایف

۱. ساخت یک کامپوننت کمکی Portal (یک‌بار، برای جلوگیری از تکرار)

فایل جدید: assets/admin/components/ui/Portal.tsx

import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';

export default function Portal({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return createPortal(children, document.body);
}

نکته: چون این SPA فقط client-side رندر می‌شود، createPortal مستقیم هم کار می‌کند؛ ولی داشتن wrapper واحد الگو را در همه‌جا یکدست و آینده‌محور می‌کند. اگر ترجیح می‌دهی بدون کامپوننت جدید پیش بروی، می‌توانی در هر فایل مستقیم createPortal(..., document.body) بزنی (مثل ImageCropModal.tsx). یکی از این دو روش را انتخاب و در همه‌ی موارد یکسان اعمال کن.

۲. انتقال ConfirmDialog.tsx به portal

import Portal from './Portal';
// ...
  if (!open) return null;
  return (
    <Portal>
      <div className="overlay" onClick={onCancel}>
        {/* بدون تغییر */}
      </div>
    </Portal>
  );

۳. همین کار برای بقیه‌ی مودال‌های .overlay بدون portal

هر جای زیر که .overlay مستقیم رندر می‌شود را داخل <Portal>...</Portal> بپیچ (مسیر import را نسبت به محل فایل تنظیم کن: از pages/../components/ui/Portal؛ از components/layout/../ui/Portal؛ از components/ui/./Portal):

  • components/ui/InviteDoctorModal.tsx (خط ~42)
  • components/layout/Topbar.tsx (خط ~75) — اگر این overlay فقط بک‌دراپ سایدبار موبایل است و مشکل جانمایی ندارد، فقط در صورت نیاز؛ ولی برای یکدستی بهتر است portal شود.
  • pages/UsersPage.tsx (خط ~104)
  • pages/ClinicsPage.tsx (خط ~247)
  • pages/PreRegistrationsPage.tsx (خط ~207)
  • pages/ClinicDetailPage.tsx (خط ~354) — این تنها overlay بدون portal این فایل است؛ بقیه (خطوط ~150، ~1177، ~1305، ~1315) از قبل createPortal دارند، آن‌ها را دست نزن.

۴. بررسی نبود مورد جاافتاده

بعد از اعمال، این جستجو را بزن و مطمئن شو هر className="overlay" یا className='overlay' یا داخل createPortal است یا داخل <Portal>:

grep -rn "className=[\"']overlay" assets/admin --include="*.tsx"

هر موردی که هیچ‌کدام نبود را هم portal کن.

نکات مهم

  • کلاس .overlay و .modal در styles.css درست‌اند (place-items: centerCSS را تغییر نده — مشکل صرفاً از context جانمایی fixed به‌خاطر ancestor transform‌دار است، نه از خود overlay.
  • محتوای داخل .overlay (شامل onClick={onCancel} بک‌دراپ و stopPropagation روی .modal) نباید تغییر کند؛ فقط دور کل بلوک .overlay یک <Portal> اضافه می‌شود.
  • ui/Modal.tsx و ImageCropModal.tsx را تغییر نده (از قبل درست‌اند).
  • تغییر فقط frontend است؛ هیچ endpoint/API عوض نمی‌شود → نیازی به به‌روزرسانی docs/api/* یا تست backend نیست.
  • بعد از تغییر: ddev exec npx tsc --noEmit --project tsconfig.json (صفر خطا) و ddev exec yarn dev (باید webpack compiled successfully بدهد؛ خطای lightningcss.linux-arm64-gnu پیش‌زمینه‌ای و بی‌ربط است).
  • تست دستی: در چند صفحه (کاربران، کلینیک‌ها، پیش‌ثبت‌نام، جزئیات دکتر/کلینیک) مودال/ConfirmDialog را باز کن و مطمئن شو وسط صفحه می‌افتد نه پایین.