fix(calendar): add type="button" to all buttons in PersianCalendar to prevent form submission
9.4 KiB
رفع دو باگ Modal و تقویم شمسی در پنل ادمین
پروژه
clinicpro (Admin SPA — React 19 داخل Symfony/Encore).
زمینه
در پنل ادمین دو باگ UI مستقل وجود دارد که هر دو در /admin/profile و سایر صفحات دیده میشوند:
- مودال وسط صفحه باز نمیشود و به پایین صفحه میچسبد — در همهٔ صفحاتی که از کامپوننت مشترک
Modalاستفاده میکنند. - تقویم شمسی هنگام کار با دکمههای ناوبری بسته میشود — مثلاً در فیلد «تاریخ شروع فعالیت» وقتی وارد نمای انتخاب سال میشوی و روی دکمههای
</>(تغییر بازهٔ سال) کلیک میکنی، بهجای جابهجایی بازه، کل فرم 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)
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) — نمونهها
// دکمههای ناوبری هدر
<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 مودال را داخل آن بپیچ:
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 و
onClickoverlay وstopPropagationمودال بدون تغییر بماند. - دکمهٔ بستن
mini-btnراtype="button"کن تا اگر مودالی داخل یک<form>قرار گرفت، submit ناخواسته رخ ندهد.
۲. افزودن type="button" به همهٔ <button>های PersianCalendar.tsx
به هر پنج نوع دکمه type="button" اضافه کن:
- دکمهٔ ناوبری راست (
ChevronRightIcon) - دکمهٔ ناوبری چپ (
ChevronLeftIcon) - سلولهای روز (day view)
- سلولهای ماه (month view)
- سلولهای سال (year view)
نمونه:
<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/تغییری لازم ندارند.