box-ui-elements-mlh
Version:
337 lines (284 loc) • 11.8 kB
JavaScript
// @flow
import * as React from 'react';
import classNames from 'classnames';
import omit from 'lodash/omit';
import uniqueId from 'lodash/uniqueId';
import IconPageBack from '../../icons/general/IconPageBack';
import IconPageForward from '../../icons/general/IconPageForward';
import LinkButton from '../link/LinkButton';
import './Tabs.scss';
export const TAB_KEY = 'Tab';
export const TAB_PANEL_ROLE = 'tabpanel';
type Props = {
children: React.Node,
className?: string,
focusedIndex: number,
isDynamic?: boolean,
onKeyUp?: Function,
onTabFocus: Function,
onTabSelect?: Function,
resetActiveTab: Function,
resetFocusedTab: Function,
selectedIndex: number,
};
type State = {
tabsContainerOffsetLeft: number,
};
class TabViewPrimitive extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.tabviewID = uniqueId('tabview');
this.state = {
tabsContainerOffsetLeft: 0,
};
}
componentDidMount() {
const { isDynamic, focusedIndex } = this.props;
if (isDynamic) {
// set initial tabsContainerOffsetLeft state after first mounting
this.scrollToTab(focusedIndex);
}
}
componentDidUpdate(prevProps: Props) {
const { focusedIndex: prevFocusedIndex, selectedIndex: prevSelectedIndex } = prevProps;
const { focusedIndex, selectedIndex } = this.props;
if (this.props.isDynamic) {
if (prevFocusedIndex !== focusedIndex) {
this.scrollToTab(focusedIndex);
}
// update tabsContainerOffsetLeft state when receiving a new prop
if (prevSelectedIndex !== selectedIndex) {
this.scrollToTab(selectedIndex);
}
}
if (prevFocusedIndex !== focusedIndex) {
// have to focus after render otherwise, the focus will be lost
this.focusOnTabElement(focusedIndex);
}
}
onClickTab = (tabIndex: number) => {
const { onTabFocus, onTabSelect } = this.props;
if (onTabSelect) {
onTabSelect(tabIndex);
}
onTabFocus(tabIndex);
};
getLastElementsAnchorPoint = () => {
if (this.tabsElements.length === 0) {
return 0;
}
const lastTabElement = this.tabsElements[this.tabsElements.length - 1];
return lastTabElement.offsetLeft + lastTabElement.offsetWidth;
};
getTabsContainerOffsetLeft = () => {
if (!this.tabsContainer) {
return 0;
}
const { tabsContainerOffsetLeft } = this.state;
let viewportOffset = parseInt(tabsContainerOffsetLeft, 10) * -1;
viewportOffset = viewportOffset || 0;
return viewportOffset;
};
getTabsContainerWidth = () => (this.tabsContainer ? parseInt(this.tabsContainer.offsetWidth, 10) : 0);
tabviewID: string;
scrollToTab = (tabIndex: number) => {
if (
!this.props.isDynamic ||
this.tabsContainer === null ||
this.tabsElements.length === 0 ||
tabIndex < 0 ||
tabIndex > this.tabsElements.length - 1
) {
return;
}
const tabElementOfInterest = this.tabsElements[tabIndex];
const lastElementsAnchorPoint = this.getLastElementsAnchorPoint();
// if tabs don't overflow at all, no need to scroll
const tabsContainerWidth = this.getTabsContainerWidth();
if (lastElementsAnchorPoint <= tabsContainerWidth) {
this.setState({ tabsContainerOffsetLeft: 0 });
return;
}
// do not scroll any more if we will go past the rightmost anchor
const newOffset = Math.min(lastElementsAnchorPoint - tabsContainerWidth, tabElementOfInterest.offsetLeft);
// move the viewport
const newViewportOffset = -1 * newOffset;
this.setState({ tabsContainerOffsetLeft: newViewportOffset });
};
isRightArrowVisible = () => {
if (!this.tabsContainer) {
return false;
}
const tabsContainerOffsetLeft = this.getTabsContainerOffsetLeft();
const lastElementsAnchorPoint = this.getLastElementsAnchorPoint();
const tabsContainerWidth = this.getTabsContainerWidth();
return tabsContainerOffsetLeft + tabsContainerWidth < lastElementsAnchorPoint;
};
isLeftArrowVisible = () => {
const { focusedIndex, selectedIndex } = this.props;
const tabsContainerOffsetLeft = this.getTabsContainerOffsetLeft();
return tabsContainerOffsetLeft !== 0 && (selectedIndex !== 0 || focusedIndex !== 0);
};
focusOnTabElement = (focusedIndex: number) => {
if (focusedIndex + 1 > this.tabsElements.length || focusedIndex < 0) {
return;
}
this.tabsElements[focusedIndex].focus();
};
tabsElements = [];
tabsContainer = null;
handleKeyDown = (event: SyntheticKeyboardEvent<>) => {
const { children, focusedIndex, onTabFocus, resetFocusedTab, resetActiveTab } = this.props;
const childrenCount = React.Children.count(children);
switch (event.key) {
case 'ArrowRight':
onTabFocus(this.calculateNextIndex(focusedIndex, childrenCount));
event.preventDefault();
event.stopPropagation();
break;
case 'ArrowLeft':
onTabFocus(this.calculatePrevIndex(focusedIndex, childrenCount));
event.preventDefault();
event.stopPropagation();
break;
case 'Escape':
resetActiveTab();
break;
case TAB_KEY:
resetFocusedTab();
break;
default:
break;
}
};
calculateNextIndex = (currentIndex: number, childrenCount: number) => (currentIndex + 1) % childrenCount;
calculatePrevIndex = (currentIndex: number, childrenCount: number) =>
(currentIndex - 1 + childrenCount) % childrenCount;
/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */
renderTabs() {
const { children, selectedIndex, isDynamic } = this.props;
const { tabsContainerOffsetLeft } = this.state;
const style = isDynamic ? { left: `${tabsContainerOffsetLeft}px` } : {};
return (
<nav
className="tabs"
role="tablist"
ref={ref => {
this.tabsContainer = ref;
}}
style={style}
onKeyDown={!isDynamic ? this.handleKeyDown : null}
>
{React.Children.map(children, (tab, i) => {
const buttonProps = omit(tab.props, ['className', 'children', 'title']);
const classes = classNames('btn-plain', 'tab', i === selectedIndex ? 'is-selected' : '');
const ariaControls = `${this.tabviewID}-panel-${i + 1}`;
const ariaSelected = i === selectedIndex;
const id = `${this.tabviewID}-tab-${i + 1}`;
const { href, component, refProp } = tab.props;
const tabIndex = i === selectedIndex ? '0' : '-1';
if (href) {
return (
<LinkButton
className={classes}
aria-controls={ariaControls}
aria-selected={ariaSelected}
id={id}
role="tab"
href={href}
linkRef={ref => {
this.tabsElements[i] = ref;
}}
refProp={refProp}
tabIndex={tabIndex}
to={href}
component={component}
>
<div className="tab-title">{tab.props.title}</div>
<div className="tab-underline" />
</LinkButton>
);
}
return (
<button
className={classes}
aria-controls={ariaControls}
aria-selected={ariaSelected}
onClick={() => this.onClickTab(i)}
role="tab"
type="button"
id={id}
ref={ref => {
this.tabsElements[i] = ref;
}}
tabIndex={tabIndex}
{...buttonProps}
>
<div className="tab-title">{tab.props.title}</div>
<div className="tab-underline" />
</button>
);
})}
</nav>
);
}
/* eslint-enable jsx-a11y/no-noninteractive-element-to-interactive-role */
renderDynamicTabs() {
const { onTabFocus, focusedIndex } = this.props;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div className="dynamic-tabs-bar" onKeyDown={this.handleKeyDown}>
<button
className={classNames('btn-plain svg-container left-arrow', {
hidden: !this.isLeftArrowVisible(),
})}
onClick={() => onTabFocus(focusedIndex - 1)}
type="button"
tabIndex="-1"
>
<IconPageBack />
</button>
<div className="dynamic-tabs-wrapper">{this.renderTabs()}</div>
<button
className={classNames('btn-plain svg-container right-arrow', {
hidden: !this.isRightArrowVisible(),
})}
onClick={() => onTabFocus(focusedIndex + 1)}
type="button"
tabIndex="-1"
>
<IconPageForward />
</button>
</div>
);
}
render() {
const { children, className = '', isDynamic = false, onKeyUp, selectedIndex } = this.props;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={`tab-view ${classNames(className, {
'dynamic-tabs': isDynamic,
})}`}
onKeyUp={onKeyUp}
>
{!isDynamic ? this.renderTabs() : this.renderDynamicTabs()}
<div className="tab-panels">
{React.Children.toArray(children).map((child, i) => (
<div
key={i}
id={`${this.tabviewID}-panel-${i}`}
aria-labelledby={`${this.tabviewID}-tab-${i + 1}`}
aria-hidden={selectedIndex !== i}
className={`tab-panel ${i === selectedIndex ? 'is-selected' : ''}`}
role={TAB_PANEL_ROLE}
>
{child.props.children}
</div>
))}
</div>
</div>
);
}
}
export default TabViewPrimitive;