Files
clinicpro/.claude/prompt/fix-admin-modal-and-calendar.md
T
hamed 02c34bac8e 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
2026-07-02 11:48:34 +03:30

9.4 KiB
Raw Blame History

رفع دو باگ 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)

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 و onClick overlay و 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/ تغییری لازم ندارند.