Skip to content

Commit

Permalink
chore(Modal|Portal|Popup): use React.forwardRef() (#4253)
Browse files Browse the repository at this point in the history
* chore(Modal|Portal): use React.forwardRef()

* remove redundant statics

* migrate Popup
  • Loading branch information
layershifter committed Dec 13, 2022
1 parent 07702de commit 648796a
Show file tree
Hide file tree
Showing 16 changed files with 849 additions and 705 deletions.
354 changes: 179 additions & 175 deletions src/addons/Portal/Portal.js

Large diffs are not rendered by default.

44 changes: 22 additions & 22 deletions src/addons/Portal/PortalInner.js
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
import { handleRef, Ref } from '@fluentui/react-component-ref'
import _ from 'lodash'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import React from 'react'
import { createPortal } from 'react-dom'

import { customPropTypes, isBrowser, makeDebugger } from '../../lib'
import { customPropTypes, isBrowser, makeDebugger, useEventCallback } from '../../lib'
import usePortalElement from './usePortalElement'

const debug = makeDebugger('portalInner')
const debug = makeDebugger('PortalInner')

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

componentWillUnmount() {
debug('componentWillUnmount()')
_.invoke(this.props, 'onUnmount', null, this.props)
}
const element = usePortalElement(props.children, ref)

handleRef = (c) => {
debug('handleRef', c)
handleRef(this.props.innerRef, c)
}
React.useEffect(() => {
debug('componentDidMount()')
handleMount()

render() {
if (!isBrowser()) return null
const { children, mountNode = document.body } = this.props
return () => {
debug('componentWillUnmount()')
handleUnmount()
}
}, [])

return createPortal(<Ref innerRef={this.handleRef}>{children}</Ref>, mountNode)
if (!isBrowser()) {
return null
}
}

return createPortal(element, props.mountNode || document.body)
})

PortalInner.displayName = 'PortalInner'
PortalInner.propTypes = {
/** Primary content. */
children: PropTypes.node.isRequired,
Expand Down
30 changes: 30 additions & 0 deletions src/addons/Portal/usePortalElement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react'
import ReactIs from 'react-is'

import { useMergedRefs } from '../../lib'

/**
* Assigns merged ref to an existing element is possible or wraps it with an additional "div".
*
* @param {React.ReactNode} node
* @param {React.Ref} userRef
*/
export default function usePortalElement(node, userRef) {
const ref = useMergedRefs(node.ref, userRef)

if (React.isValidElement(node)) {
if (ReactIs.isForwardRef(node)) {
return React.cloneElement(node, { ref })
}

if (typeof node.type === 'string') {
return React.cloneElement(node, { ref })
}
}

return (
<div data-suir-portal='true' ref={ref}>
{node}
</div>
)
}
25 changes: 25 additions & 0 deletions src/addons/Portal/utils/useTrigger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react'

import { useMergedRefs } from '../../../lib'
import validateTrigger from './validateTrigger'

/**
* @param {React.ReactNode} trigger
* @param {React.Ref} triggerRef
*/
function useTrigger(trigger, triggerRef) {
const ref = useMergedRefs(trigger?.ref, triggerRef)

if (trigger) {
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
validateTrigger(trigger)
}

return [ref, React.cloneElement(trigger, { ref })]
}

return [ref, null]
}

