From 40f8ca24e80ceb41e8c5d05d1f9d5e8f77113370 Mon Sep 17 00:00:00 2001 From: Peter Pal Hudak Date: Tue, 27 Aug 2024 15:24:20 +0200 Subject: [PATCH] feat(ui-modal): modify modal to support less strict children Closes: INSTUI-4094 --- packages/ui-modal/src/Modal/README.md | 94 +++++++++++++++++++ .../src/Modal/__new-tests__/Modal.test.tsx | 41 ++++---- packages/ui-modal/src/Modal/index.tsx | 42 +++------ packages/ui-modal/src/Modal/props.ts | 20 +--- 4 files changed, 129 insertions(+), 68 deletions(-) diff --git a/packages/ui-modal/src/Modal/README.md b/packages/ui-modal/src/Modal/README.md index 7ad9b8af22..945e6bbcdc 100644 --- a/packages/ui-modal/src/Modal/README.md +++ b/packages/ui-modal/src/Modal/README.md @@ -1051,6 +1051,100 @@ On smaller viewports (like mobile devices or scaled-up UI), we don't want to los render() ``` +### Using custom children + +Occasionally, you might find it useful to incorporate custom components within a `Modal`, such as a higher-order component for `Modal.Header` or `Modal.Body` or not using built in child components at all. Although this approach is typically not advised, it can sometimes aid in code splitting or achieving more streamlined code, especially for more intricate and sizable `Modal`s. + +Below example demonstrates how to use a higher-order component for `Modal.Body`. `Modal` consists of a `Modal.Header`, a custom `WrappedModalBody` component, and a `View` component. Properties `variant` and `overflow` are passed down to child components. While the original `Modal.Header`, `Modal.Body` and `Modal.Footer` components use these properties, please note that these might cause unpredictable side effects for custom components. + +```js +--- +type: example +--- + +class Example extends React.Component { + constructor (props) { + super(props) + + this.state = { + open: false + } + } + + handleButtonClick = () => { + this.setState(function (state) { + return { open: !state.open } + }) + }; + + renderCloseButton () { + return ( + + ) + } + + render () { + return ( +
+ + { this.setState({ open: false }) }} + size="large" + label="Modal Dialog: Hello World" + shouldCloseOnDocumentClick + variant='inverse' + overflow='scroll' + > + + {this.renderCloseButton()} + This is a Modal with a Modal.Body wrapped in to a HOC + + + WrappedModalBody inherits the variant and overflow properties automatically + {lorem.paragraphs(5)} + + + This View child does not inherit the variant and overflow properties + {lorem.paragraphs(5)} + + +
+ ) + } +} + +const withLogger = (WrappedComponent) => { + class WithLogger extends React.Component { + componentDidMount() { + console.log('WrappedModelBody mounted'); + } + render() { + return ; + } + } + + return WithLogger; +} + +const WrappedModalBody = withLogger(Modal.Body) + +render() +``` + ### Guidelines ```js diff --git a/packages/ui-modal/src/Modal/__new-tests__/Modal.test.tsx b/packages/ui-modal/src/Modal/__new-tests__/Modal.test.tsx index 7f8c5b22b2..cbbeaf0a92 100644 --- a/packages/ui-modal/src/Modal/__new-tests__/Modal.test.tsx +++ b/packages/ui-modal/src/Modal/__new-tests__/Modal.test.tsx @@ -30,6 +30,7 @@ import '@testing-library/jest-dom' import { Modal, ModalHeader, ModalBody, ModalFooter } from '../index' import type { ModalProps } from '../props' +import { View } from '@instructure/ui-view' describe('', () => { let consoleWarningMock: ReturnType @@ -168,6 +169,23 @@ describe('', () => { expect(modalBody).toBeInTheDocument() }) + it('should handle custom children', async () => { + const bodyText = 'Modal-body-text' + const { findByText } = render( + + + This is a custom child + + {bodyText} + + ) + const modalBody = await findByText(bodyText) + const customChild = await findByText('This is a custom child') + + expect(modalBody).toBeInTheDocument() + expect(customChild).toBeInTheDocument() + }) + it('should apply the aria attributes', async () => { const { findByRole } = render( @@ -350,29 +368,6 @@ describe('', () => { expect(consoleErrorMock).not.toHaveBeenCalled() }) - it('should not pass validation when children are invalid', async () => { - const { findByRole } = render( - - Foo Bar Baz - - - - Hello World - - ) - const dialog = await findByRole('dialog') - const expectedErrorMessage = - 'Expected children of Modal in one of the following formats:' - - expect(dialog).toBeInTheDocument() - expect(consoleErrorMock).toHaveBeenCalledWith( - expect.any(String), - expect.any(String), - expect.stringContaining(expectedErrorMessage), - expect.any(String) - ) - }) - it('should pass inverse variant to children when set', async () => { let headerRef: ModalHeader | null = null let bodyRef: ModalBody | null = null diff --git a/packages/ui-modal/src/Modal/index.tsx b/packages/ui-modal/src/Modal/index.tsx index acf083d483..03edd968e2 100644 --- a/packages/ui-modal/src/Modal/index.tsx +++ b/packages/ui-modal/src/Modal/index.tsx @@ -23,13 +23,9 @@ */ /** @jsx jsx */ -import React, { Children, Component } from 'react' +import { Children, Component, isValidElement, ReactElement } from 'react' -import { - passthroughProps, - safeCloneElement, - matchComponentTypes -} from '@instructure/ui-react-utils' +import { passthroughProps, safeCloneElement } from '@instructure/ui-react-utils' import { createChainedFunction } from '@instructure/ui-utils' import { testable } from '@instructure/ui-testable' @@ -40,11 +36,8 @@ import { Dialog } from '@instructure/ui-dialog' import { Mask } from '@instructure/ui-overlays' import { ModalHeader } from './ModalHeader' -import type { ModalHeaderProps } from './ModalHeader/props' import { ModalBody } from './ModalBody' -import type { ModalBodyProps } from './ModalBody/props' import { ModalFooter } from './ModalFooter' -import type { ModalFooterProps } from './ModalFooter/props' import { withStyle, jsx } from '@instructure/emotion' @@ -59,10 +52,6 @@ import type { ModalPropsForTransition } from './props' -type HeaderChild = React.ComponentElement -type BodyChild = React.ComponentElement -type FooterChild = React.ComponentElement - /** --- category: components @@ -160,23 +149,18 @@ class Modal extends Component { renderChildren() { const { children, variant, overflow } = this.props - return Children.map( - children as (HeaderChild | BodyChild | FooterChild)[], - (child) => { - if (!child) return // ignore null, falsy children - - if (matchComponentTypes(child, [ModalBody])) { - return safeCloneElement(child, { - variant: variant, - overflow: child.props.overflow || overflow - }) - } else { - return safeCloneElement(child, { - variant: variant - }) - } + return Children.map(children as ReactElement, (child) => { + if (!child) return // ignore null, falsy children + + if (isValidElement(child)) { + return safeCloneElement(child, { + variant: variant, + overflow: (child?.props as { overflow: string })?.overflow || overflow + }) + } else { + return child } - ) + }) } renderDialog( diff --git a/packages/ui-modal/src/Modal/props.ts b/packages/ui-modal/src/Modal/props.ts index 6e6551acfc..a8a8033d68 100644 --- a/packages/ui-modal/src/Modal/props.ts +++ b/packages/ui-modal/src/Modal/props.ts @@ -25,18 +25,11 @@ import React from 'react' import PropTypes from 'prop-types' -import { - element, - Children as ChildrenPropTypes -} from '@instructure/ui-prop-types' +import { element } from '@instructure/ui-prop-types' import { transitionTypePropType } from '@instructure/ui-motion' import { Dialog } from '@instructure/ui-dialog' -import { ModalHeader } from './ModalHeader' -import { ModalBody } from './ModalBody' -import { ModalFooter } from './ModalFooter' - import type { AsElementType, PropValidators, @@ -154,9 +147,9 @@ type ModalPropsForDialog = { type ModalOwnProps = { /** - * The children to be rendered within the ``. Children must be type of: `Modal.Header`, `Modal.Body`, `Modal.Footer`. The `Modal.Body` child is required, and they have to follow this order. + * Recommended children types are: `Modal.Header`, `Modal.Body`, `Modal.Footer`. Custom children can be used as well. `Variant` and `overflow` properties are always passed down to children. */ - children: React.ReactNode // TODO: enforceOrder([ModalHeader, ModalBody, ModalFooter], [ModalHeader, ModalBody], [ModalBody, ModalFooter], [ModalBody]) + children: React.ReactNode /** * The size of the `` content @@ -205,12 +198,7 @@ type ModalState = { const propTypes: PropValidators = { label: PropTypes.string.isRequired, - children: ChildrenPropTypes.enforceOrder( - [ModalHeader, ModalBody, ModalFooter], - [ModalHeader, ModalBody], - [ModalBody, ModalFooter], - [ModalBody] - ), + children: PropTypes.node, as: PropTypes.elementType, size: PropTypes.oneOf(['auto', 'small', 'medium', 'large', 'fullscreen']), variant: PropTypes.oneOf(['default', 'inverse']),