Skip to content

Commit bd0b217

Browse files
authored
Feature/modals (#24)
* Added modals
1 parent 96877a9 commit bd0b217

File tree

12 files changed

+510
-1
lines changed

12 files changed

+510
-1
lines changed

src/components/Modal/Modal.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import * as React from 'react';
2+
import PropTypes from 'prop-types';
3+
import clsx from 'clsx';
4+
import { createPortal } from 'react-dom';
5+
import { useEffect, useRef, useState } from 'react';
6+
import { mergeRefs } from '@/components/utils/mergeRefs';
7+
import { ModalManager } from '@/components/Modal/modalManager';
8+
9+
let modalManager: ModalManager;
10+
11+
export const getManager = (): ModalManager => {
12+
if (!modalManager) {
13+
modalManager = new ModalManager();
14+
}
15+
16+
return modalManager;
17+
}
18+
19+
export interface ModalProps extends React.HTMLAttributes<HTMLDivElement> {
20+
/**
21+
* Indicates whether the modal should be shown
22+
*/
23+
show?: boolean;
24+
/**
25+
* Hook called when modal tries to hide
26+
*/
27+
onHide?: () => void;
28+
/**
29+
* Indicates whether the modal should be horizontally centered. Default: true
30+
*/
31+
horizontallyCentered?: boolean;
32+
/**
33+
* Indicates whether the modal uses a darkened backdrop. Default: true
34+
*/
35+
backdrop?: boolean
36+
}
37+
38+
const Modal = React.forwardRef<HTMLDivElement, ModalProps>((
39+
{
40+
backdrop = true,
41+
children,
42+
horizontallyCentered = true,
43+
onHide,
44+
show,
45+
},
46+
ref
47+
): React.ReactElement => {
48+
const modalRef = useRef<HTMLDivElement>(null);
49+
const [shown, setShown] = useState(false);
50+
51+
const hideModal = (event?: React.MouseEvent): void => {
52+
if (event && event.target !== modalRef.current) {
53+
return;
54+
}
55+
56+
setShown(false);
57+
getManager().removeModal(modalRef);
58+
if (onHide) {
59+
onHide();
60+
}
61+
}
62+
63+
const showModal = (): void => {
64+
setShown(true);
65+
getManager().addModal({
66+
ref: modalRef,
67+
backdrop
68+
})
69+
}
70+
71+
useEffect((): void => {
72+
if (!shown && show) {
73+
showModal();
74+
} else if (shown && !show) {
75+
hideModal();
76+
}
77+
}, [show]);
78+
79+
return createPortal(
80+
show ? (
81+
<div
82+
ref={mergeRefs(ref, modalRef)}
83+
className={clsx(
84+
'modal-container'
85+
)}
86+
onClick={hideModal}
87+
role="dialog"
88+
aria-modal={true}
89+
>
90+
<div
91+
className={clsx(
92+
'modal-content',
93+
horizontallyCentered && 'horizontal-center',
94+
)}
95+
>
96+
{children}
97+
</div>
98+
</div>
99+
) : undefined,
100+
document.body
101+
);
102+
});
103+
104+
Modal.displayName = 'Modal';
105+
Modal.propTypes = {
106+
backdrop: PropTypes.bool,
107+
children: PropTypes.node,
108+
onHide: PropTypes.func,
109+
show: PropTypes.bool,
110+
horizontallyCentered: PropTypes.bool
111+
}
112+
113+
export default Modal;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import { mount } from 'enzyme';
3+
import { Modal } from '@/components';
4+
import { ModalProps } from '@/components/Modal/Modal';
5+
6+
describe('Test modal', function (): void {
7+
it('should render modal', (): void => {
8+
const modal = mount(
9+
<div>
10+
<Modal show={true} />
11+
</div>
12+
)
13+
14+
expect(modal.find('.modal-container').length).toBe(1);
15+
});
16+
17+
it('should hide modal when show prop changes to false', (): void => {
18+
const modal = mount<ModalProps>(
19+
<Modal show={true} />
20+
);
21+
22+
modal.setProps({
23+
show: false
24+
})
25+
26+
expect(modal.find('.modal-container').length).toBe(0);
27+
})
28+
29+
it('should hide the modal on container click', (): void => {
30+
const mockFn = jest.fn();
31+
32+
const modal = mount<ModalProps>(
33+
<Modal
34+
show={true}
35+
onHide={mockFn}
36+
/>
37+
);
38+
39+
modal.find('.modal-container').simulate('click');
40+
modal.setProps({
41+
show: false
42+
});
43+
44+
expect(modal.find('.modal-container').length).toBe(0);
45+
expect(mockFn).toHaveBeenCalled();
46+
})
47+
48+
it('should not hide modal when clicked on modal', (): void => {
49+
const mockFn = jest.fn();
50+
51+
const modal = mount<ModalProps>(
52+
<Modal
53+
show={true}
54+
onHide={mockFn}
55+
/>
56+
);
57+
58+
modal.find('.modal-content').simulate('click');
59+
60+
expect(mockFn).not.toHaveBeenCalled();
61+
})
62+
63+
it('should ignore show prop change when shown state is the same', (): void => {
64+
const mockFn = jest.fn();
65+
66+
const modal = mount<ModalProps>(
67+
<Modal
68+
show={false}
69+
onHide={mockFn}
70+
/>
71+
);
72+
73+
modal.setProps({
74+
show: false
75+
});
76+
77+
expect(mockFn).not.toHaveBeenCalled();
78+
});
79+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ModalManager } from '@/components/Modal/modalManager';
2+
import { createRef } from 'react';
3+
4+
describe('Test modalManager', function () {
5+
it('should add backdrop if modal needs it', () => {
6+
const manager = new ModalManager();
7+
manager.addModal({
8+
ref: createRef(),
9+
backdrop: true
10+
})
11+
12+
expect(document.body.querySelector('.modal-backdrop')).not.toBeNull();
13+
});
14+
15+
it('should add and remove class from body', () => {
16+
const ref = createRef<HTMLDivElement>();
17+
const manager = new ModalManager();
18+
manager.addModal({
19+
ref: ref
20+
})
21+
22+
expect(document.body.classList.contains('has-modal')).toBeTruthy();
23+
24+
manager.removeModal(ref);
25+
26+
expect(document.body.classList.contains('has-modal')).toBeFalsy();
27+
});
28+
29+
it('should do nothing when ref not found', () => {
30+
const ref = createRef<HTMLDivElement>();
31+
const otherRef = createRef<HTMLDivElement>();
32+
const manager = new ModalManager();
33+
manager.addModal({
34+
ref: ref
35+
})
36+
37+
manager.removeModal(otherRef);
38+
39+
expect(manager.modals.length).toBe(1);
40+
});
41+
42+
it('should do nothing when the last modal with no backdrop', () => {
43+
const manager = new ModalManager();
44+
manager.addModal({
45+
ref: createRef<HTMLDivElement>(),
46+
backdrop: false
47+
});
48+
manager.removeBackdrop();
49+
50+
expect(document.body.querySelector('.modal-backdrop')).toBeNull();
51+
})
52+
});

src/components/Modal/index.tsx

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

src/components/Modal/modalManager.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { RefObject } from 'react';
2+
3+
export const MODAL_BACKDROP_CLASS = 'modal-backdrop';
4+
5+
export interface StoredModal {
6+
ref: RefObject<HTMLDivElement>;
7+
backdrop?: boolean;
8+
}
9+
10+
export class ModalManager {
11+
modals: StoredModal[] = [];
12+
13+
public addModal(modal: StoredModal): void {
14+
this.modals.push(modal);
15+
16+
if (modal.backdrop) {
17+
this.placeBackdrop();
18+
}
19+
20+
if (!document.body.classList.contains('has-modal')) {
21+
document.body.classList.add('has-modal');
22+
}
23+
}
24+
25+
public removeModal(ref: RefObject<HTMLDivElement>): void {
26+
const index = this.modals.findIndex(
27+
(modal: StoredModal) => modal.ref === ref
28+
);
29+
30+
if (index !== -1) {
31+
this.modals.splice(index, 1);
32+
}
33+
34+
if (this.modals.length === 0) {
35+
document.body.classList.remove('has-modal');
36+
this.removeBackdrop();
37+
} else {
38+
this.placeBackdrop();
39+
}
40+
}
41+
42+
public placeBackdrop(): void {
43+
const backdrop = document.getElementsByClassName(MODAL_BACKDROP_CLASS).item(0);
44+
const topModalWithBackdrop = this.modals.slice().reverse().find((modal: StoredModal) => modal.backdrop);
45+
46+
if (topModalWithBackdrop) {
47+
if (backdrop === null) {
48+
const backdropElement = document.createElement('div');
49+
backdropElement.classList.add(MODAL_BACKDROP_CLASS);
50+
document.body.insertBefore(backdropElement, topModalWithBackdrop.ref.current);
51+
} else {
52+
topModalWithBackdrop.ref.current?.before(backdrop);
53+
}
54+
}
55+
}
56+
57+
public removeBackdrop(): void {
58+
const backdrop = document.getElementsByClassName(MODAL_BACKDROP_CLASS).item(0);
59+
60+
if (backdrop) {
61+
document.body.removeChild(backdrop);
62+
}
63+
}
64+
}

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from './Grid';
55
export * from './Page';
66
export * from './Panel';
77
export * from './Icon';
8+
export * from './Modal';
89
export * from './utils';
910
export * from './TextField';
1011
export * from './SelectField';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { mergeRefs } from '@/components/utils/mergeRefs';
2+
3+
describe('mergeRefs test', () => {
4+
it('should return null when falsy values applied', () => {
5+
const foo = mergeRefs(null);
6+
7+
expect(foo).toBeNull();
8+
});
9+
10+
it('should return first if refs length = 1', () => {
11+
const foo = () => ({});
12+
13+
const ref = mergeRefs(foo);
14+
15+
expect(ref).toBe(foo);
16+
});
17+
18+
it('should handle object and callback refs', () => {
19+
let callbackRef: any = null;
20+
const objectRef = { current: null };
21+
22+
const refs = mergeRefs((node: HTMLDivElement) => {
23+
callbackRef = node;
24+
}, objectRef);
25+
26+
//@ts-ignore
27+
refs(document.createElement('div'));
28+
29+
expect(callbackRef).toBeInstanceOf(HTMLDivElement);
30+
expect(objectRef.current).toBeInstanceOf(HTMLDivElement);
31+
})
32+
33+
it('should do nothing when node is null', () => {
34+
const mockFn = jest.fn();
35+
36+
const refs = mergeRefs(() => {
37+
mockFn();
38+
}, { current: null });
39+
40+
//@ts-ignore
41+
refs(null);
42+
43+
expect(mockFn).not.toHaveBeenCalled();
44+
})
45+
})

src/components/utils/generateGuuid.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const generateGuid = (): string => {
2+
const segment = () => Math.floor(1 + Math.random() * 65536)
3+
.toString(16)
4+
.substring(1);
5+
6+
return `${segment()}${segment()}-`
7+
+ `${segment()}${segment()}-`
8+
+ `${segment()}${segment()}-`
9+
+ `${segment()}${segment()}`;
10+
};
11+
12+
export {
13+
generateGuid
14+
}

0 commit comments

Comments
 (0)