diff --git a/library/styleguideComponents/ReactComponent/index.js b/library/styleguideComponents/ReactComponent/index.js index e60a9b78f..dd21b8005 100644 --- a/library/styleguideComponents/ReactComponent/index.js +++ b/library/styleguideComponents/ReactComponent/index.js @@ -73,7 +73,7 @@ export default class ReactComponent extends Component { diff --git a/src/components/Tab/index.js b/src/components/Tab/index.js index 43a251802..391425ecc 100644 --- a/src/components/Tab/index.js +++ b/src/components/Tab/index.js @@ -60,6 +60,7 @@ class TabItem extends Component { id, ariaControls, fullWidth, + variant, disabled, } = this.props; const isActive = this.isSelected(); @@ -68,6 +69,7 @@ class TabItem extends Component { {label} diff --git a/src/components/Tab/styled/button.js b/src/components/Tab/styled/button.js index 4845a120a..3a10f83a7 100644 --- a/src/components/Tab/styled/button.js +++ b/src/components/Tab/styled/button.js @@ -43,7 +43,6 @@ const StyledAnchor = attachThemeAttrs(styled.button)` } &:hover { - background-color: ${props => props.palette.action.hover}; color: ${props => props.palette.text.label}; z-index: 1; } @@ -51,22 +50,35 @@ const StyledAnchor = attachThemeAttrs(styled.button)` &:focus { text-decoration: none; outline: 0; - border-radius: 14px 14px 0 0; } - &::after { - content: ''; - position: absolute; - left: -2px; - height: 20px; - width: 1px; - background-color: ${props => props.palette.border.divider}; - box-sizing: border-box; - } + ${props => + props.variant === 'card' && + ` + &:hover { + background-color: ${props.palette.action.hover}; + } - :hover::after { - background-color: transparent; - } + &::after { + content: ''; + position: absolute; + left: -2px; + height: 20px; + width: 1px; + background-color: ${props.palette.border.divider}; + box-sizing: border-box; + } + + :hover::after { + background-color: transparent; + } + + `}; + ${props => + props.variant === 'linear' && + ` + border-radius: 0; + `}; @media (max-width: 600px) { height: 100%; @@ -94,15 +106,15 @@ const StyledAnchor = attachThemeAttrs(styled.button)` border-radius: 0; } } - + ${props => props.isActive && + props.variant === 'card' && ` z-index: 2; background-color: ${props.palette.background.main}; color: ${props.palette.brand.main}; box-shadow: ${props.shadows.shadow_4}; - border-radius: 14px 14px 0 0; &:hover, &:active, &:visited, &:focus { background-color: ${props.palette.background.main}; @@ -178,6 +190,33 @@ const StyledAnchor = attachThemeAttrs(styled.button)` } } `}; + ${props => + props.isActive && + props.variant === 'linear' && + ` + z-index: 2; + color: ${props.palette.brand.main}; + + &:hover, &:active, &:visited, &:focus { + color: ${props.palette.brand.main}; + } + + @media (max-width: 600px) { + position: relative; + width: 100%; + + &::before { + content: ""; + height: 0.15rem; + width: 100%; + left: 0; + bottom: 0; + position: absolute; + background-color: ${props.palette.brand.main}; + border-radius: 100px; + } + } + `}; ${props => props.disabled && ` diff --git a/src/components/Tab/styled/container.js b/src/components/Tab/styled/container.js index 6ba79b2cd..9b6fba632 100644 --- a/src/components/Tab/styled/container.js +++ b/src/components/Tab/styled/container.js @@ -31,6 +31,7 @@ const StyledContainer = attachThemeAttrs(styled.li)` `}; ${props => props.isActive && + props.variant === 'card' && ` background-color: ${props.palette.background.main}; color: ${props.palette.brand.main}; @@ -109,6 +110,19 @@ const StyledContainer = attachThemeAttrs(styled.li)` box-sizing: border-box; } `}; + ${props => + props.isActive && + props.variant === 'linear' && + ` + color: ${props.palette.brand.main}; + z-index: 2; + + @media (max-width: 600px) { + border-radius: 0; + position: relative; + width: 100%; + } + `}; `; export default StyledContainer; diff --git a/src/components/Tabset/__test__/tabset.spec.js b/src/components/Tabset/__test__/tabset.spec.js index 7d097c5d8..51983c5ab 100644 --- a/src/components/Tabset/__test__/tabset.spec.js +++ b/src/components/Tabset/__test__/tabset.spec.js @@ -9,6 +9,8 @@ import { getChildrenTotalWidth, getChildrenTotalWidthUpToClickedTab, getTabIndexFromName, + getTabsMeta, + getTabMeta, } from './../utils'; import StyledButton from './../../Tab/styled/button'; @@ -23,6 +25,8 @@ jest.mock('./../utils.js', () => ({ isNotSameChildren: jest.fn(() => false), getUpdatedTabsetChildren: jest.fn(() => []), getTabIndexFromName: jest.fn(() => 0), + getTabsMeta: jest.fn(() => {}), + getTabMeta: jest.fn(() => {}), })); const registerTabMockFn = jest.fn(); @@ -139,6 +143,58 @@ describe('', () => { expect(component.instance().updateButtonsVisibility).toHaveBeenCalledTimes(1); expect(component.instance().isFirstTime).toBe(false); }); + it('should call updateIndicator function if the active tab name is changed', () => { + getTabIndexFromName.mockReset(); + getTabIndexFromName.mockReturnValue(0); + const component = mount( + + + + + , + ); + component.instance().updateIndicator = jest.fn(); + component.setProps({ activeTabName: 'tab-2' }); + expect(component.instance().updateIndicator).toHaveBeenCalledTimes(1); + }); + it('should call updateIndicator function when the tabset width is changed', () => { + getTabIndexFromName.mockReset(); + getTabIndexFromName.mockReturnValue(0); + getTabsMeta.mockReset(); + getTabsMeta.mockReturnValue({ width: 10 }); + getTabMeta.mockReset(); + getTabMeta.mockReturnValue({ left: 15 }); + const component = mount( + + + + + , + ); + component.instance().updateIndicator = jest.fn(); + component.setState({ tabsetMeta: { width: 9 }, tabMeta: { left: 15 } }); + component.setProps({ variant: 'linear' }); + expect(component.instance().updateIndicator).toHaveBeenCalledTimes(1); + }); + it('should call updateIndicator function when the tab position is changed', () => { + getTabIndexFromName.mockReset(); + getTabIndexFromName.mockReturnValue(0); + getTabsMeta.mockReset(); + getTabsMeta.mockReturnValue({ width: 10 }); + getTabMeta.mockReset(); + getTabMeta.mockReturnValue({ left: 15 }); + const component = mount( + + + + + , + ); + component.instance().updateIndicator = jest.fn(); + component.setState({ tabsetMeta: { width: 10 }, tabMeta: { left: 14 } }); + component.setProps({ variant: 'linear' }); + expect(component.instance().updateIndicator).toHaveBeenCalledTimes(1); + }); it('should set the left button disabled to true', () => { getLeftButtonDisabledState.mockReset(); getLeftButtonDisabledState.mockReturnValue(true); diff --git a/src/components/Tabset/index.d.ts b/src/components/Tabset/index.d.ts index 9a40a267a..9ee73bcea 100644 --- a/src/components/Tabset/index.d.ts +++ b/src/components/Tabset/index.d.ts @@ -5,6 +5,7 @@ export interface TabsetProps extends BaseProps { activeTabName?: string; onSelect?: (event: MouseEvent, name: string) => void; fullWidth?: boolean; + variant?: 'card' | 'linear'; id?: string; children?: ReactNode; } diff --git a/src/components/Tabset/index.js b/src/components/Tabset/index.js index 8d04f8701..11ef4bd33 100644 --- a/src/components/Tabset/index.js +++ b/src/components/Tabset/index.js @@ -14,6 +14,8 @@ import { getUpdatedTabsetChildren, getRightButtonDisabledState, getLeftButtonDisabledState, + getTabsMeta, + getTabMeta, } from './utils'; import RightThinChevron from './rightThinChevron'; import LeftThinChevron from './leftThinChevron'; @@ -25,6 +27,7 @@ import StyledTabset from './styled/tabset'; import StyledInnerContainer from './styled/innerContainer'; import StyledButtonGroup from './styled/buttonGroup'; import StyledButtonIcon from './styled/buttonIcon'; +import StyledIndicator from './styled/indicator'; const RIGHT_SIDE = 1; const LEFT_SIDE = -1; @@ -39,6 +42,9 @@ export default class Tabset extends Component { this.state = { tabsetChildren: [], areButtonsVisible: false, + tabsetMeta: {}, + tabMeta: {}, + areIndicatorVisible: false, }; this.isFirstTime = true; this.tabsetRef = React.createRef(); @@ -50,6 +56,7 @@ export default class Tabset extends Component { this.handleLeftButtonClick = this.handleLeftButtonClick.bind(this); this.handleRightButtonClick = this.handleRightButtonClick.bind(this); this.updateButtonsVisibility = this.updateButtonsVisibility.bind(this); + this.updateIndicator = this.updateIndicator.bind(this); this.handleSelect = this.handleSelect.bind(this); this.keyHandlerMap = { [RIGHT_KEY]: () => this.selectTab(RIGHT_SIDE), @@ -65,9 +72,9 @@ export default class Tabset extends Component { } componentDidUpdate(prevProp) { - const { tabsetChildren } = this.state; + const { children, variant, activeTabName } = this.props; + const { tabsetChildren, tabsetMeta, tabMeta } = this.state; const { isFirstTime } = this; - const { children } = this.props; const areAllChildrenRegistered = children.length === tabsetChildren.length; if (isNotSameChildren(children, prevProp.children)) { this.updateButtonsVisibility(); @@ -76,6 +83,22 @@ export default class Tabset extends Component { this.updateButtonsVisibility(); this.isFirstTime = false; } + + if (variant === 'linear') { + const tabIndex = getTabIndexFromName(tabsetChildren, activeTabName); + if (tabIndex !== -1) { + const tabset = this.tabsetRef.current; + const currentTabsetMeta = getTabsMeta(tabset); + const currentTabMeta = getTabMeta(activeTabName, tabsetChildren); + if ( + activeTabName !== prevProp.activeTabName || + tabMeta.left !== currentTabMeta.left || + tabsetMeta.width !== currentTabsetMeta.width + ) { + this.updateIndicator(); + } + } + } } componentWillUnmount() { @@ -101,6 +124,18 @@ export default class Tabset extends Component { this.setState({ areButtonsVisible: showButtons }); } + updateIndicator() { + const tabset = this.tabsetRef.current; + const { activeTabName } = this.props; + const { tabsetChildren } = this.state; + const tabsetMeta = getTabsMeta(tabset); + const tabMeta = getTabMeta(activeTabName, tabsetChildren); + const showIndicator = + Math.trunc(tabMeta.left) >= Math.trunc(tabsetMeta.left) && + Math.trunc(tabMeta.right) <= Math.trunc(tabsetMeta.right); + this.setState({ tabsetMeta, tabMeta, areIndicatorVisible: showIndicator }); + } + handleKeyPressed(event) { if (this.keyHandlerMap[event.keyCode]) { return this.keyHandlerMap[event.keyCode](); @@ -221,10 +256,13 @@ export default class Tabset extends Component { } render() { - const { activeTabName, fullWidth, children, style, className, id } = this.props; - const { areButtonsVisible } = this.state; + const { activeTabName, fullWidth, variant, children, style, className, id } = this.props; + const { areButtonsVisible, tabsetMeta, tabMeta, areIndicatorVisible } = this.state; const { screenWidth } = this; const showButtons = areButtonsVisible || screenWidth < 600; + const showIndicator = variant === 'linear' && areIndicatorVisible; + const indicatorWidth = tabMeta.width || 0; + const indicatorStart = Math.trunc(tabMeta.left) - Math.trunc(tabsetMeta.left) || 0; const context = { activeTabName, onSelect: this.handleSelect, @@ -232,14 +270,16 @@ export default class Tabset extends Component { privateUnRegisterTab: this.unRegisterTab, privateUpdateTab: this.updateTab, fullWidth, + variant, }; return ( - + + + + ); } @@ -280,6 +326,8 @@ Tabset.propTypes = { /** If true, the tabs will grow to use all the available space. * This value defaults to false. */ fullWidth: PropTypes.bool, + /** The variant changes the appearance of the Tabset. Accepted variants include card and linear. The default value is card. */ + variant: PropTypes.oneOf(['card', 'linear']), /** The id of the outer element. */ id: PropTypes.string, /** A CSS class for the outer element, in addition to the component's base classes. */ @@ -297,6 +345,7 @@ Tabset.defaultProps = { activeTabName: undefined, onSelect: () => {}, fullWidth: false, + variant: 'card', className: undefined, style: undefined, children: null, diff --git a/src/components/Tabset/readme.md b/src/components/Tabset/readme.md index d5d569a83..bd43991c5 100644 --- a/src/components/Tabset/readme.md +++ b/src/components/Tabset/readme.md @@ -524,7 +524,7 @@ class TabsetExample extends React.Component { id="tabset-3" onSelect={this.handleOnSelect} activeTabName={selected} - className="rainbow-p-horizontal_x-large" + variant="linear" > ; ``` + +##### Tabset linear + +```js +import React from 'react'; +import { Tabset, Tab } from 'react-rainbow-components'; +import styled from 'styled-components'; + +const StyledTabLabel = styled.div` + font-family: Lato; + font-size: 14px; + letter-spacing: 0.46px; +`; + +const StyledContainer = styled.div` + max-width: 600px; +`; + +const StyledContent = styled.div.attrs(props => { + return props.theme.rainbow.palette; +})` + background: ${props => props.background.main}; + width: 100%; + border-radius: 0.875rem 0.875rem; +`; + +const StyledTabContentTitle = styled.div.attrs(props => { + return props.theme.rainbow.palette; +})` + color: ${props => props.text.main}; + font-family: Lato; + font-size: 18px; + font-weight: 600; + line-height: 0.83; +`; + +const StyledTabContentText = styled.div` + font-family: Lato; + font-size: 14px; + line-height: 1.43; +`; + +const StyledIcon = styled.div` + width: 60px; + height: 50px; + border-radius: 100px; + background-color: rgba(227, 231, 233, 0.25); + display: flex; + justify-content: center; + align-content: center; + align-items: center; +`; + +const facebookIconStyles = { color: '#3c5997', height: 30 }; +const twitterIconStyles = { color: '#00b0f3', width: 30 }; +const googleIconStyles = { height: 30 }; + +class TabsetExample extends React.Component { + constructor(props) { + super(props); + this.state = { selected: 'companies' }; + this.handleOnSelect = this.handleOnSelect.bind(this); + } + + handleOnSelect(event, selected) { + this.setState({ selected }); + } + + getTabContent() { + const { selected } = this.state; + + if (selected === 'companies') { + return ( +
+ + + + +
+ + Google + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore. + +
+
+ + + + +
+ + Facebook + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore. + +
+
+ + + + +
+ + Twitter + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore. + +
+
+
+ ); + } + return ( + + In a primary rainbow, the arc shows red on the outer part and violet on the inner + side. This rainbow is caused by light being refracted when entering a droplet of + water, then reflected inside on the back of the droplet and refracted again when + leaving it. + + ); + } + + render() { + const { selected } = this.state; + return ( +
+ + + Companies} /> + + Customers} /> + + {this.getTabContent()} + +
+ ); + } +} + + ; +``` diff --git a/src/components/Tabset/styled/container.js b/src/components/Tabset/styled/container.js index e6852990a..e80827c70 100644 --- a/src/components/Tabset/styled/container.js +++ b/src/components/Tabset/styled/container.js @@ -2,6 +2,7 @@ import styled from 'styled-components'; import attachThemeAttrs from '../../../styles/helpers/attachThemeAttrs'; const StyledContainer = attachThemeAttrs(styled.div)` + position: relative; width: 100%; box-sizing: border-box; @@ -9,6 +10,12 @@ const StyledContainer = attachThemeAttrs(styled.div)` border-bottom: solid 1px ${props => props.palette.border.divider}; background-color: ${props => props.palette.background.main} !important; } + + ${props => + props.variant === 'linear' && + ` + border-bottom: solid 1px ${props.palette.border.divider}; + `}; `; export default StyledContainer; diff --git a/src/components/Tabset/styled/indicator.js b/src/components/Tabset/styled/indicator.js new file mode 100644 index 000000000..4cdf2c579 --- /dev/null +++ b/src/components/Tabset/styled/indicator.js @@ -0,0 +1,20 @@ +import styled from 'styled-components'; +import attachThemeAttrs from '../../../styles/helpers/attachThemeAttrs'; + +const StyledIndicator = attachThemeAttrs(styled.span)` + position: absolute; + left: ${props => props.indicatorStart}px; + bottom: 0; + height: 4px; + width: ${props => props.indicatorWidth}px; + border-radius: 10px; + background-color: ${props => props.palette.brand.main}; + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + + @media (max-width: 600px) { + height: 0; + width: 0; + } +`; + +export default StyledIndicator; diff --git a/src/components/Tabset/styled/innerContainer.js b/src/components/Tabset/styled/innerContainer.js index 4f74ee05c..db7a7747d 100644 --- a/src/components/Tabset/styled/innerContainer.js +++ b/src/components/Tabset/styled/innerContainer.js @@ -22,6 +22,11 @@ const StyledInnerContainer = styled.ul` ` justify-content: space-between; `}; + ${props => + props.variant === 'linear' && + ` + padding: 0; + `}; `; export default StyledInnerContainer; diff --git a/src/components/Tabset/utils.js b/src/components/Tabset/utils.js index ed4afaec1..794edf1aa 100644 --- a/src/components/Tabset/utils.js +++ b/src/components/Tabset/utils.js @@ -94,3 +94,29 @@ export function getRightButtonDisabledState(params) { } return false; } + +export function getTabsMeta(tabsNode) { + const rect = tabsNode.getBoundingClientRect(); + const tabsMeta = { + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + width: rect.width, + }; + return tabsMeta; +} + +export function getTabMeta(activeTabName, tabsetChildren) { + const tabIndex = getTabIndexFromName(tabsetChildren, activeTabName); + const tabNode = tabsetChildren[tabIndex].ref; + const rect = tabNode.getBoundingClientRect(); + const tabMeta = { + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + width: rect.width, + }; + return tabMeta; +}