From ba29749d7580dc2f8fbe00445655bf33d431b5f8 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 17 Oct 2024 09:18:35 -0700 Subject: [PATCH] feat: add types for and some related components (#3242) --- .../{ModalContext.jsx => ModalContext.tsx} | 35 +++++---- .../{ModalDialog.jsx => ModalDialog.tsx} | 74 +++++++++++++------ ...DialogHeader.jsx => ModalDialogHeader.tsx} | 28 ++++--- src/Modal/{ModalLayer.jsx => ModalLayer.tsx} | 34 ++++----- src/Modal/{Portal.jsx => Portal.tsx} | 17 +++-- ...alDialog.test.jsx => ModalDialog.test.tsx} | 26 ++++--- ...odalLayer.test.jsx => ModalLayer.test.tsx} | 10 +-- .../{Portal.test.jsx => Portal.test.tsx} | 6 +- src/index.d.ts | 8 +- src/index.js | 8 +- 10 files changed, 145 insertions(+), 101 deletions(-) rename src/Modal/{ModalContext.jsx => ModalContext.tsx} (50%) rename src/Modal/{ModalDialog.jsx => ModalDialog.tsx} (64%) rename src/Modal/{ModalDialogHeader.jsx => ModalDialogHeader.tsx} (57%) rename src/Modal/{ModalLayer.jsx => ModalLayer.tsx} (79%) rename src/Modal/{Portal.jsx => Portal.tsx} (80%) rename src/Modal/tests/{ModalDialog.test.jsx => ModalDialog.test.tsx} (80%) rename src/Modal/tests/{ModalLayer.test.jsx => ModalLayer.test.tsx} (90%) rename src/Modal/tests/{Portal.test.jsx => Portal.test.tsx} (83%) diff --git a/src/Modal/ModalContext.jsx b/src/Modal/ModalContext.tsx similarity index 50% rename from src/Modal/ModalContext.jsx rename to src/Modal/ModalContext.tsx index f374c3ee6f..a9bdd3b702 100644 --- a/src/Modal/ModalContext.jsx +++ b/src/Modal/ModalContext.tsx @@ -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({ 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( () => ({ onClose, isOpen, isBlocking }), [onClose, isOpen, isBlocking], ); @@ -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; diff --git a/src/Modal/ModalDialog.jsx b/src/Modal/ModalDialog.tsx similarity index 64% rename from src/Modal/ModalDialog.jsx rename to src/Modal/ModalDialog.tsx index 6814b3c22e..6ad659ca06 100644 --- a/src/Modal/ModalDialog.jsx +++ b/src/Modal/ModalDialog.tsx @@ -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'; @@ -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 ( @@ -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; diff --git a/src/Modal/ModalDialogHeader.jsx b/src/Modal/ModalDialogHeader.tsx similarity index 57% rename from src/Modal/ModalDialogHeader.jsx rename to src/Modal/ModalDialogHeader.tsx index 0a0ff4ca9a..9299db8295 100644 --- a/src/Modal/ModalDialogHeader.jsx +++ b/src/Modal/ModalDialogHeader.tsx @@ -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(({ + 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 */ @@ -26,9 +37,4 @@ ModalDialogHeader.propTypes = { className: PropTypes.string, }; -ModalDialogHeader.defaultProps = { - as: 'div', - className: undefined, -}; - export default ModalDialogHeader; diff --git a/src/Modal/ModalLayer.jsx b/src/Modal/ModalLayer.tsx similarity index 79% rename from src/Modal/ModalLayer.jsx rename to src/Modal/ModalLayer.tsx index 1cc38cf0f4..dc74923403 100644 --- a/src/Modal/ModalLayer.jsx +++ b/src/Modal/ModalLayer.tsx @@ -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
{children}
; } @@ -35,9 +31,18 @@ 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 @@ -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'); @@ -63,7 +68,7 @@ function ModalLayer({ return null; } - const handleClose = isBlocking ? null : onClose; + const handleClose = isBlocking ? undefined : onClose; return ( @@ -102,10 +107,5 @@ ModalLayer.propTypes = { zIndex: PropTypes.number, }; -ModalLayer.defaultProps = { - isBlocking: false, - zIndex: undefined, -}; - export { ModalBackdrop, ModalContentContainer }; export default ModalLayer; diff --git a/src/Modal/Portal.jsx b/src/Modal/Portal.tsx similarity index 80% rename from src/Modal/Portal.jsx rename to src/Modal/Portal.tsx index cf30fa1c30..d06dc2aa5e 100644 --- a/src/Modal/Portal.jsx +++ b/src/Modal/Portal.tsx @@ -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 { + private rootName: string; + + private rootElement: HTMLElement | null; + + constructor(props: Props) { super(props); this.rootName = 'paragon-portal-root'; // istanbul ignore if @@ -31,8 +38,4 @@ class Portal extends React.Component { } } -Portal.propTypes = { - children: PropTypes.node.isRequired, -}; - export default Portal; diff --git a/src/Modal/tests/ModalDialog.test.jsx b/src/Modal/tests/ModalDialog.test.tsx similarity index 80% rename from src/Modal/tests/ModalDialog.test.jsx rename to src/Modal/tests/ModalDialog.test.tsx index 6ca06dbdcc..93759521eb 100644 --- a/src/Modal/tests/ModalDialog.test.jsx +++ b/src/Modal/tests/ModalDialog.test.tsx @@ -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 ( - - {children} - - ); -}); - describe('ModalDialog', () => { it('renders a dialog with aria-label and content', () => { const onClose = jest.fn(); @@ -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( + + The title +

The hidden content

