Skip to content

Commit

Permalink
feat(Accordion): add alwaysOpen prop
Browse files Browse the repository at this point in the history
  • Loading branch information
kyletsang committed Oct 12, 2021
1 parent 415b636 commit febc741
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 24 deletions.
24 changes: 16 additions & 8 deletions src/Accordion.tsx
Expand Up @@ -2,24 +2,27 @@ import classNames from 'classnames';
import * as React from 'react';
import { useMemo } from 'react';
import PropTypes from 'prop-types';
import { SelectCallback } from '@restart/ui/types';
import { useUncontrolled } from 'uncontrollable';
import { useBootstrapPrefix } from './ThemeProvider';
import AccordionBody from './AccordionBody';
import AccordionButton from './AccordionButton';
import AccordionCollapse from './AccordionCollapse';
import AccordionContext from './AccordionContext';
import AccordionContext, {
AccordionSelectCallback,
AccordionEventKey,
} from './AccordionContext';
import AccordionHeader from './AccordionHeader';
import AccordionItem from './AccordionItem';
import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers';

export interface AccordionProps
extends Omit<React.HTMLAttributes<HTMLElement>, 'onSelect'>,
BsPrefixProps {
activeKey?: string;
defaultActiveKey?: string;
onSelect?: SelectCallback;
activeKey?: AccordionEventKey;
defaultActiveKey?: AccordionEventKey;
onSelect?: AccordionSelectCallback;
flush?: boolean;
alwaysOpen?: boolean;
}

const propTypes = {
Expand All @@ -30,13 +33,16 @@ const propTypes = {
bsPrefix: PropTypes.string,

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

/** The default active key that is expanded on start */
defaultActiveKey: PropTypes.string,
defaultActiveKey: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),

/** Renders accordion edge-to-edge with its parent container */
flush: PropTypes.bool,

/** Allow accordion items to stay open when another item is opened */
alwaysOpen: PropTypes.bool,
};

