Skip to content

Feature/modals #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Dec 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { createPortal } from 'react-dom';
import { useEffect, useRef, useState } from 'react';
import { mergeRefs } from '@/components/utils/mergeRefs';
import { ModalManager } from '@/components/Modal/modalManager';

let modalManager: ModalManager;

export const getManager = (): ModalManager => {
if (!modalManager) {
modalManager = new ModalManager();
}

return modalManager;
}

export interface ModalProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Indicates whether the modal should be shown
*/
show?: boolean;
/**
* Hook called when modal tries to hide
*/
onHide?: () => void;
/**
* Indicates whether the modal should be horizontally centered. Default: true
*/
horizontallyCentered?: boolean;
/**
* Indicates whether the modal uses a darkened backdrop. Default: true
*/
backdrop?: boolean
}

const Modal = React.forwardRef<HTMLDivElement, ModalProps>((
{
backdrop = true,
children,
horizontallyCentered = true,
onHide,
show,
},
ref
): React.ReactElement => {
const modalRef = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);

const hideModal = (event?: React.MouseEvent): void => {
if (event && event.target !== modalRef.current) {
return;
}

setShown(false);
getManager().removeModal(modalRef);
if (onHide) {
onHide();
}
}

const showModal = (): void => {
setShown(true);
getManager().addModal({
ref: modalRef,
backdrop
})
}

useEffect((): void => {
if (!shown && show) {
showModal();
} else if (shown && !show) {
hideModal();
}
}, [show]);

return createPortal(
show ? (
<div
ref={mergeRefs(ref, modalRef)}
className={clsx(
'modal-container'
)}
onClick={hideModal}
role="dialog"
aria-modal={true}
>
<div
className={clsx(
'modal-content',
horizontallyCentered && 'horizontal-center',
)}
>
{children}
</div>
</div>
) : undefined,
document.body
);
});

Modal.displayName = 'Modal';
Modal.propTypes = {
backdrop: PropTypes.bool,
children: PropTypes.node,
onHide: PropTypes.func,
show: PropTypes.bool,
horizontallyCentered: PropTypes.bool
}

export default Modal;
79 changes: 79 additions & 0 deletions src/components/Modal/__tests__/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import { mount } from 'enzyme';
import { Modal } from '@/components';
import { ModalProps } from '@/components/Modal/Modal';

describe('Test modal', function (): void {
it('should render modal', (): void => {
const modal = mount(
<div>
<Modal show={true} />
</div>
)

expect(modal.find('.modal-container').length).toBe(1);
});

it('should hide modal when show prop changes to false', (): void => {
const modal = mount<ModalProps>(
<Modal show={true} />
);

modal.setProps({
show: false
})

expect(modal.find('.modal-container').length).toBe(0);
})

it('should hide the modal on container click', (): void => {
const mockFn = jest.fn();

const modal = mount<ModalProps>(
<Modal
show={true}
onHide={mockFn}
/>
);

modal.find('.modal-container').simulate('click');
modal.setProps({
show: false
});

expect(modal.find('.modal-container').length).toBe(0);
expect(mockFn).toHaveBeenCalled();
})

it('should not hide modal when clicked on modal', (): void => {
const mockFn = jest.fn();

const modal = mount<ModalProps>(
<Modal
show={true}
onHide={mockFn}
/>
);

modal.find('.modal-content').simulate('click');

expect(mockFn).not.toHaveBeenCalled();
})

it('should ignore show prop change when shown state is the same', (): void => {
const mockFn = jest.fn();

const modal = mount<ModalProps>(
<Modal
show={false}
onHide={mockFn}
/>
);

modal.setProps({
show: false
});

expect(mockFn).not.toHaveBeenCalled();
});
});
52 changes: 52 additions & 0 deletions src/components/Modal/__tests__/modalManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ModalManager } from '@/components/Modal/modalManager';
import { createRef } from 'react';

