feat: implement useOverlayDismiss hook for consistent modal dismissal behavior
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user