Skip to content

Commit

Permalink
chore(Accordion): use React.forwardRef() (#4249)
Browse files Browse the repository at this point in the history
  • Loading branch information
layershifter committed Dec 12, 2022
1 parent 31f1100 commit 6ec8faa
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 104 deletions.
82 changes: 82 additions & 0 deletions src/lib/hooks/useAutoControlledValue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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.
*
* - defaultState - default state or factory initializer
* - state - controllable state, undefined state means internal state will be used
* - initialState - Used to initialize state if all user provided states are undefined
*
* @param {{ defaultState?: any, state: any, initialState: any }} options
*
* @see https://reactjs.org/docs/uncontrolled-components.html
* @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 stateRef = React.useRef(state)

React.useEffect(() => {
stateRef.current = state
}, [state])

// To match the behavior of the setter returned by React.useState, this callback's identity
// should never change. This means it MUST NOT directly reference variables that can change.
const setState = React.useCallback((newState) => {
// React dispatch can use a factory
// https://reactjs.org/docs/hooks-reference.html#functional-updates
if (typeof newState === 'function') {
stateRef.current = newState(stateRef.current)
} else {
stateRef.current = newState
}

setInternalState(stateRef.current)
}, [])

return [state, setState]
}

export default useAutoControlledValue
1 change: 1 addition & 0 deletions src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,6 @@ export { makeDebugger }
// Hooks
//

