Skip to content

Commit f9d400c

Browse files
committed
chore(Accordion): use React.forwardRef() (#4249)
1 parent 6fb9143 commit f9d400c

File tree

10 files changed

+197
-104
lines changed

10 files changed

+197
-104
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import * as React from 'react'
2+
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+
39+
/**
40+
* A hook that allows optional user control, implements an interface similar to `React.useState()`.
41+
* Useful for components which allow uncontrolled and controlled behaviours for users.
42+
*
43+
* - defaultState - default state or factory initializer
44+
* - state - controllable state, undefined state means internal state will be used
45+
* - initialState - Used to initialize state if all user provided states are undefined
46+
*
47+
* @param {{ defaultState?: any, state: any, initialState: any }} options
48+
*
49+
* @see https://reactjs.org/docs/uncontrolled-components.html
50+
* @see https://reactjs.org/docs/hooks-state.html
51+
*/
52+
function useAutoControlledValue(options) {
53+
const isControlled = useIsControlled(options.state)
54+
const initialState =
55+
typeof options.defaultState === 'undefined' ? options.initialState : options.defaultState
56+
57+
const [internalState, setInternalState] = React.useState(initialState)
58+
const state = isControlled ? options.state : internalState
59+
const stateRef = React.useRef(state)
60+
61+
React.useEffect(() => {
62+
stateRef.current = state
63+
}, [state])
64+
65+
// To match the behavior of the setter returned by React.useState, this callback's identity
66+
// should never change. This means it MUST NOT directly reference variables that can change.
67+
const setState = React.useCallback((newState) => {
68+
// React dispatch can use a factory
69+
// https://reactjs.org/docs/hooks-reference.html#functional-updates
70+
if (typeof newState === 'function') {
71+
stateRef.current = newState(stateRef.current)
72+
} else {
73+
stateRef.current = newState
74+
}
75+
76+
setInternalState(stateRef.current)
77+
}, [])
78+
79+
return [state, setState]
80+
}
81+
82+
export default useAutoControlledValue

src/lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,6 @@ export { makeDebugger }
4646
// Hooks
4747
//
4848

49+
export useAutoControlledValue from './hooks/useAutoControlledValue'
4950
export useClassNamesOnNode from './hooks/useClassNamesOnNode'
5051
export useEventCallback from './hooks/useEventCallback'

src/modules/Accordion/Accordion.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import AccordionTitle from './AccordionTitle'
1111
/**
1212
* An accordion allows users to toggle the display of sections of content.
1313
*/
14-
function Accordion(props) {
14+
const Accordion = React.forwardRef(function (props, ref) {
1515
const { className, fluid, inverted, styled } = props
1616

1717
const classes = cx(
@@ -23,9 +23,11 @@ function Accordion(props) {
2323
)
2424
const rest = getUnhandledProps(Accordion, props)
2525

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

30+
Accordion.displayName = 'Accordion'
2931
Accordion.propTypes = {
3032
/** Additional classes. */
3133
className: PropTypes.string,

src/modules/Accordion/AccordionAccordion.js

Lines changed: 71 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,97 +4,99 @@ import PropTypes from 'prop-types'
44
import React from 'react'
55

66
import {
7-
ModernAutoControlledComponent as Component,
87
childrenUtils,
98
createShorthandFactory,
109
customPropTypes,
1110
getElementType,
1211
getUnhandledProps,
12+
useAutoControlledValue,
13+
useEventCallback,
1314
} from '../../lib'
1415
import AccordionPanel from './AccordionPanel'
1516

16-
const warnIfPropsAreInvalid = (props, state) => {
17-
const { exclusive } = props
18-
const { activeIndex } = state
19-
20-
/* eslint-disable no-console */
21-
if (exclusive && typeof activeIndex !== 'number') {
22-
console.error('`activeIndex` must be a number if `exclusive` is true')
23-
} else if (!exclusive && !_.isArray(activeIndex)) {
24-
console.error('`activeIndex` must be an array if `exclusive` is false')
25-
}
26-
/* eslint-enable no-console */
17+
/**
18+
* @param {Boolean} exclusive
19+
* @param {Number} activeIndex
20+
* @param {Number} itemIndex
21+
*/
22+
function isIndexActive(exclusive, activeIndex, itemIndex) {
23+
return exclusive ? activeIndex === itemIndex : _.includes(activeIndex, itemIndex)
2724
}
2825

2926
/**
30-
* An Accordion can contain sub-accordions.
27+
* @param {Boolean} exclusive
28+
* @param {Number} activeIndex
29+
* @param {Number} itemIndex
3130
*/
32-
export default class AccordionAccordion extends Component {
33-
getInitialAutoControlledState({ exclusive }) {
34-
return { activeIndex: exclusive ? -1 : [] }
35-
}
36-
37-
componentDidMount() {
38-
if (process.env.NODE_ENV !== 'production') {
39-
warnIfPropsAreInvalid(this.props, this.state)
40-
}
31+
function computeNewIndex(exclusive, activeIndex, itemIndex) {
32+
if (exclusive) {
33+
return itemIndex === activeIndex ? -1 : itemIndex
4134
}
4235

43-
componentDidUpdate() {
44-
if (process.env.NODE_ENV !== 'production') {
45-
warnIfPropsAreInvalid(this.props, this.state)
46-
}
36+
// check to see if index is in array, and remove it, if not then add it
37+
if (_.includes(activeIndex, itemIndex)) {
38+
return _.without(activeIndex, itemIndex)
4739
}
4840

49-
computeNewIndex = (index) => {
50-
const { exclusive } = this.props
51-
const { activeIndex } = this.state
52-
53-
if (exclusive) return index === activeIndex ? -1 : index
54-
55-
// check to see if index is in array, and remove it, if not then add it
56-
return _.includes(activeIndex, index) ? _.without(activeIndex, index) : [...activeIndex, index]
57-
}
41+
return [...activeIndex, itemIndex]
42+
}
5843

59-
handleTitleClick = (e, titleProps) => {
44+
/**
45+
* An Accordion can contain sub-accordions.
46+
*/
47+
const AccordionAccordion = React.forwardRef(function (props, ref) {
48+
const { className, children, exclusive, panels } = props
49+
const [activeIndex, setActiveIndex] = useAutoControlledValue({
50+
state: props.activeIndex,
51+
defaultState: props.defaultActiveIndex,
52+
initialState: () => (exclusive ? -1 : []),
53+
})
54+
55+
const classes = cx('accordion', className)
56+
const rest = getUnhandledProps(AccordionAccordion, props)
57+
const ElementType = getElementType(AccordionAccordion, props)
58+
59+
const handleTitleClick = useEventCallback((e, titleProps) => {
6060
const { index } = titleProps
6161

62-
this.setState({ activeIndex: this.computeNewIndex(index) })
63-
_.invoke(this.props, 'onTitleClick', e, titleProps)
62+
setActiveIndex(computeNewIndex(exclusive, activeIndex, index))
63+
_.invoke(props, 'onTitleClick', e, titleProps)
64+
})
65+
66+
if (process.env.NODE_ENV !== 'production') {
67+
React.useEffect(() => {
68+
/* eslint-disable no-console */
69+
if (exclusive && typeof activeIndex !== 'number') {
70+
console.error('`activeIndex` must be a number if `exclusive` is true')
71+
} else if (!exclusive && !_.isArray(activeIndex)) {
72+
console.error('`activeIndex` must be an array if `exclusive` is false')
73+
}
74+
/* eslint-enable no-console */
75+
}, [exclusive, activeIndex])
6476
}
6577

66-
isIndexActive = (index) => {
67-
const { exclusive } = this.props
68-
const { activeIndex } = this.state
69-
70-
return exclusive ? activeIndex === index : _.includes(activeIndex, index)
71-
}
78+
return (
79+
<ElementType {...rest} className={classes} ref={ref}>
80+
{childrenUtils.isNil(children)
81+
? _.map(panels, (panel, index) =>
82+
AccordionPanel.create(panel, {
83+
defaultProps: {
84+
active: isIndexActive(exclusive, activeIndex, index),
85+
index,
86+
onTitleClick: handleTitleClick,
87+
},
88+
}),
89+
)
90+
: children}
91+
</ElementType>
92+
)
93+
})
7294

73-
render() {
74-
const { className, children, panels } = this.props
75-
76-
const classes = cx('accordion', className)
77-
const rest = getUnhandledProps(AccordionAccordion, this.props)
78-
const ElementType = getElementType(AccordionAccordion, this.props)
79-
80-
return (
81-
<ElementType {...rest} className={classes}>
82-
{childrenUtils.isNil(children)
83-
? _.map(panels, (panel, index) =>
84-
AccordionPanel.create(panel, {
85-
defaultProps: {
86-
active: this.isIndexActive(index),
87-
index,
88-
onTitleClick: this.handleTitleClick,
89-
},
90-
}),
91-
)
92-
: children}
93-
</ElementType>
94-
)
95-
}
95+
AccordionAccordion.defaultProps = {
96+
exclusive: true,
9697
}
9798

99+
AccordionAccordion.displayName = 'AccordionAccordion'
98100
AccordionAccordion.propTypes = {
99101
/** An element type to render as (string or function). */
100102
as: PropTypes.elementType,
@@ -140,10 +142,6 @@ AccordionAccordion.propTypes = {
140142
]),
141143
}
142144

143-
AccordionAccordion.defaultProps = {
144-
exclusive: true,
145-
}
146-
147-
AccordionAccordion.autoControlledProps = ['activeIndex']
148-
149145
AccordionAccordion.create = createShorthandFactory(AccordionAccordion, (content) => ({ content }))
146+
147+
export default AccordionAccordion

src/modules/Accordion/AccordionContent.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,21 @@ import {
1414
/**
1515
* A content sub-component for Accordion component.
1616
*/
17-
function AccordionContent(props) {
17+
const AccordionContent = React.forwardRef(function (props, ref) {
1818
const { active, children, className, content } = props
19+
1920
const classes = cx('content', useKeyOnly(active, 'active'), className)
2021
const rest = getUnhandledProps(AccordionContent, props)
2122
const ElementType = getElementType(AccordionContent, props)
2223

2324
return (
24-
<ElementType {...rest} className={classes}>
25+
<ElementType {...rest} className={classes} ref={ref}>
2526
{childrenUtils.isNil(children) ? content : children}
2627
</ElementType>
2728
)
28-
}
29+
})
2930

31+
AccordionContent.displayName = 'AccordionContent'
3032
AccordionContent.propTypes = {
3133
/** An element type to render as (string or function). */
3234
as: PropTypes.elementType,

src/modules/Accordion/AccordionTitle.js

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import cx from 'clsx'
22
import _ from 'lodash'
33
import PropTypes from 'prop-types'
4-
import React, { Component } from 'react'
4+
import React from 'react'
55

66
import {
77
childrenUtils,
@@ -10,40 +10,42 @@ import {
1010
getElementType,
1111
getUnhandledProps,
1212
useKeyOnly,
13+
useEventCallback,
1314
} from '../../lib'
1415
import Icon from '../../elements/Icon'
1516

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

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

25-
const classes = cx(useKeyOnly(active, 'active'), 'title', className)
26-
const rest = getUnhandledProps(AccordionTitle, this.props)
27-
const ElementType = getElementType(AccordionTitle, this.props)
28-
const iconValue = _.isNil(icon) ? 'dropdown' : icon
29-
30-
if (!childrenUtils.isNil(children)) {
31-
return (
32-
<ElementType {...rest} className={classes} onClick={this.handleClick}>
33-
{children}
34-
</ElementType>
35-
)
36-
}
28+
const handleClick = useEventCallback((e) => {
29+
_.invoke(props, 'onClick', e, props)
30+
})
3731

32+
if (!childrenUtils.isNil(children)) {
3833
return (
39-
<ElementType {...rest} className={classes} onClick={this.handleClick}>
40-
{Icon.create(iconValue, { autoGenerateKey: false })}
41-
{content}
34+
<ElementType {...rest} className={classes} onClick={handleClick} ref={ref}>
35+
{children}
4236
</ElementType>
4337
)
4438
}
45-
}
4639

40+
return (
41+
<ElementType {...rest} className={classes} onClick={handleClick} ref={ref}>
42+
{Icon.create(iconValue, { autoGenerateKey: false })}
43+
{content}
44+
</ElementType>
45+
)
46+
})
47+
48+
AccordionTitle.displayName = 'AccordionTitle'
4749
AccordionTitle.propTypes = {
4850
/** An element type to render as (string or function). */
4951
as: PropTypes.elementType,
@@ -75,3 +77,5 @@ AccordionTitle.propTypes = {
7577
onClick: PropTypes.func,
7678
}
7779
AccordionTitle.create = createShorthandFactory(AccordionTitle, (content) => ({ content }))
80+
81+
export default AccordionTitle

0 commit comments

Comments
 (0)