From febc741167566aeeb7f621dd09ec777529ce4eb8 Mon Sep 17 00:00:00 2001 From: kyletsang <6854874+kyletsang@users.noreply.github.com> Date: Tue, 12 Oct 2021 16:40:33 -0700 Subject: [PATCH] feat(Accordion): add `alwaysOpen` prop --- src/Accordion.tsx | 24 ++++++++---- src/AccordionButton.tsx | 28 +++++++++++--- src/AccordionCollapse.tsx | 4 +- src/AccordionContext.ts | 22 +++++++++-- test/AccordionButtonSpec.js | 14 ++++--- test/AccordionSpec.js | 48 ++++++++++++++++++++++++ www/src/examples/Accordion/AlwaysOpen.js | 26 +++++++++++++ www/src/pages/components/accordion.mdx | 8 ++++ 8 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 www/src/examples/Accordion/AlwaysOpen.js diff --git a/src/Accordion.tsx b/src/Accordion.tsx index 8dc07c7b0a..cd2937cf84 100644 --- a/src/Accordion.tsx +++ b/src/Accordion.tsx @@ -2,13 +2,15 @@ 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'; @@ -16,10 +18,11 @@ import { BsPrefixProps, BsPrefixRefForwardingComponent } from './helpers'; export interface AccordionProps extends Omit, 'onSelect'>, BsPrefixProps { - activeKey?: string; - defaultActiveKey?: string; - onSelect?: SelectCallback; + activeKey?: AccordionEventKey; + defaultActiveKey?: AccordionEventKey; + onSelect?: AccordionSelectCallback; flush?: boolean; + alwaysOpen?: boolean; } const propTypes = { @@ -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> = @@ -49,6 +55,7 @@ const Accordion: BsPrefixRefForwardingComponent<'div', AccordionProps> = className, onSelect, flush, + alwaysOpen, ...controlledProps } = useUncontrolled(props, { activeKey: 'onSelect', @@ -59,8 +66,9 @@ const Accordion: BsPrefixRefForwardingComponent<'div', AccordionProps> = () => ({ activeEventKey: activeKey, onSelect, + alwaysOpen, }), - [activeKey, onSelect], + [activeKey, onSelect, alwaysOpen], ); return ( diff --git a/src/AccordionButton.tsx b/src/AccordionButton.tsx index 6e7e2e0d35..3b343660d7 100644 --- a/src/AccordionButton.tsx +++ b/src/AccordionButton.tsx @@ -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'; @@ -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); }; } @@ -75,7 +91,7 @@ const AccordionButton: BsPrefixRefForwardingComponent< className={classNames( className, bsPrefix, - eventKey !== activeEventKey && 'collapsed', + !isAccordionItemSelected(activeEventKey, eventKey) && 'collapsed', )} /> ); diff --git a/src/AccordionCollapse.tsx b/src/AccordionCollapse.tsx index 2a8bd3c132..34fa2e98b8 100644 --- a/src/AccordionCollapse.tsx +++ b/src/AccordionCollapse.tsx @@ -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 { @@ -46,7 +46,7 @@ const AccordionCollapse: BsPrefixRefForwardingComponent< return ( diff --git a/src/AccordionContext.ts b/src/AccordionContext.ts index 85671c7d20..0b90f5fab2 100644 --- a/src/AccordionContext.ts +++ b/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, +) => 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({}); diff --git a/test/AccordionButtonSpec.js b/test/AccordionButtonSpec.js index 8d692f2d48..f8e351ddf7 100644 --- a/test/AccordionButtonSpec.js +++ b/test/AccordionButtonSpec.js @@ -1,5 +1,5 @@ import { mount } from 'enzyme'; - +import { fireEvent, render } from '@testing-library/react'; import AccordionButton from '../src/AccordionButton'; describe('', () => { @@ -11,9 +11,13 @@ describe('', () => { mount().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(); - wrapper.simulate('click'); + it('Should call onClick', () => { + const onClickSpy = sinon.spy(); + const { getByTestId } = render( + , + ); + fireEvent.click(getByTestId('btn')); + + onClickSpy.should.be.calledOnce; }); }); diff --git a/test/AccordionSpec.js b/test/AccordionSpec.js index d7f48f39ca..dd490854d0 100644 --- a/test/AccordionSpec.js +++ b/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'; @@ -186,4 +187,51 @@ describe('', () => { .getDOMNode() .className.should.include('show'); }); + + it('should allow multiple items to stay open', () => { + const onSelectSpy = sinon.spy(); + + const { getByText } = render( + + + header0 + body + + + header1 + body + + , + ); + + 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( + + + header0 + body + + + header1 + body + + , + ); + + fireEvent.click(getByText('header1')); + + onSelectSpy.should.be.calledWith(['0']); + }); }); diff --git a/www/src/examples/Accordion/AlwaysOpen.js b/www/src/examples/Accordion/AlwaysOpen.js new file mode 100644 index 0000000000..0b949aa900 --- /dev/null +++ b/www/src/examples/Accordion/AlwaysOpen.js @@ -0,0 +1,26 @@ + + + Accordion Item #1 + + 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 Item #2 + + 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. + + +; diff --git a/www/src/pages/components/accordion.mdx b/www/src/pages/components/accordion.mdx index 11f61270e9..4a50cfd616 100644 --- a/www/src/pages/components/accordion.mdx +++ b/www/src/pages/components/accordion.mdx @@ -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'; @@ -37,6 +38,13 @@ Add `flush` to remove the default background-color, some borders, and some round +### 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`. + + + ## Custom Accordions You can still create card-based accordions like those in Bootstrap 4. You can hook