Skip to content
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

feat: add types for <ModalDialog> and some related components #3242

Merged
merged 2 commits into from
Oct 17, 2024
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
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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fixing a minor type error - handleClose was getting passed to <FocusOn> via onEscapeKey but was sometimes null, whereas onEscapeKey expects either a function or undefined, not null.

Type 'null' is not assignable to type '((event: Event) => void) | undefined'.ts(2322)
types.d.ts(15, 5): The expected type comes from property 'onEscapeKey'


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
Loading