Skip to content

Commit d630ef1

Browse files
authored
feat: respect onCloseAttempt prop in GlobalModal (#2802)
1 parent 0268652 commit d630ef1

File tree

3 files changed

+119
-18
lines changed

3 files changed

+119
-18
lines changed

src/components/Modal/GlobalModal.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import clsx from 'clsx';
22
import type { PropsWithChildren } from 'react';
3+
import { useCallback } from 'react';
34
import React, { useEffect, useRef } from 'react';
45
import { FocusScope } from '@react-aria/focus';
56

@@ -12,41 +13,55 @@ import {
1213
useModalDialog,
1314
useModalDialogIsOpen,
1415
} from '../Dialog';
15-
import type { ModalProps } from './Modal';
16+
import type { ModalCloseEvent, ModalCloseSource, ModalProps } from './Modal';
1617

1718
export const GlobalModal = ({
1819
children,
1920
className,
2021
onClose,
22+
onCloseAttempt,
2123
open,
2224
}: PropsWithChildren<ModalProps>) => {
2325
const { t } = useTranslationContext('Modal');
2426

2527
const dialog = useModalDialog();
2628
const isOpen = useModalDialogIsOpen();
2729
const innerRef = useRef<HTMLDivElement | null>(null);
28-
const closeRef = useRef<HTMLButtonElement | null>(null);
30+
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
31+
32+
const maybeClose = useCallback(
33+
(source: ModalCloseSource, event: ModalCloseEvent) => {
34+
const allow = onCloseAttempt?.(source, event);
35+
if (allow !== false) {
36+
onClose?.(event);
37+
dialog.close();
38+
}
39+
},
40+
[dialog, onClose, onCloseAttempt],
41+
);
2942

3043
const handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
31-
if (innerRef.current?.contains(event.target as HTMLButtonElement | HTMLDivElement))
32-
return;
33-
onClose?.(event);
34-
dialog.close();
44+
const target = event.target as HTMLButtonElement | HTMLDivElement;
45+
if (!innerRef.current || !closeButtonRef.current) return;
46+
if (innerRef.current?.contains(target)) return;
47+
48+
if (closeButtonRef.current.contains(target)) {
49+
maybeClose('button', event);
50+
} else if (!innerRef.current.contains(target)) {
51+
maybeClose('overlay', event);
52+
}
3553
};
3654

3755
useEffect(() => {
3856
if (!isOpen) return;
3957

4058
const handleKeyDown = (event: KeyboardEvent) => {
41-
if (event.key === 'Escape') {
42-
onClose?.(event as unknown as React.KeyboardEvent);
43-
dialog.close();
44-
}
59+
if (event.key === 'Escape') maybeClose('escape', event);
4560
};
4661

4762
document.addEventListener('keydown', handleKeyDown);
4863
return () => document.removeEventListener('keydown', handleKeyDown);
49-
}, [dialog, onClose, isOpen]);
64+
}, [isOpen, maybeClose]);
5065

