diff --git a/packages/chrome/package.json b/packages/chrome/package.json
index 55f0579f724..7dfeaef79bb 100644
--- a/packages/chrome/package.json
+++ b/packages/chrome/package.json
@@ -20,7 +20,6 @@
},
"dependencies": {
"@zendeskgarden/react-selection": "^4.6.0",
- "@zendeskgarden/svg-icons": "^4.0.1",
"classnames": "^2.2.5"
},
"peerDependencies": {
@@ -31,9 +30,10 @@
"styled-components": "^3.2.6"
},
"devDependencies": {
- "@zendeskgarden/css-chrome": "3.3.2",
+ "@zendeskgarden/css-chrome": "3.4.0",
"@zendeskgarden/css-variables": "5.1.1",
- "@zendeskgarden/react-theming": "^3.1.3"
+ "@zendeskgarden/react-theming": "^3.1.3",
+ "@zendeskgarden/svg-icons": "4.4.5"
},
"keywords": [
"components",
diff --git a/packages/chrome/src/containers/AccordionContainer.example.md b/packages/chrome/src/containers/AccordionContainer.example.md
new file mode 100644
index 00000000000..e875bbfd704
--- /dev/null
+++ b/packages/chrome/src/containers/AccordionContainer.example.md
@@ -0,0 +1,57 @@
+```jsx
+const {
+ zdSpacing,
+ zdSpacingSm,
+ zdSpacingXs,
+ zdColorGrey300
+} = require('@zendeskgarden/css-variables');
+const DownIcon = require('svg-react-loader?name=Down!@zendeskgarden/svg-icons/src/16/chevron-down-fill.svg');
+
+const UpIcon = styled(DownIcon)`
+ transform: rotate(180deg);
+`;
+
+const StyledAccordion = styled.div`
+ border: 1px solid ${zdColorGrey300};
+ border-radius: ${zdSpacingXs};
+`;
+
+const StyledHeadingButton = styled.button`
+ background: none;
+ border: 0;
+ display: block;
+ font-size: 1em;
+ font-weight: normal;
+ margin: 0;
+ padding: ${zdSpacingSm} ${zdSpacing};
+ position: relative;
+ text-align: left;
+ width: 100%;
+`;
+
+const StyledIconWrapper = styled.div`
+ float: right;
+`;
+
+const StyledPanel = styled.div`
+ margin: 0;
+ padding: ${zdSpacingSm} ${zdSpacing};
+ border-top: 1px solid ${zdColorGrey300};
+
+ display: ${props => (props.hidden ? 'none' : 'block')};
+`;
+
+
+ {({ getHeadingProps, getHeadingButtonProps, getPanelProps, expanded }) => (
+
+
+
+ Accordion Header With Custom Styling
+ {expanded ? : }
+
+ Panel contents
+
+
+ )}
+;
+```
diff --git a/packages/chrome/src/containers/AccordionContainer.js b/packages/chrome/src/containers/AccordionContainer.js
new file mode 100644
index 00000000000..3b430772903
--- /dev/null
+++ b/packages/chrome/src/containers/AccordionContainer.js
@@ -0,0 +1,108 @@
+/**
+ * Copyright Zendesk, Inc.
+ *
+ * Use of this source code is governed under the Apache License, Version 2.0
+ * found at http://www.apache.org/licenses/LICENSE-2.0.
+ */
+
+import PropTypes from 'prop-types';
+import {
+ ControlledComponent,
+ IdManager,
+ composeEventHandlers
+} from '@zendeskgarden/react-selection';
+
+export default class AccordionContainer extends ControlledComponent {
+ static propTypes = {
+ /**
+ * Whether the accordion is currently expanded
+ */
+ expanded: PropTypes.bool,
+ /**
+ * @param {Object} newState - The updated state
+ * @param {Any} newState.expanded - Whether the accordion is currently expanded
+ */
+ onStateChange: PropTypes.func,
+ /**
+ * @param {Object} renderProps
+ * @param {Function} renderProps.getHeadingProps - Props to be spread onto each heading element. ({ headingLevel }) is required.
+ * @param {Function} renderProps.getHeadingButtonProps - Props to be spread onto each heading button element.
+ * @param {Function} renderProps.getPanelProps - Props to be spread onto each panel element.
+ * @param {Any} renderProps.expanded - Whether the accordion is currently expanded
+ */
+ children: PropTypes.func,
+ /**
+ * Identical to children
+ */
+ render: PropTypes.func,
+ /**
+ * The root ID to use for descendants. A unique ID is created if none is provided.
+ **/
+ id: PropTypes.string
+ };
+
+ constructor(...args) {
+ super(...args);
+
+ this.state = {
+ expanded: false,
+ id: IdManager.generateId('garden-accordion-container')
+ };
+ }
+
+ getHeaderId = () => `${this.getControlledState().id}-header`;
+
+ getPanelId = () => `${this.getControlledState().id}-panel`;
+
+ getHeadingProps = ({ role = 'heading', headingLevel, ...other } = {}) => {
+ if (!headingLevel) {
+ throw new Error(
+ 'Accessibility Error: You must apply the `headingLevel` prop to the element that contains your heading. Equivalent to `aria-level`.'
+ );
+ }
+
+ return {
+ role,
+ 'aria-level': headingLevel,
+ ...other
+ };
+ };
+
+ getHeadingButtonProps = ({ onClick, ...other } = {}) => {
+ const { expanded } = this.getControlledState();
+
+ return {
+ id: this.getHeaderId(),
+ 'aria-controls': this.getPanelId(),
+ 'aria-expanded': expanded,
+ onClick: composeEventHandlers(onClick, () => {
+ this.setControlledState({ expanded: !expanded });
+ }),
+ ...other
+ };
+ };
+
+ getPanelProps = ({ role = 'region', ...other } = {}) => {
+ const { expanded } = this.getControlledState();
+
+ return {
+ role,
+ 'aria-hidden': !expanded,
+ 'aria-labelledby': this.getHeaderId(),
+ id: this.getPanelId(),
+ ...other
+ };
+ };
+
+ render() {
+ const { children, render = children } = this.props;
+ const { expanded } = this.getControlledState();
+
+ return render({
+ getHeadingProps: this.getHeadingProps,
+ getHeadingButtonProps: this.getHeadingButtonProps,
+ getPanelProps: this.getPanelProps,
+ expanded
+ });
+ }
+}
diff --git a/packages/chrome/src/containers/AccordionContainer.spec.js b/packages/chrome/src/containers/AccordionContainer.spec.js
new file mode 100644
index 00000000000..8e6d0c51b92
--- /dev/null
+++ b/packages/chrome/src/containers/AccordionContainer.spec.js
@@ -0,0 +1,128 @@
+/**
+ * Copyright Zendesk, Inc.
+ *
+ * Use of this source code is governed under the Apache License, Version 2.0
+ * found at http://www.apache.org/licenses/LICENSE-2.0.
+ */
+
+import React from 'react';
+import { mountWithTheme } from '@zendeskgarden/react-testing';
+import AccordionContainer from './AccordionContainer';
+
+describe('AccordionContainer', () => {
+ let wrapper;
+ let onChangeSpy;
+
+ const BasicExample = props => (
+
+ {({ getHeadingProps, getHeadingButtonProps, getPanelProps }) => (
+
+
+
+
+
Panel Content
+
+ )}
+
+ );
+
+ beforeEach(() => {
+ onChangeSpy = jest.fn();
+ wrapper = mountWithTheme();
+ });
+
+ const findHeading = enzymeWrapper => enzymeWrapper.find('[data-test-id="heading"]');
+ const findHeadingButton = enzymeWrapper => enzymeWrapper.find('[data-test-id="heading-button"]');
+ const findPanel = enzymeWrapper => enzymeWrapper.find('[data-test-id="panel"]');
+
+ describe('getHeadingProps()', () => {
+ it('throws accessibility error if no headingLevel is provided', () => {
+ console.error = jest.fn(); // eslint-disable-line no-console
+
+ expect(() => {
+ wrapper = mountWithTheme(
+
+ {({ getHeadingProps, getHeadingButtonProps, getPanelProps }) => (
+
+
+
+
+
Panel Content
+
+ )}
+
+ );
+ }).toThrow(
+ 'Accessibility Error: You must apply the `headingLevel` prop to the element that contains your heading. Equivalent to `aria-level`.'
+ );
+ });
+
+ it('applies correct role attribute', () => {
+ expect(findHeading(wrapper)).toHaveProp('role', 'heading');
+ });
+ });
+
+ describe('getHeadingButtonProps()', () => {
+ it('applies correct id attribute', () => {
+ expect(findHeadingButton(wrapper)).toHaveProp('id', 'test-header');
+ });
+
+ it('applies correct aria-controls attribute', () => {
+ expect(findHeadingButton(wrapper)).toHaveProp('aria-controls', 'test-panel');
+ });
+
+ describe('aria-expanded', () => {
+ it('applies correct attribute when collapsed', () => {
+ expect(findHeadingButton(wrapper)).toHaveProp('aria-expanded', false);
+ });
+
+ it('applies correct attribute when expanded', () => {
+ wrapper = mountWithTheme();
+
+ expect(findHeadingButton(wrapper)).toHaveProp('aria-expanded', true);
+ });
+ });
+
+ describe('onClick()', () => {
+ it('calls onStateChange with correct value when collapsed', () => {
+ findHeadingButton(wrapper).simulate('click');
+ expect(onChangeSpy).toHaveBeenCalledWith({ expanded: true });
+ });
+
+ it('calls onStateChange with correct value when expanded', () => {
+ wrapper = mountWithTheme();
+
+ findHeadingButton(wrapper).simulate('click');
+ expect(onChangeSpy).toHaveBeenCalledWith({ expanded: false });
+ });
+ });
+ });
+
+ describe('getPanelProps()', () => {
+ it('applies correct role attribute', () => {
+ expect(findPanel(wrapper)).toHaveProp('role', 'region');
+ });
+
+ it('applies correct aria-labelledby attribute', () => {
+ expect(findPanel(wrapper)).toHaveProp('aria-labelledby', 'test-header');
+ });
+
+ it('applies correct id attribute', () => {
+ expect(findPanel(wrapper)).toHaveProp('id', 'test-panel');
+ });
+
+ describe('aria-hidden', () => {
+ it('applies correct attribute when collapsed', () => {
+ expect(findPanel(wrapper)).toHaveProp('aria-hidden', true);
+ });
+
+ it('applies correct attribute when expanded', () => {
+ wrapper = mountWithTheme();
+
+ expect(findPanel(wrapper)).toHaveProp('aria-hidden', false);
+ });
+ });
+ });
+});
diff --git a/packages/chrome/src/index.js b/packages/chrome/src/index.js
index 97be2ae9327..4df80c264f2 100644
--- a/packages/chrome/src/index.js
+++ b/packages/chrome/src/index.js
@@ -5,6 +5,7 @@
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/
+export { default as AccordionContainer } from './containers/AccordionContainer';
export { default as Chrome } from './views/Chrome';
export { default as Body } from './views/body/Body';
export { default as Content } from './views/body/Content';
@@ -22,3 +23,4 @@ export { default as NavItemText } from './views/nav/NavItemText';
export { default as SubNav } from './views/subnav/SubNav';
export { default as SubNavItem } from './views/subnav/SubNavItem';
export { default as SubNavItemText } from './views/subnav/SubNavItemText';
+export { default as CollapsibleSubNavItem } from './views/subnav/CollapsibleSubNavItem';
diff --git a/packages/chrome/src/views/Chrome.example.md b/packages/chrome/src/views/Chrome.example.md
index 2f38e2053b2..0302aec142e 100644
--- a/packages/chrome/src/views/Chrome.example.md
+++ b/packages/chrome/src/views/Chrome.example.md
@@ -22,7 +22,8 @@ const PersonIcon = require('svg-react-loader?name=Settings!@zendeskgarden/svg-ic
initialState = {
currentNavItem: 'home',
currentSubnavItem: 'item-1',
- expanded: true
+ expanded: true,
+ showCollapsed: false
};
@@ -99,6 +100,33 @@ initialState = {
>
Subnav 2
+
setState({ showCollapsed })}
+ >
+ setState({ currentSubnavItem: 'collapsed-item-1' })}
+ href="#/"
+ >
+ Item 1
+
+ setState({ currentSubnavItem: 'collapsed-item-2' })}
+ href="#/"
+ >
+ Item 2
+
+ setState({ currentSubnavItem: 'collapsed-item-3' })}
+ href="#/"
+ >
+ Item 3
+
+
setState({ currentSubnavItem: 'item-3' })}
diff --git a/packages/chrome/src/views/subnav/CollapsibleSubNavItem.js b/packages/chrome/src/views/subnav/CollapsibleSubNavItem.js
new file mode 100644
index 00000000000..4b47ef8b3d1
--- /dev/null
+++ b/packages/chrome/src/views/subnav/CollapsibleSubNavItem.js
@@ -0,0 +1,108 @@
+/**
+ * Copyright Zendesk, Inc.
+ *
+ * Use of this source code is governed under the Apache License, Version 2.0
+ * found at http://www.apache.org/licenses/LICENSE-2.0.
+ */
+
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import styled from 'styled-components';
+import classNames from 'classnames';
+import ChromeStyles from '@zendeskgarden/css-chrome';
+import { retrieveTheme } from '@zendeskgarden/react-theming';
+
+import AccordionContainer from '../../containers/AccordionContainer';
+import SubNavItem from './SubNavItem';
+
+const COMPONENT_ID = 'chrome.collapsible_sub_nav_item';
+const PANEL_COMPONENT_ID = 'chrome.collapsible_sub_nav_item_panel';
+
+/** Accepts all `` props */
+const StyledSubNavItemHeader = styled(SubNavItem).attrs({
+ 'data-garden-id': COMPONENT_ID,
+ 'data-garden-version': PACKAGE_VERSION,
+ className: props =>
+ classNames(ChromeStyles['c-chrome__subnav__item--header'], {
+ [ChromeStyles['is-expanded']]: props.expanded
+ })
+})`
+ ${props => retrieveTheme(COMPONENT_ID, props)};
+`;
+
+StyledSubNavItemHeader.propTypes = {
+ expanded: PropTypes.bool
+};
+
+/** Accepts all `
` props */
+const StyledSubNavPanel = styled.div.attrs({
+ 'data-garden-id': PANEL_COMPONENT_ID,
+ 'data-garden-version': PACKAGE_VERSION,
+ className: props =>
+ classNames(ChromeStyles['c-chrome__subnav__panel'], {
+ [ChromeStyles['is-hidden']]: props.isHidden
+ })
+})`
+ ${props => retrieveTheme(PANEL_COMPONENT_ID, props)};
+`;
+
+StyledSubNavPanel.propTypes = {
+ isHidden: PropTypes.bool
+};
+
+/**
+ * Accepts all `