diff --git a/src/components/Nav/Nav.css.ts b/src/components/Nav/Nav.css.ts index 0f6e5c2a7..bb7ab0031 100644 --- a/src/components/Nav/Nav.css.ts +++ b/src/components/Nav/Nav.css.ts @@ -6,6 +6,7 @@ import baseStyles from '../../styles/resets/baseStyles.css' export const config = { indicatorTransition: 'opacity 100ms ease', + padding: '10px', } export const NavUI = styled('nav', { pure: false })` @@ -24,7 +25,7 @@ export const ListUI = styled('ul', { pure: false })` ` export const ItemUI = styled('li', { pure: false })` - padding: 0 10px; + padding: 0; transform: translateZ(0); &.is-disabled { @@ -36,6 +37,7 @@ export const ItemUI = styled('li', { pure: false })` color: ${getColor('charcoal.200')}; display: block; text-decoration: none !important; + padding: 0 ${config.padding}; &:hover { color: ${getColor('charcoal.300')}; @@ -81,13 +83,13 @@ export const ErrorWrapperUI = styled(Flexy.Item)` export const IndicatorUI = styled('div')` background: ${getColor('blue.500')}; border-radius: 9999px; - bottom: 0; + bottom: -1px; height: 2px; opacity: 0; left: 0; + right: 0; position: absolute; transition: ${config.indicatorTransition}; - width: 100%; will-change: opacity; ${({ isActive }) => diff --git a/src/components/TabBar/README.md b/src/components/TabBar/README.md new file mode 100644 index 000000000..746678c96 --- /dev/null +++ b/src/components/TabBar/README.md @@ -0,0 +1,28 @@ +# TabBar + +This component is provides a Tab-like component with support for [react-router](https://github.com/ReactTraining/react-router). The [TabBar.Item](./Item.md) component is a simple link to the [Nav.Item](../Nav/docs/Item.md) component. + +TabBar is a wrapper containing a [Toolbar](../Toolbar/README.md), a [Nav](../Nav/README.md) and a secondary (left or right aligned) content placeholder. + +## Example + +```jsx + + + Home + + + About + + + Contact + + +``` + +## Props + +| Prop | Type | Default | Description | +| ---------- | -------- | ------- | --------------------------------------------------------------------------------------------------- | +| className | `string` | | The className of the component. | +| secContent | `any` | | A right or left aligned placeholder that will be render inside the toolbar as the secondary content | diff --git a/src/components/TabBar/TabBar.css.ts b/src/components/TabBar/TabBar.css.ts new file mode 100644 index 000000000..5220ec332 --- /dev/null +++ b/src/components/TabBar/TabBar.css.ts @@ -0,0 +1,69 @@ +import styled from '../styled' +import Toolbar from '../Toolbar' + +import { getColor } from '../../styles/utilities/color' +import baseStyles from '../../styles/resets/baseStyles.css' + +const getAlignment = align => { + switch (align) { + case 'center': + return 'center' + default: + return 'flex-start' + } +} + +const getDirection = align => { + switch (align) { + case 'right': + return 'row-reverse' + default: + return 'row' + } +} + +export const TabBarUI = styled('nav')` + --BlueConfigGlobalFontSize: 14px; + ${baseStyles}; + display: flex; + margin: 0 auto; + + .c-Toolbar { + justify-content: ${props => getAlignment(props.align)}; + flex-direction: ${props => getDirection(props.align)}; + } + + .c-ToolbarWrapper { + width: 100%; + } +` + +export const SecContentUI = styled(Toolbar.Item)` + font-size: 14px; + color: ${getColor('charcoal.200')}; + display: flex; + align-items: center; + margin-bottom: calc(8px * -1); + + &.is-defaultItem { + margin-left: ${props => (props.align !== 'right' ? 'auto' : '0')}; + margin-right: ${props => (props.align === 'right' ? 'auto' : '0')}; + } + + b, + strong { + font-weight: 500; + color: ${getColor('charcoal.600')}; + } +` + +// adjust margin to have to active bar hover the toolbar border +export const ToolbarUI = styled(Toolbar)` + width: 100%; + padding-left: 0; + padding-right: 0; + + &.is-placement-top.is-size-sm .c-Nav { + /* margin-bottom: calc(9px * -1); */ + } +` diff --git a/src/components/TabBar/TabBar.tsx b/src/components/TabBar/TabBar.tsx new file mode 100644 index 000000000..028aabc1d --- /dev/null +++ b/src/components/TabBar/TabBar.tsx @@ -0,0 +1,60 @@ +import * as React from 'react' +import propConnect from '../PropProvider/propConnect' +import getValidProps from '@helpscout/react-utils/dist/getValidProps' +import Nav from '../Nav' +import Toolbar from '../Toolbar' +import { classNames } from '../../utilities/classNames' +import { noop } from '../../utilities/other' +import { TabBarUI, SecContentUI, ToolbarUI } from './TabBar.css' +import { COMPONENT_KEY } from './TabBar.utils' + +export interface Props { + className?: string + children?: any + innerRef: (node: HTMLElement) => void + secContent?: any + align?: 'left' | 'center' | 'right' +} + +export class TabBar extends React.Component { + static className = 'c-TabBar' + static defaultProps = { + innerRef: noop, + align: 'left', + } + + static Item = Nav.Item + + getClassName() { + const { className } = this.props + return classNames(TabBar.className, className) + } + + render() { + const { children, innerRef, secContent, align, ...rest } = this.props + + return ( + + + + + + {secContent && ( + {secContent} + )} + + + ) + } +} + +const PropConnectedComponent = propConnect(COMPONENT_KEY, { pure: false })( + TabBar +) + +export default PropConnectedComponent diff --git a/src/components/TabBar/TabBar.utils.ts b/src/components/TabBar/TabBar.utils.ts new file mode 100644 index 000000000..d9c1a42e9 --- /dev/null +++ b/src/components/TabBar/TabBar.utils.ts @@ -0,0 +1 @@ +export const COMPONENT_KEY = 'TabBar' diff --git a/src/components/TabBar/__tests__/TabBar.test.tsx b/src/components/TabBar/__tests__/TabBar.test.tsx new file mode 100644 index 000000000..083e37a35 --- /dev/null +++ b/src/components/TabBar/__tests__/TabBar.test.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import { mount, render } from 'enzyme' +import { TabBar } from '../TabBar' +import { SecContentUI, TabBarUI } from '../TabBar.css' +import Item from '../../Nav/Nav.Item' +import Toolbar from '../../Toolbar' + +describe('className', () => { + test('Has default className', () => { + const wrapper = render() + + expect(wrapper.hasClass('c-TabBar')).toBeTruthy() + }) + + test('Can render custom className', () => { + const customClassName = 'blue' + const wrapper = render() + + expect(wrapper.hasClass(customClassName)).toBeTruthy() + }) +}) + +describe('Sub-components', () => { + test('Has Item sub-component', () => { + expect(TabBar.Item).toBe(Item) + }) +}) + +describe('Render', () => { + test('Has a Toolbar component', () => { + const wrapper = mount() + expect(wrapper.find(Toolbar).length).toBeTruthy() + }) +}) + +describe('Align', () => { + test('Sets align prop to TabBarUI', () => { + const align = 'right' + const wrapper = mount() + expect(wrapper.find(TabBarUI).prop('align')).toBe(align) + }) + + test('Sets align prop to childrens', () => { + const align = 'right' + const wrapper = mount() + expect(wrapper.find(TabBarUI).prop('align')).toBe(align) + expect(wrapper.find(SecContentUI).prop('align')).toBe(align) + }) +}) + +describe('Secondary content', () => { + test('Renders nothing if prop is empty', () => { + const wrapper = mount() + expect(wrapper.find(SecContentUI).length).toBeFalsy() + }) + test('Renders secondary content text', () => { + const text = 'sec content text' + const wrapper = mount() + expect(wrapper.find(SecContentUI).text()).toBe(text) + }) + + test('Renders secondary content child', () => { + const text = 'this is a test' + const node = {text} + const wrapper = mount() + expect(wrapper.find(SecContentUI).text()).toBe(text) + }) +}) diff --git a/src/components/TabBar/docs/TabBar.md b/src/components/TabBar/docs/TabBar.md new file mode 100644 index 000000000..0c3622ab4 --- /dev/null +++ b/src/components/TabBar/docs/TabBar.md @@ -0,0 +1,25 @@ +# TabBar + +This component is provides navigation interactions with support for [react-router](https://github.com/ReactTraining/react-router). The [TabBar.Item](./Item.md) component is a simple link to the [Nav.Item](../Nav/docs/Item.md) component. + +## Example + +```jsx + + + Home + + + About + + + Contact + + +``` + +## Props + +| Prop | Type | Default | Description | +| --------- | -------- | ------- | ------------------------------- | +| className | `string` | | The className of the component. | diff --git a/src/components/TabBar/index.ts b/src/components/TabBar/index.ts new file mode 100644 index 000000000..58ee39610 --- /dev/null +++ b/src/components/TabBar/index.ts @@ -0,0 +1,3 @@ +import TabBar from './TabBar' + +export default TabBar diff --git a/src/components/index.js b/src/components/index.js index 68b1dd51d..ce26fa2e6 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -107,6 +107,7 @@ export { default as Text } from './Text' export { default as ThemeProvider } from './ThemeProvider' export { default as Timeline } from './Timeline' export { default as Timestamp } from './Timestamp' +export { default as TabBar } from './TabBar' export { default as Toolbar } from './Toolbar' export { default as Tooltip } from './Tooltip' export { default as Truncate } from './Truncate' diff --git a/stories/TabBar.stories.js b/stories/TabBar.stories.js new file mode 100644 index 000000000..e88aca71c --- /dev/null +++ b/stories/TabBar.stories.js @@ -0,0 +1,126 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { TabBar } from '../src/index.js' +import Dropdown from '../src/components/Dropdown/V2/' +import Button from '../src/components/Button' + +import { MemoryRouter as Router, Route } from 'react-router-dom' +import { createSpec, faker } from '@helpscout/helix' + +import { withAktiv, withHsApp } from './utils' + +import { + withKnobs, + boolean, + number, + text, + select, +} from '@storybook/addon-knobs' + +const ItemSpec = createSpec({ + id: faker.random.uuid(), + label: faker.name.firstName(), + value: faker.name.firstName(), +}) + +const routerDecorator = storyFn => { + return {storyFn()} +} + +const stories = storiesOf('TabBar', module) +stories.addDecorator(withKnobs) +stories.addDecorator(withAktiv) +stories.addDecorator(routerDecorator) + +const renderTabBarItem = () => { + const itemHome = text('itemHomeText', 'Home') + const itemOne = text('itemOneText', 'One') + const itemTwo = text('itemTwoText', 'Two') + const itemThree = text('itemThreeText', 'Three') + const itemThreeError = text('itemThreeError', 'Something went wrong') + const itemFour = text('itemFourText', 'Four') + const itemFourDisabled = boolean('itemFourDisabled', true) + + return [ + + {itemHome} + , + + {itemOne} + , + + {itemTwo} + , + + {itemThree} + , + + {itemFour} + , + ] +} + +const dropdownContent = ( + + Dropdown + + } + /> +) + +stories.add('default', () => {renderTabBarItem()}) + +stories.add('center align', () => ( + {renderTabBarItem()} +)) + +stories.add('right align', () => ( + {renderTabBarItem()} +)) + +stories.add('with secondary content', () => { + const secContent = ( + + 13,456 items + + ) + return {renderTabBarItem()} +}) + +stories.add('right align with secondary content', () => { + const secContent = ( + + 13,456 items + + ) + return ( + + {renderTabBarItem()} + + ) +}) + +stories.add('with secondary content dropdown', () => { + return {renderTabBarItem()} +}) + +const storiesHsApp = storiesOf('TabBar/HS App', module) +storiesHsApp.addDecorator(withKnobs) +storiesHsApp.addDecorator(withHsApp) +storiesHsApp.addDecorator(routerDecorator) +storiesHsApp.add('with secondary content', () => { + const secContent = ( + + 13,456 items + + ) + return {renderTabBarItem()} +}) + +storiesHsApp.add('with secondary dropdown', () => { + return {renderTabBarItem()} +})