Skip to content

Commit 157615b

Browse files
authored
feat: allow restrictions on modal close behavior (#2797)
1 parent f5319f6 commit 157615b

File tree

2 files changed

+83
-11
lines changed

2 files changed

+83
-11
lines changed

src/components/Modal/Modal.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,72 @@
11
import clsx from 'clsx';
2-
import type { PropsWithChildren } from 'react';
2+
import { type PropsWithChildren, useCallback } from 'react';
33
import React, { useEffect, useRef } from 'react';
44
import { FocusScope } from '@react-aria/focus';
55

66
import { CloseIconRound } from './icons';
77

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

10+
type CloseEvent =
11+
| KeyboardEvent
12+
| React.KeyboardEvent
13+
| React.MouseEvent<HTMLButtonElement | HTMLDivElement>;
14+
export type ModalCloseSource = 'overlay' | 'button' | 'escape';
15+
1016
export type ModalProps = {
1117
/** If true, modal is opened or visible. */
1218
open: boolean;
1319
/** Custom class to be applied to the modal root div */
1420
className?: string;
1521
/** Callback handler for closing of modal. */
16-
onClose?: (
17-
event: React.KeyboardEvent | React.MouseEvent<HTMLButtonElement | HTMLDivElement>,
18-
) => void;
22+
onClose?: (event: CloseEvent) => void;
23+
/** Optional handler to intercept closing logic. Return false to prevent onClose. */
24+
onCloseAttempt?: (source: ModalCloseSource, event: CloseEvent) => boolean;
1925
};
2026

2127
export const Modal = ({
2228
children,
2329
className,
2430
onClose,
31+
onCloseAttempt,
2532
open,
2633
}: PropsWithChildren<ModalProps>) => {
2734
const { t } = useTranslationContext('Modal');
2835

2936
const innerRef = useRef<HTMLDivElement | null>(null);
30-
const closeRef = useRef<HTMLButtonElement | null>(null);
37+
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
38+
39+
const maybeClose = useCallback(
40+
(source: ModalCloseSource, event: CloseEvent) => {
41+
const allow = onCloseAttempt?.(source, event);
42+
if (allow !== false) {
43+
onClose?.(event);
44+
}
45+
},
46+
[onClose, onCloseAttempt],
47+
);
3148

3249
const handleClick = (event: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
3350
const target = event.target as HTMLButtonElement | HTMLDivElement;
34-
if (!innerRef.current || !closeRef.current) return;
51+
if (!innerRef.current || !closeButtonRef.current) return;
3552

36-
if (!innerRef.current.contains(target) || closeRef.current.contains(target))
37-
onClose?.(event);
53+
if (closeButtonRef.current.contains(target)) {
54+
maybeClose('button', event);
55+
} else if (!innerRef.current.contains(target)) {
56+
maybeClose('overlay', event);
57+
}
3858
};
3959

4060
useEffect(() => {
4161
if (!open) return;
4262

4363
const handleKeyDown = (event: KeyboardEvent) => {
44-
if (event.key === 'Escape') onClose?.(event as unknown as React.KeyboardEvent);
64+
if (event.key === 'Escape') maybeClose('escape', event);
4565
};
4666

4767
document.addEventListener('keydown', handleKeyDown);
4868
return () => document.removeEventListener('keydown', handleKeyDown);
49-
}, [onClose, open]);
69+
}, [maybeClose, open]);
5070

5171
if (!open) return null;
5272

@@ -58,7 +78,7 @@ export const Modal = ({
5878
<FocusScope autoFocus contain>
5979
<button
6080
className='str-chat__modal__close-button'
61-
ref={closeRef}
81+
ref={closeButtonRef}
6282
title={t('Close')}
6383
>
6484
<CloseIconRound />

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import '@testing-library/jest-dom';
55

66
import { Modal } from '../Modal';
77

8+
const CLOSE_BUTTON_SELECTOR = '.str-chat__modal__close-button';
9+
810
describe('Modal', () => {
911
afterEach(cleanup);
1012

@@ -95,4 +97,54 @@ describe('Modal', () => {
9597
const { container } = render(<Modal onClose={() => {}} open={false} />);
9698
expect(container).toBeEmptyDOMElement();
9799
});
100+
101+
it('should call onClose if onCloseAttempt returns true', () => {
102+
const onClose = jest.fn();
103+
const onCloseAttempt = () => true;
104+
const { container } = render(
105+
<Modal onClose={onClose} onCloseAttempt={onCloseAttempt} open />,
106+
);
107+
108+
fireEvent(
109+
document,
110+
new KeyboardEvent('keydown', {
111+
key: 'Escape',
112+
}),
113+
);
114+
115+
expect(onClose).toHaveBeenCalledTimes(1);
116+
117+
fireEvent.click(container.firstChild);
118+
119+
expect(onClose).toHaveBeenCalledTimes(2);
120+
121+
fireEvent.click(container.querySelector(CLOSE_BUTTON_SELECTOR));
122+
123+
expect(onClose).toHaveBeenCalledTimes(3);
124+
});
125+
126+
it('should not call onClose if onCloseAttempt returns false', () => {
127+
const onClose = jest.fn();
128+
const onCloseAttempt = () => false;
129+
const { container } = render(
130+
<Modal onClose={onClose} onCloseAttempt={onCloseAttempt} open />,
131+
);
132+
133+
fireEvent(
134+
document,
135+
new KeyboardEvent('keydown', {
136+
key: 'Escape',
137+
}),
138+
);
139+
140+
expect(onClose).toHaveBeenCalledTimes(0);
141+
142+
fireEvent.click(container.firstChild);
143+
144+
expect(onClose).toHaveBeenCalledTimes(0);
145+
146+
fireEvent.click(container.querySelector(CLOSE_BUTTON_SELECTOR));
147+
148+
expect(onClose).toHaveBeenCalledTimes(0);
149+
});
98150
});

0 commit comments

Comments
 (0)