fix(modal): render Modal with React Portal to center it on the screen

fix(calendar): add type="button" to all buttons in PersianCalendar to prevent form submission
This commit is contained in:
hamed
2026-07-02 11:48:34 +03:30
parent 0a1e03d0ce
commit 02c34bac8e
3 changed files with 161 additions and 3 deletions
@@ -0,0 +1,151 @@
# رفع دو باگ Modal و تقویم شمسی در پنل ادمین
## پروژه
`clinicpro` (Admin SPA — React 19 داخل Symfony/Encore).
## زمینه
در پنل ادمین دو باگ UI مستقل وجود دارد که هر دو در `/admin/profile` و سایر صفحات دیده می‌شوند:
1. **مودال وسط صفحه باز نمی‌شود و به پایین صفحه می‌چسبد** — در همهٔ صفحاتی که از کامپوننت مشترک `Modal` استفاده می‌کنند.
2. **تقویم شمسی هنگام کار با دکمه‌های ناوبری بسته می‌شود** — مثلاً در فیلد «تاریخ شروع فعالیت» وقتی وارد نمای انتخاب سال می‌شوی و روی دکمه‌های `<` / `>` (تغییر بازهٔ سال) کلیک می‌کنی، به‌جای جابه‌جایی بازه، کل فرم submit و مودال بسته می‌شود.
## مشکل / هدف
### باگ ۱ — علت
کامپوننت `Modal` محتوای خود را **inline در همان‌جای درخت DOM** رندر می‌کند (بدون Portal). کلاس `.overlay` از `position: fixed; inset: 0; display: grid; place-items: center` استفاده می‌کند که باید نسبت به viewport وسط‌چین کند؛ اما وقتی یکی از عناصر والد یک containing-block برای `position: fixed` بسازد (هر عنصری با `transform` / `filter` / `perspective` / `contain: paint` / `will-change`)، مبنای `fixed` از viewport به آن والد تغییر می‌کند و overlay داخل جعبهٔ بلندِ آن والد کشیده می‌شود؛ در نتیجه `place-items: center` مودال را در وسط آن جعبهٔ بلند (که پایین‌تر از دید کاربر است) قرار می‌دهد، نه وسط صفحه. راه‌حل قطعی و مستقل از اینکه کدام والد مقصر است: رندر Modal با **React Portal روی `document.body`**.
### باگ ۲ — علت
در `PersianCalendar.tsx` هیچ‌کدام از `<button>`ها `type` ندارند. طبق HTML، `<button>` بدون `type` داخل یک `<form>` مقدار پیش‌فرض **`type="submit"`** می‌گیرد. این تقویم داخل فرم ویرایش پروفایل/پزشک رندر می‌شود (`<form id="edit-doctor-form" onSubmit={handleSubmit(...)}>` در `DoctorDetailPage.tsx`)، پس هر کلیک روی دکمه‌های ناوبری (`<` / `>`) یا سلول‌های روز/ماه/سال، فرم را submit می‌کند → mutation ذخیره اجرا می‌شود و در `onSuccess` فرم/مودال بسته می‌شود. راه‌حل: افزودن `type="button"` به **همهٔ** `<button>`های داخل `PersianCalendar`.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `clinicpro/assets/admin/components/ui/Modal.tsx` | کامپوننت مشترک مودال — نیاز به Portal |
| `clinicpro/assets/admin/components/ui/PersianCalendar.tsx` | تقویم شمسی popup — buttonها بدون `type` |
| `clinicpro/assets/admin/pages/DoctorDetailPage.tsx` | مصرف‌کننده؛ تقویم داخل `<form id="edit-doctor-form">` (فقط برای درک زمینه — تغییر لازم ندارد) |
| `clinicpro/assets/admin/styles.css` | کلاس‌های `.overlay` / `.modal` (خط ۶۱۴ به بعد — تغییر لازم ندارد) |
## وضعیت فعلی
### `Modal.tsx` (بدون Portal)
```tsx
import React, { useEffect } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
// ...
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: sizeMap[size] }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>{title}</h2>
<button className="mini-btn" onClick={onClose}>
<XMarkIcon style={{ width: 18, height: 18 }} />
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
);
}
```
### `PersianCalendar.tsx` (buttonها بدون `type`) — نمونه‌ها
```tsx
// دکمه‌های ناوبری هدر
<button onClick={mode === 'year' ? () => setYearRangeStart(s => s - 12) : nextMonth} style={{ ...navBtnStyle, ... }}>
<ChevronRightIcon style={{ width: 16, height: 16 }} />
</button>
// ...
<button onClick={mode === 'year' ? () => setYearRangeStart(s => s + 12) : prevMonth} style={{ ...navBtnStyle, ... }}>
<ChevronLeftIcon style={{ width: 16, height: 16 }} />
</button>
// سلول روز
<button key={i} onClick={() => selectDay(day)} style={{ ... }}> {day.toLocaleString('fa-IR')} </button>
// سلول ماه
<button key={m} onClick={() => { setViewMonth(m); setMode('day'); }} style={{ ... }}> {mName} </button>
// سلول سال
<button key={y} onClick={() => { setViewYear(y); setMode('month'); }} style={{ ... }}> {faYear(y)} </button>
```
`mini-btn` بستن در `Modal` هم بدون `type` است و باید اصلاح شود (اگر مودالی داخل فرم قرار گیرد).
## وظایف
### ۱. رندر `Modal` با Portal روی `document.body`
`createPortal` را از `react-dom` وارد کن و کل markup مودال را داخل آن بپیچ:
```tsx
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon } from '@heroicons/react/24/outline';
// ... داخل کامپوننت، بعد از `if (!open) return null;`
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: sizeMap[size] }} onClick={(e) => e.stopPropagation()}>
{/* ... بدون تغییر ... */}
</div>
</div>,
document.body
);
```
نکته‌ها:
- Portal تضمین می‌کند overlay فرزندِ مستقیم `body` باشد، پس `position: fixed` همیشه نسبت به viewport محاسبه می‌شود و باگ چسبیدن به پایین در همهٔ صفحات رفع می‌شود.
- منطق Escape و `onClick` overlay و `stopPropagation` مودال بدون تغییر بماند.
- دکمهٔ بستن `mini-btn` را `type="button"` کن تا اگر مودالی داخل یک `<form>` قرار گرفت، submit ناخواسته رخ ندهد.
### ۲. افزودن `type="button"` به همهٔ `<button>`های `PersianCalendar.tsx`
به هر پنج نوع دکمه `type="button"` اضافه کن:
- دکمهٔ ناوبری راست (`ChevronRightIcon`)
- دکمهٔ ناوبری چپ (`ChevronLeftIcon`)
- سلول‌های روز (day view)
- سلول‌های ماه (month view)
- سلول‌های سال (year view)
نمونه:
```tsx
<button
type="button"
onClick={mode === 'year' ? () => setYearRangeStart(s => s - 12) : nextMonth}
style={{ ...navBtnStyle, visibility: mode === 'month' ? 'hidden' : 'visible' }}
>
<ChevronRightIcon style={{ width: 16, height: 16 }} />
</button>
```
این کار از submit ناخواستهٔ فرمِ دربرگیرنده جلوگیری می‌کند و تقویم هنگام کار با `<` / `>` و انتخاب سال/ماه باز می‌ماند؛ فقط انتخاب «روز» (که `onChange` + `onClose` را صدا می‌زند) آن را می‌بندد.
### ۳. (اختیاری، اگر جای دیگری هم مشکل مشابه بود) بررسی سریر سایر تقویم‌ها
`PersianDatePicker.tsx` (والدِ `PersianCalendar`) دکمهٔ باز/بستن‌اش `<div>` است نه `<button>`، پس مشکل submit ندارد؛ نیازی به تغییر نیست. فقط مطمئن شو `PersianCalendar` (که popup مشترک است) اصلاح شده.
## نکات مهم
- **علت دقیق باگ ۲ = نبود `type="button"` در فرم.** فقط با پوشش «همهٔ» دکمه‌های تقویم رفع می‌شود؛ اگر حتی یکی جا بماند، همان دکمه فرم را submit می‌کند.
- **Portal رفع ریشه‌ای باگ ۱ است** و مستقل از اینکه کدام والد containing-block می‌سازد کار می‌کند؛ نیازی به تغییر `styles.css` نیست.
- بعد از Portal، اطمینان حاصل کن `z-index: 1000` روی `.overlay` هنوز بالاتر از سایر عناصر است (چون حالا فرزند body است معمولاً بالاتر هم می‌آید — مشکلی نیست).
- هیچ کتابخانهٔ جدیدی اضافه نکن؛ `react-dom` از قبل موجود است.
- بعد از تغییر: `ddev exec npx tsc --noEmit --project tsconfig.json` و `ddev exec yarn dev` برای build. سپس دستی در `/admin/profile` تست کن: (الف) باز شدن مودال ویرایش در وسط صفحه؛ (ب) باز ماندن تقویم «تاریخ شروع فعالیت» هنگام کلیک روی `<` / `>` و انتخاب سال، و بسته شدن فقط با انتخاب روز.
- این تغییر فقط UI/رفتار کلاینت است؛ API و مستندات `docs/api/` تغییری لازم ندارند.
+5 -3
View File
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon } from '@heroicons/react/24/outline';
type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
@@ -29,7 +30,7 @@ export default function Modal({ open, title, size = 'md', onClose, children, foo
if (!open) return null;
return (
return createPortal(
<div className="overlay" onClick={onClose}>
<div
className="modal"
@@ -38,13 +39,14 @@ export default function Modal({ open, title, size = 'md', onClose, children, foo
>
<div className="modal-head">
<h2>{title}</h2>
<button className="mini-btn" onClick={onClose}>
<button type="button" className="mini-btn" onClick={onClose}>
<XMarkIcon style={{ width: 18, height: 18 }} />
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
</div>,
document.body
);
}
@@ -127,6 +127,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<button
type="button"
onClick={mode === 'year' ? () => setYearRangeStart(s => s - 12) : nextMonth}
style={{ ...navBtnStyle, visibility: mode === 'month' ? 'hidden' : 'visible' }}
>
@@ -157,6 +158,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
)}
<button
type="button"
onClick={mode === 'year' ? () => setYearRangeStart(s => s + 12) : prevMonth}
style={{ ...navBtnStyle, visibility: mode === 'month' ? 'hidden' : 'visible' }}
>
@@ -182,6 +184,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
return (
<button
key={i}
type="button"
onClick={() => selectDay(day)}
style={{
width: 32, height: 32, borderRadius: '50%', fontSize: 12,
@@ -211,6 +214,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
return (
<button
key={m}
type="button"
onClick={() => { setViewMonth(m); setMode('day'); }}
style={{ ...gridItemStyle, background: active ? 'var(--primary)' : 'transparent', color: active ? '#fff' : 'var(--text)' }}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = 'var(--surface-2)'; }}
@@ -231,6 +235,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
return (
<button
key={y}
type="button"
onClick={() => { setViewYear(y); setMode('month'); }}
style={{ ...gridItemStyle, background: active ? 'var(--primary)' : 'transparent', color: active ? '#fff' : 'var(--text)' }}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = 'var(--surface-2)'; }}