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 `
+ +
+

+ Content +

+
+
+
+ + +`; + +exports[`CollapsibleSubNavItem States renders focused styling if provided 1`] = ` + + +
+
+ + + + +
+ +
+

+ Content +

+
+
+
+
+
+`; + +exports[`CollapsibleSubNavItem States renders hovered styling if provided 1`] = ` + + +
+
+ + + + +
+ +
+

+ Content +

+
+
+
+
+
+`; + +exports[`CollapsibleSubNavItem renders default styling 1`] = ` + + +
+
+ + + + +
+ +
+

+ Content +

+
+
+
+
+
+`; diff --git a/packages/chrome/styleguide.config.js b/packages/chrome/styleguide.config.js index e38b05236f2..0693ba2839e 100644 --- a/packages/chrome/styleguide.config.js +++ b/packages/chrome/styleguide.config.js @@ -1,8 +1,14 @@ +/** + * 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. + */ + /** * Package specific styleguide configuration * https://github.com/styleguidist/react-styleguidist/blob/master/docs/Configuration.md */ - module.exports = { sections: [ { @@ -35,6 +41,10 @@ module.exports = { ] } ] + }, + { + name: 'Containers', + components: '../../packages/chrome/src/containers/[A-Z]*.js' } ] };