Skip to content

Commit 71c19dc

Browse files
committed
chore(Modal|Portal|Popup): use React.forwardRef() (#4253)
* chore(Modal|Portal): use React.forwardRef() * remove redundant statics * migrate Popup
1 parent 2c6a38e commit 71c19dc

File tree

16 files changed

+849
-705
lines changed

16 files changed

+849
-705
lines changed

src/addons/Portal/Portal.js

Lines changed: 179 additions & 175 deletions
Large diffs are not rendered by default.

src/addons/Portal/PortalInner.js

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,40 @@
1-
import { handleRef, Ref } from '@fluentui/react-component-ref'
21
import _ from 'lodash'
32
import PropTypes from 'prop-types'
4-
import React, { Component } from 'react'
3+
import React from 'react'
54
import { createPortal } from 'react-dom'
65

7-
import { customPropTypes, isBrowser, makeDebugger } from '../../lib'
6+
import { customPropTypes, isBrowser, makeDebugger, useEventCallback } from '../../lib'
7+
import usePortalElement from './usePortalElement'
88

9-
const debug = makeDebugger('portalInner')
9+
const debug = makeDebugger('PortalInner')
1010

1111
/**
1212
* An inner component that allows you to render children outside their parent.
1313
*/
14-
class PortalInner extends Component {
15-
componentDidMount() {
16-
debug('componentDidMount()')
17-
_.invoke(this.props, 'onMount', null, this.props)
18-
}
14+
const PortalInner = React.forwardRef(function (props, ref) {
15+
const handleMount = useEventCallback(() => _.invoke(props, 'onMount', null, props))
16+
const handleUnmount = useEventCallback(() => _.invoke(props, 'onUnmount', null, props))
1917

20-
componentWillUnmount() {
21-
debug('componentWillUnmount()')
22-
_.invoke(this.props, 'onUnmount', null, this.props)
23-
}
18+
const element = usePortalElement(props.children, ref)
2419

25-
handleRef = (c) => {
26-
debug('handleRef', c)
27-
handleRef(this.props.innerRef, c)
28-
}
20+
React.useEffect(() => {
21+
debug('componentDidMount()')
22+
handleMount()
2923

30-
render() {
31-
if (!isBrowser()) return null
32-
const { children, mountNode = document.body } = this.props
24+
return () => {
25+
debug('componentWillUnmount()')
26+
handleUnmount()
27+
}
28+
}, [])
3329

34-
return createPortal(<Ref innerRef={this.handleRef}>{children}</Ref>, mountNode)
30+
if (!isBrowser()) {
31+
return null
3532
}
36-
}
3733

34+
return createPortal(element, props.mountNode || document.body)
35+
})
36+
37+
PortalInner.displayName = 'PortalInner'
3838
PortalInner.propTypes = {
3939
/** Primary content. */
4040
children: PropTypes.node.isRequired,

src/addons/Portal/usePortalElement.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react'
2+
import ReactIs from 'react-is'
3+
4+
import { useMergedRefs } from '../../lib'
5+
6+
/**
7+
* Assigns merged ref to an existing element is possible or wraps it with an additional "div".
8+
*
9+
* @param {React.ReactNode} node
10+
* @param {React.Ref} userRef
11+
*/
12+
export default function usePortalElement(node, userRef) {
13+
const ref = useMergedRefs(node.ref, userRef)
14+
15+
if (React.isValidElement(node)) {
16+
if (ReactIs.isForwardRef(node)) {
17+
return React.cloneElement(node, { ref })
18+
}
19+
20+
if (typeof node.type === 'string') {
21+
return React.cloneElement(node, { ref })
22+
}
23+
}
24+
25+
return (
26+
<div data-suir-portal='true' ref={ref}>
27+
{node}
28+
</div>
29+
)
30+
}

src/addons/Portal/utils/useTrigger.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react'
2+
3+
import { useMergedRefs } from '../../../lib'
4+
import validateTrigger from './validateTrigger'
5+
6+
/**
7+
* @param {React.ReactNode} trigger
8+
* @param {React.Ref} triggerRef
9+
*/
10+
function useTrigger(trigger, triggerRef) {
11+
const ref = useMergedRefs(trigger?.ref, triggerRef)
12+
13+
if (trigger) {
14+
/* istanbul ignore else */
15+
if (process.env.NODE_ENV !== 'production') {
16+
validateTrigger(trigger)
17+
}
18+
19+
return [ref, React.cloneElement(trigger, { ref })]
20+
}
21+
22+
return [ref, null]
23+
}
24+
25+
export default useTrigger

src/addons/Portal/utils/validateTrigger.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,9 @@ import * as ReactIs from 'react-is'
55
* Asserts that a passed element can be used cloned a props will be applied properly.
66
*/
77
export default function validateTrigger(element) {
8-
if (element) {
9-
React.Children.only(element)
8+
React.Children.only(element)
109

11-
if (ReactIs.isFragment(element)) {
12-
throw new Error('An "React.Fragment" cannot be used as a `trigger`.')
13-
}
10+
if (ReactIs.isFragment(element)) {
11+
throw new Error('An "React.Fragment" cannot be used as a `trigger`.')
1412
}
1513
}

src/lib/doesNodeContainClick.js

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,20 @@ import _ from 'lodash'
1010
* @returns {boolean}
1111
*/
1212
const doesNodeContainClick = (node, e) => {
13-
if (_.some([e, node], _.isNil)) return false
13+
if (_.some([e, node], _.isNil)) {
14+
return false
15+
}
1416

1517
// if there is an e.target and it is in the document, use a simple node.contains() check
1618
if (e.target) {
1719
_.invoke(e.target, 'setAttribute', 'data-suir-click-target', true)
1820

1921
if (document.querySelector('[data-suir-click-target=true]')) {
2022
_.invoke(e.target, 'removeAttribute', 'data-suir-click-target')
21-
return node.contains(e.target)
23+
24+
if (typeof node.contains === 'function') {
25+
return node.contains(e.target)
26+
}
2227
}
2328
}
2429

@@ -29,18 +34,31 @@ const doesNodeContainClick = (node, e) => {
2934
// return early if the event properties aren't available
3035
// prevent measuring the node and repainting if we don't need to
3136
const { clientX, clientY } = e
32-
if (_.some([clientX, clientY], _.isNil)) return false
37+
38+
if (_.some([clientX, clientY], _.isNil)) {
39+
return false
40+
}
41+
42+
if (typeof node.getClientRects !== 'function') {
43+
return false
44+
}
3345

3446
// false if the node is not visible
3547
const clientRects = node.getClientRects()
48+
3649
// Heads Up!
3750
// getClientRects returns a DOMRectList, not an array nor a plain object
3851
// We explicitly avoid _.isEmpty and check .length to cover all possible shapes
39-
if (!node.offsetWidth || !node.offsetHeight || !clientRects || !clientRects.length) return false
52+
if (!node.offsetWidth || !node.offsetHeight || !clientRects || !clientRects.length) {
53+
return false
54+
}
4055

4156
// false if the node doesn't have a valid bounding rect
4257
const { top, bottom, left, right } = _.first(clientRects)
43-
if (_.some([top, bottom, left, right], _.isNil)) return false
58+
59+
if (_.some([top, bottom, left, right], _.isNil)) {
60+
return false
61+
}
4462

4563
// we add a small decimal to the upper bound just to make it inclusive
4664
// don't add an whole pixel (1) as the event/node values may be decimal sensitive

src/lib/hooks/useAutoControlledValue.js

Lines changed: 2 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,5 @@
11
import * as React from 'react'
22

3-
/**
4-
* Helper hook to handle previous comparison of controlled/uncontrolled. Prints an error when "isControlled" value
5-
* switches between subsequent renders.
6-
*/
7-
function useIsControlled(controlledValue) {
8-
const [isControlled] = React.useState(controlledValue !== undefined)
9-
10-
if (process.env.NODE_ENV !== 'production') {
11-
// We don't want these warnings in production even though it is against native behaviour
12-
React.useEffect(() => {
13-
if (isControlled !== (controlledValue !== undefined)) {
14-
const error = new Error()
15-
16-
const controlWarning = isControlled
17-
? 'a controlled value to be uncontrolled'
18-
: 'an uncontrolled value to be controlled'
19-
const undefinedWarning = isControlled ? 'defined to an undefined' : 'undefined to a defined'
20-
21-
// eslint-disable-next-line no-console
22-
console.error(
23-
[
24-
// Default react error
25-
`A component is changing ${controlWarning}'. This is likely caused by the value changing from `,
26-
`${undefinedWarning} value, which should not happen. Decide between using a controlled or uncontrolled `,
27-
'input element for the lifetime of the component.',
28-
'More info: https://reactjs.org/link/controlled-components',
29-
error.stack,
30-
].join(' '),
31-
)
32-
}
33-
}, [isControlled, controlledValue])
34-
}
35-
36-
return isControlled
37-
}
38-
393
/**
404
* A hook that allows optional user control, implements an interface similar to `React.useState()`.
415
* Useful for components which allow uncontrolled and controlled behaviours for users.
@@ -50,12 +14,11 @@ function useIsControlled(controlledValue) {
5014
* @see https://reactjs.org/docs/hooks-state.html
5115
*/
5216
function useAutoControlledValue(options) {
53-
const isControlled = useIsControlled(options.state)
5417
const initialState =
5518
typeof options.defaultState === 'undefined' ? options.initialState : options.defaultState
56-
5719
const [internalState, setInternalState] = React.useState(initialState)
58-
const state = isControlled ? options.state : internalState
20+
21+
const state = typeof options.state === 'undefined' ? internalState : options.state
5922
const stateRef = React.useRef(state)
6023

6124
React.useEffect(() => {

src/lib/hooks/usePrevious.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as React from 'react'
2+
3+
/**
4+
* Hook keeping track of a given value from a previous execution of the component the Hook is used in.
5+
*
6+
* @see https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
7+
*/
8+
function usePrevious(value) {
9+
const ref = React.useRef()
10+
11+
React.useEffect(() => {
12+
ref.current = value
13+
})
14+
15+
return ref.current
16+
}
17+
18+
export default usePrevious

src/lib/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,6 @@ export { makeDebugger }
4949
export useAutoControlledValue from './hooks/useAutoControlledValue'
5050
export useClassNamesOnNode from './hooks/useClassNamesOnNode'
5151
export useEventCallback from './hooks/useEventCallback'
52+
export useIsomorphicLayoutEffect from './hooks/useIsomorphicLayoutEffect'
5253
export useMergedRefs from './hooks/useMergedRefs'
54+
export usePrevious from './hooks/usePrevious'

0 commit comments

Comments
 (0)