diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..50ba66f --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -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 { + /** + * 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(( + { + backdrop = true, + children, + horizontallyCentered = true, + onHide, + show, + }, + ref +): React.ReactElement => { + const modalRef = useRef(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 ? ( +
+
+ {children} +
+
+ ) : 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; diff --git a/src/components/Modal/__tests__/Modal.test.tsx b/src/components/Modal/__tests__/Modal.test.tsx new file mode 100644 index 0000000..6f8f84c --- /dev/null +++ b/src/components/Modal/__tests__/Modal.test.tsx @@ -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( +
+ +
+ ) + + expect(modal.find('.modal-container').length).toBe(1); + }); + + it('should hide modal when show prop changes to false', (): void => { + const modal = mount( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + modal.setProps({ + show: false + }); + + expect(mockFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Modal/__tests__/modalManager.test.ts b/src/components/Modal/__tests__/modalManager.test.ts new file mode 100644 index 0000000..f942bce --- /dev/null +++ b/src/components/Modal/__tests__/modalManager.test.ts @@ -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(); + 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(); + const otherRef = createRef(); + 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(), + backdrop: false + }); + manager.removeBackdrop(); + + expect(document.body.querySelector('.modal-backdrop')).toBeNull(); + }) +}); diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx new file mode 100644 index 0000000..c6b3568 --- /dev/null +++ b/src/components/Modal/index.tsx @@ -0,0 +1 @@ +export { default as Modal } from './Modal'; diff --git a/src/components/Modal/modalManager.ts b/src/components/Modal/modalManager.ts new file mode 100644 index 0000000..c6a7e5a --- /dev/null +++ b/src/components/Modal/modalManager.ts @@ -0,0 +1,64 @@ +import { RefObject } from 'react'; + +export const MODAL_BACKDROP_CLASS = 'modal-backdrop'; + +export interface StoredModal { + ref: RefObject; + 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): 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); + } + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 41a7ea9..b8208c2 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'; diff --git a/src/components/utils/__tests__/mergeRefs.test.ts b/src/components/utils/__tests__/mergeRefs.test.ts new file mode 100644 index 0000000..9884903 --- /dev/null +++ b/src/components/utils/__tests__/mergeRefs.test.ts @@ -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(); + }) +}) diff --git a/src/components/utils/generateGuuid.ts b/src/components/utils/generateGuuid.ts new file mode 100644 index 0000000..964a683 --- /dev/null +++ b/src/components/utils/generateGuuid.ts @@ -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 +} diff --git a/src/components/utils/mergeRefs.ts b/src/components/utils/mergeRefs.ts new file mode 100644 index 0000000..05c4b1e --- /dev/null +++ b/src/components/utils/mergeRefs.ts @@ -0,0 +1,26 @@ +import { MutableRefObject, RefCallback } from 'react'; + +type Ref = MutableRefObject | RefCallback; + +const mergeRefs = (...refs: (Ref | null)[]): Ref | null => { + // @ts-ignore + const filteredRefs: Exclude | null, null>[] = refs.filter(Boolean); + if (!filteredRefs.length) return null; + if (filteredRefs.length === 1) return filteredRefs[0]; + + return (current: T | null): void => { + if (current !== null) { + filteredRefs.forEach((filteredRef: Ref): void => { + if (typeof filteredRef === 'function') { + filteredRef(current); + } else { + filteredRef.current = current; + } + }) + } + } +} + +export { + mergeRefs +} diff --git a/src/style/components/_modal.scss b/src/style/components/_modal.scss new file mode 100644 index 0000000..fdfe593 --- /dev/null +++ b/src/style/components/_modal.scss @@ -0,0 +1,32 @@ +.has-modal { + overflow: hidden; +} +.modal-backdrop { + position: fixed; + height: 100vh; + width: 100vw; + background: rgba(0, 0, 0, .5); + top: 0; + left: 0; +} + +.modal-container { + position: fixed; + height: 100%; + width: 100vw; + top: 0; + left: 0; + display: block; + overflow: auto; + + .modal-content { + max-width: 500px; + margin-top: var(--base-gutter); + margin-bottom: var(--base-gutter); + + &.horizontal-center { + margin-left: auto; + margin-right: auto; + } + } +} diff --git a/src/style/index.scss b/src/style/index.scss index 251cabe..cc5e4be 100644 --- a/src/style/index.scss +++ b/src/style/index.scss @@ -17,6 +17,7 @@ @use "components/tags"; @use "components/overlay"; @use "components/tooltips"; +@use "components/modal"; /** * 3. Layout diff --git a/www/src/index.tsx b/www/src/index.tsx index a276c94..a49e321 100644 --- a/www/src/index.tsx +++ b/www/src/index.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import * as ReactDom from 'react-dom'; import '../../src/style/index.scss'; -import { Button, Card, Col, Grid, Icon, Page, Row, SelectField, Tag, TextField, Variant } from '../../src'; +import { Button, Card, Col, Grid, Icon, Modal, Page, Row, SelectField, Tag, TextField, Variant } from '../../src'; import Container from '../../src/components/Container/Container'; import FormGroup from '../../src/components/Form/Group'; import FormLabel from '../../src/components/Form/Label'; @@ -32,6 +32,79 @@ const TestControllable = () => { ); } +const TestModals = () => { + const [modalOpen, setModalOpen] = useState(false); + const [secondModalOpen, setSecondModalOpen] = useState(false); + const [thirdModalOpen, setThirdModalOpen] = useState(false); + + return ( + <> + + { + setModalOpen(false); + }} + > + + + + { + setSecondModalOpen(false); + }} + backdrop={false} + > + + + + Content + + + + + Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! Lorem ipsum dolor sit amet, consectetur adipisicing elit. Beatae blanditiis commodi dicta distinctio fugiat labore laboriosam nisi nobis, possimus quasi recusandae repellendus repudiandae tempore. A deleniti omnis perspiciatis quos vero! + + + + + ) +}; + ReactDom.render( @@ -302,6 +375,14 @@ ReactDom.render( + + + + + + + +