Skip to content

Commit f62da57

Browse files
authored
feat(Accordion): add alwaysOpen prop (react-bootstrap#6091)
1 parent c8b59aa commit f62da57

File tree

8 files changed

+150
-24
lines changed

8 files changed

+150
-24
lines changed

src/Accordion.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,27 @@ import classNames from 'classnames';
22
import * as React from 'react';
33
import { useMemo } from 'react';
44
import PropTypes from 'prop-types';
5-
import { SelectCallback } from '@restart/ui/types';
65
import { useUncontrolled } from 'uncontrollable';
76
import { useBootstrapPrefix } from './ThemeProvider';
87
import AccordionBody from './AccordionBody';
98
import AccordionButton from './AccordionButton';
109
import AccordionCollapse from './AccordionCollapse';
11-
import AccordionContext from './AccordionContext';
10+
import AccordionContext, {
11+
AccordionSelectCallback,
12+
AccordionEventKey,
13+
} from './AccordionContext';
1214
import AccordionHeader from './AccordionHeader';
1315
import AccordionItem from './AccordionItem';
1416
import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers';
1517

1618
export interface AccordionProps
1719
extends Omit<React.HTMLAttributes<HTMLElement>, 'onSelect'>,
1820
BsPrefixProps {
19-
activeKey?: string;
20-
defaultActiveKey?: string;
21-
onSelect?: SelectCallback;
21+
activeKey?: AccordionEventKey;
22+
defaultActiveKey?: AccordionEventKey;
23+
onSelect?: AccordionSelectCallback;
2224
flush?: boolean;
25+
alwaysOpen?: boolean;
2326
}
2427

2528
const propTypes = {
@@ -30,13 +33,16 @@ const propTypes = {
3033
bsPrefix: PropTypes.string,
3134

3235
/** The current active key that corresponds to the currently expanded card */
33-
activeKey: PropTypes.string,
36+
activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
3437

3538
/** The default active key that is expanded on start */
36-
defaultActiveKey: PropTypes.string,
39+
defaultActiveKey: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
3740

3841
/** Renders accordion edge-to-edge with its parent container */
3942
flush: PropTypes.bool,
43+
44+
/** Allow accordion items to stay open when another item is opened */
45+
alwaysOpen: PropTypes.bool,
4046
};
4147

4248
const Accordion: BsPrefixRefForwardingComponent<'div', AccordionProps> =
@@ -49,6 +55,7 @@ const Accordion: BsPrefixRefForwardingComponent<'div', AccordionProps> =
4955
className,
5056
onSelect,
5157
flush,
58+
alwaysOpen,
5259
...controlledProps
5360
} = useUncontrolled(props, {
5461
activeKey: 'onSelect',
@@ -59,8 +66,9 @@ const Accordion: BsPrefixRefForwardingComponent<'div', AccordionProps> =
5966
() => ({
6067
activeEventKey: activeKey,
6168
onSelect,
69+
alwaysOpen,
6270
}),
63-
[activeKey, onSelect],
71+
[activeKey, onSelect, alwaysOpen],
6472
);
6573

6674
return (

src/AccordionButton.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import * as React from 'react';
22
import { useContext } from 'react';
33
import classNames from 'classnames';
44
import PropTypes from 'prop-types';
5-
import AccordionContext from './AccordionContext';
5+
import AccordionContext, {
6+
isAccordionItemSelected,
7+
AccordionEventKey,
8+
} from './AccordionContext';
69
import AccordionItemContext from './AccordionItemContext';
710
import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers';
811
import { useBootstrapPrefix } from './ThemeProvider';
@@ -28,17 +31,30 @@ export function useAccordionButton(
2831
eventKey: string,
2932
onClick?: EventHandler,
3033
): EventHandler {
31-
const { activeEventKey, onSelect } = useContext(AccordionContext);
34+
const { activeEventKey, onSelect, alwaysOpen } = useContext(AccordionContext);
3235

3336
return (e) => {
3437
/*
3538
Compare the event key in context with the given event key.
3639
If they are the same, then collapse the component.
3740
*/
38-
const eventKeyPassed = eventKey === activeEventKey ? null : eventKey;
41+
let eventKeyPassed: AccordionEventKey =
42+
eventKey === activeEventKey ? null : eventKey;
43+
if (alwaysOpen) {
44+
if (Array.isArray(activeEventKey)) {
45+
if (activeEventKey.includes(eventKey)) {
46+
eventKeyPassed = activeEventKey.filter((k) => k !== eventKey);
47+
} else {
48+
eventKeyPassed = [...activeEventKey, eventKey];
49+
}
50+
} else {
51+
// activeEventKey is undefined.
52+
eventKeyPassed = [eventKey];
53+
}
54+
}
3955

40-
if (onSelect) onSelect(eventKeyPassed, e);
41-
if (onClick) onClick(e);
56+
onSelect?.(eventKeyPassed, e);
57+
onClick?.(e);
4258
};
4359
}
4460

@@ -75,7 +91,7 @@ const AccordionButton: BsPrefixRefForwardingComponent<
7591
className={classNames(
7692
className,
7793
bsPrefix,
78-
eventKey !== activeEventKey && 'collapsed',
94+
!isAccordionItemSelected(activeEventKey, eventKey) && 'collapsed',
7995
)}
8096
/>
8197
);

src/AccordionCollapse.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
55
import { Transition } from 'react-transition-group';
66
import { useBootstrapPrefix } from './ThemeProvider';
77
import Collapse, { CollapseProps } from './Collapse';
8-
import AccordionContext from './AccordionContext';
8+
import AccordionContext, { isAccordionItemSelected } from './AccordionContext';
99
import { BsPrefixRefForwardingComponent, BsPrefixProps } from './helpers';
1010

1111
export interface AccordionCollapseProps extends BsPrefixProps, CollapseProps {
@@ -46,7 +46,7 @@ const AccordionCollapse: BsPrefixRefForwardingComponent<
4646
return (
4747
<Collapse
4848
ref={ref}
49-
in={activeEventKey === eventKey}
49+
in={isAccordionItemSelected(activeEventKey, eventKey)}
5050
{...props}
5151
className={classNames(className, bsPrefix)}
5252
>

src/AccordionContext.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
import * as React from 'react';
2-
import { SelectCallback } from '@restart/ui/types';
2+
3+
export type AccordionEventKey = string | string[] | null | undefined;
4+
5+
export declare type AccordionSelectCallback = (
6+
eventKey: AccordionEventKey,
7+
e: React.SyntheticEvent<unknown>,
8+
) => void;
39

410
export interface AccordionContextValue {
5-
activeEventKey?: string;
6-
onSelect?: SelectCallback;
11+
activeEventKey?: AccordionEventKey;
12+
onSelect?: AccordionSelectCallback;
13+
alwaysOpen?: boolean;
14+
}
15+
16+
export function isAccordionItemSelected(
17+
activeEventKey: AccordionEventKey,
18+
eventKey: string,
19+
): boolean {
20+
return Array.isArray(activeEventKey)
21+
? activeEventKey.includes(eventKey)
22+
: activeEventKey === eventKey;
723
}
824

925
const context = React.createContext<AccordionContextValue>({});

test/AccordionButtonSpec.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { mount } from 'enzyme';
2-
2+
import { fireEvent, render } from '@testing-library/react';
33
import AccordionButton from '../src/AccordionButton';
44

55
describe('<AccordionButton>', () => {
@@ -11,9 +11,13 @@ describe('<AccordionButton>', () => {
1111
mount(<AccordionButton as="div" />).assertSingle('div.accordion-button');
1212
});
1313

14-
// Just to get full coverage on the useAccordionButton click handler.
15-
it('Should just work if there is no onSelect or onClick handler', () => {
16-
const wrapper = mount(<AccordionButton />);
17-
wrapper.simulate('click');
14+
it('Should call onClick', () => {
15+
const onClickSpy = sinon.spy();
16+
const { getByTestId } = render(
17+
<AccordionButton data-testid="btn" onClick={onClickSpy} />,
18+
);
19+
fireEvent.click(getByTestId('btn'));
20+
21+
onClickSpy.should.be.calledOnce;
1822
});
1923
});

test/AccordionSpec.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { mount } from 'enzyme';
2+
import { fireEvent, render } from '@testing-library/react';
23

34
import Accordion from '../src/Accordion';
45
import AccordionCollapse from '../src/AccordionCollapse';
@@ -186,4 +187,51 @@ describe('<Accordion>', () => {
186187
.getDOMNode()
187188
.className.should.include('show');
188189
});
190+
191+
it('should allow multiple items to stay open', () => {
192+
const onSelectSpy = sinon.spy();
193+
194+
const { getByText } = render(
195+
<Accordion onSelect={onSelectSpy} alwaysOpen>
196+
<Accordion.Item eventKey="0">
197+
<Accordion.Header>header0</Accordion.Header>
198+
<Accordion.Body>body</Accordion.Body>
199+
</Accordion.Item>
200+
<Accordion.Item eventKey="1">
201+
<Accordion.Header>header1</Accordion.Header>
202+
<Accordion.Body>body</Accordion.Body>
203+
</Accordion.Item>
204+
</Accordion>,
205+
);
206+
207+
fireEvent.click(getByText('header0'));
208+
fireEvent.click(getByText('header1'));
209+
210+
onSelectSpy.should.be.calledWith(['0', '1']);
211+
});
212+
213+
it('should remove only one of the active indices', () => {
214+
const onSelectSpy = sinon.spy();
215+
216+
const { getByText } = render(
217+
<Accordion
218+
onSelect={onSelectSpy}
219+
defaultActiveKey={['0', '1']}
220+
alwaysOpen
221+
>
222+
<Accordion.Item eventKey="0">
223+
<Accordion.Header>header0</Accordion.Header>
224+
<Accordion.Body>body</Accordion.Body>
225+
</Accordion.Item>
226+
<Accordion.Item eventKey="1">
227+
<Accordion.Header>header1</Accordion.Header>
228+
<Accordion.Body>body</Accordion.Body>
229+
</Accordion.Item>
230+
</Accordion>,
231+
);
232+
233+
fireEvent.click(getByText('header1'));
234+
235+
onSelectSpy.should.be.calledWith(['0']);
236+
});
189237
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Accordion defaultActiveKey={['0']} alwaysOpen>
2+
<Accordion.Item eventKey="0">
3+
<Accordion.Header>Accordion Item #1</Accordion.Header>
4+
<Accordion.Body>
5+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
6+
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
7+
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
8+
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
9+
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
10+
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
11+
est laborum.
12+
</Accordion.Body>
13+
</Accordion.Item>
14+
<Accordion.Item eventKey="1">
15+
<Accordion.Header>Accordion Item #2</Accordion.Header>
16+
<Accordion.Body>
17+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
18+
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
19+
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
20+
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
21+
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
22+
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
23+
est laborum.
24+
</Accordion.Body>
25+
</Accordion.Item>
26+
</Accordion>;

www/src/pages/components/accordion.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ReactPlayground from '../../components/ReactPlayground';
77
import Basic from '../../examples/Accordion/Basic';
88
import AllCollapse from '../../examples/Accordion/AllCollapse';
99
import Flush from '../../examples/Accordion/Flush';
10+
import AlwaysOpen from '../../examples/Accordion/AlwaysOpen';
1011
import CustomToggle from '../../examples/Accordion/CustomToggle.js';
1112
import ContextAwareToggle from '../../examples/Accordion/ContextAwareToggle.js';
1213

@@ -37,6 +38,13 @@ Add `flush` to remove the default background-color, some borders, and some round
3738

3839
<ReactPlayground codeText={Flush} />
3940

41+
### Always open
42+
43+
You can make accordion items stay open when another item is opened by using the `alwaysOpen` prop. If you're looking to
44+
control the component, you must use an array of strings for `activeKey` or `defaultActiveKey`.
45+
46+
<ReactPlayground codeText={AlwaysOpen} />
47+
4048
## Custom Accordions
4149

4250
You can still create card-based accordions like those in Bootstrap 4. You can hook

0 commit comments

Comments
 (0)