Skip to content

Commit 98342c7

Browse files
Feat dismissible modal (#525)
* feat(modal): add dismissible prop (#497) * feat(modal): close on Escape keydown functionality (#497)
1 parent 2f58ae2 commit 98342c7

File tree

5 files changed

+106
-3
lines changed

5 files changed

+106
-3
lines changed

src/docs/pages/ModalPage.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@ const ModalPage: FC = () => {
4545
</>
4646
),
4747
},
48+
{
49+
title: 'Dismissable modal',
50+
code: (
51+
<>
52+
<Button onClick={() => setOpenModal('dismissible')}>Toggle modal</Button>
53+
<Modal dismissible show={openModal === 'dismissible'} onClose={() => setOpenModal(undefined)}>
54+
<Modal.Header>Terms of Service</Modal.Header>
55+
<Modal.Body>
56+
<div className="space-y-6">
57+
<p className="text-base leading-relaxed text-gray-500 dark:text-gray-400">
58+
With less than a month to go before the European Union enacts new consumer privacy laws for its
59+
citizens, companies around the world are updating their terms of service agreements to comply.
60+
</p>
61+
<p className="text-base leading-relaxed text-gray-500 dark:text-gray-400">
62+
The European Union’s General Data Protection Regulation (G.D.P.R.) goes into effect on May 25 and is
63+
meant to ensure a common set of data rights in the European Union. It requires organizations to notify
64+
users as soon as possible of high-risk data breaches that could personally affect them.
65+
</p>
66+
</div>
67+
</Modal.Body>
68+
<Modal.Footer>
69+
<Button onClick={() => setOpenModal(undefined)}>I accept</Button>
70+
<Button color="gray" onClick={() => setOpenModal(undefined)}>
71+
Decline
72+
</Button>
73+
</Modal.Footer>
74+
</Modal>
75+
</>
76+
),
77+
},
4878
{
4979
title: 'Pop-up modal',
5080
code: (

src/lib/components/Modal/Modal.spec.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,44 @@ describe('Components / Modal', () => {
3434
await waitFor(() => expect(root.childNodes.length).toBe(0));
3535
});
3636

37+
it('should be closed by clicking outside if the "dismissible" prop is passed.', async () => {
38+
const root = document.createElement('div');
39+
const user = userEvent.setup();
40+
41+
render(<TestModal root={root} dismissible />);
42+
43+
const openButton = screen.getByRole('button');
44+
45+
await user.click(openButton);
46+
47+
const modal = within(root).getByRole('dialog');
48+
49+
expect(modal).toHaveAttribute('aria-hidden', 'false');
50+
51+
await user.click(modal);
52+
53+
expect(modal).toHaveAttribute('aria-hidden', 'true');
54+
});
55+
56+
it('should be closed by Esc key press.', async () => {
57+
const root = document.createElement('div');
58+
const user = userEvent.setup();
59+
60+
render(<TestModal root={root} dismissible />);
61+
62+
const openButton = screen.getByRole('button');
63+
64+
await user.click(openButton);
65+
66+
const modal = within(root).getByRole('dialog');
67+
68+
expect(modal).toHaveAttribute('aria-hidden', 'false');
69+
70+
await user.keyboard('[Escape]');
71+
72+
expect(modal).toHaveAttribute('aria-hidden', 'true');
73+
});
74+
3775
describe('A11y', () => {
3876
it('should have `role="dialog"`', async () => {
3977
const user = userEvent.setup();
@@ -90,7 +128,7 @@ describe('Components / Modal', () => {
90128
});
91129
});
92130

93-
const TestModal = ({ root }: Pick<ModalProps, 'root'>): JSX.Element => {
131+
const TestModal = ({ root, dismissible = false }: Pick<ModalProps, 'root' | 'dismissible'>): JSX.Element => {
94132
const [open, setOpen] = useState(false);
95133

96134
const setInputRef = useCallback(
@@ -105,7 +143,7 @@ const TestModal = ({ root }: Pick<ModalProps, 'root'>): JSX.Element => {
105143
return (
106144
<>
107145
<Button onClick={() => setOpen(true)}>Toggle modal</Button>
108-
<Modal root={root} show={open} onClose={() => setOpen(false)}>
146+
<Modal dismissible={dismissible} root={root} show={open} onClose={() => setOpen(false)}>
109147
<Modal.Header>Terms of Service</Modal.Header>
110148
<Modal.Body>
111149
<div className="space-y-6">

src/lib/components/Modal/Modal.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import classNames from 'classnames';
2-
import { ComponentProps, FC, PropsWithChildren, useEffect, useRef } from 'react';
2+
import { ComponentProps, FC, MouseEvent, PropsWithChildren, useEffect, useRef } from 'react';
33
import { createPortal } from 'react-dom';
4+
import { useKeyDown } from '../../hooks';
45
import type { FlowbiteBoolean, FlowbitePositions, FlowbiteSizes } from '../Flowbite/FlowbiteTheme';
56
import { useTheme } from '../Flowbite/ThemeContext';
67
import { ModalBody } from './ModalBody';
@@ -51,6 +52,7 @@ export interface ModalProps extends PropsWithChildren<ComponentProps<'div'>> {
5152
root?: HTMLElement;
5253
show?: boolean;
5354
size?: keyof ModalSizes;
55+
dismissible?: boolean;
5456
}
5557

5658
const ModalComponent: FC<ModalProps> = ({
@@ -60,6 +62,7 @@ const ModalComponent: FC<ModalProps> = ({
6062
popup,
6163
size = '2xl',
6264
position = 'center',
65+
dismissible = false,
6366
onClose,
6467
className,
6568
...props
@@ -93,13 +96,26 @@ const ModalComponent: FC<ModalProps> = ({
9396
};
9497
}, []);
9598

99+
useKeyDown('Escape', () => {
100+
if (dismissible && onClose) {
101+
onClose();
102+
}
103+
});
104+
105+
const handleOnClick = (e: MouseEvent<HTMLDivElement>) => {
106+
if (dismissible && e.target === e.currentTarget && onClose) {
107+
onClose();
108+
}
109+
};
110+
96111
return createPortal(
97112
<ModalContext.Provider value={{ popup, onClose }}>
98113
<div
99114
aria-hidden={!show}
100115
className={classNames(theme.base, theme.positions[position], show ? theme.show.on : theme.show.off, className)}
101116
data-testid="modal"
102117
role="dialog"
118+
onClick={handleOnClick}
103119
{...props}
104120
>
105121
<div className={classNames(theme.content.base, theme.sizes[size])}>

src/lib/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as useKeyDown } from './useKeyDown';

src/lib/hooks/useKeyDown.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useEffect } from 'react';
2+
3+
const useKeyDown = (key: string, callback: () => void) => {
4+
const handleKeyDown = (event: KeyboardEvent) => {
5+
if (event.key === key) {
6+
callback();
7+
}
8+
};
9+
10+
useEffect(() => {
11+
document.addEventListener('keydown', handleKeyDown);
12+
return () => {
13+
document.removeEventListener('keydown', handleKeyDown);
14+
};
15+
}, [key, callback]);
16+
};
17+
18+
export default useKeyDown;

0 commit comments

Comments
 (0)