5166
useEffect(() => {
5267
if (open && !dialog.isOpen) {
@@ -68,7 +83,7 @@ export const GlobalModal = ({
6883
<FocusScope autoFocus contain>
6984
<button
7085
className='str-chat__modal__close-button'
71-
ref={closeRef}
86+
ref={closeButtonRef}
7287
title={t('Close')}
7388
type='button'
7489
>

src/components/Modal/Modal.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import { CloseIconRound } from './icons';
77

88
import { useTranslationContext } from '../../context';
99

10-
type CloseEvent =
10+
export type ModalCloseEvent =
1111
| KeyboardEvent
1212
| React.KeyboardEvent
1313
| React.MouseEvent<HTMLButtonElement | HTMLDivElement>;
14+
1415
export type ModalCloseSource = 'overlay' | 'button' | 'escape';
1516

1617
export type ModalProps = {
@@ -19,9 +20,9 @@ export type ModalProps = {
1920
/** Custom class to be applied to the modal root div */
2021
className?: string;
2122
/** Callback handler for closing of modal. */
22-
onClose?: (event: CloseEvent) => void;
23+
onClose?: (event: ModalCloseEvent) => void;
2324
/** Optional handler to intercept closing logic. Return false to prevent onClose. */
24-
onCloseAttempt?: (source: ModalCloseSource, event: CloseEvent) => boolean;
25+
onCloseAttempt?: (source: ModalCloseSource, event: ModalCloseEvent) => boolean;
2526
};
2627

2728
export const Modal = ({
@@ -37,7 +38,7 @@ export const Modal = ({
3738
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
3839

3940
const maybeClose = useCallback(
40-
(source: ModalCloseSource, event: CloseEvent) => {
41+
(source: ModalCloseSource, event: ModalCloseEvent) => {
4142
const allow = onCloseAttempt?.(source, event);
4243
if (allow !== false) {
4344
onClose?.(event);

src/components/Modal/__tests__/GlobalModal.test.js

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import '@testing-library/jest-dom';
66
import { GlobalModal } from '../GlobalModal';
77
import { ModalDialogManagerProvider } from '../../../context';
88

9+
const CLOSE_BUTTON_SELECTOR = '.str-chat__modal__close-button';
10+
const OVERLAY_SELECTOR = '.str-chat__modal';
11+
912
const renderComponent = ({ props } = {}) =>
1013
render(
1114
<ModalDialogManagerProvider>
@@ -95,14 +98,96 @@ describe('GlobalModal', () => {
9598

9699
it('should call onClose if the modal overlay is clicked', () => {
97100
const onClose = jest.fn();
98-
const { container, debug } = renderComponent({
101+
const { container } = renderComponent({
99102
props: { children: textContent, onClose, open: true },
100103
});
101-
console.log(debug(container));
104+
102105
const dialogOverlay = container.querySelector('.str-chat__modal');
103106

104107
fireEvent.click(dialogOverlay);
105108

106109
expect(onClose).toHaveBeenCalledTimes(1);
107110
});
111+
112+
it('should call onClose if onCloseAttempt returns true and Escape pressed', () => {
113+
const onClose = jest.fn();
114+
const onCloseAttempt = () => true;
115+
renderComponent({
116+
props: { children: textContent, onClose, onCloseAttempt, open: true },
117+
});
118+
119+
fireEvent(
120+
document,
121+
new KeyboardEvent('keydown', {
122+
key: 'Escape',
123+
}),
124+
);
125+
126+
expect(onClose).toHaveBeenCalledTimes(1);
127+
});
128+
129+
it('should call onClose if onCloseAttempt returns true and overlay clicked', () => {
130+
const onClose = jest.fn();
131+
const onCloseAttempt = () => true;
132+
const { container } = renderComponent({
133+
props: { children: textContent, onClose, onCloseAttempt, open: true },
134+
});
135+
136+
fireEvent.click(container.querySelector(OVERLAY_SELECTOR));
137+
138+
expect(onClose).toHaveBeenCalledTimes(1);
139+
});
140+
141+
it('should call onClose if onCloseAttempt returns true and close button clicked', () => {
142+
const onClose = jest.fn();
143+
const onCloseAttempt = () => true;
144+
const { container } = renderComponent({
145+
props: { children: textContent, onClose, onCloseAttempt, open: true },
146+
});
147+
148+
fireEvent.click(container.querySelector(CLOSE_BUTTON_SELECTOR));
149+
150+
expect(onClose).toHaveBeenCalledTimes(1);
151+
});
152+
153+
it('should call onClose if onCloseAttempt returns false and Escape pressed', () => {
154+
const onClose = jest.fn();
155+
const onCloseAttempt = () => false;
156+
renderComponent({
157+
props: { children: textContent, onClose, onCloseAttempt, open: true },
158+
});
159+
160+
fireEvent(
161+
document,
162+
new KeyboardEvent('keydown', {
163+
key: 'Escape',
164+
}),
165+
);
166+
167+
expect(onClose).not.toHaveBeenCalled();
168+
});
169+
170+
it('should call onClose if onCloseAttempt returns false and overlay clicked', () => {
171+
const onClose = jest.fn();
172+
const onCloseAttempt = () => false;
173+
const { container } = renderComponent({
174+
props: { children: textContent, onClose, onCloseAttempt, open: true },
175+
});
176+
177+
fireEvent.click(container.querySelector(OVERLAY_SELECTOR));
178+
179+
expect(onClose).not.toHaveBeenCalled();
180+
});
181+
182+
it('should call onClose if onCloseAttempt returns false and close button clicked', () => {
183+
const onClose = jest.fn();
184+
const onCloseAttempt = () => false;
185+
const { container } = renderComponent({
186+
props: { children: textContent, onClose, onCloseAttempt, open: true },
187+
});
188+
189+
fireEvent.click(container.querySelector(CLOSE_BUTTON_SELECTOR));
190+
191+
expect(onClose).not.toHaveBeenCalled();
192+
});
108193
});

0 commit comments

Comments
 (0)