diff --git a/src/components/adslot-ui/VerticalNavigation/index.jsx b/src/components/adslot-ui/VerticalNavigation/index.jsx new file mode 100644 index 000000000..51d505e66 --- /dev/null +++ b/src/components/adslot-ui/VerticalNavigation/index.jsx @@ -0,0 +1,123 @@ +import _ from 'lodash'; +import classnames from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; +import './styles.scss'; + +const MenuItem = ({ children }) => children; + +class VerticalNavigation extends React.Component { + static propTypes = { + isCollapsed: PropTypes.bool, + onClick: PropTypes.func, + dts: PropTypes.string, + className: PropTypes.string, + }; + + static defaultProps = { + isCollapsed: false, + }; + + constructor(props) { + super(props); + this.menuList = this.renderMenu(props); + this.contentList = this.renderContent(props); + } + + shouldComponentUpdate(nextProps) { + this.menuList = this.renderMenu(nextProps); + + // only render collapse/expand + if (nextProps.isCollapsed !== this.props.isCollapsed) { + return true; + } + + this.contentList = this.renderContent(nextProps); + return true; + } + + getActiveTabIndex = children => { + const activeIndex = _.findIndex(children, 'props.isActive'); + return activeIndex === -1 ? 0 : activeIndex; + }; + + renderContent = ({ children }) => { + const activeTabIndex = this.getActiveTabIndex(children); + const contentList = React.Children.map(children, (child, index) => { + if (!child.props.content) { + // eslint-disable-next-line no-console + console.warn('Navigation does not render MenuItem that have no content prop.'); + return null; + } + + const contentClassnames = classnames([ + 'aui--vertical-navigation-component__content-item', + { 'aui--vertical-navigation-component__content-item-is-active': index === activeTabIndex }, + ]); + + return ( +
+ {child} +
+ ); + }); + + return _.compact(contentList); + }; + + renderMenu = ({ children, isCollapsed }) => { + const menuList = []; + const activeTabIndex = this.getActiveTabIndex(children); + + React.Children.forEach(children, (child, index) => { + if (!child.props.content) { + // eslint-disable-next-line no-console + console.warn('Navigation does not render MenuItem that have no content prop.'); + return; + } + + const classNames = classnames([ + 'aui--vertical-navigation-component__menu-item', + { 'aui--vertical-navigation-component__menu-item-is-active': index === activeTabIndex }, + ]); + menuList.push( +
+ {child.props.content({ isCollapsed })} +
+ ); + }); + + return menuList; + }; + + render() { + const { className, dts, isCollapsed } = this.props; + const componentClasses = classnames('aui--vertical-navigation-component', className); + + const menuClasses = classnames([ + 'aui--vertical-navigation-component__menu', + 'aui--vertical-navigation-component__menu-is-animated', + { + 'aui--vertical-navigation-component__menu-is-collapsed': isCollapsed, + }, + ]); + + return ( +
+
+
+
+
+
+
+ {this.menuList} +
+
{this.contentList}
+
+ ); + } +} + +VerticalNavigation.MenuItem = MenuItem; + +export default VerticalNavigation; diff --git a/src/components/adslot-ui/VerticalNavigation/index.spec.jsx b/src/components/adslot-ui/VerticalNavigation/index.spec.jsx new file mode 100644 index 000000000..2e5b84d55 --- /dev/null +++ b/src/components/adslot-ui/VerticalNavigation/index.spec.jsx @@ -0,0 +1,111 @@ +import VerticalNav from 'adslot-ui/VerticalNavigation'; +import { mount } from 'enzyme'; +import React from 'react'; +import sinon from 'sinon'; + +describe('VerticalNavComponent', () => { + const makeProps = override => ({ + isCollapsed: false, + onClick: sinon.spy(), + dts: 'test-dts', + className: 'custom-class', + ...override, + }); + const makeMenuItemProps = override => ({ + isActive: false, + dts: 'menu-item-dts', + content: sinon.spy(), + onClick: sinon.spy(), + ...override, + }); + + let sandbox; + + before(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => sandbox.restore()); + + it('should render with props', () => { + const menuLabel1 = () =>
Tab 1
; + const menuLabel2 = () =>
Tab 2
; + + const wrapper = mount( + + Content 1 + Content 1 + + ); + + expect(wrapper.find('.aui--vertical-navigation-component.custom-class')).to.have.length(1); + + const menuItems = wrapper.find('.aui--vertical-navigation-component__menu-item'); + expect(menuItems).to.have.length(3); // 1 collapse, 2 menu items + expect(menuItems.at(0).find('.aui--vertical-navigation-component__menu-item-collapse')).to.have.length(1); + expect(menuItems.at(1).text()).to.equal('Tab 1'); + expect(menuItems.at(2).text()).to.equal('Tab 2'); + }); + + it('should dispaly warnings if child element does not have `content` prop', () => { + sandbox.stub(console, 'warn'); + const wrapper = mount( + +
Some random element
+
+ ); + + // only renders collapse item + expect(wrapper.find('.aui--vertical-navigation-component__menu-item')).to.have.length(1); + /* eslint-disable no-console */ + expect(console.warn.calledTwice).to.equal(true); + expect(console.warn.args[0]).to.eql(['Navigation does not render MenuItem that have no content prop.']); + /* eslint-enable no-console */ + }); + + it('should only update menu if props.isCollapsed is changed', () => { + const menuLabel1 = () =>
Tab 1
; + const menuLabel2 = () =>
Tab 2
; + const wrapper = mount( + + + Content 1 + + Content 1 + + ); + + const renderMenuSpy = sandbox.spy(wrapper.instance(), 'renderMenu'); + const renderContentSpy = sandbox.spy(wrapper.instance(), 'renderContent'); + wrapper.setProps({ isCollapsed: true }); + + expect(renderMenuSpy.called).to.equal(true); + expect(renderContentSpy.called).to.equal(false); + }); + + it('should render both menu and content if active tab changes', () => { + const menuLabel1 = () =>
Tab 1
; + const menuLabel2 = () =>
Tab 2
; + const wrapper = mount( + + + Content 1 + + Content 1 + + ); + const renderMenuSpy = sandbox.spy(wrapper.instance(), 'renderMenu'); + const renderContentSpy = sandbox.spy(wrapper.instance(), 'renderContent'); + wrapper.setProps({ + children: [ + Content 1, + + Content 1 + , + ], + }); + + expect(renderMenuSpy.called).to.equal(true); + expect(renderContentSpy.called).to.equal(true); + }); +}); diff --git a/src/components/adslot-ui/VerticalNavigation/styles.scss b/src/components/adslot-ui/VerticalNavigation/styles.scss new file mode 100644 index 000000000..011bf734b --- /dev/null +++ b/src/components/adslot-ui/VerticalNavigation/styles.scss @@ -0,0 +1,67 @@ +@import '~styles/variable'; +@import '~styles/mixin'; + +$menu-min-width: 60px; +$menu-max-width: 260px; + +.aui--vertical-navigation-component { + display: flex; + flex-direction: row; + + .aui--vertical-navigation-component__menu { + width: $menu-max-width; + font-size: $font-size-subheader; + font-family: $font-family-sans-serif; + font-weight: $font-weight-bold; + + &.aui--vertical-navigation-component__menu-is-collapsed { + width: $menu-min-width; + } + + &.aui--vertical-navigation-component__menu-is-animated { + transition: width 1s ease-in-out; + } + + .aui--vertical-navigation-component__menu-item { + height: 60px; + padding: 10px; + margin-right: 6px; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + white-space: nowrap; + overflow: hidden; + + .aui--vertical-navigation-component__menu-item-collapse { + display: flex; + width: 40px; + height: 40px; + flex-direction: column; + justify-content: center; + padding: 8px; + + &:hover { + border-radius: 50%; + background-color: $color-gray-lighter; + } + } + + .aui--vertical-navigation-component__menu-item-collapse-icon { + @include svg-icon('~styles/icons/burger.svg', 24px, 24px); + } + } + } + + .aui--vertical-navigation-component__content { + flex-grow: 1; + + .aui--vertical-navigation-component__content-item { + display: none; + + &.aui--vertical-navigation-component__content-item-is-active { + display: block; + } + } + } +} diff --git a/src/components/adslot-ui/index.js b/src/components/adslot-ui/index.js index 3baa2bdb0..d9a1d381a 100644 --- a/src/components/adslot-ui/index.js +++ b/src/components/adslot-ui/index.js @@ -31,11 +31,13 @@ import HoverDropdownMenu from 'adslot-ui/HoverDropdownMenu'; import fastStatelessWrapper from 'adslot-ui/fastStatelessWrapper'; import InformationBox from 'adslot-ui/InformationBox'; import Nav from 'adslot-ui/Navigation'; +import VerticalNav from 'adslot-ui/VerticalNavigation'; import OverlayLoader from 'adslot-ui/OverlayLoader'; import ActionPanel from 'adslot-ui/ActionPanel'; export { Accordion, + ActionPanel, AlertInput, ButtonGroup, Carousel, @@ -69,5 +71,5 @@ export { InformationBox, HoverDropdownMenu, OverlayLoader, - ActionPanel, + VerticalNav, }; diff --git a/src/index.js b/src/index.js index 7e1a37c01..a548295f4 100644 --- a/src/index.js +++ b/src/index.js @@ -44,6 +44,7 @@ import { import { Accordion, + ActionPanel, AlertInput, ButtonGroup, Carousel, @@ -77,11 +78,12 @@ import { InformationBox, HoverDropdownMenu, OverlayLoader, - ActionPanel, + VerticalNav, } from 'adslot-ui'; export { Accordion, + ActionPanel, Alert, AlertInput, Avatar, @@ -146,5 +148,5 @@ export { InformationBox, HoverDropdownMenu, OverlayLoader, - ActionPanel, + VerticalNav, }; diff --git a/src/styles/icons/burger.svg b/src/styles/icons/burger.svg new file mode 100644 index 000000000..41ed01c17 --- /dev/null +++ b/src/styles/icons/burger.svg @@ -0,0 +1,39 @@ + + + + +Artboard 24 copy 2 + + + + + + + + + + + + + + + + + + + + diff --git a/src/styles/icons/details.svg b/src/styles/icons/details.svg new file mode 100644 index 000000000..f5daca238 --- /dev/null +++ b/src/styles/icons/details.svg @@ -0,0 +1,43 @@ + + + + +Artboard 24 copy 2 + + + + + + + + + + + + + + + + + + + + + diff --git a/src/styles/mixin.scss b/src/styles/mixin.scss index fead5517b..ea8d42436 100644 --- a/src/styles/mixin.scss +++ b/src/styles/mixin.scss @@ -3,3 +3,11 @@ text-overflow: ellipsis; white-space: nowrap; } + +@mixin svg-icon($uri, $width: $icon-size, $height: $icon-size) { + width: $width; + height: $height; + background-repeat: no-repeat; + background-size: contain; + background-image: url($uri); +} diff --git a/www/components/Layout/index.jsx b/www/components/Layout/index.jsx index 78e892f90..db4a5be19 100644 --- a/www/components/Layout/index.jsx +++ b/www/components/Layout/index.jsx @@ -58,6 +58,7 @@ import InformationBoxExample from '../../examples/InformationBoxExample'; import SplitPaneExample from '../../examples/SplitPaneExample'; import HoverDropdownMenuExample from '../../examples/HoverDropdownMenuExample'; import NavigationExample from '../../examples/NavigationExample'; +import VerticalNavigationExample from '../../examples/VerticalNavigationExample'; import OverlayLoaderExample from '../../examples/OverlayLoaderExample'; import SearchExample from '../../examples/SearchExample'; import ActionPanelExample from '../../examples/ActionPanelExample'; @@ -93,7 +94,7 @@ const componentsBySection = { 'typography-and-text-layout': ['text-ellipsis'], 'stats-and-data': ['count-badge', 'statistic', 'totals', 'slicey'], 'icons-and-graphics': ['svg-symbol', 'svg-symbol-circle'], - navigation: ['breadcrumb', 'tab', 'hover-dropdown-menu', 'navigation-tabs'], + navigation: ['breadcrumb', 'tab', 'hover-dropdown-menu', 'navigation-tabs', 'vertical-navigation-tabs'], 'feedback-and-states': ['alert', 'empty', 'spinner', 'overlay-loader', 'pretty-diff', 'status-pill'], dialogue: ['popover', 'help-icon-popover', 'avatar'], modals: ['confirm-modal'], @@ -210,6 +211,7 @@ class PageLayout extends React.Component { + diff --git a/www/examples/VerticalNavigationExample.jsx b/www/examples/VerticalNavigationExample.jsx new file mode 100644 index 000000000..d9a88ff94 --- /dev/null +++ b/www/examples/VerticalNavigationExample.jsx @@ -0,0 +1,128 @@ +import React from 'react'; +import Example from '../components/Example'; +import { VerticalNav } from '../../src'; + +const MenuContent = ({ isCollapsed, isActive, label, icon }) => ( +
+
{icon}
+
{label}
+
+); + +class VerticalNavigationExample extends React.PureComponent { + state = { + isCollapsed: false, + activeTab: 'Tab 1', + }; + + handleCollapse = () => this.setState(prevState => ({ isCollapsed: !prevState.isCollapsed })); + + handleMenuClick = tabName => () => this.setState({ activeTab: tabName }); + + renderMenu1 = ({ isCollapsed }) => ( + :
, + }} + /> + ); + + render() { + return ( + + + this is a content + + ( + , + }} + /> + )} + > + this is another content + + + ); + } +} + +const exampleProps = { + componentName: 'Vertical Navigation Tabs', + exampleCodeSnippet: ` + + + this is a content + + ( + , + }} + /> + )} + > + this is another content + + + `, + propTypeSectionArray: [ + { + propTypes: [ + { + propType: 'isCollapsed', + type: 'bool', + defaultValue:
false
, + }, + { + propType: 'dts', + type: 'string', + note: 'data-test-selector; used for testing purposes', + }, + { + propType: 'onClick', + type: 'func', + note: ( +
+ event handler for clicking on the collapse/expand button
+
const onClick = () => ...
+
+ ), + }, + { + propType: 'className', + type: 'string', + }, + ], + }, + ], +}; + +export default () => ( + + + +); diff --git a/www/examples/styles.scss b/www/examples/styles.scss index e4c39ff0e..f8956d0d5 100644 --- a/www/examples/styles.scss +++ b/www/examples/styles.scss @@ -1,4 +1,5 @@ @import '~styles/variable'; +@import '~styles/mixin'; @mixin accent($id, $color-accent) { .tag-component-accent { @@ -185,6 +186,53 @@ } } } + + &.vertical-navigation-tabs-example { + .aui--vertical-navigation-component__menu-item { + &.aui--vertical-navigation-component__menu-item-is-active { + .menu-icon { + border-radius: 50%; + background-color: $color-gray-lighter; + } + } + } + + .menu-content { + display: flex; + flex-direction: row; + + &:hover { + .menu-icon { + border-radius: 50%; + background-color: $color-gray-lighter; + } + } + + .menu-icon { + width: 40px; + height: 40px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 8px; + + .product-details-icon { + @include svg-icon('~styles/icons/details.svg', 24px, 24px); + } + + .burger-icon { + @include svg-icon('~styles/icons/burger.svg', 24px, 24px); + } + } + + .menu-label { + display: flex; + flex-direction: column; + justify-content: center; + padding: 8px; + } + } + } } .full-width {