@targetprocess/tabs
Version:
400 lines (337 loc) • 11.5 kB
JSX
import React from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import ResizeDetector from 'react-resize-detector'
import ShowMore from './ShowMore'
import {TabPanel} from './TabPanel'
import TabInner from './TabInner'
import styles from './style/index.css'
import {throttle} from '../../base/utils/throttle'
import {debounce} from '../../base/utils/debounce'
const tabPrefix = 'tab-'
const panelPrefix = 'panel-'
// By itself, Tab is just a dummy component that is used for a cleaner Tabs API
// and as marker for actual tabs to prevent storing them as huge array
export const Tab = () => null
Tab.propTypes = {
header: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
id: PropTypes.string.isRequired,
children: PropTypes.node,
disabled: PropTypes.bool
}
/**
* Tabs component is an aggregate root for several content panes
* (via Tabs.Tab component), each with its own children, and a header
* to switch between them.
*
* Each `<Tab />` must have a `header` (that can be arbitary react element but
* is most commonly just a string), `id` and regular children.
*
* Top-level `<Tabs />` can work in either controlled or uncontrolled mode.
* In controlled mode, it uses `initialActiveTabId` to determine which
* tab is open by default, and then manages manually manages changing tabs
* on appropriate header clicks.
* In uncontrolled mode, it uses `activeTabId` prop to determine which tab
* is open and exposes `onActiveTabChanged` callback that is invoked each time
* a user clicks on a tab.
**/
export class Tabs extends React.Component {
static propTypes = {
/** Name of some important part of the component */
name: PropTypes.string.isRequired,
activeTabId: PropTypes.string,
initialActiveTabId: PropTypes.string,
onActiveTabChanged: PropTypes.func,
resizeThrottle: PropTypes.number,
tabSizeTimeout: PropTypes.number,
className: PropTypes.string
}
static defaultProps = {
resizeThrottle: 100,
tabSizeTimeout: 200
}
tabRefs = {}
activeTabElement = null
state = {
activeTabId: this.props.initialActiveTabId,
sliderLeft: 0,
sliderWidth: 0,
tabDimensions: {},
blockWidth: 0,
tabsTotalWidth: 0,
showMoreWidth: 40,
focusedTabKey: null
}
isControlledMode = () => typeof this.props.activeTabId !== 'undefined'
changeTab = tabId => {
this.setState({activeTabId: tabId})
if (typeof this.props.onActiveTabChanged === 'function') {
this.props.onActiveTabChanged(tabId)
}
}
componentDidMount() {
setTimeout(() => {
this.setTabsDimensions()
this.adjustSlider()
}, this.props.tabSizeTimeout)
}
componentDidUpdate(previousProps, previousState) {
const activeTabChanged = this.isControlledMode()
? this.props.activeTabId !== previousProps.activeTabId
: this.state.activeTabId !== previousState.activeTabId
const {blockWidth} = this.state
if (activeTabChanged) {
this.adjustSlider()
}
if (!blockWidth) {
this.setTabsDimensions()
}
}
shouldComponentUpdate(nextProps, nextState) {
const {
activeTabId,
blockWidth,
showMoreWidth,
sliderLeft,
sliderWidth,
tabsTotalWidth,
focusedTabKey
} = this.state
const {
initialActiveTabId,
onActiveTabChanged,
resizeThrottle,
tabSizeTimeout,
children
} = this.props
return (
nextState.blockWidth !== blockWidth ||
nextState.showMoreWidth !== showMoreWidth ||
nextState.sliderLeft !== sliderLeft ||
nextState.sliderWidth !== sliderWidth ||
nextState.tabsTotalWidth !== tabsTotalWidth ||
nextState.focusedTabKey !== focusedTabKey ||
nextState.activeTabId !== activeTabId ||
nextProps.initialActiveTabId !== initialActiveTabId ||
nextProps.onActiveTabChanged !== onActiveTabChanged ||
nextProps.resizeThrottle !== resizeThrottle ||
nextProps.tabSizeTimeout !== tabSizeTimeout ||
nextProps.children !== children
)
}
setTabsDimensions = () => {
window.requestAnimationFrame(() => {
if (!this.tabsWrapper) {
// it shouldn't happens evern. Just paranoic check
return
}
// initial wrapper width calculation
const blockWidth = this.tabsWrapper.offsetWidth
// calculate width and offset for each tab
let tabsTotalWidth = 0
const tabDimensions = {}
Object.keys(this.tabRefs).forEach(key => {
if (this.tabRefs[key]) {
const width = this.tabRefs[key].tab.offsetWidth
tabDimensions[key.replace(tabPrefix, '')] = {width, offset: tabsTotalWidth}
tabsTotalWidth += width
}
})
this.setState({tabDimensions, tabsTotalWidth, blockWidth})
})
}
adjustSlider = () => {
window.requestAnimationFrame(() => {
if (this.activeTabElement && this.activeTabElement.tab) {
this.setState({
sliderLeft: this.activeTabElement.tab.firstChild.offsetLeft,
sliderWidth: this.activeTabElement.tab.firstChild.clientWidth
})
} else {
this.setState({sliderLeft: 0, sliderWidth: 0})
}
})
}
onResizeThrottled = throttle(() => {
if (this.tabsWrapper) {
this.setState({blockWidth: this.tabsWrapper.offsetWidth})
}
this.adjustSlider()
}, this.props.resizeThrottle)
handleHeaderResize = debounce(() => {
this.adjustSlider()
}, 50)
renderTabHeader = headerProp => {
const isRenderProp = typeof headerProp === 'function'
if (!isRenderProp) {
return headerProp
}
return headerProp({
onResize: this.handleHeaderResize
})
}
getTabs = allTabs => {
const {blockWidth, tabsTotalWidth, tabDimensions, showMoreWidth} = this.state
const activeTabId = this.getActiveTabId(allTabs)
const availableWidth = blockWidth - (tabsTotalWidth > blockWidth ? showMoreWidth : 0)
return allTabs.reduce(
(result, item) => {
const {id, header, children, disabled} = item.props
const selected = activeTabId === id
const payload = {tabIndex: result.tabIndex, selected, disabled, id}
const tabPayload = {
...payload,
header
}
const panelPayload = {
...payload,
children
}
const tabWidth = tabDimensions[id] ? tabDimensions[id].width : 0
/* eslint-disable no-param-reassign */
if (
// initial call
!blockWidth ||
// all tabs are fit into the block
blockWidth > tabsTotalWidth ||
// current tab fit into the block
result.availableWidth - tabWidth - showMoreWidth > 0
) {
result.tabsVisible.push(tabPayload)
} else {
result.tabsHidden.push(tabPayload)
if (selected) {
result.isSelectedTabHidden = true
}
}
/* eslint-enable no-param-reassign */
result.panels[id] = panelPayload // eslint-disable-line no-param-reassign
result.availableWidth -= tabWidth
result.tabIndex += 1
return result
},
{
tabsVisible: [],
tabsHidden: [],
panels: {},
isSelectedTabHidden: false,
tabIndex: 0,
availableWidth
}
)
}
getActiveTabId = tabs => {
const [firstTab] = tabs
if (this.isControlledMode()) {
const tabId = this.props.activeTabId
if (tabId && tabs.some(tab => tab.props.id === tabId)) {
return tabId
}
return firstTab.props.id
}
const {activeTabId} = this.state
if (typeof activeTabId === 'undefined') {
if (!(firstTab && firstTab.props)) {
return null
}
return firstTab.props.id || null
}
return activeTabId
}
onFocusTab = focusedTabKey => () => this.setState({focusedTabKey})
onBlurTab = () => this.setState({focusedTabKey: null})
onKeyDown = event => {
if (event.key === 'Enter' && this.state.focusedTabKey !== null) {
this.changeTab(this.state.focusedTabKey)
}
}
onClick = tab => !tab.disabled && this.changeTab(tab.id)
getTabProps = ({header, id, selected, tabIndex, disabled}) => ({
selected,
tabIndex,
children: this.renderTabHeader(header),
key: tabPrefix + id,
id: tabPrefix + id,
ref: tab => {
if (selected) {
this.activeTabElement = tab
}
return (this.tabRefs[tabPrefix + id] = tab)
},
originalKey: id,
onClick: () => this.onClick({disabled, id}),
getOnFocusCallback: this.onFocusTab,
onBlur: this.onBlurTab,
panelId: panelPrefix + id,
classNames: classNames(
styles['tabs__header__tab-item'],
selected && styles['tabs__header__tab-item--active'],
disabled && styles['tabs__header__tab-item--disabled']
)
})
getPanelProps = ({id, children}) => ({
children,
key: panelPrefix + id,
id: panelPrefix + id,
tabId: tabPrefix + id
})
showMoreChanged = element => {
if (!element) {
return
}
const showMoreWidth = element.offsetWidth
if (this.state.showMoreWidth === showMoreWidth) {
return
}
this.setState({
showMoreWidth
})
}
getShowMoreProps = isSelectedTabHidden => ({
onShowMoreChanged: this.showMoreChanged,
hasChildSelected: isSelectedTabHidden
})
getSliderStyle = isSelectedTabHidden => ({
left: isSelectedTabHidden ? '100%' : this.state.sliderLeft,
width: isSelectedTabHidden ? '0' : this.state.sliderWidth
})
render() {
const allTabs = React.Children.toArray(this.props.children).filter(child => child.type === Tab)
const activeTabId = this.getActiveTabId(allTabs)
const {tabsVisible, tabsHidden, panels, isSelectedTabHidden} = this.getTabs(allTabs)
const activeTab = panels[activeTabId] || allTabs[0]
return (
<div className={classNames(styles['tabs'], this.props.className)}>
<div className={styles.tabs__header}>
<div
ref={tabs => (this.tabsWrapper = tabs)}
onKeyDown={this.onKeyDown}
className={styles.tabs__header__container}
>
{tabsVisible.map(tab => (
<TabInner {...this.getTabProps(tab)} />
))}
{
<ShowMore {...this.getShowMoreProps(isSelectedTabHidden)}>
{tabsHidden.map(tab => (
<TabInner {...this.getTabProps(tab)} />
))}
</ShowMore>
}
</div>
<div className={styles.tabs__header__line}>
<div
className={styles.tabs__header__line__slider}
style={this.getSliderStyle(isSelectedTabHidden)}
/>
</div>
</div>
<div className={styles.tabs__body}>
{activeTab && <TabPanel {...this.getPanelProps(activeTab)} />}
{<ResizeDetector handleWidth onResize={this.onResizeThrottled} />}
</div>
</div>
)
}
}
Tabs.Tab = Tab