+ // پیشوند پایدار برای کلاسها: کلاسهای emotion در build تولیدی برچسب ندارند و
+ // «منوی باز است یا نه» با آنها قابل تشخیص نیست. useOverlayDismiss به همین
+ // نشانه نگاه میکند تا Esc اول منو را ببندد نه مودال را.
+ classNamePrefix="cp-select"
options={options}
value={selected}
onChange={(opt) => onChange?.(opt ? opt.value : null)}
diff --git a/assets/admin/hooks/useOverlayDismiss.test.tsx b/assets/admin/hooks/useOverlayDismiss.test.tsx
new file mode 100644
index 00000000..c7bde643
--- /dev/null
+++ b/assets/admin/hooks/useOverlayDismiss.test.tsx
@@ -0,0 +1,129 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import Modal from '@/components/ui/Modal';
+
+/**
+ * باگی که این تستها میبندند: پسزمینه یک onClick ساده داشت، پس هر کلیکی که روی
+ * آن «تمام میشد» فرم نیمهپرشده را میبست — درگِ متن از داخل به بیرون، یا کلیک روی
+ * گزینهای که همان لحظه unmount میشد.
+ */
+function renderModal(onClose: () => void) {
+ return render(
+
+
+ ,
+ );
+}
+
+const overlay = () => document.querySelector('.overlay') as HTMLElement;
+const modal = () => document.querySelector('.modal') as HTMLElement;
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('بستن مودال با پسزمینه', () => {
+ it('فشردن و رها کردن روی پسزمینه، مودال را میبندد', () => {
+ const onClose = vi.fn();
+ renderModal(onClose);
+
+ fireEvent.mouseDown(overlay());
+ fireEvent.click(overlay());
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('درگِ متن از داخل مودال به بیرون، آن را نمیبندد', () => {
+ const onClose = vi.fn();
+ renderModal(onClose);
+
+ // شروع روی محتوای مودال، پایان روی پسزمینه — همان حرکتِ انتخاب متن.
+ fireEvent.mouseDown(screen.getByPlaceholderText('نام'));
+ fireEvent.click(overlay());
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('کلیک داخل مودال هیچوقت آن را نمیبندد', () => {
+ const onClose = vi.fn();
+ renderModal(onClose);
+
+ fireEvent.mouseDown(modal());
+ fireEvent.click(modal());
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('کلیکِ بعدی روی پسزمینه پس از یک درگ، دوباره درست کار میکند', () => {
+ const onClose = vi.fn();
+ renderModal(onClose);
+
+ fireEvent.mouseDown(screen.getByPlaceholderText('نام'));
+ fireEvent.click(overlay());
+ expect(onClose).not.toHaveBeenCalled();
+
+ fireEvent.mouseDown(overlay());
+ fireEvent.click(overlay());
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('بستن مودال با Esc', () => {
+ it('Esc مودال را میبندد', () => {
+ const onClose = vi.fn();
+ renderModal(onClose);
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('وقتی منوی انتخاب باز است، Esc مودال را نمیبندد', () => {
+ const onClose = vi.fn();
+ renderModal(onClose);
+
+ // همان نشانهای که react-select با classNamePrefix میگذارد.
+ const menu = document.createElement('div');
+ menu.className = 'cp-select__menu';
+ document.body.appendChild(menu);
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(onClose).not.toHaveBeenCalled();
+
+ menu.remove();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('وقتی تقویم باز است، Esc مودال را نمیبندد', () => {
+ const onClose = vi.fn();
+ renderModal(onClose);
+
+ const calendar = document.createElement('div');
+ calendar.className = 'cp-calendar';
+ document.body.appendChild(calendar);
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('کلیدهای دیگر کاری نمیکنند', () => {
+ const onClose = vi.fn();
+ renderModal(onClose);
+
+ fireEvent.keyDown(document, { key: 'Enter' });
+ fireEvent.keyDown(document, { key: 'a' });
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('مودالِ بسته به Esc گوش نمیدهد', () => {
+ const onClose = vi.fn();
+ render(
محتوا
);
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/assets/admin/hooks/useOverlayDismiss.ts b/assets/admin/hooks/useOverlayDismiss.ts
new file mode 100644
index 00000000..826d9e87
--- /dev/null
+++ b/assets/admin/hooks/useOverlayDismiss.ts
@@ -0,0 +1,52 @@
+import { useCallback, useEffect, useRef } from 'react';
+import type { MouseEvent as ReactMouseEvent } from 'react';
+
+/**
+ * لایههایی که خودشان با Esc بسته میشوند و مودال نباید جایشان بسته شود:
+ * منوی باز react-select و تقویم شمسی.
+ */
+const OPEN_LAYER_SELECTOR = '.cp-select__menu, .cp-calendar';
+
+/**
+ * رفتار بستنِ یک مودال: کلیک روی پسزمینه و کلید Esc.
+ *
+ * قبلاً پسزمینه یک `onClick={onClose}` ساده داشت و هر کلیکی که *روی آن تمام میشد*
+ * مودال را میبست — انتخاب متن داخل فرم و رها کردن موس بیرون، یا کلیک روی گزینهای
+ * که همان لحظه unmount میشد. بستنِ ناخواستهٔ فرمِ نیمهپرشده آزاردهندهترین باگ
+ * پنل بود، پس شرط سختتر شد: هم فشردن و هم رها کردن باید روی خودِ پسزمینه باشد.
+ *
+ * ```tsx
+ * const dismiss = useOverlayDismiss(onClose);
+ *
…
+ * ```
+ */
+export function useOverlayDismiss(onClose: () => void, enabled = true) {
+ const pressedOnOverlay = useRef(false);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ // دراپداون یا تقویمِ باز، خودش با Esc بسته میشود؛ مودال باید بماند.
+ if (document.querySelector(OPEN_LAYER_SELECTOR)) return;
+ onClose();
+ };
+
+ document.addEventListener('keydown', onKey);
+
+ return () => document.removeEventListener('keydown', onKey);
+ }, [enabled, onClose]);
+
+ const onMouseDown = useCallback((e: ReactMouseEvent
) => {
+ pressedOnOverlay.current = e.target === e.currentTarget;
+ }, []);
+
+ const onClick = useCallback((e: ReactMouseEvent) => {
+ const started = pressedOnOverlay.current;
+ pressedOnOverlay.current = false;
+ if (started && e.target === e.currentTarget) onClose();
+ }, [onClose]);
+
+ return { onMouseDown, onClick };
+}
diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx
index 846a373c..b142b4ac 100644
--- a/assets/admin/pages/ClinicDetailPage.tsx
+++ b/assets/admin/pages/ClinicDetailPage.tsx
@@ -27,6 +27,7 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
import { latinDigitsField } from '../lib/forms';
import Switch from '../components/ui/Switch';
+import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
// Fix leaflet icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -238,9 +239,11 @@ function EditModal({ clinic, onClose, onSaved }: {
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
+ const dismiss = useOverlayDismiss(onClose);
+
return createPortal(
-
-
e.stopPropagation()}>
+
+
ویرایش کلینیک