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:
@@ -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/` تغییری لازم ندارند.
|
||||
@@ -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)'; }}
|
||||
|
||||
Reference in New Issue
Block a user