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 Sep 4, 2024
1 parent c2d239f commit 992d562
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 68 deletions.
93 changes: 93 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,99 @@ 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. `Modal.Header` and `WrappedModalBody` inherit the `variant` and `overflow` properties automatically, while the `View` component does not.
```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
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
23 changes: 0 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 @@ -350,29 +350,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
40 changes: 10 additions & 30 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 } 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,14 @@ 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, (child: any) => {
if (!child) return // ignore null, falsy children

return safeCloneElement(child, {
variant: variant,
overflow: child?.props?.overflow || overflow
})
})
}

renderDialog(
Expand Down
18 changes: 3 additions & 15 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 @@ -156,7 +149,7 @@ 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.
*/
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 992d562

Please sign in to comment.