feat: implement Portal component and refactor modals to use it for improved positioning
This commit is contained in:
@@ -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 (
|
||||
<div className="overlay" onClick={onCancel}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
{/* ... */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`.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 (
|
||||
<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>`:
|
||||
|
||||
```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` یک `<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 را باز کن و مطمئن شو وسط صفحه میافتد نه پایین.
|
||||
@@ -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 && (
|
||||
<Portal>
|
||||
<div
|
||||
className="overlay"
|
||||
style={{ placeItems: 'stretch', justifyContent: 'flex-start' }}
|
||||
@@ -176,6 +178,7 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () =>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Portal>
|
||||
<div className="overlay" onClick={onCancel}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
@@ -61,5 +63,6 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Portal>
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
@@ -71,5 +73,6 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -350,7 +350,7 @@ function EditModal({ clinic, onClose, onSaved }: {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
@@ -455,7 +455,8 @@ function EditModal({ clinic, onClose, onSaved }: {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
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 && (
|
||||
<EditModal
|
||||
clinic={clinic}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }}
|
||||
/>,
|
||||
document.body,
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invite doctor modal */}
|
||||
{inviteOpen && uuid && createPortal(
|
||||
{inviteOpen && uuid && (
|
||||
<InviteDoctorModal
|
||||
clinicUuid={uuid}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
|
||||
/>,
|
||||
document.body,
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirm */}
|
||||
|
||||
@@ -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 && (
|
||||
<Portal>
|
||||
<div className="overlay" onClick={() => setAddOpen(false)}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
@@ -287,6 +289,7 @@ export default function ClinicsPage() {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import Portal from '../components/ui/Portal';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
@@ -204,6 +205,7 @@ export default function PreRegistrationsPage() {
|
||||
|
||||
{/* Reject Dialog */}
|
||||
{rejectTarget && (
|
||||
<Portal>
|
||||
<div className="overlay" onClick={() => setRejectTarget(null)}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
@@ -232,6 +234,7 @@ export default function PreRegistrationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Portal>
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 400 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
@@ -140,6 +142,7 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user