feat: implement useOverlayDismiss hook for consistent modal dismissal behavior

This commit is contained in:
hamed
2026-08-10 11:27:16 +03:30
parent 8a18457751
commit 3365a0427e
12 changed files with 227 additions and 36 deletions
@@ -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(
<Modal open title="ثبت نوبت" onClose={onClose}>
<input placeholder="نام" />
</Modal>,
);
}
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(<Modal open={false} title="ثبت نوبت" onClose={onClose}><p>محتوا</p></Modal>);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
});
+52
View File
@@ -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);
* <div className="overlay" {...dismiss}> … </div>
* ```
*/
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<HTMLElement>) => {
pressedOnOverlay.current = e.target === e.currentTarget;
}, []);
const onClick = useCallback((e: ReactMouseEvent<HTMLElement>) => {
const started = pressedOnOverlay.current;
pressedOnOverlay.current = false;
if (started && e.target === e.currentTarget) onClose();
}, [onClose]);
return { onMouseDown, onClick };
}