diff --git a/docs/lib/Components/AccordionPage.js b/docs/lib/Components/AccordionPage.js new file mode 100644 index 000000000..715dcfae4 --- /dev/null +++ b/docs/lib/Components/AccordionPage.js @@ -0,0 +1,149 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ +import React from 'react'; +import { PrismCode } from 'react-prism'; +import PageTitle from '../UI/PageTitle'; +import SectionTitle from '../UI/SectionTitle'; + +import AccordionExample from '../examples/Accordion'; +const AccordionExampleSource = require('!!raw-loader!../examples/Accordion'); + +import UncontrolledAccordionExample from '../examples/UncontrolledAccordion'; +const UncontrolledAccordionExampleSource = require('!!raw-loader!../examples/UncontrolledAccordion'); + +export default class AccordionPage extends React.Component { + render() { + return ( +
+ +
+ +
+
+          
+            { AccordionExampleSource}
+          
+        
+ Properties +
+          
+{`Accordion.propTypes = {
+    openId: Proptypes.string.isRequired,
+    toggle: Proptypes.func.isRequired,
+    tag: tagPropType,
+    className: PropTypes.string,
+    cssModule: PropTypes.object,
+    innerRef: PropTypes.oneOfType([
+      PropTypes.object,
+      PropTypes.string,
+      PropTypes.func,
+    ]),
+    children: PropTypes.node,
+};
+
+AccordionBody.propTypes = {
+  tag: tagPropType,
+  className: PropTypes.string,
+  cssModule: PropTypes.object,
+  innerRef: PropTypes.oneOfType([
+    PropTypes.object,
+    PropTypes.string,
+    PropTypes.func,
+  ]),
+  children: PropTypes.node,
+  accordionId: PropTypes.string.isRequired,
+};
+
+AccordionHeader.propTypes = {
+  tag: tagPropType,
+  className: PropTypes.string,
+  cssModule: PropTypes.object,
+  innerRef: PropTypes.oneOfType([
+    PropTypes.object,
+    PropTypes.string,
+    PropTypes.func,
+  ]),
+  children: PropTypes.node,
+  targetId: PropTypes.string.isRequired,
+};
+
+AccordionItem.propTypes = {
+  tag: tagPropType,
+  className: PropTypes.string,
+  cssModule: PropTypes.object,
+  innerRef: PropTypes.oneOfType([
+    PropTypes.object,
+    PropTypes.string,
+    PropTypes.func,
+  ]),
+  children: PropTypes.node,
+};
+`}
+          
+        
+
+ +
+
+          
+            { UncontrolledAccordionExampleSource }
+          
+        
+ Properties +
+          
+{`UncontrolledAccordion.propTypes = {
+    tag: tagPropType,
+    className: PropTypes.string,
+    cssModule: PropTypes.object,
+    innerRef: PropTypes.oneOfType([
+      PropTypes.object,
+      PropTypes.string,
+      PropTypes.func,
+    ]),
+    children: PropTypes.node,
+};
+
+AccordionBody.propTypes = {
+  tag: tagPropType,
+  className: PropTypes.string,
+  cssModule: PropTypes.object,
+  innerRef: PropTypes.oneOfType([
+    PropTypes.object,
+    PropTypes.string,
+    PropTypes.func,
+  ]),
+  children: PropTypes.node,
+  accordionId: PropTypes.string.isRequired,
+};
+
+AccordionHeader.propTypes = {
+  tag: tagPropType,
+  className: PropTypes.string,
+  cssModule: PropTypes.object,
+  innerRef: PropTypes.oneOfType([
+    PropTypes.object,
+    PropTypes.string,
+    PropTypes.func,
+  ]),
+  children: PropTypes.node,
+  targetId: PropTypes.string.isRequired,
+};
+
+AccordionItem.propTypes = {
+  tag: tagPropType,
+  className: PropTypes.string,
+  cssModule: PropTypes.object,
+  innerRef: PropTypes.oneOfType([
+    PropTypes.object,
+    PropTypes.string,
+    PropTypes.func,
+  ]),
+  children: PropTypes.node,
+};
+`}
+          
+        
+
+ ); + } +} diff --git a/docs/lib/Components/index.js b/docs/lib/Components/index.js index 687790723..c1ea17a44 100644 --- a/docs/lib/Components/index.js +++ b/docs/lib/Components/index.js @@ -2,6 +2,10 @@ import React from 'react'; import Content from '../UI/Content'; const items = [ + { + name: 'Accordion', + to: '/components/accordion/' + }, { name: 'Alerts', to: '/components/alerts/' diff --git a/docs/lib/examples/Accordion.js b/docs/lib/examples/Accordion.js new file mode 100644 index 000000000..4a323d2e0 --- /dev/null +++ b/docs/lib/examples/Accordion.js @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; +import { Accordion, AccordionBody, AccordionHeader, AccordionItem } from 'reactstrap'; + +const Example = (props) => { + const [openId, setOpenId] = useState(); + const toggle = (id) => { + openId === id ? setOpenId(undefined) : setOpenId(id); + }; + + return ( +
+ + + + Accordion Item 1 + + + This is the first item's accordion body. + You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the .accordion-body, though the transition does limit overflow. + + + + + Accordion Item 2 + + + This is the second item's accordion body. + You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the .accordion-body, though the transition does limit overflow. + + + + + Accordion Item 3 + + + This is the third item's accordion body. + You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the .accordion-body, though the transition does limit overflow. + + + +
+ ); +}; + +export default Example; diff --git a/docs/lib/examples/UncontrolledAccordion.js b/docs/lib/examples/UncontrolledAccordion.js new file mode 100644 index 000000000..e675b68c1 --- /dev/null +++ b/docs/lib/examples/UncontrolledAccordion.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { UncontrolledAccordion, AccordionBody, AccordionHeader, AccordionItem } from 'reactstrap'; + +const Example = (props) => { + return ( +
+ + + + Accordion Item 1 + + + This is the first item's accordion body. + You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the .accordion-body, though the transition does limit overflow. + + + + + Accordion Item 2 + + + This is the second item's accordion body. + You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the .accordion-body, though the transition does limit overflow. + + + + + Accordion Item 3 + + + This is the third item's accordion body. + You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the .accordion-body, though the transition does limit overflow. + + + +
+ ); +}; + +export default Example; diff --git a/docs/lib/routes.js b/docs/lib/routes.js index 1b0419b4d..145bf3a53 100644 --- a/docs/lib/routes.js +++ b/docs/lib/routes.js @@ -17,6 +17,7 @@ import PopoversPage from './Components/PopoversPage'; import ProgressPage from './Components/ProgressPage'; import TooltipsPage from './Components/TooltipsPage'; import BadgePage from './Components/BadgePage'; +import AccordionPage from './Components/AccordionPage'; import MediaPage from './Components/MediaPage'; import ModalsPage from './Components/ModalsPage'; import CardPage from './Components/CardPage'; @@ -41,7 +42,8 @@ const routes = ( - + + diff --git a/package.json b/package.json index 40f8d9a8a..770d0dc43 100644 --- a/package.json +++ b/package.json @@ -267,7 +267,7 @@ "react-transition-group": "^3.0.0" }, "peerDependencies": { - "react": ">=16.3.0", + "react": ">=16.8.0", "react-dom": ">=16.3.0" }, "devDependencies": { @@ -306,7 +306,7 @@ "mini-css-extract-plugin": "^1.4.0", "ncp": "^2.0.0", "raw-loader": "^1.0.0", - "react": "^16.3.2", + "react": "^16.8.0", "react-app-rewired": "^1.6.2", "react-dom": "^16.3.2", "react-helmet": "^5.0.3", diff --git a/src/Accordion.js b/src/Accordion.js new file mode 100644 index 000000000..295bbb0f4 --- /dev/null +++ b/src/Accordion.js @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; +import { AccordionContext } from './AccordionContext'; + +const propTypes = { + tag: tagPropType, + className: PropTypes.string, + cssModule: PropTypes.object, + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), + children: PropTypes.node, + openId: PropTypes.string.isRequired, + toggle: PropTypes.func.isRequired, +}; + +const defaultProps = { + tag: 'div' +}; + +const Accordion = (props) => { + const { + openId, + toggle, + className, + cssModule, + tag: Tag, + innerRef, + ...attributes + } = props; + const classes = mapToCssModules(classNames( + className, + 'accordion', + ), cssModule); + + const accordionContext = useMemo(() => ({ + openId, + toggle, + })); + + return ( + + + + ); +}; + +Accordion.propTypes = propTypes; +Accordion.defaultProps = defaultProps; + +export default Accordion; diff --git a/src/AccordionBody.js b/src/AccordionBody.js new file mode 100644 index 000000000..bf35876aa --- /dev/null +++ b/src/AccordionBody.js @@ -0,0 +1,53 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; +import Collapse from './Collapse'; +import { AccordionContext } from './AccordionContext'; + +const propTypes = { + tag: tagPropType, + className: PropTypes.string, + cssModule: PropTypes.object, + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), + children: PropTypes.node, + accordionId: PropTypes.string.isRequired, +}; + +const defaultProps = { + tag: 'div' +}; + +const AccordionItem = (props) => { + const { + className, + cssModule, + tag: Tag, + innerRef, + children, + accordionId, + ...attributes + } = props; + + const { openId } = useContext(AccordionContext); + + const classes = mapToCssModules(classNames( + className, + 'accordion-collapse', + ), cssModule); + + return ( + + {children} + + ); +}; + +AccordionItem.propTypes = propTypes; +AccordionItem.defaultProps = defaultProps; + +export default AccordionItem; diff --git a/src/AccordionContext.js b/src/AccordionContext.js new file mode 100644 index 000000000..5495c1bec --- /dev/null +++ b/src/AccordionContext.js @@ -0,0 +1,10 @@ +import React from 'react'; + +/** + * AccordionContext + * { + * toggle: PropTypes.func.isRequired, + * openId: PropTypes.string, + * } + */ +export const AccordionContext = React.createContext({}); diff --git a/src/AccordionHeader.js b/src/AccordionHeader.js new file mode 100644 index 000000000..4bdd95bf0 --- /dev/null +++ b/src/AccordionHeader.js @@ -0,0 +1,58 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; +import { AccordionContext } from './AccordionContext'; + +const propTypes = { + tag: tagPropType, + className: PropTypes.string, + cssModule: PropTypes.object, + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), + children: PropTypes.node, + targetId: PropTypes.string.isRequired, +}; + +const defaultProps = { + tag: 'h2' +}; + +const AccordionHeader = (props) => { + const { + className, + cssModule, + tag: Tag, + innerRef, + children, + targetId, + ...attributes + } = props; + const { openId, toggle } = useContext(AccordionContext); + + const classes = mapToCssModules(classNames( + className, + 'accordion-header', + ), cssModule); + + const buttonClasses = mapToCssModules(classNames( + 'accordion-button', + { collapsed: openId !== targetId }, + ), cssModule); + + return ( + + + + ); +}; + +AccordionHeader.propTypes = propTypes; +AccordionHeader.defaultProps = defaultProps; + +export default AccordionHeader; diff --git a/src/AccordionItem.js b/src/AccordionItem.js new file mode 100644 index 000000000..684c9cbf9 --- /dev/null +++ b/src/AccordionItem.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; + +const propTypes = { + tag: tagPropType, + className: PropTypes.string, + cssModule: PropTypes.object, + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), + children: PropTypes.node, +}; + +const defaultProps = { + tag: 'div' +}; + +const AccordionItem = (props) => { + const { + className, + cssModule, + tag: Tag, + innerRef, + ...attributes + } = props; + const classes = mapToCssModules(classNames( + className, + 'accordion-item', + ), cssModule); + + return ( + + ); +}; + +AccordionItem.propTypes = propTypes; +AccordionItem.defaultProps = defaultProps; + +export default AccordionItem; diff --git a/src/UncontrolledAccordion.js b/src/UncontrolledAccordion.js new file mode 100644 index 000000000..43eb094e9 --- /dev/null +++ b/src/UncontrolledAccordion.js @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { tagPropType } from './utils'; +import Accordion from './Accordion'; + +const propTypes = { + tag: tagPropType, + className: PropTypes.string, + cssModule: PropTypes.object, + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), + children: PropTypes.node, +}; + +const defaultProps = { + tag: 'div' +}; + +const UncntrolledAccordion = (props) => { + const [openId, setOpenId] = useState(); + const toggle = (id) => { + openId === id ? setOpenId(undefined) : setOpenId(id); + }; + + return ; +}; + +Accordion.propTypes = propTypes; +Accordion.defaultProps = defaultProps; + +export default UncntrolledAccordion; diff --git a/src/__tests__/Accordion.spec.js b/src/__tests__/Accordion.spec.js new file mode 100644 index 000000000..2a81c7242 --- /dev/null +++ b/src/__tests__/Accordion.spec.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { Accordion } from '../'; + +describe('Accordion', () => { + it('should render with "accordion" class', () => { + const wrapper = mount( {}} />); + + expect(wrapper.find('.accordion').length).toBe(1); + }); + + it('should render additional classes', () => { + const wrapper = mount( {}} />); + + expect(wrapper.find('.accordion').hasClass('other')).toBe(true); + }); + + it('should render custom tag', () => { + const wrapper = mount( {}} />); + + expect(wrapper.find('main.accordion').length).toBe(1); + }); +}); diff --git a/src/__tests__/AccordionBody.spec.js b/src/__tests__/AccordionBody.spec.js new file mode 100644 index 000000000..24b7018ba --- /dev/null +++ b/src/__tests__/AccordionBody.spec.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { AccordionBody, AccordionContext } from '../'; + +describe('AccordionBody', () => { + it('should render with "accordion-body" class within "accordion-collapse', () => { + const wrapper = mount(); + + const accordionBody = wrapper.find('.accordion-collapse.collapse'); + + expect(accordionBody.find('.accordion-body').length).toBe(1); + expect(accordionBody.hasClass('show')).toBe(false); + }); + + it('should render additional classes', () => { + const wrapper = mount(); + + expect(wrapper.find('.accordion-collapse.collapse').hasClass('other')).toBe(true); + }); + + it('should render custom tag', () => { + const wrapper = mount(); + + expect(wrapper.find('.accordion-collapse.collapse').find('div.accordion-body').length).toBe(1); + }); + + it('should be open if openId == id', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('.accordion-collapse.collapse.show').length).toBe(1); + }); +}); diff --git a/src/__tests__/AccordionHeader.spec.js b/src/__tests__/AccordionHeader.spec.js new file mode 100644 index 000000000..6eef1572e --- /dev/null +++ b/src/__tests__/AccordionHeader.spec.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { AccordionHeader, AccordionContext } from '../'; + +describe('AccordionHeader', () => { + it('should render with "accordion-header" class', () => { + const wrapper = mount(); + + expect(wrapper.find('.accordion-header').find('.accordion-button.collapsed').length).toBe(1); + }); + + it('should render additional classes', () => { + const wrapper = mount(); + + expect(wrapper.find('.accordion-header').hasClass('other')).toBe(true); + }); + + it('should render custom tag', () => { + const wrapper = mount(); + + expect(wrapper.find('div.accordion-header').length).toBe(1); + }); + + it('should be open if openId == targetId', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('.accordion-header').find('.accordion-button.collapsed').length).toBe(0); + }); + + it('should toggle collapse if accordion-button is clicked', () => { + const toggle = jest.fn(); + + const wrapper = mount( + + + + ); + + const accordionButton = wrapper.find('.accordion-header').find('.accordion-button'); + accordionButton.simulate('click'); + + expect(toggle.mock.calls.length).toBe(1); + }); +}); diff --git a/src/__tests__/AccordionItem.spec.js b/src/__tests__/AccordionItem.spec.js new file mode 100644 index 000000000..55ceffcc1 --- /dev/null +++ b/src/__tests__/AccordionItem.spec.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AccordionItem } from '../'; + +describe('AccordionItem', () => { + it('should render with "accordion-item" class', () => { + const wrapper = shallow(); + + expect(wrapper.hasClass('accordion-item')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('accordion-item')).toBe(true); + }); + + it('should render custom tag', () => { + const wrapper = shallow(); + + expect(wrapper.hasClass('accordion-item')).toBe(true); + expect(wrapper.find('li').length).toBe(1); + }); +}); diff --git a/src/__tests__/UncontrolledAccordion.spec.js b/src/__tests__/UncontrolledAccordion.spec.js new file mode 100644 index 000000000..62c6113c2 --- /dev/null +++ b/src/__tests__/UncontrolledAccordion.spec.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { UncontrolledAccordion } from '../'; + +describe('Accordion', () => { + it('should render with "accordion" class', () => { + const wrapper = mount(); + + expect(wrapper.find('.accordion').length).toBe(1); + }); + + it('should render additional classes', () => { + const wrapper = mount(); + + expect(wrapper.find('.accordion').hasClass('other')).toBe(true); + }); + + it('should render custom tag', () => { + const wrapper = mount(); + + expect(wrapper.find('main.accordion').length).toBe(1); + }); +}); diff --git a/src/index.js b/src/index.js index e9e4416ef..6d5d1657e 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,12 @@ export DropdownMenu from './DropdownMenu'; export DropdownToggle from './DropdownToggle'; export { DropdownContext } from './DropdownContext'; export Fade from './Fade'; +export Accordion from './Accordion'; +export UncontrolledAccordion from './UncontrolledAccordion'; +export AccordionHeader from './AccordionHeader'; +export AccordionItem from './AccordionItem'; +export AccordionBody from './AccordionBody'; +export { AccordionContext } from './AccordionContext'; export Badge from './Badge'; export Card from './Card'; export CardGroup from './CardGroup'; diff --git a/types/index.d.ts b/types/index.d.ts index a9a654c09..497ca9f96 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,3 +1,9 @@ +export { default as Accordion } from './lib/Accordion'; +export { AccordionProps } from './lib/Accordion'; +export { default as AccordionHeader } from './lib/AccordionHeader'; +export { AccordionHeaderProps } from './lib/AccordionHeader'; +export { default as AccordionItem } from './lib/AccordionItem'; +export { AccordionItemProps } from './lib/AccordionItem'; export { default as Alert } from './lib/Alert'; export { AlertProps } from './lib/Alert'; export { default as Badge } from './lib/Badge'; diff --git a/types/lib/Accordion.d.ts b/types/lib/Accordion.d.ts new file mode 100644 index 000000000..7ea45265f --- /dev/null +++ b/types/lib/Accordion.d.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { CSSModule } from './index'; + +export interface AccordionProps extends React.HTMLAttributes { + tag?: React.ElementType; + cssModule?: CSSModule; + innerRef?: React.Ref; +} + +declare class Accordion extends React.Component {} +export default Accordion; diff --git a/types/lib/AccordionBody.d.ts b/types/lib/AccordionBody.d.ts new file mode 100644 index 000000000..fb43e8de0 --- /dev/null +++ b/types/lib/AccordionBody.d.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { CSSModule } from './index'; + +export interface AccordionBodyProps extends React.HTMLAttributes { + tag?: React.ElementType; + cssModule?: CSSModule; + innerRef?: React.Ref; + accordionId: string; +} + +declare class AccordionBody extends React.Component {} +export default AccordionBody; diff --git a/types/lib/AccordionHeader.d.ts b/types/lib/AccordionHeader.d.ts new file mode 100644 index 000000000..3e00a2f93 --- /dev/null +++ b/types/lib/AccordionHeader.d.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { CSSModule } from './index'; + +export interface AccordionHeaderProps extends React.HTMLAttributes { + tag?: React.ElementType; + cssModule?: CSSModule; + innerRef?: React.Ref; + targetId: string; +} + +declare class AccordionHeader extends React.Component {} +export default AccordionHeader; diff --git a/types/lib/AccordionItem.d.ts b/types/lib/AccordionItem.d.ts new file mode 100644 index 000000000..c0ea0ede8 --- /dev/null +++ b/types/lib/AccordionItem.d.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { CSSModule } from './index'; + +export interface AccordionItemProps extends React.HTMLAttributes { + tag?: React.ElementType; + cssModule?: CSSModule; + innerRef?: React.Ref; +} + +declare class AccordionItem extends React.Component {} +export default AccordionItem; diff --git a/yarn.lock b/yarn.lock index c43f64409..0c449e7d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11538,10 +11538,10 @@ react-transition-group@^3.0.0: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" -react@^16.3.2: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" - integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== +react@^16.8.0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1"