diff --git a/docs/components/Layout/index.jsx b/docs/components/Layout/index.jsx
index b6ffb43b7..df8a95aa2 100644
--- a/docs/components/Layout/index.jsx
+++ b/docs/components/Layout/index.jsx
@@ -39,6 +39,7 @@ import SvgSymbolExample from '../../examples/SvgSymbolExample';
import SvgSymbolCircleExample from '../../examples/SvgSymbolCircleExample';
import TileGridExample from '../../examples/TileGridExample';
import AccordionExample from '../../examples/AccordionExample';
+import AccordionPanelExample from '../../examples/AccordionPanelExample';
import CarouselExample from '../../examples/CarouselExample';
import ConfirmModalExample from '../../examples/ConfirmModalExample';
import HelpIconPopoverExample from '../../examples/HelpIconPopoverExample';
@@ -227,6 +228,7 @@ class PageLayout extends React.Component {
+
diff --git a/docs/components/Navigation/index.jsx b/docs/components/Navigation/index.jsx
index cd53e1cb1..20ef1efd9 100644
--- a/docs/components/Navigation/index.jsx
+++ b/docs/components/Navigation/index.jsx
@@ -14,35 +14,29 @@ const contentFactory = navigateTo => componentName => (
);
-const panelFactory = (navigateTo, currentOpenPanel) => (section, sectionName) => ({
+const panelFactory = navigateTo => (section, sectionName) => ({
id: sectionName,
title: _.startCase(sectionName),
content:
{_.map(section, contentFactory(navigateTo))}
,
- isCollapsed: sectionName !== currentOpenPanel,
});
class Navigation extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- currentOpenPanel: initialOpenPanel,
- };
- this.togglePanel = this.togglePanel.bind(this);
- }
-
- togglePanel(panelId) {
+ togglePanel = panelId => {
const nextPanel = panelId === this.state.currentOpenPanel ? '' : panelId;
this.setState({ currentOpenPanel: nextPanel });
- }
+ };
render() {
- const panels = _.map(
- this.props.componentsBySection,
- panelFactory(this.props.navigateTo, this.state.currentOpenPanel)
- );
+ const panels = _.map(this.props.componentsBySection, panelFactory(this.props.navigateTo));
return (
-
+
+ {_.map(panels, panel => (
+
+ {panel.content}
+
+ ))}
+
);
}
diff --git a/docs/examples/AccordionExample.jsx b/docs/examples/AccordionExample.jsx
index c1e1565bf..9114e4ac3 100644
--- a/docs/examples/AccordionExample.jsx
+++ b/docs/examples/AccordionExample.jsx
@@ -1,67 +1,68 @@
import React from 'react';
-import _ from 'lodash';
-import Immutable from 'seamless-immutable';
import Example from '../components/Example';
-import { Accordion, Checkbox } from '../../src';
-
-const initialState = {
- accordionPanels: [
- {
- id: '1',
- icon: { href: './docs/assets/svg-symbols.svg#list' },
- title: 'Filter by region',
- isCollapsed: true,
- content: (
-
- ),
- },
- {
- id: '2',
- icon: { href: './docs/assets/svg-symbols.svg#list' },
- title: 'Filter by device',
- isCollapsed: false,
- content: (
-
- ),
- },
- ],
-};
+import { Accordion, Checkbox, Panel } from '../../src';
class AccordionExample extends React.Component {
- constructor() {
- super();
- this.state = initialState;
- this.toggleAccordionPanel = this.toggleAccordionPanel.bind(this);
- }
+ state = {
+ clickHistory: [],
+ };
- toggleAccordionPanel(panelId) {
- const nextPanels = Immutable.from(this.state.accordionPanels).asMutable({
- deep: true,
+ onPanelClick = panelId => {
+ this.setState({
+ clickHistory: [...this.state.clickHistory, `onPanelClick triggered on ${panelId}`],
});
- const panelToToggle = _.find(nextPanels, { id: panelId });
- panelToToggle.isCollapsed = !panelToToggle.isCollapsed;
- this.setState({ accordionPanels: nextPanels });
- }
+ };
render() {
- return ;
+ return (
+
+
+
+
+
+
+
+
+
+ test
+
+ Filter 1
+
+
+ Filter 2
+
+
+ Filter 3
+
+
+ Filter 4
+
+
+
+
+ {this.state.clickHistory.map((text, index) => (
+
{text}
+ ))}
+
+
+ );
}
}
@@ -73,7 +74,31 @@ const exampleProps = {
areas of the list.
),
- exampleCodeSnippet: '',
+ exampleCodeSnippet: `
+
+
+
+
+
+
+ `,
propTypeSectionArray: [
{
propTypes: [
@@ -83,16 +108,35 @@ const exampleProps = {
note: 'render `data-test-selector` onto the component. It can be useful for testing.',
},
{
- propType: 'panels',
- type: (
+ propType: 'onPanelClick',
+ type: 'func',
+ note: (
- arrayOf Panels
+ onPanelClick(panelId)
+
+ takes in a single parameter which is the id of the clicked panel.
),
},
{
- propType: 'onPanelClick',
- type: 'func',
+ propType: 'defaultActivePanelIds',
+ type: 'arrayOf(string)',
+ },
+ {
+ propType: 'maxExpand',
+ type: "oneOfType(number, string('max'))",
+ defaultValue: 'max',
+ note:
+ 'determine how many Panels can be expanded, accepted value is a positive number, or `max` to have no restriction',
+ },
+ {
+ propType: 'children',
+ type: 'node',
+ note: (
+
+ Panel(s) to be rendered inside the Accordion. See Accordion.Panels
+
+ ),
},
],
},
diff --git a/docs/examples/AccordionPanelExample.jsx b/docs/examples/AccordionPanelExample.jsx
new file mode 100644
index 000000000..091d13c32
--- /dev/null
+++ b/docs/examples/AccordionPanelExample.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import Example from '../components/Example';
+import { Accordion } from '../../src';
+
+class AccordionPanelExample extends React.Component {
+ state = {
+ isCollapsed: false,
+ };
+
+ onPanelClick = () => this.setState({ isCollapsed: !this.state.isCollapsed });
+
+ render() {
+ return (
+
+ Panel content
+
+ );
+ }
+}
+
+const exampleProps = {
+ componentName: 'Accordion.Panel',
+ designNotes: (
+
+ See Panel
+
+ ),
+ exampleCodeSnippet: `
+ Panel content
+ `,
+ propTypeSectionArray: [],
+};
+
+export default () => (
+
+
+
+);
diff --git a/docs/examples/PanelExample.jsx b/docs/examples/PanelExample.jsx
index 7c6a7bd7e..2051cff2e 100644
--- a/docs/examples/PanelExample.jsx
+++ b/docs/examples/PanelExample.jsx
@@ -94,7 +94,6 @@ const exampleProps = {
{
propType: 'isCollapsed',
type: 'boolean',
- defaultValue: 'false',
note: '',
},
{
diff --git a/docs/examples/styles.scss b/docs/examples/styles.scss
index d00f03d7d..ceed43da8 100644
--- a/docs/examples/styles.scss
+++ b/docs/examples/styles.scss
@@ -28,7 +28,7 @@
}
}
- &.accordion-example,
+ &.accordion-panel-example,
&.card-example {
.adslot-ui-example {
width: 240px;
diff --git a/src/components/adslot-ui/Accordion/AccordionPanel/index.jsx b/src/components/adslot-ui/Accordion/AccordionPanel/index.jsx
new file mode 100644
index 000000000..8764f2ec9
--- /dev/null
+++ b/src/components/adslot-ui/Accordion/AccordionPanel/index.jsx
@@ -0,0 +1,7 @@
+import Panel from '../../Panel';
+
+class AccordionPanel extends Panel {
+ static displayName = 'Accordion.Panel';
+}
+
+export default AccordionPanel;
diff --git a/src/components/adslot-ui/Accordion/index.jsx b/src/components/adslot-ui/Accordion/index.jsx
index 0f35e4bdb..6688e35ea 100644
--- a/src/components/adslot-ui/Accordion/index.jsx
+++ b/src/components/adslot-ui/Accordion/index.jsx
@@ -1,35 +1,91 @@
import _ from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
-import Panel from 'adslot-ui/Panel';
import Card from 'alexandria/Card';
+import AccordionPanel from './AccordionPanel';
-const AccordionComponent = ({ dts, panels, onPanelClick }) => (
-
-
- {_.map(panels, panel => {
- const panelDts = dts ? `panel-${panel.id}` : undefined;
- const panelProps = {
- key: panel.id,
- onClick: onPanelClick,
- dts: panelDts,
- ..._.omit(panel, ['content', 'onClick']),
- };
-
- return {panel.content};
- })}
-
-
-);
-
-const accordionPanelPropTypes = _.pick(Panel.propTypes, ['id', 'icon', 'title', 'isCollapsed']);
-
-accordionPanelPropTypes.content = PropTypes.node;
-
-AccordionComponent.propTypes = {
- dts: PropTypes.string,
- panels: PropTypes.arrayOf(PropTypes.shape(accordionPanelPropTypes)).isRequired,
- onPanelClick: PropTypes.func.isRequired,
-};
+class AccordionComponent extends React.PureComponent {
+ static propTypes = {
+ dts: PropTypes.string,
+ onPanelClick: PropTypes.func,
+ children: PropTypes.node,
+ defaultActivePanelIds: PropTypes.arrayOf(PropTypes.string),
+ maxExpand: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf(['max'])]),
+ };
+
+ static defaultProps = {
+ maxExpand: 'max',
+ };
+
+ state = {
+ activePanelIds:
+ this.props.maxExpand === 'max'
+ ? this.props.defaultActivePanelIds
+ : _.slice(this.props.defaultActivePanelIds, 0, this.props.maxExpand),
+ };
+
+ onPanelClick = panelId => {
+ const { maxExpand } = this.props;
+
+ if (_.includes(this.state.activePanelIds, panelId)) {
+ // remove panelId out of the active list
+ this.setState({ activePanelIds: _.without(this.state.activePanelIds, panelId) });
+ } else {
+ // drop panels from the beginning if max opened panels count is reached
+ let newActivePanelIds = [...this.state.activePanelIds, panelId];
+ if (maxExpand !== 'max' && newActivePanelIds.length > maxExpand) {
+ newActivePanelIds = _.drop(newActivePanelIds, newActivePanelIds.length - maxExpand);
+ }
+
+ this.setState({ activePanelIds: newActivePanelIds });
+ }
+
+ if (this.props.onPanelClick) {
+ this.props.onPanelClick(panelId);
+ }
+ };
+
+ validateProps() {
+ const { maxExpand } = this.props;
+
+ // validate maxExpand value
+ switch (true) {
+ case _.isNumber(maxExpand) && maxExpand <= 0:
+ case _.isString(maxExpand) && maxExpand !== 'max':
+ throw new Error("maxExpand must be a positive number or 'max'");
+ default:
+ break;
+ }
+ }
+
+ renderPanelFromChildren = child => {
+ const { id, isCollapsed } = child.props;
+
+ // prevent rendering if child is not an instance of Accordion.Panel
+ if (child.type.displayName !== AccordionPanel.displayName) {
+ return null;
+ }
+
+ // respects child.props.isCollapsed for controlled behaviour
+ return React.cloneElement(child, {
+ ...child.props,
+ onClick: this.onPanelClick,
+ isCollapsed: _.isNil(isCollapsed) ? !_.includes(this.state.activePanelIds, id) : isCollapsed,
+ });
+ };
+
+ render() {
+ const { children, dts } = this.props;
+ this.validateProps();
+
+ return (
+
+ {React.Children.map(children, this.renderPanelFromChildren)}
+
+ );
+ }
+}
+
+AccordionComponent.Panel = AccordionPanel;
export default AccordionComponent;
diff --git a/src/components/adslot-ui/Accordion/index.spec.jsx b/src/components/adslot-ui/Accordion/index.spec.jsx
index 135b205a3..6b967598a 100644
--- a/src/components/adslot-ui/Accordion/index.spec.jsx
+++ b/src/components/adslot-ui/Accordion/index.spec.jsx
@@ -1,29 +1,49 @@
import _ from 'lodash';
import React from 'react';
import sinon from 'sinon';
-import { shallow, mount } from 'enzyme';
-import { Accordion, Panel } from 'adslot-ui';
+import { mount, shallow } from 'enzyme';
+import { Accordion } from 'adslot-ui';
import Card from 'alexandria/Card';
import PanelMocks from 'adslot-ui/Panel/mocks';
describe('AccordionComponent', () => {
const { panel1, panel2, panel3 } = PanelMocks;
+ const makeProps = override =>
+ _.merge(
+ {
+ dts: 'my-accordion',
+ onPanelClick: sinon.stub(),
+ defaultActivePanelIds: [],
+ maxExpand: 'max',
+ },
+ override
+ );
+
it('should render with defaults', () => {
- const wrapper = shallow();
+ const wrapper = shallow(
+
+ {panel1.content}
+
+ );
const cardElement = wrapper.find(Card.Content);
expect(cardElement).to.have.length(1);
- expect(cardElement.children()).to.have.length(0);
+ expect(cardElement.children()).to.have.length(1);
});
it('should render with props', () => {
- const panels = [panel1, panel2, panel3];
- const wrapper = shallow();
+ const wrapper = shallow(
+
+ {panel1.content}
+ {panel2.content}
+ {panel3.content}
+
+ );
const cardElement = wrapper.find(Card.Content);
expect(cardElement).to.have.length(1);
- const panelElements = cardElement.find(Panel);
+ const panelElements = cardElement.find(Accordion.Panel);
expect(panelElements).to.have.length(3);
const panelElement1 = panelElements.at(0);
@@ -41,48 +61,120 @@ describe('AccordionComponent', () => {
});
it('should pass onPanelClick down to panels', () => {
- const callback = sinon.spy();
- const panels = [panel1, panel2, panel3];
- const wrapper = mount();
- const panelElements = wrapper.find(Panel);
+ const props = makeProps();
+ const wrapper = mount(
+
+ {panel1.content}
+ {panel2.content}
+ {panel3.content}
+
+ );
+ const panelElements = wrapper.find(Accordion.Panel);
panelElements
.at(0)
- .childAt(0)
- .childAt(0)
+ .find('.panel-component-header')
.simulate('click');
panelElements
.at(1)
- .childAt(0)
- .childAt(0)
+ .find('.panel-component-header')
.simulate('click');
panelElements
.at(2)
- .childAt(0)
- .childAt(0)
+ .find('.panel-component-header')
.simulate('click');
- expect(callback.callCount).to.equal(3);
- expect(callback.firstCall.calledWith('1')).to.equal(true);
- expect(callback.secondCall.calledWith('2')).to.equal(true);
- expect(callback.thirdCall.calledWith('3')).to.equal(true);
+ expect(props.onPanelClick.callCount).to.equal(3);
+ expect(props.onPanelClick.firstCall.calledWith('1')).to.equal(true);
+ expect(props.onPanelClick.secondCall.calledWith('2')).to.equal(true);
+ expect(props.onPanelClick.thirdCall.calledWith('3')).to.equal(true);
});
- it('should pass custom props down to panels', () => {
- const panels = [panel1, panel2, { ...panel3, className: 'test-class', randomProp: 'random-prop-value' }];
- const wrapper = mount();
+ it('should remove active panel id from active list when clicking on an expanded panel', () => {
+ const wrapper = mount(
+
+ {panel1.content}
+ {panel2.content}
+ {panel3.content}
+
+ );
+ wrapper
+ .find(Accordion.Panel)
+ .at(0)
+ .find('.panel-component-header')
+ .simulate('click');
+
+ expect(wrapper.state()).to.eql({ activePanelIds: [] });
+ });
+
+ it('should replace active panel id when props.maxExpand is less than current opened Panels count', () => {
+ const wrapper = mount(
+
+ {panel1.content}
+ {panel2.content}
+ {panel3.content}
+
+ );
+ wrapper
+ .find(Accordion.Panel)
+ .at(1)
+ .find('.panel-component-header')
+ .simulate('click');
+
+ expect(wrapper.state()).to.eql({ activePanelIds: ['2'] });
+ });
+
+ it('should respect isCollapsed in Panel children', () => {
+ const props = makeProps();
+ delete props.onPanelClick;
+
+ const wrapper = mount(
+
+
+ {panel1.content}
+
+ {panel2.content}
+ {panel3.content}
+
+ );
+ wrapper
+ .find(Accordion.Panel)
+ .at(0)
+ .find('.panel-component-header')
+ .simulate('click');
expect(
wrapper
- .find(Panel)
- .at(2)
- .prop('className')
- ).to.equal('test-class');
- expect(
- wrapper
- .find(Panel)
- .at(2)
- .prop('randomProp')
- ).to.equal('random-prop-value');
+ .find(Accordion.Panel)
+ .at(0)
+ .prop('isCollapsed')
+ ).to.equal(true);
+ });
+
+ it('should throw error if props.maxExpand has invalid value', () => {
+ const wrapper = shallow(
+
+ {panel1.content}
+
+ );
+
+ try {
+ wrapper.setProps({ maxExpand: -1 });
+ wrapper.instance().validateProps();
+ throw new Error('should not reach');
+ } catch (err) {
+ expect(err.message).to.equal("maxExpand must be a positive number or 'max'");
+ }
+ });
+
+ it('should ignore children that are not an instance of Accordion.Panel', () => {
+ const wrapper = mount(
+
+ test
+ {panel1.content}
+
+ );
+
+ expect(wrapper.find('.should-not-render')).to.have.length(0);
});
});
diff --git a/src/components/adslot-ui/Panel/index.jsx b/src/components/adslot-ui/Panel/index.jsx
index c4c7a4f2e..c16cfc240 100644
--- a/src/components/adslot-ui/Panel/index.jsx
+++ b/src/components/adslot-ui/Panel/index.jsx
@@ -2,38 +2,36 @@ import React from 'react';
import PropTypes from 'prop-types';
import SvgSymbol from 'alexandria/SvgSymbol';
import classnames from 'classnames';
+import './styles.scss';
-require('./styles.scss');
+class PanelComponent extends React.PureComponent {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ className: PropTypes.string,
+ dts: PropTypes.string,
+ icon: PropTypes.shape(SvgSymbol.propTypes),
+ title: PropTypes.node.isRequired,
+ isCollapsed: PropTypes.bool,
+ onClick: PropTypes.func,
+ children: PropTypes.node,
+ };
-const PanelComponent = ({ id, className, dts, icon, title, isCollapsed, onClick, children }) => {
- const classesCombined = classnames(['panel-component', className, { collapsed: isCollapsed }]);
+ onHeaderClick = () => this.props.onClick(this.props.id);
- const onHeaderClick = () => onClick(id);
+ render() {
+ const { className, children, dts, icon, isCollapsed, title } = this.props;
+ const classesCombined = classnames(['panel-component', className, { collapsed: isCollapsed }]);
- return (
-
-
- {icon ?
: null}
- {title}
+ return (
+
+
+ {icon ? : null}
+ {title}
+
+
{children}
-
{children}
-
- );
-};
-
-PanelComponent.propTypes = {
- id: PropTypes.string.isRequired,
- className: PropTypes.string,
- dts: PropTypes.string,
- icon: PropTypes.shape(SvgSymbol.propTypes),
- title: PropTypes.node.isRequired,
- isCollapsed: PropTypes.bool,
- onClick: PropTypes.func.isRequired,
- children: PropTypes.node,
-};
-
-PanelComponent.defaultProps = {
- isCollapsed: false,
-};
+ );
+ }
+}
export default PanelComponent;
diff --git a/src/components/adslot-ui/Panel/mocks.js b/src/components/adslot-ui/Panel/mocks.js
index 5b0d8947d..2313608a8 100644
--- a/src/components/adslot-ui/Panel/mocks.js
+++ b/src/components/adslot-ui/Panel/mocks.js
@@ -4,6 +4,7 @@ import _ from 'lodash';
const panel1 = {
id: '1',
title: 'Panel 1',
+ dts: 'panel-1',
onClick: _.noop,
};
@@ -24,6 +25,7 @@ const panel3 = {
className: 'test-class-1 test-class-2',
onClick: _.noop,
content: 'Panel 3 content',
+ dts: 'panel-3',
};
const PanelMocks = immutable({