From f3ca6d844fb1fa8f76fab50cd91fbaeab973f5db Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 4 Jul 2026 22:05:27 +0330 Subject: [PATCH] feat: implement Portal component and refactor modals to use it for improved positioning --- .../prompt/fix-modal-portal-positioning.md | 133 ++++++++++++++++++ assets/admin/components/layout/Topbar.tsx | 3 + assets/admin/components/ui/ConfirmDialog.tsx | 3 + .../admin/components/ui/InviteDoctorModal.tsx | 3 + assets/admin/components/ui/Portal.tsx | 9 ++ assets/admin/pages/ClinicDetailPage.tsx | 17 ++- assets/admin/pages/ClinicsPage.tsx | 3 + assets/admin/pages/PreRegistrationsPage.tsx | 3 + assets/admin/pages/UsersPage.tsx | 3 + 9 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 .claude/prompt/fix-modal-portal-positioning.md create mode 100644 assets/admin/components/ui/Portal.tsx diff --git a/.claude/prompt/fix-modal-portal-positioning.md b/.claude/prompt/fix-modal-portal-positioning.md new file mode 100644 index 00000000..8b71311f --- /dev/null +++ b/.claude/prompt/fix-modal-portal-positioning.md @@ -0,0 +1,133 @@ +# رفع افتادن مودال‌ها به پایین صفحه (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 — همین الگو در بقیه هم هست): + +```tsx +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 ( +
+
e.stopPropagation()}> + {/* ... */} +
+
+ ); +} +``` + +`.overlay` (styles.css:613) — درست است، دست نزن: + +```css +.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` + +```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 + +```tsx +import Portal from './Portal'; +// ... + if (!open) return null; + return ( + +
+ {/* بدون تغییر */} +
+
+ ); +``` + +### ۳. همین کار برای بقیه‌ی مودال‌های `.overlay` بدون portal + +هر جای زیر که `.overlay` مستقیم رندر می‌شود را داخل `...` بپیچ (مسیر 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` است یا داخل ``: + +```bash +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` یک `` اضافه می‌شود. +- `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 را باز کن و مطمئن شو وسط صفحه می‌افتد نه پایین. diff --git a/assets/admin/components/layout/Topbar.tsx b/assets/admin/components/layout/Topbar.tsx index 7e956b52..c0dce499 100644 --- a/assets/admin/components/layout/Topbar.tsx +++ b/assets/admin/components/layout/Topbar.tsx @@ -4,6 +4,7 @@ import { MagnifyingGlassIcon, XMarkIcon, CheckIcon, } from '@heroicons/react/24/outline'; import { useUiStore, HUES, type BrandHue } from '../../stores/uiStore'; +import Portal from '../ui/Portal'; export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () => void }) { const toggleSidebar = useUiStore((s) => s.toggleSidebar); @@ -71,6 +72,7 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () => {/* Settings Panel */} {settingsPanelOpen && ( +
+
)} ); diff --git a/assets/admin/components/ui/ConfirmDialog.tsx b/assets/admin/components/ui/ConfirmDialog.tsx index 22dfa1a7..f031428b 100644 --- a/assets/admin/components/ui/ConfirmDialog.tsx +++ b/assets/admin/components/ui/ConfirmDialog.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline'; +import Portal from './Portal'; interface Props { open: boolean; @@ -27,6 +28,7 @@ export default function ConfirmDialog({ if (!open) return null; return ( +
e.stopPropagation()}>
@@ -61,5 +63,6 @@ export default function ConfirmDialog({
+
); } diff --git a/assets/admin/components/ui/InviteDoctorModal.tsx b/assets/admin/components/ui/InviteDoctorModal.tsx index 97e87feb..51fc66c8 100644 --- a/assets/admin/components/ui/InviteDoctorModal.tsx +++ b/assets/admin/components/ui/InviteDoctorModal.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import MobileInput from './MobileInput'; +import Portal from './Portal'; import { iranMobileSchema } from '../../lib/utils'; const inviteSchema = z.object({ @@ -39,6 +40,7 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr }); return ( +
e.stopPropagation()}>
@@ -71,5 +73,6 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
+ ); } diff --git a/assets/admin/components/ui/Portal.tsx b/assets/admin/components/ui/Portal.tsx new file mode 100644 index 00000000..5ccda953 --- /dev/null +++ b/assets/admin/components/ui/Portal.tsx @@ -0,0 +1,9 @@ +import React, { 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); +} diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index c7f2dfc3..2c3c433c 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -350,7 +350,7 @@ function EditModal({ clinic, onClose, onSaved }: { const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic'); - return ( + return createPortal(
e.stopPropagation()}>
@@ -455,7 +455,8 @@ function EditModal({ clinic, onClose, onSaved }: {
-
+
, + document.body, ); } @@ -1301,24 +1302,22 @@ export default function ClinicDetailPage() { onCancel={() => setDetachDoctorConfirm(null)} /> - {/* Edit modal — portal to escape Leaflet transform context */} - {editOpen && createPortal( + {/* Edit modal — self-portals to escape transform context */} + {editOpen && ( setEditOpen(false)} onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }} - />, - document.body, + /> )} {/* Invite doctor modal */} - {inviteOpen && uuid && createPortal( + {inviteOpen && uuid && ( setInviteOpen(false)} onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }} - />, - document.body, + /> )} {/* Delete confirm */} diff --git a/assets/admin/pages/ClinicsPage.tsx b/assets/admin/pages/ClinicsPage.tsx index 69b51875..adaa4ab5 100644 --- a/assets/admin/pages/ClinicsPage.tsx +++ b/assets/admin/pages/ClinicsPage.tsx @@ -17,6 +17,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { Clinic } from '../types'; import { useAuthStore } from '../stores/authStore'; import MobileInput from '../components/ui/MobileInput'; +import Portal from '../components/ui/Portal'; import { formatDate, formatNumber, iranMobileSchema } from '../lib/utils'; import Pagination from '../components/ui/Pagination'; import ConfirmDialog from '../components/ui/ConfirmDialog'; @@ -244,6 +245,7 @@ export default function ClinicsPage() { {/* Add Modal */} {addOpen && ( +
setAddOpen(false)}>
e.stopPropagation()}>
@@ -287,6 +289,7 @@ export default function ClinicsPage() {
+ )}
setRejectTarget(null)}>
e.stopPropagation()}>
@@ -232,6 +234,7 @@ export default function PreRegistrationsPage() {
+ )}
); diff --git a/assets/admin/pages/UsersPage.tsx b/assets/admin/pages/UsersPage.tsx index c0912f86..979b51bd 100644 --- a/assets/admin/pages/UsersPage.tsx +++ b/assets/admin/pages/UsersPage.tsx @@ -8,6 +8,7 @@ import { } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; +import Portal from '../components/ui/Portal'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import { formatDate, formatNumber, maskMobile } from '../lib/utils'; import ConfirmDialog from '../components/ui/ConfirmDialog'; @@ -101,6 +102,7 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: { }) { const [selected, setSelected] = useState(getPrimaryRole(user.roles)); return ( +
e.stopPropagation()}>
@@ -140,6 +142,7 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
+
); }