const Accordion: BsPrefixRefForwardingComponent<'div', AccordionProps> =
Expand All @@ -49,6 +55,7 @@ const Accordion: BsPrefixRefForwardingComponent<'div', AccordionProps> =
className,
onSelect,
flush,
alwaysOpen,
...controlledProps
} = useUncontrolled(props, {
activeKey: 'onSelect',
Expand All @@ -59,8 +66,9 @@ const Accordion: BsPrefixRefForwardingComponent<'div', AccordionProps> =
() => ({
activeEventKey: activeKey,
onSelect,
alwaysOpen,
}),
[activeKey, onSelect],
[activeKey, onSelect, alwaysOpen],
);

return (
Expand Down
28 changes: 22 additions & 6 deletions src/AccordionButton.tsx
Expand Up @@ -2,7 +2,10 @@ import * as React from 'react';
import { useContext } from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import AccordionContext from './AccordionContext';
import AccordionContext, {
isAccordionItemSelected,
AccordionEventKey,
} from './AccordionContext';
import AccordionItemContext from './AccordionItemContext';
import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers';
import { useBootstrapPrefix } from './ThemeProvider';
Expand All @@ -28,17 +31,30 @@ export function useAccordionButton(
eventKey: string,
onClick?: EventHandler,
): EventHandler {
const { activeEventKey, onSelect } = useContext(AccordionContext);
const { activeEventKey, onSelect, alwaysOpen } = useContext(AccordionContext);

return (e) => {
/*
Compare the event key in context with the given event key.
If they are the same, then collapse the component.
*/
const eventKeyPassed = eventKey === activeEventKey ? null : eventKey;
let eventKeyPassed: AccordionEventKey =
eventKey === activeEventKey ? null : eventKey;
if (alwaysOpen) {
if (Array.isArray(activeEventKey)) {
if (activeEventKey.includes(eventKey)) {
eventKeyPassed = activeEventKey.filter((k) => k !== eventKey);
} else {
eventKeyPassed = [...activeEventKey, eventKey];
}
} else {
// activeEventKey is undefined.
eventKeyPassed = [eventKey];
}
}

if (onSelect) onSelect(eventKeyPassed, e);
if (onClick) onClick(e);
onSelect?.(eventKeyPassed, e);
onClick?.(e);
};
}

Expand Down Expand Up @@ -75,7 +91,7 @@ const AccordionButton: BsPrefixRefForwardingComponent<
className={classNames(
className,
bsPrefix,
eventKey !== activeEventKey && 'collapsed',
!isAccordionItemSelected(activeEventKey, eventKey) && 'collapsed',
)}
/>
);
Expand Down
4 changes: 2 additions & 2 deletions src/AccordionCollapse.tsx
Expand Up @@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import { Transition } from 'react-transition-group';
import { useBootstrapPrefix } from './ThemeProvider';
import Collapse, { CollapseProps } from './Collapse';
import AccordionContext from './AccordionContext';
import AccordionContext, { isAccordionItemSelected } from './AccordionContext';
import { BsPrefixRefForwardingComponent, BsPrefixProps } from './helpers';

export interface AccordionCollapseProps extends BsPrefixProps, CollapseProps {
Expand Down Expand Up @@ -46,7 +46,7 @@ const AccordionCollapse: BsPrefixRefForwardingComponent<
return (
<Collapse
ref={ref}
in={activeEventKey === eventKey}
in={isAccordionItemSelected(activeEventKey, eventKey)}
{...props}
className={classNames(className, bsPrefix)}
>
Expand Down
22 changes: 19 additions & 3 deletions src/AccordionContext.ts
@@ -1,9 +1,25 @@
import * as React from 'react';
import { SelectCallback } from '@restart/ui/types';

export type AccordionEventKey = string | string[] | null | undefined;

export declare type AccordionSelectCallback = (
eventKey: AccordionEventKey,
e: React.SyntheticEvent<unknown>,
) => void;

export interface AccordionContextValue {
activeEventKey?: string;
onSelect?: SelectCallback;
activeEventKey?: AccordionEventKey;
onSelect?: AccordionSelectCallback;
alwaysOpen?: boolean;
}

export function isAccordionItemSelected(
activeEventKey: AccordionEventKey,
eventKey: string,
): boolean {
return Array.isArray(activeEventKey)
? activeEventKey.includes(eventKey)
: activeEventKey === eventKey;
}

const context = React.createContext<AccordionContextValue>({});
Expand Down
14 changes: 9 additions & 5 deletions test/AccordionButtonSpec.js
@@ -1,5 +1,5 @@
import { mount } from 'enzyme';

import { fireEvent, render } from '@testing-library/react';
import AccordionButton from '../src/AccordionButton';

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

// Just to get full coverage on the useAccordionButton click handler.
it('Should just work if there is no onSelect or onClick handler', () => {
const wrapper = mount(<AccordionButton />);
wrapper.simulate('click');
it('Should call onClick', () => {
const onClickSpy = sinon.spy();
const { getByTestId } = render(
<AccordionButton data-testid="btn" onClick={onClickSpy} />,
);
fireEvent.click(getByTestId('btn'));

onClickSpy.should.be.calledOnce;
});
});
48 changes: 48 additions & 0 deletions test/AccordionSpec.js
@@ -1,4 +1,5 @@
import { mount } from 'enzyme';
import { fireEvent, render } from '@testing-library/react';

import Accordion from '../src/Accordion';
import AccordionCollapse from '../src/AccordionCollapse';
Expand Down Expand Up @@ -186,4 +187,51 @@ describe('<Accordion>', () => {
.getDOMNode()
.className.should.include('show');
});

it('should allow multiple items to stay open', () => {
const onSelectSpy = sinon.spy();

const { getByText } = render(
<Accordion onSelect={onSelectSpy} alwaysOpen>
<Accordion.Item eventKey="0">
<Accordion.Header>header0</Accordion.Header>
<Accordion.Body>body</Accordion.Body>
</Accordion.Item>
<Accordion.Item eventKey="1">
<Accordion.Header>header1</Accordion.Header>
<Accordion.Body>body</Accordion.Body>
</Accordion.Item>
</Accordion>,
);

fireEvent.click(getByText('header0'));
fireEvent.click(getByText('header1'));

onSelectSpy.should.be.calledWith(['0', '1']);
});

it('should remove only one of the active indices', () => {
const onSelectSpy = sinon.spy();

const { getByText } = render(
<Accordion
onSelect={onSelectSpy}
defaultActiveKey={['0', '1']}
alwaysOpen
>
<Accordion.Item eventKey="0">
<Accordion.Header>header0</Accordion.Header>
<Accordion.Body>body</Accordion.Body>
</Accordion.Item>
<Accordion.Item eventKey="1">
<Accordion.Header>header1</Accordion.Header>
<Accordion.Body>body</Accordion.Body>
</Accordion.Item>
</Accordion>,
);

fireEvent.click(getByText('header1'));

onSelectSpy.should.be.calledWith(['0']);
});
});
26 changes: 26 additions & 0 deletions www/src/examples/Accordion/AlwaysOpen.js
@@ -0,0 +1,26 @@
<Accordion defaultActiveKey={['0']} alwaysOpen>
<Accordion.Item eventKey="0">
<Accordion.Header>Accordion Item #1</Accordion.Header>
<Accordion.Body>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</Accordion.Body>
</Accordion.Item>
<Accordion.Item eventKey="1">
<Accordion.Header>Accordion Item #2</Accordion.Header>
<Accordion.Body>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</Accordion.Body>
</Accordion.Item>
</Accordion>;
8 changes: 8 additions & 0 deletions www/src/pages/components/accordion.mdx
Expand Up @@ -7,6 +7,7 @@ import ReactPlayground from '../../components/ReactPlayground';
import Basic from '../../examples/Accordion/Basic';
import AllCollapse from '../../examples/Accordion/AllCollapse';
import Flush from '../../examples/Accordion/Flush';
import AlwaysOpen from '../../examples/Accordion/AlwaysOpen';
import CustomToggle from '../../examples/Accordion/CustomToggle.js';
import ContextAwareToggle from '../../examples/Accordion/ContextAwareToggle.js';

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

<ReactPlayground codeText={Flush} />

### Always open

You can make accordion items stay open when another item is opened by using the `alwaysOpen` prop. If you're looking to
control the component, you must use an array of strings for `activeKey` or `defaultActiveKey`.

<ReactPlayground codeText={AlwaysOpen} />

## Custom Accordions

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

0 comments on commit febc741

Please sign in to comment.