export default useTrigger
8 changes: 3 additions & 5 deletions src/addons/Portal/utils/validateTrigger.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import * as ReactIs from 'react-is'
* Asserts that a passed element can be used cloned a props will be applied properly.
*/
export default function validateTrigger(element) {
if (element) {
React.Children.only(element)
React.Children.only(element)

if (ReactIs.isFragment(element)) {
throw new Error('An "React.Fragment" cannot be used as a `trigger`.')
}
if (ReactIs.isFragment(element)) {
throw new Error('An "React.Fragment" cannot be used as a `trigger`.')
}
}
28 changes: 23 additions & 5 deletions src/lib/doesNodeContainClick.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,20 @@ import _ from 'lodash'
* @returns {boolean}
*/
const doesNodeContainClick = (node, e) => {
if (_.some([e, node], _.isNil)) return false
if (_.some([e, node], _.isNil)) {
return false
}

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

if (document.querySelector('[data-suir-click-target=true]')) {
_.invoke(e.target, 'removeAttribute', 'data-suir-click-target')
return node.contains(e.target)

if (typeof node.contains === 'function') {
return node.contains(e.target)
}
}
}

Expand All @@ -29,18 +34,31 @@ const doesNodeContainClick = (node, e) => {
// return early if the event properties aren't available
// prevent measuring the node and repainting if we don't need to
const { clientX, clientY } = e
if (_.some([clientX, clientY], _.isNil)) return false

if (_.some([clientX, clientY], _.isNil)) {
return false
}

if (typeof node.getClientRects !== 'function') {
return false
}

// false if the node is not visible
const clientRects = node.getClientRects()

// Heads Up!
// getClientRects returns a DOMRectList, not an array nor a plain object
// We explicitly avoid _.isEmpty and check .length to cover all possible shapes
if (!node.offsetWidth || !node.offsetHeight || !clientRects || !clientRects.length) return false
if (!node.offsetWidth || !node.offsetHeight || !clientRects || !clientRects.length) {
return false
}

// false if the node doesn't have a valid bounding rect
const { top, bottom, left, right } = _.first(clientRects)
if (_.some([top, bottom, left, right], _.isNil)) return false

if (_.some([top, bottom, left, right], _.isNil)) {
return false
}

// we add a small decimal to the upper bound just to make it inclusive
// don't add an whole pixel (1) as the event/node values may be decimal sensitive
Expand Down
41 changes: 2 additions & 39 deletions src/lib/hooks/useAutoControlledValue.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,5 @@
import * as React from 'react'

/**
* Helper hook to handle previous comparison of controlled/uncontrolled. Prints an error when "isControlled" value
* switches between subsequent renders.
*/
function useIsControlled(controlledValue) {
const [isControlled] = React.useState(controlledValue !== undefined)

if (process.env.NODE_ENV !== 'production') {
// We don't want these warnings in production even though it is against native behaviour
React.useEffect(() => {
if (isControlled !== (controlledValue !== undefined)) {
const error = new Error()

const controlWarning = isControlled
? 'a controlled value to be uncontrolled'
: 'an uncontrolled value to be controlled'
const undefinedWarning = isControlled ? 'defined to an undefined' : 'undefined to a defined'

// eslint-disable-next-line no-console
console.error(
[
// Default react error
`A component is changing ${controlWarning}'. This is likely caused by the value changing from `,
`${undefinedWarning} value, which should not happen. Decide between using a controlled or uncontrolled `,
'input element for the lifetime of the component.',
'More info: https://reactjs.org/link/controlled-components',
error.stack,
].join(' '),
)
}
}, [isControlled, controlledValue])
}

return isControlled
}

/**
* A hook that allows optional user control, implements an interface similar to `React.useState()`.
* Useful for components which allow uncontrolled and controlled behaviours for users.
Expand All @@ -50,12 +14,11 @@ function useIsControlled(controlledValue) {
* @see https://reactjs.org/docs/hooks-state.html
*/
function useAutoControlledValue(options) {
const isControlled = useIsControlled(options.state)
const initialState =
typeof options.defaultState === 'undefined' ? options.initialState : options.defaultState

const [internalState, setInternalState] = React.useState(initialState)
const state = isControlled ? options.state : internalState

const state = typeof options.state === 'undefined' ? internalState : options.state
const stateRef = React.useRef(state)

React.useEffect(() => {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/hooks/usePrevious.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react'

/**
* Hook keeping track of a given value from a previous execution of the component the Hook is used in.
*
* @see https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
*/
function usePrevious(value) {
const ref = React.useRef()

React.useEffect(() => {
ref.current = value
})

return ref.current
}

export default usePrevious
2 changes: 2 additions & 0 deletions src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,6 @@ export { makeDebugger }
export useAutoControlledValue from './hooks/useAutoControlledValue'
export useClassNamesOnNode from './hooks/useClassNamesOnNode'
export useEventCallback from './hooks/useEventCallback'
export useIsomorphicLayoutEffect from './hooks/useIsomorphicLayoutEffect'
export useMergedRefs from './hooks/useMergedRefs'
export usePrevious from './hooks/usePrevious'
Loading

0 comments on commit 648796a

Please sign in to comment.