Skip to content

Commit

Permalink
feat(modal): major refactor and features
Browse files Browse the repository at this point in the history
- refactor to new style
- use isCSSModule to genClassNames
- memoize effects, callbacks
- feat: new prop to set modal overlay color
- feat: allow setting a custom max-width to size prop
  • Loading branch information
unleashit committed Jan 25, 2024
1 parent 79ffef9 commit 0a5a704
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 112 deletions.
13 changes: 13 additions & 0 deletions demos/frontend/src/components/modal/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.button {
color: #fff;
text-transform: uppercase;
background-color: #494949;
border: 0;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
}

.button:hover {
background-color: #777777;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ exports[`<Modal /> renders when isOpen property is true 1`] = `
onClick={[Function]}
style={
{
"backgroundColor": undefined,
"backgroundColor": "rgba(0,0,0,.8)",
"zIndex": "auto",
}
}
Expand Down
46 changes: 23 additions & 23 deletions packages/modal/src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { shallow } from 'enzyme';
import * as React from 'react';

import { ModalFooter, ModalHeader } from '../defaults/components';

describe('<Modal />', () => {
let wrapper: any;

it('<ModalHeader/> renders with title prop', () => {
wrapper = shallow(<ModalHeader title="Login" />);

expect(wrapper.find('h3')).toHaveLength(1);
expect(wrapper).toMatchSnapshot();

expect(wrapper.find('h3').text()).toEqual('Login');
});

it('<ModalFooter/> renders with title prop', () => {
wrapper = shallow(<ModalFooter title={"I'm a footer"} />);

expect(wrapper.text()).toEqual("I'm a footer");
});
});
// import { shallow } from 'enzyme';
// import * as React from 'react';
//
// import { DefaultFooter, DefaultHeader } from '../defaults/components';
//
// describe('<Modal />', () => {
// let wrapper: any;
//
// it('<ModalHeader/> renders with title prop', () => {
// wrapper = shallow(<DefaultHeader title="Login" />);
//
// expect(wrapper.find('h3')).toHaveLength(1);
// expect(wrapper).toMatchSnapshot();
//
// expect(wrapper.find('h3').text()).toEqual('Login');
// });
//
// it('<ModalFooter/> renders with title prop', () => {
// wrapper = shallow(<DefaultFooter title={"I'm a footer"} />);
//
// expect(wrapper.text()).toEqual("I'm a footer");
// });
// });
19 changes: 0 additions & 19 deletions packages/modal/src/defaults/components.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion packages/modal/src/images/icons.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as React from 'react';
import React from 'react';

export const closeIcon = (
<svg x="0px" y="0px" width="12px" height="12px" viewBox="0 0 348.333 348.334">
Expand Down
141 changes: 73 additions & 68 deletions packages/modal/src/modal.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
import React, {
useState,
useCallback,
useMemo,
useRef,
useLayoutEffect,
} from 'react';
import {
utils,
useHandleEscapeKey,
useHighestZindex,
useToggleBodyStyleProp,
} from '@unleashit/common';
import * as React from 'react';

import {
ModalFooter,
ModalHeader,
CustomHeaderFooterProps,
} from './defaults/components';
import { closeIcon } from './images/icons';

const cssUnits = ['px', '%', 'em', 'rem', 'vw', 'vh', 'vmin', 'vmax'] as const;

export interface ModalProps {
isOpen: boolean;
size?: 'small' | 'medium' | 'large' | 'full';
size?:
| 'small'
| 'medium'
| 'large'
| 'full'
| `${number}${(typeof cssUnits)[number]}`;
onClose?: () => void;
closeOnOverlayClick?: boolean;
animationSupport?: boolean;
animationCloseTimeout?: number;
header?: React.FC<CustomHeaderFooterProps> | React.ReactElement | string;
footer?: React.FC<CustomHeaderFooterProps> | string;
overlayColor?: string;
header?: React.FC<any> | string;
footer?: React.FC<any> | string;
overlayColor?: string | false | null;
closeBtn?: boolean;
cssModule?: { [key: string]: string };
cssModule?: Record<string, string>;
children?: React.ReactNode;
}

const { isCSSModule, returnComponentFormat } = utils;
const { genClassNames } = utils;

export const Modal = ({
isOpen = false,
Expand All @@ -40,16 +47,16 @@ export const Modal = ({
animationCloseTimeout = 300,
header: Header,
footer: Footer,
overlayColor,
cssModule: theme = {},
overlayColor = 'rgba(0,0,0,.8)',
cssModule = {},
children,
}: ModalProps) => {
const [isHidden, setIsHidden] = React.useState(!isOpen);
const [isAnimated, setIsAnimated] = React.useState(isOpen);
const timeoutRef = React.useRef<number>();
const [isHidden, setIsHidden] = useState(!isOpen);
const [isAnimated, setIsAnimated] = useState(isOpen);
const timeoutRef = useRef<number>();

// allow time for animation after modal is opened/closed
React.useLayoutEffect(() => {
useLayoutEffect(() => {
if (isOpen) {
setIsHidden(false);
if (animationSupport) {
Expand All @@ -67,93 +74,91 @@ export const Modal = ({
setIsHidden(true);
}

// clear the above timeout on unmount
return () => window.clearTimeout(timeoutRef.current);
}, [animationCloseTimeout, animationSupport, isOpen]);

// clear the above timeout on unmount
// React.useEffect(() => () => window.clearTimeout(timeoutRef.current), []);

// add overflow: hidden to <body> when modal is active
useToggleBodyStyleProp('overflow', 'hidden', isOpen);

// returns highest z-index in use + 1 or 'auto'
const modalZindex = useHighestZindex();

// close the modal when user clicks on the overlay
const handleOverlayClick = (e: React.MouseEvent): any => {
if (!closeOnOverlayClick) return;

e.stopPropagation();
const isContainer = (e.target as HTMLDivElement).getAttribute('data-modal');
if (isContainer) {
onClose();
}
};

// close the modal when user clicks esc key
useHandleEscapeKey(isOpen, onClose);

const userComponent = (
// eslint-disable-next-line @typescript-eslint/ban-types
C: React.ReactElement | Function | string | undefined,
Default: React.FC<any>,
) => {
if (!C) return null;
if (typeof C === 'string') {
return <Default theme={theme} title={C} />;
}
const { clsName } = React.useMemo(
() => genClassNames(Modal.displayName, cssModule),
[cssModule],
);

return returnComponentFormat(C, { theme });
};
// close the modal when user clicks on the overlay
const handleOverlayClick = useCallback(
(e: React.MouseEvent): any => {
if (!closeOnOverlayClick) return;

e.stopPropagation();
const isContainer = (e.target as HTMLDivElement).getAttribute(
'data-modal',
);
if (isContainer) {
onClose();
}
},
[closeOnOverlayClick, onClose],
);

const customWidth = useMemo(
() =>
cssUnits.some((unit) => size.endsWith(unit))
? { maxWidth: size }
: undefined,
[size],
);

return !isHidden ? (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
onClick={handleOverlayClick}
data-modal="true"
className={isCSSModule(theme.overlay, 'unl-modal__overlay')}
style={{ backgroundColor: overlayColor, zIndex: modalZindex }}
className={clsName('overlay')}
style={{
backgroundColor: !overlayColor ? 'transparent' : overlayColor,
zIndex: modalZindex,
}}
>
<div
className={`${isCSSModule(
theme.child,
`unl-modal__child`,
)} ${isCSSModule(
theme[`child--${size}`],
`unl-modal__child--${size}`,
)} ${
animationSupport && isAnimated
? `${isCSSModule(theme[`child--in`], 'unl-modal__child--in')}`
: ''
}`}
className={`${clsName('child')}${
customWidth ? '' : ` ${clsName(`child--${size}`)}`
} ${animationSupport && isAnimated ? `${clsName('child--in')}` : ''}`}
style={customWidth}
>
{closeBtn && (
<div className={isCSSModule(theme.close, 'unl-modal__close')}>
<div className={clsName('close')}>
<button
onClick={onClose}
type="button"
className={isCSSModule(theme.closeBtn, 'unl-modal__close-btn')}
className={clsName('closeBtn')}
>
{closeIcon}
</button>
</div>
)}
{Header && (
<div className={isCSSModule(theme.header, 'unl-modal__header')}>
{userComponent(Header, ModalHeader)}
</div>
<header className={clsName('header')}>
{typeof Header === 'string' ? Header : <Header />}
</header>
)}
<div className={isCSSModule(theme.body, 'unl-modal__body')}>
{children}
</div>
<div className={clsName('body')}>{children}</div>
{Footer && (
<div className={isCSSModule(theme.footer, 'unl-modal__footer')}>
{userComponent(Footer, ModalFooter)}
</div>
<footer className={clsName('footer')}>
{typeof Footer === 'string' ? Footer : <Footer />}
</footer>
)}
</div>
</div>
) : null;
};

Modal.displayName = 'modal';
export default Modal;

0 comments on commit 0a5a704

Please sign in to comment.