export useAutoControlledValue from './hooks/useAutoControlledValue'
export useClassNamesOnNode from './hooks/useClassNamesOnNode'
export useEventCallback from './hooks/useEventCallback'
8 changes: 5 additions & 3 deletions src/modules/Accordion/Accordion.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import AccordionTitle from './AccordionTitle'
/**
* An accordion allows users to toggle the display of sections of content.
*/
function Accordion(props) {
const Accordion = React.forwardRef(function (props, ref) {
const { className, fluid, inverted, styled } = props

const classes = cx(
Expand All @@ -23,9 +23,11 @@ function Accordion(props) {
)
const rest = getUnhandledProps(Accordion, props)

return <AccordionAccordion {...rest} className={classes} />
}
// TODO: extract behavior into useAccordion() hook instead of "AccordionAccordion" component
return <AccordionAccordion {...rest} className={classes} ref={ref} />
})

Accordion.displayName = 'Accordion'
Accordion.propTypes = {
/** Additional classes. */
className: PropTypes.string,
Expand Down
144 changes: 71 additions & 73 deletions src/modules/Accordion/AccordionAccordion.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,97 +4,99 @@ import PropTypes from 'prop-types'
import React from 'react'

import {
ModernAutoControlledComponent as Component,
childrenUtils,
createShorthandFactory,
customPropTypes,
getElementType,
getUnhandledProps,
useAutoControlledValue,
useEventCallback,
} from '../../lib'
import AccordionPanel from './AccordionPanel'

const warnIfPropsAreInvalid = (props, state) => {
const { exclusive } = props
const { activeIndex } = state

/* eslint-disable no-console */
if (exclusive && typeof activeIndex !== 'number') {
console.error('`activeIndex` must be a number if `exclusive` is true')
} else if (!exclusive && !_.isArray(activeIndex)) {
console.error('`activeIndex` must be an array if `exclusive` is false')
}
/* eslint-enable no-console */
/**
* @param {Boolean} exclusive
* @param {Number} activeIndex
* @param {Number} itemIndex
*/
function isIndexActive(exclusive, activeIndex, itemIndex) {
return exclusive ? activeIndex === itemIndex : _.includes(activeIndex, itemIndex)
}

/**
* An Accordion can contain sub-accordions.
* @param {Boolean} exclusive
* @param {Number} activeIndex
* @param {Number} itemIndex
*/
export default class AccordionAccordion extends Component {
getInitialAutoControlledState({ exclusive }) {
return { activeIndex: exclusive ? -1 : [] }
}

componentDidMount() {
if (process.env.NODE_ENV !== 'production') {
warnIfPropsAreInvalid(this.props, this.state)
}
function computeNewIndex(exclusive, activeIndex, itemIndex) {
if (exclusive) {
return itemIndex === activeIndex ? -1 : itemIndex
}

componentDidUpdate() {
if (process.env.NODE_ENV !== 'production') {
warnIfPropsAreInvalid(this.props, this.state)
}
// check to see if index is in array, and remove it, if not then add it
if (_.includes(activeIndex, itemIndex)) {
return _.without(activeIndex, itemIndex)
}

computeNewIndex = (index) => {
const { exclusive } = this.props
const { activeIndex } = this.state

if (exclusive) return index === activeIndex ? -1 : index

// check to see if index is in array, and remove it, if not then add it
return _.includes(activeIndex, index) ? _.without(activeIndex, index) : [...activeIndex, index]
}
return [...activeIndex, itemIndex]
}

handleTitleClick = (e, titleProps) => {
/**
* An Accordion can contain sub-accordions.
*/
const AccordionAccordion = React.forwardRef(function (props, ref) {
const { className, children, exclusive, panels } = props
const [activeIndex, setActiveIndex] = useAutoControlledValue({
state: props.activeIndex,
defaultState: props.defaultActiveIndex,
initialState: () => (exclusive ? -1 : []),
})

const classes = cx('accordion', className)
const rest = getUnhandledProps(AccordionAccordion, props)
const ElementType = getElementType(AccordionAccordion, props)

const handleTitleClick = useEventCallback((e, titleProps) => {
const { index } = titleProps

this.setState({ activeIndex: this.computeNewIndex(index) })
_.invoke(this.props, 'onTitleClick', e, titleProps)
setActiveIndex(computeNewIndex(exclusive, activeIndex, index))
_.invoke(props, 'onTitleClick', e, titleProps)
})

if (process.env.NODE_ENV !== 'production') {
React.useEffect(() => {
/* eslint-disable no-console */
if (exclusive && typeof activeIndex !== 'number') {
console.error('`activeIndex` must be a number if `exclusive` is true')
} else if (!exclusive && !_.isArray(activeIndex)) {
console.error('`activeIndex` must be an array if `exclusive` is false')
}
/* eslint-enable no-console */
}, [exclusive, activeIndex])
}

isIndexActive = (index) => {
const { exclusive } = this.props
const { activeIndex } = this.state

return exclusive ? activeIndex === index : _.includes(activeIndex, index)
}
return (
<ElementType {...rest} className={classes} ref={ref}>
{childrenUtils.isNil(children)
? _.map(panels, (panel, index) =>
AccordionPanel.create(panel, {
defaultProps: {
active: isIndexActive(exclusive, activeIndex, index),
index,
onTitleClick: handleTitleClick,
},
}),
)
: children}
</ElementType>
)
})

render() {
const { className, children, panels } = this.props

const classes = cx('accordion', className)
const rest = getUnhandledProps(AccordionAccordion, this.props)
const ElementType = getElementType(AccordionAccordion, this.props)

return (
<ElementType {...rest} className={classes}>
{childrenUtils.isNil(children)
? _.map(panels, (panel, index) =>
AccordionPanel.create(panel, {
defaultProps: {
active: this.isIndexActive(index),
index,
onTitleClick: this.handleTitleClick,
},
}),
)
: children}
</ElementType>
)
}
AccordionAccordion.defaultProps = {
exclusive: true,
}

AccordionAccordion.displayName = 'AccordionAccordion'
AccordionAccordion.propTypes = {
/** An element type to render as (string or function). */
as: PropTypes.elementType,
Expand Down Expand Up @@ -140,10 +142,6 @@ AccordionAccordion.propTypes = {
]),
}

AccordionAccordion.defaultProps = {
exclusive: true,
}

AccordionAccordion.autoControlledProps = ['activeIndex']

AccordionAccordion.create = createShorthandFactory(AccordionAccordion, (content) => ({ content }))

export default AccordionAccordion
8 changes: 5 additions & 3 deletions src/modules/Accordion/AccordionContent.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@ import {
/**
* A content sub-component for Accordion component.
*/
function AccordionContent(props) {
const AccordionContent = React.forwardRef(function (props, ref) {
const { active, children, className, content } = props

const classes = cx('content', useKeyOnly(active, 'active'), className)
const rest = getUnhandledProps(AccordionContent, props)
const ElementType = getElementType(AccordionContent, props)

return (
<ElementType {...rest} className={classes}>
<ElementType {...rest} className={classes} ref={ref}>
{childrenUtils.isNil(children) ? content : children}
</ElementType>
)
}
})

AccordionContent.displayName = 'AccordionContent'
AccordionContent.propTypes = {
/** An element type to render as (string or function). */
as: PropTypes.elementType,
Expand Down
46 changes: 25 additions & 21 deletions src/modules/Accordion/AccordionTitle.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import cx from 'clsx'
import _ from 'lodash'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import React from 'react'

import {
childrenUtils,
Expand All @@ -10,40 +10,42 @@ import {
getElementType,
getUnhandledProps,
useKeyOnly,
useEventCallback,
} from '../../lib'
import Icon from '../../elements/Icon'

/**
* A title sub-component for Accordion component.
*/
export default class AccordionTitle extends Component {
handleClick = (e) => _.invoke(this.props, 'onClick', e, this.props)
const AccordionTitle = React.forwardRef(function (props, ref) {
const { active, children, className, content, icon } = props

render() {
const { active, children, className, content, icon } = this.props
const classes = cx(useKeyOnly(active, 'active'), 'title', className)
const rest = getUnhandledProps(AccordionTitle, props)
const ElementType = getElementType(AccordionTitle, props)
const iconValue = _.isNil(icon) ? 'dropdown' : icon

const classes = cx(useKeyOnly(active, 'active'), 'title', className)
const rest = getUnhandledProps(AccordionTitle, this.props)
const ElementType = getElementType(AccordionTitle, this.props)
const iconValue = _.isNil(icon) ? 'dropdown' : icon

if (!childrenUtils.isNil(children)) {
return (
<ElementType {...rest} className={classes} onClick={this.handleClick}>
{children}
</ElementType>
)
}
const handleClick = useEventCallback((e) => {
_.invoke(props, 'onClick', e, props)
})

if (!childrenUtils.isNil(children)) {
return (
<ElementType {...rest} className={classes} onClick={this.handleClick}>
{Icon.create(iconValue, { autoGenerateKey: false })}
{content}
<ElementType {...rest} className={classes} onClick={handleClick} ref={ref}>
{children}
</ElementType>
)
}
}

return (
<ElementType {...rest} className={classes} onClick={handleClick} ref={ref}>
{Icon.create(iconValue, { autoGenerateKey: false })}
{content}
</ElementType>
)
})

AccordionTitle.displayName = 'AccordionTitle'
AccordionTitle.propTypes = {
/** An element type to render as (string or function). */
as: PropTypes.elementType,
Expand Down Expand Up @@ -75,3 +77,5 @@ AccordionTitle.propTypes = {
onClick: PropTypes.func,
}
AccordionTitle.create = createShorthandFactory(AccordionTitle, (content) => ({ content }))

export default AccordionTitle
Loading

0 comments on commit 6ec8faa

Please sign in to comment.