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-filterperspective,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: center)؛ CSS را تغییر نده — مشکل صرفاً از 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 را باز کن و مطمئن شو وسط صفحه میافتد نه پایین.