Skip to content

Commit

Permalink
feat: add types for <ModalDialog> and some related components (#3242)
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald authored Oct 17, 2024
1 parent 78c8a06 commit ba29749
Show file tree
Hide file tree
Showing 10 changed files with 145 additions and 101 deletions.
35 changes: 19 additions & 16 deletions src/Modal/ModalContext.jsx → src/Modal/ModalContext.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';

const ModalContext = React.createContext({
interface ContextData {
onClose: () => void;
isOpen: boolean;
isBlocking: boolean;
}

const ModalContext = React.createContext<ContextData>({
onClose: () => {},
isOpen: false,
isBlocking: false,
});

function ModalContextProvider({
onClose, isOpen, isBlocking, children,
onClose,
isOpen,
isBlocking = false,
children = null,
}: {
onClose: () => void;
isOpen: boolean;
isBlocking?: boolean;
children?: React.ReactNode;
}) {
const modalContextValue = useMemo(
const modalContextValue = useMemo<ContextData>(
() => ({ onClose, isOpen, isBlocking }),
[onClose, isOpen, isBlocking],
);
Expand All @@ -20,17 +35,5 @@ function ModalContextProvider({
);
}

ModalContextProvider.propTypes = {
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
isBlocking: PropTypes.bool,
isOpen: PropTypes.bool.isRequired,
};

ModalContextProvider.defaultProps = {
children: null,
isBlocking: false,
};

export { ModalContextProvider };
export default ModalContext;
74 changes: 50 additions & 24 deletions src/Modal/ModalDialog.jsx → src/Modal/ModalDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useMediaQuery } from 'react-responsive';
import ModalLayer from './ModalLayer';
// @ts-ignore for now - this needs to be converted to TypeScript
import ModalCloseButton from './ModalCloseButton';
import ModalDialogHeader from './ModalDialogHeader';
// @ts-ignore for now - this needs to be converted to TypeScript
import ModalDialogTitle from './ModalDialogTitle';
// @ts-ignore for now - this needs to be converted to TypeScript
import ModalDialogFooter from './ModalDialogFooter';
// @ts-ignore for now - this needs to be converted to TypeScript
import ModalDialogBody from './ModalDialogBody';
// @ts-ignore for now - this needs to be converted to TypeScript
import ModalDialogHero from './ModalDialogHero';

import Icon from '../Icon';
Expand All @@ -16,22 +21,57 @@ import { Close } from '../../icons';

export const MODAL_DIALOG_CLOSE_LABEL = 'Close';

interface Props {
/** Specifies the content of the dialog */
children: React.ReactNode;
/** The aria-label of the dialog */
title: string;
/** A callback to close the modal dialog, e.g. when Escape is pressed */
onClose: () => void;
/** Is the modal dialog open or closed? */
isOpen?: boolean;
/** The close 'x' icon button in the top right of the dialog box */
hasCloseButton?: boolean;
/** Size determines the maximum width of the dialog box */
size?: 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
/** The visual style of the dialog box */
variant?: 'default' | 'warning' | 'danger' | 'success' | 'dark';
/** The label supplied to the close icon button if one is rendered */
closeLabel?: string;
/** Specifies class name to append to the base element */
className?: string;
/**
* Determines where a scrollbar should appear if a modal is too large for the
* viewport. When false, the ``ModalDialog``. Body receives a scrollbar, when true
* the browser window itself receives the scrollbar.
*/
isFullscreenScroll?: boolean;
/** To show full screen view on mobile screens */
isFullscreenOnMobile?: boolean;
/** Prevent clicking on the backdrop or pressing Esc to close the modal */
isBlocking?: boolean;
/** Specifies the z-index of the modal */
zIndex?: number;
/** Specifies whether overflow is visible in the modal */
isOverflowVisible?: boolean;
}

function ModalDialog({
children,
title,
isOpen,
isOpen = false,
onClose,
size,
variant,
hasCloseButton,
closeLabel,
isFullscreenScroll,
size = 'md',
variant = 'default',
hasCloseButton = true,
closeLabel = MODAL_DIALOG_CLOSE_LABEL,
isFullscreenScroll = false,
className,
isFullscreenOnMobile,
isBlocking,
isFullscreenOnMobile = false,
isBlocking = false,
zIndex,
isOverflowVisible,
}) {
isOverflowVisible = true,
}: Props) {
const isMobile = useMediaQuery({ query: '(max-width: 767.98px)' });
const showFullScreen = (isFullscreenOnMobile && isMobile);
return (
Expand Down Expand Up @@ -126,20 +166,6 @@ ModalDialog.propTypes = {
isOverflowVisible: PropTypes.bool,
};

ModalDialog.defaultProps = {
isOpen: false,
hasCloseButton: true,
size: 'md',
variant: 'default',
closeLabel: MODAL_DIALOG_CLOSE_LABEL,
className: undefined,
isFullscreenScroll: false,
isFullscreenOnMobile: false,
isBlocking: false,
zIndex: undefined,
isOverflowVisible: true,
};

ModalDialog.Header = ModalDialogHeader;
ModalDialog.Title = ModalDialogTitle;
ModalDialog.Footer = ModalDialogFooter;
Expand Down
28 changes: 17 additions & 11 deletions src/Modal/ModalDialogHeader.jsx → src/Modal/ModalDialogHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
/* eslint-disable react/require-default-props */
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import type { ComponentWithAsProp } from '../utils/types/bootstrap';

function ModalDialogHeader({
as,
export interface Props {
as?: string;
children: React.ReactNode;
className?: string;
}

type HeaderType = ComponentWithAsProp<'div', Props>;

const ModalDialogHeader: HeaderType = React.forwardRef<HTMLDivElement, Props>(({
as = 'div',
children,
...props
}) {
return React.createElement(
}, ref) => (
React.createElement(
as,
{
...props,
ref,
className: classNames('pgn__modal-header', props.className),
},
children,
);
}
)
));

ModalDialogHeader.propTypes = {
/** Specifies the base element */
Expand All @@ -26,9 +37,4 @@ ModalDialogHeader.propTypes = {
className: PropTypes.string,
};

ModalDialogHeader.defaultProps = {
as: 'div',
className: undefined,
};

export default ModalDialogHeader;
34 changes: 17 additions & 17 deletions src/Modal/ModalLayer.jsx → src/Modal/ModalLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Portal from './Portal';
import { ModalContextProvider } from './ModalContext';

// istanbul ignore next
function ModalBackdrop({ onClick }) {
function ModalBackdrop({ onClick }: { onClick?: () => void }) {
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
Expand All @@ -22,22 +22,27 @@ ModalBackdrop.propTypes = {
onClick: PropTypes.func,
};

ModalBackdrop.defaultProps = {
onClick: undefined,
};

// istanbul ignore next
function ModalContentContainer({ children }) {
function ModalContentContainer({ children = null }: { children?: React.ReactNode }) {
return <div className="pgn__modal-content-container">{children}</div>;
}

ModalContentContainer.propTypes = {
children: PropTypes.node,
};

ModalContentContainer.defaultProps = {
children: null,
};
interface Props {
/** Specifies the contents of the modal */
children: React.ReactNode;
/** A callback function for when the modal is dismissed */
onClose: () => void;
/** Is the modal dialog open or closed */
isOpen: boolean;
/** Prevent clicking on the backdrop or pressing Esc to close the modal */
isBlocking?: boolean;
/** Specifies the z-index of the modal */
zIndex?: number;
}

/**
* The ModalLayer should be used for any component that wishes to engage the user
Expand All @@ -46,8 +51,8 @@ ModalContentContainer.defaultProps = {
* component is that if a modal object is visible then it is "enabled"
*/
function ModalLayer({
children, onClose, isOpen, isBlocking, zIndex,
}) {
children, onClose, isOpen, isBlocking = false, zIndex,
}: Props) {
useEffect(() => {
if (isOpen) {
document.body.classList.add('pgn__hidden-scroll-padding-right');
Expand All @@ -63,7 +68,7 @@ function ModalLayer({
return null;
}

const handleClose = isBlocking ? null : onClose;
const handleClose = isBlocking ? undefined : onClose;

return (
<ModalContextProvider onClose={onClose} isOpen={isOpen} isBlocking={isBlocking}>
Expand Down Expand Up @@ -102,10 +107,5 @@ ModalLayer.propTypes = {
zIndex: PropTypes.number,
};

ModalLayer.defaultProps = {
isBlocking: false,
zIndex: undefined,
};

export { ModalBackdrop, ModalContentContainer };
export default ModalLayer;
17 changes: 10 additions & 7 deletions src/Modal/Portal.jsx → src/Modal/Portal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

class Portal extends React.Component {
constructor(props) {
interface Props {
children: React.ReactNode;
}

class Portal extends React.Component<Props> {
private rootName: string;

private rootElement: HTMLElement | null;

constructor(props: Props) {
super(props);
this.rootName = 'paragon-portal-root';
// istanbul ignore if
Expand Down Expand Up @@ -31,8 +38,4 @@ class Portal extends React.Component {
}
}

Portal.propTypes = {
children: PropTypes.node.isRequired,
};

export default Portal;
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,6 @@ import { render, screen } from '@testing-library/react';

import ModalDialog from '../ModalDialog';

jest.mock('../ModalLayer', () => function ModalLayerMock(props) {
// eslint-disable-next-line react/prop-types
const { children, ...otherProps } = props;
return (
<modal-layer {...otherProps}>
{children}
</modal-layer>
);
});

describe('ModalDialog', () => {
it('renders a dialog with aria-label and content', () => {
const onClose = jest.fn();
Expand Down Expand Up @@ -45,6 +35,22 @@ describe('ModalDialog', () => {
expect(dialogNode).toHaveAttribute('aria-label', 'My dialog');
expect(screen.getByText('The content')).toBeInTheDocument();
});

it('is hidden by default', () => {
const onClose = jest.fn();
render(
<ModalDialog
title="My dialog"
onClose={onClose}
>
<ModalDialog.Header><ModalDialog.Title>The title</ModalDialog.Title></ModalDialog.Header>
<ModalDialog.Body><p>The hidden content</p></ModalDialog.Body>
<ModalDialog.Footer><ModalDialog.CloseButton>Cancel</ModalDialog.CloseButton></ModalDialog.Footer>
</ModalDialog>,
);

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});

describe('ModalDialog with Hero', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ import userEvent from '@testing-library/user-event';
import ModalLayer from '../ModalLayer';

/* eslint-disable react/prop-types */
jest.mock('../Portal', () => function PortalMock(props) {
jest.mock('../Portal', () => function PortalMock(props: any) {
const { children, ...otherProps } = props;
return (
<paragon-portal {...otherProps}>
{children}
</paragon-portal>
// @ts-ignore this fake element. (Property 'paragon-portal' does not exist on type 'JSX.IntrinsicElements')
<paragon-portal {...otherProps}>{children}</paragon-portal>
);
});

jest.mock('react-focus-on', () => ({
FocusOn: jest.fn().mockImplementation((props) => {
const { children, ...otherProps } = props;
return (
// @ts-ignore this fake element. (Property 'focus-on' does not exist on type 'JSX.IntrinsicElements')
<focus-on data-testid="focus-on" {...otherProps}>{children}</focus-on>
);
}),
Expand Down Expand Up @@ -117,7 +117,7 @@ describe('<ModalLayer />', () => {
);
expect(FocusOn).toHaveBeenCalledWith(
expect.objectContaining({
onEscapeKey: null,
onEscapeKey: undefined,
}),
// note: this 2nd function argument represents the
// `refOrContext` (in this case, the context value
Expand Down
Loading

0 comments on commit ba29749

Please sign in to comment.