+ Cancel +
, + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); }); describe('ModalDialog with Hero', () => { diff --git a/src/Modal/tests/ModalLayer.test.jsx b/src/Modal/tests/ModalLayer.test.tsx similarity index 90% rename from src/Modal/tests/ModalLayer.test.jsx rename to src/Modal/tests/ModalLayer.test.tsx index bc93b66013..f316086cea 100644 --- a/src/Modal/tests/ModalLayer.test.jsx +++ b/src/Modal/tests/ModalLayer.test.tsx @@ -6,12 +6,11 @@ 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 ( - - {children} - + // @ts-ignore this fake element. (Property 'paragon-portal' does not exist on type 'JSX.IntrinsicElements') + {children} ); }); @@ -19,6 +18,7 @@ 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') {children} ); }), @@ -117,7 +117,7 @@ describe('', () => { ); expect(FocusOn).toHaveBeenCalledWith( expect.objectContaining({ - onEscapeKey: null, + onEscapeKey: undefined, }), // note: this 2nd function argument represents the // `refOrContext` (in this case, the context value diff --git a/src/Modal/tests/Portal.test.jsx b/src/Modal/tests/Portal.test.tsx similarity index 83% rename from src/Modal/tests/Portal.test.jsx rename to src/Modal/tests/Portal.test.tsx index 0f3ec32d70..7cda3ac9e0 100644 --- a/src/Modal/tests/Portal.test.jsx +++ b/src/Modal/tests/Portal.test.tsx @@ -21,7 +21,7 @@ describe('', () => { const portalRoot = getPortalRoot(); expect(portalRoot).not.toBeNull(); - expect(portalRoot.children[0].id).toBe('portal-content-a'); + expect(portalRoot!.children[0].id).toBe('portal-content-a'); }); it('renders both contents in a single #paragon-portal-root div', () => { @@ -38,7 +38,7 @@ describe('', () => { const portalRoot = getPortalRoot(); expect(portalRoot).not.toBeNull(); - expect(portalRoot.children[0].id).toBe('portal-content-a'); - expect(portalRoot.children[1].id).toBe('portal-content-b'); + expect(portalRoot!.children[0].id).toBe('portal-content-a'); + expect(portalRoot!.children[1].id).toBe('portal-content-b'); }); }); diff --git a/src/index.d.ts b/src/index.d.ts index 4e6817b735..217ee12a89 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -12,7 +12,11 @@ export { default as Container, ContainerSize } from './Container'; export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink'; export { default as Icon } from './Icon'; export { default as IconButton, IconButtonWithTooltip } from './IconButton'; +export { default as ModalContext } from './Modal/ModalContext'; +export { default as ModalDialog, MODAL_DIALOG_CLOSE_LABEL } from './Modal/ModalDialog'; +export { default as ModalLayer } from './Modal/ModalLayer'; export { default as Overlay, OverlayTrigger } from './Overlay'; +export { default as Portal } from './Modal/Portal'; export { default as Tooltip } from './Tooltip'; // // // // // // // // // // // // // // // // // // // // // // // // // // // @@ -103,11 +107,7 @@ export const FullscreenModal: any, FULLSCREEN_MODAL_CLOSE_LABEL: string; // from export const MarketingModal: any; // from './Modal/MarketingModal'; export const StandardModal: any, STANDARD_MODAL_CLOSE_LABEL: string; // from './Modal/StandardModal'; export const AlertModal: any; // from './Modal/AlertModal'; -export const ModalLayer: any; // from './Modal/ModalLayer'; -export const ModalDialog: any, MODAL_DIALOG_CLOSE_LABEL: string; // from './Modal/ModalDialog'; export const ModalPopup: any; // from './Modal/ModalPopup'; -export const ModalContext: any; // from './Modal/ModalContext'; -export const Portal: any; // from './Modal/Portal'; export const PopperElement: any; // from './Modal/PopperElement'; export const diff --git a/src/index.js b/src/index.js index 521f91d861..59b0b28cd2 100644 --- a/src/index.js +++ b/src/index.js @@ -12,7 +12,11 @@ export { default as Container } from './Container'; export { default as Hyperlink, HYPER_LINK_EXTERNAL_LINK_ALT_TEXT, HYPER_LINK_EXTERNAL_LINK_TITLE } from './Hyperlink'; export { default as Icon } from './Icon'; export { default as IconButton, IconButtonWithTooltip } from './IconButton'; +export { default as ModalContext } from './Modal/ModalContext'; +export { default as ModalDialog, MODAL_DIALOG_CLOSE_LABEL } from './Modal/ModalDialog'; +export { default as ModalLayer } from './Modal/ModalLayer'; export { default as Overlay, OverlayTrigger } from './Overlay'; +export { default as Portal } from './Modal/Portal'; export { default as Tooltip } from './Tooltip'; // // // // // // // // // // // // // // // // // // // // // // // // // // // @@ -103,11 +107,7 @@ export { default as FullscreenModal, FULLSCREEN_MODAL_CLOSE_LABEL } from './Moda export { default as MarketingModal } from './Modal/MarketingModal'; export { default as StandardModal, STANDARD_MODAL_CLOSE_LABEL } from './Modal/StandardModal'; export { default as AlertModal } from './Modal/AlertModal'; -export { default as ModalLayer } from './Modal/ModalLayer'; -export { default as ModalDialog, MODAL_DIALOG_CLOSE_LABEL } from './Modal/ModalDialog'; export { default as ModalPopup } from './Modal/ModalPopup'; -export { default as ModalContext } from './Modal/ModalContext'; -export { default as Portal } from './Modal/Portal'; export { default as PopperElement } from './Modal/PopperElement'; export {