describe('Test modalManager', function () {
it('should add backdrop if modal needs it', () => {
const manager = new ModalManager();
manager.addModal({
ref: createRef(),
backdrop: true
})

expect(document.body.querySelector('.modal-backdrop')).not.toBeNull();
});

it('should add and remove class from body', () => {
const ref = createRef<HTMLDivElement>();
const manager = new ModalManager();
manager.addModal({
ref: ref
})

expect(document.body.classList.contains('has-modal')).toBeTruthy();

manager.removeModal(ref);

expect(document.body.classList.contains('has-modal')).toBeFalsy();
});

it('should do nothing when ref not found', () => {
const ref = createRef<HTMLDivElement>();
const otherRef = createRef<HTMLDivElement>();
const manager = new ModalManager();
manager.addModal({
ref: ref
})

manager.removeModal(otherRef);

expect(manager.modals.length).toBe(1);
});

it('should do nothing when the last modal with no backdrop', () => {
const manager = new ModalManager();
manager.addModal({
ref: createRef<HTMLDivElement>(),
backdrop: false
});
manager.removeBackdrop();

expect(document.body.querySelector('.modal-backdrop')).toBeNull();
})
});
1 change: 1 addition & 0 deletions src/components/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Modal } from './Modal';
64 changes: 64 additions & 0 deletions src/components/Modal/modalManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { RefObject } from 'react';

export const MODAL_BACKDROP_CLASS = 'modal-backdrop';

export interface StoredModal {
ref: RefObject<HTMLDivElement>;
backdrop?: boolean;
}

export class ModalManager {
modals: StoredModal[] = [];

public addModal(modal: StoredModal): void {
this.modals.push(modal);

if (modal.backdrop) {
this.placeBackdrop();
}

if (!document.body.classList.contains('has-modal')) {
document.body.classList.add('has-modal');
}
}

public removeModal(ref: RefObject<HTMLDivElement>): void {
const index = this.modals.findIndex(
(modal: StoredModal) => modal.ref === ref
);

if (index !== -1) {
this.modals.splice(index, 1);
}

if (this.modals.length === 0) {
document.body.classList.remove('has-modal');
this.removeBackdrop();
} else {
this.placeBackdrop();
}
}

public placeBackdrop(): void {
const backdrop = document.getElementsByClassName(MODAL_BACKDROP_CLASS).item(0);
const topModalWithBackdrop = this.modals.slice().reverse().find((modal: StoredModal) => modal.backdrop);

if (topModalWithBackdrop) {
if (backdrop === null) {
const backdropElement = document.createElement('div');
backdropElement.classList.add(MODAL_BACKDROP_CLASS);
document.body.insertBefore(backdropElement, topModalWithBackdrop.ref.current);
} else {
topModalWithBackdrop.ref.current?.before(backdrop);
}
}
}

public removeBackdrop(): void {
const backdrop = document.getElementsByClassName(MODAL_BACKDROP_CLASS).item(0);

if (backdrop) {
document.body.removeChild(backdrop);
}
}
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './Grid';
export * from './Page';
export * from './Panel';
export * from './Icon';
export * from './Modal';
export * from './utils';
export * from './TextField';
export * from './SelectField';
Expand Down
45 changes: 45 additions & 0 deletions src/components/utils/__tests__/mergeRefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { mergeRefs } from '@/components/utils/mergeRefs';

describe('mergeRefs test', () => {
it('should return null when falsy values applied', () => {
const foo = mergeRefs(null);

expect(foo).toBeNull();
});

it('should return first if refs length = 1', () => {
const foo = () => ({});

const ref = mergeRefs(foo);

expect(ref).toBe(foo);
});

it('should handle object and callback refs', () => {
let callbackRef: any = null;
const objectRef = { current: null };

const refs = mergeRefs((node: HTMLDivElement) => {
callbackRef = node;
}, objectRef);

//@ts-ignore
refs(document.createElement('div'));

expect(callbackRef).toBeInstanceOf(HTMLDivElement);
expect(objectRef.current).toBeInstanceOf(HTMLDivElement);
})

it('should do nothing when node is null', () => {
const mockFn = jest.fn();

const refs = mergeRefs(() => {
mockFn();
}, { current: null });

//@ts-ignore
refs(null);

expect(mockFn).not.toHaveBeenCalled();
})
})
14 changes: 14 additions & 0 deletions src/components/utils/generateGuuid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const generateGuid = (): string => {
const segment = () => Math.floor(1 + Math.random() * 65536)
.toString(16)
.substring(1);

return `${segment()}${segment()}-`
+ `${segment()}${segment()}-`
+ `${segment()}${segment()}-`
+ `${segment()}${segment()}`;
};

export {
generateGuid
}
Loading