Skip to content

Commit

Permalink
feat(ui-modal): modify modal to support less strict children
Browse files Browse the repository at this point in the history
Closes: INSTUI-4094
  • Loading branch information
joyenjoyer committed Oct 16, 2024
1 parent 79fd2b0 commit 40f8ca2
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 68 deletions.
94 changes: 94 additions & 0 deletions packages/ui-modal/src/Modal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,100 @@ On smaller viewports (like mobile devices or scaled-up UI), we don't want to los
render(<Example />)
```
### 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 (
<CloseButton
color="primary-inverse"
placement="end"
offset="small"
onClick={this.handleButtonClick}
screenReaderLabel="Close"
/>
)
}
render () {
return (
<div style={{ padding: '0 0 11rem 0', margin: '0 auto' }}>
<Button onClick={this.handleButtonClick}>
{this.state.open ? 'Close' : 'Open'} the Modal
</Button>
<Modal
as="form"
open={this.state.open}
onDismiss={() => { this.setState({ open: false }) }}
size="large"
label="Modal Dialog: Hello World"
shouldCloseOnDocumentClick
variant='inverse'
overflow='scroll'
>
<Modal.Header>
{this.renderCloseButton()}
<Heading>This is a Modal with a Modal.Body wrapped in to a HOC</Heading>
</Modal.Header>
<WrappedModalBody>
<Heading level='h3'>WrappedModalBody inherits the variant and overflow properties automatically</Heading>
<Text lineHeight="double">{lorem.paragraphs(5)}</Text>
</WrappedModalBody>
<View
as="div"
margin="small"
padding="large"
background="primary">
<Heading level='h3'>This View child does not inherit the variant and overflow properties</Heading>
<Text>{lorem.paragraphs(5)}</Text>
</View>
</Modal>
</div>
)
}
}
const withLogger = (WrappedComponent) => {
class WithLogger extends React.Component {
componentDidMount() {
console.log('WrappedModelBody mounted');
}
render() {
return <WrappedComponent {...this.props} />;
}
}
return WithLogger;
}
const WrappedModalBody = withLogger(Modal.Body)
render(<Example />)
```
### Guidelines
```js
Expand Down
41 changes: 18 additions & 23 deletions packages/ui-modal/src/Modal/__new-tests__/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('<Modal />', () => {
let consoleWarningMock: ReturnType<typeof vi.spyOn>
Expand Down Expand Up @@ -168,6 +169,23 @@ describe('<Modal />', () => {
expect(modalBody).toBeInTheDocument()
})

it('should handle custom children', async () => {
const bodyText = 'Modal-body-text'
const { findByText } = render(
<Modal open label="Modal Dialog" shouldReturnFocus={false}>
<View>
This is a custom child
</View>
<Modal.Body>{bodyText}</Modal.Body>
</Modal>
)
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(
<Modal open label="Modal Dialog" shouldReturnFocus={false}>
Expand Down Expand Up @@ -350,29 +368,6 @@ describe('<Modal />', () => {
expect(consoleErrorMock).not.toHaveBeenCalled()
})

it('should not pass validation when children are invalid', async () => {
const { findByRole } = render(
<Modal open label="Modal Dialog" shouldReturnFocus={false}>
<Modal.Body>Foo Bar Baz</Modal.Body>
<Modal.Footer>
<button>Cancel</button>
</Modal.Footer>
<Modal.Header>Hello World</Modal.Header>
</Modal>
)
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
Expand Down
42 changes: 13 additions & 29 deletions packages/ui-modal/src/Modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'

Expand All @@ -59,10 +52,6 @@ import type {
ModalPropsForTransition
} from './props'

type HeaderChild = React.ComponentElement<ModalHeaderProps, ModalHeader>
type BodyChild = React.ComponentElement<ModalBodyProps, ModalBody>
type FooterChild = React.ComponentElement<ModalFooterProps, ModalFooter>

/**
---
category: components
Expand Down Expand Up @@ -160,23 +149,18 @@ class Modal extends Component<ModalProps, ModalState> {
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<BodyChild>(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(
Expand Down
20 changes: 4 additions & 16 deletions packages/ui-modal/src/Modal/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -154,9 +147,9 @@ type ModalPropsForDialog = {

type ModalOwnProps = {
/**
* The children to be rendered within the `<Modal />`. 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 `<Modal />` content
Expand Down Expand Up @@ -205,12 +198,7 @@ type ModalState = {

const propTypes: PropValidators<PropKeys> = {
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']),
Expand Down

0 comments on commit 40f8ca2

Please sign in to comment.