diff --git a/src/components/common/UnifiedModal.test.tsx b/src/components/common/UnifiedModal.test.tsx
index 7e1637764..adbca4290 100644
--- a/src/components/common/UnifiedModal.test.tsx
+++ b/src/components/common/UnifiedModal.test.tsx
@@ -39,6 +39,24 @@ test('closes through backdrop and escape', () => {
expect(onClose).toHaveBeenCalledTimes(2);
});
+test('keeps the modal open when a pointer press starts inside and releases over the backdrop', () => {
+ const onClose = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ const dialog = screen.getByRole('dialog');
+ const backdrop = dialog.parentElement as HTMLElement;
+
+ fireEvent.pointerDown(screen.getByRole('button', { name: '窗口内容' }));
+ fireEvent.pointerUp(backdrop);
+ fireEvent.click(backdrop);
+
+ expect(onClose).not.toHaveBeenCalled();
+});
+
test('supports disabling escape close while keeping the custom close button chrome', () => {
const onClose = vi.fn();
render(
diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx
index 6129beab1..93b63f894 100644
--- a/src/components/common/UnifiedModal.tsx
+++ b/src/components/common/UnifiedModal.tsx
@@ -4,6 +4,7 @@ import {
type ReactNode,
useEffect,
useId,
+ useRef,
} from 'react';
import { createPortal } from 'react-dom';
@@ -118,6 +119,7 @@ function UnifiedModalContent({
const generatedTitleId = useId();
const descriptionId = useId();
const titleId = titleIdProp ?? generatedTitleId;
+ const backdropPointerSequenceRef = useRef
(null);
useEffect(() => {
if (!open || closeDisabled || !closeOnEscape) {
@@ -175,10 +177,26 @@ function UnifiedModalContent({
{
+ backdropPointerSequenceRef.current =
+ event.target === event.currentTarget;
+ }}
+ onPointerUpCapture={(event) => {
+ backdropPointerSequenceRef.current =
+ backdropPointerSequenceRef.current === true &&
+ event.target === event.currentTarget;
+ }}
+ onPointerCancelCapture={() => {
+ backdropPointerSequenceRef.current = false;
+ }}
onClick={(event) => {
+ const pointerSequenceStayedOnBackdrop =
+ backdropPointerSequenceRef.current !== false;
+ backdropPointerSequenceRef.current = null;
if (
closeOnBackdrop &&
!closeDisabled &&
+ pointerSequenceStayedOnBackdrop &&
event.target === event.currentTarget
) {
onClose();