react-cm-ui
Version:
React UI for Healthy Church
689 lines (601 loc) • 20.4 kB
JSX
/* eslint-disable linebreak-style */
import React, {
Component,
} from 'react';
import {
find,
findIndex,
get,
isFunction,
isEqual,
sortBy,
throttle,
} from 'lodash';
import ClassNames from 'classnames';
import PropTypes from 'prop-types';
import ResizeDetector from 'react-resize-detector';
import {
BEM_NAVIGATION_SECTIONAL_TABS,
BEM_NAVIGATION_TAB_ROOT_CLASS,
} from '../../global/constants';
import {
PADDING_X,
} from './constants';
import DropdownButton from '../../inputs/dropdownButton';
import SectionalTab from './sectionalTab';
import SectionalTabContent from './sectionalTabContent';
import withStyles from '../../styles/withStyles';
const PREFIX_TAB = 'tab-';
const CONTENT_PREFIX = 'content-';
const HIDDEN_TABS_ICON_WIDTH = 30;
const WIDTH_OFFSET = 5; // it ain't neccesary to be too precise when resizing and calculating hidden tabs
const propTypes = {
/**
* beforeChange callback: return false to prevent tab change
*/
beforeChange: PropTypes.func,
/**
* Custom classes to override the default styling
*/
classes: PropTypes.shape({
indicator: PropTypes.string,
root: PropTypes.string,
sectionalTab: PropTypes.string,
sectionalTabsPanel: PropTypes.string,
sectionalTabsPanelContent: PropTypes.string,
}),
/**
* onChange active tab callback
*/
onChange: PropTypes.func,
/**
* List of the tabs objects
*/
items: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
title: PropTypes.string.isRequired,
getContent: PropTypes.func,
onClick: PropTypes.func,
}),
),
/**
* Resize recalculation frecuency in milliseconds
*/
resizeThrottle: PropTypes.number,
/**
* Use this prop to programatically change the selected tab
*/
selectedTabKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* Renders the content set inside the item object under the tabs panel
*/
withContent: PropTypes.bool,
};
const defaultProps = {
beforeChange: undefined,
classes: null,
items: [],
onChange: undefined,
resizeThrottle: 100,
selectedTabKey: undefined,
withContent: false,
};
const styles = (theme) => {
const borderColorSecondary = get(theme, 'palette.border.secondary');
const colorActivePrimary = get(theme, 'palette.active.primary');
const colorHighlight = get(theme, 'palette.cyan[500]');
const fontWeightMedium = get(theme, 'typography.fontWeightMedium');
const textColorSecondary = get(theme, 'palette.text.secondary');
const textColorPrimary = get(theme, 'palette.text.primary');
return {
indicator: {
backgroundColor: colorActivePrimary,
bottom: 0,
height: 2,
position: 'absolute',
transition: 'all 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
},
root: {
position: 'relative',
'& .button_dropdown': {
margin: '0',
padding: 0,
width: HIDDEN_TABS_ICON_WIDTH,
'& .icon': {
marginRight: '00 !important',
},
'&-open': {
'& .icon-use-path': {
fill: `${colorHighlight} !important`,
},
},
},
},
sectionalTabsPanel: {
display: 'flex',
flexWrap: 'wrap',
margin: [[0, -PADDING_X]],
},
sectionalTabsPanelContent: {
padding: [[PADDING_X, 0]],
borderTop: `1px solid ${borderColorSecondary}`,
},
sectionalTab: {
backgroundColor: 'transparent',
border: 0,
borderRadius: 0,
cursor: 'pointer',
outline: 'none',
padding: [[5, PADDING_X]],
whiteSpace: 'nowrap',
zIndex: 1,
},
sectionalTabLabel: {
fontWeight: fontWeightMedium,
color: textColorSecondary,
transition: 'color 0.1s, border-bottom 0.1s',
'&:hover': {
color: textColorPrimary,
},
},
sectionalTabLabelSelected: {
color: `${textColorPrimary} !important`,
},
};
};
/**
* Component capable to hide/show its tabs under a drop down button according to the container size.
* It swipes the hidden tabs on click making them visible.
*/
class SectionalTabs extends Component {
constructor(props) {
super(props);
this.state = {
blockWidth: 0,
items: props.items || [],
selectedTabKey: props.selectedTabKey,
selectedTabDimensions: {
left: null,
width: null,
},
tabDimensions: {},
tabsTotalWidth: 0,
};
this.getClassNamesFor = this.getClassNamesFor.bind(this);
this.getExpandedTabs = this.getExpandedTabs.bind(this);
this.getPanelProps = this.getPanelProps.bind(this);
this.getSelectedTabKey = this.getSelectedTabKey.bind(this);
this.getTabs = this.getTabs.bind(this);
this.getTabProps = this.getTabProps.bind(this);
this.onChangeTab = this.onChangeTab.bind(this);
this.onResize = this.onResize.bind(this);
this.onResizeThrottled = throttle(this.onResize, props.resizeThrottle, { trailing: true });
this.selectedTabKeyProp = props.selectedTabKey;
this.setTabsDimensions = this.setTabsDimensions.bind(this);
this.setSelectedTabDimensions = this.setSelectedTabDimensions.bind(this);
this.tabRefs = {};
}
componentDidMount() {
this.setTabsDimensions();
}
shouldComponentUpdate(nextProps, nextState) {
const {
items,
} = this.props;
const {
blockWidth,
selectedTabKey,
selectedTabDimensions,
tabsTotalWidth,
items: itemsState,
} = this.state;
const shouldUpdate = (
!isEqual(nextProps.items, items) ||
nextState.tabsTotalWidth !== tabsTotalWidth ||
nextState.blockWidth !== blockWidth ||
nextProps.selectedTabKey !== this.selectedTabKeyProp ||
nextState.selectedTabKey !== selectedTabKey ||
!isEqual(itemsState, nextState.items) ||
!isEqual(selectedTabDimensions, nextState.selectedTabDimensions)
);
return shouldUpdate;
}
componentDidUpdate(prevProps) {
const {
items,
selectedTabKey,
} = this.props;
const {
items: prevItems,
} = prevProps;
const didPropsItemsChange = !isEqual(prevItems, items);
const didSelectedTabKeyChange = this.selectedTabKeyProp !== selectedTabKey;
const shouldUpdateState = didPropsItemsChange || didSelectedTabKeyChange;
if (shouldUpdateState) {
const newState = {
...(didSelectedTabKeyChange && {
selectedTabKey,
}),
...(didPropsItemsChange && {
items,
selectedTabKey,
}),
};
this.setState(newState);
}
if (didPropsItemsChange) {
this.setTabsDimensions();
}
this.selectedTabKeyProp = selectedTabKey;
}
onResize() {
if (this.tabsWrapperRef) {
this.setState({ blockWidth: this.tabsWrapperRef.offsetWidth });
}
}
onChangeTab(nextTabKey, evt) {
const {
beforeChange,
onChange,
} = this.props;
const {
selectedTabKey,
} = this.state;
if (isFunction(beforeChange)) {
const beforeChangeRes = beforeChange({ selectedTabKey, nextTabKey });
if (beforeChangeRes === false) {
if (evt) {
evt.preventDefault();
}
return null;
}
}
this.setState({ selectedTabKey: nextTabKey });
if (isFunction(onChange)) {
onChange(nextTabKey);
}
return null;
}
setTabsDimensions() {
if (this.tabsWrapperRef) {
const blockWidth = this.tabsWrapperRef.offsetWidth;
const updatedTabDimensions = {};
const tabRefsKeys = Object.keys(this.tabRefs);
let updatedTabsTotalWidth = 0;
tabRefsKeys.forEach((key) => {
if (this.tabRefs[key]) {
const width = this.tabRefs[key].tab.offsetWidth;
updatedTabDimensions[key.replace(PREFIX_TAB, '')] = { width, offset: updatedTabsTotalWidth };
updatedTabsTotalWidth += width;
}
});
this.setState({
tabDimensions: updatedTabDimensions,
tabsTotalWidth: updatedTabsTotalWidth,
blockWidth,
});
}
}
setSelectedTabDimensions({
left,
width,
} = {
left: null,
width: null,
}) {
this.setState({
selectedTabDimensions: {
left,
width,
},
});
}
getTabs() {
const {
blockWidth,
items,
tabDimensions,
tabsTotalWidth,
} = this.state;
const selectedTabKey = this.getSelectedTabKey();
let tabIndex = 0;
let availableWidth = blockWidth - (
tabsTotalWidth > blockWidth ?
HIDDEN_TABS_ICON_WIDTH :
0
);
let reducedItems = items.reduce(
(result, item, index) => {
const {
content,
disabled,
getContent,
key = index,
onClick,
panelClassName,
tabClassName,
title,
} = item;
const selected = selectedTabKey === key;
const payload = {
disabled,
key,
selected,
tabIndex,
};
const tabPayload = {
...payload,
className: tabClassName,
onClick,
title,
};
const panelPayload = {
...payload,
className: panelClassName,
content,
getContent,
};
const tabWidth = tabDimensions[key] ? tabDimensions[key].width : 0;
tabIndex += 1;
const isTabVisible = // initial call
!blockWidth ||
tabsTotalWidth === 0 ||
tabWidth === 0 || // posibily re render from a items.prop change
// all tabs are fit into the block
(tabWidth > 0 && blockWidth > tabsTotalWidth + WIDTH_OFFSET) ||
// current tab fit into the block
(tabWidth > 0 && availableWidth - tabWidth > WIDTH_OFFSET);
if (isTabVisible) {
result.tabsVisible.push(tabPayload);
} else {
result.tabsHidden.push(tabPayload);
if (selected && tabWidth > 0) {
// eslint-disable-next-line no-param-reassign
result.isSelectedTabHidden = true;
}
}
// eslint-disable-next-line no-param-reassign
result.panels[key] = panelPayload;
availableWidth -= tabWidth;
return result;
},
{
isSelectedTabHidden: false,
panels: {},
tabsHidden: [],
tabsVisible: [],
},
);
let sortedHiddenTabs = sortBy(reducedItems.tabsHidden, ['key']);
let sortedVisibleTabs = sortBy(reducedItems.tabsVisible, ['key']);
if (reducedItems.isSelectedTabHidden) {
const lastVisibleTab = reducedItems.tabsVisible[reducedItems.tabsVisible.length - 1];
const hiddenSelectedTab = find(reducedItems.tabsHidden, { selected: true });
const hiddenSelectedTabIndex = findIndex(reducedItems.tabsHidden, { selected: true });
reducedItems.tabsVisible.splice(
reducedItems.tabsVisible.length - 1,
1,
hiddenSelectedTab,
);
reducedItems.tabsHidden.splice(hiddenSelectedTabIndex, 1, lastVisibleTab);
sortedHiddenTabs = sortBy(reducedItems.tabsHidden, ['key']);
sortedVisibleTabs = sortBy(reducedItems.tabsVisible, ['key']);
const reSortedItems = [
...reducedItems.tabsVisible,
...sortedHiddenTabs,
]
.filter((tab) => !!tab) // makes sure there's no undefined tab when several resize events happen rapidly
.map((tab) => find(items, { key: tab.key }));
this.setState({
items: reSortedItems,
});
}
reducedItems = {
...reducedItems,
isSelectedTabHidden: false,
tabsHidden: sortedHiddenTabs,
tabsVisible: sortedVisibleTabs,
};
return reducedItems;
}
getTabProps(tab = {}) {
const {
classes,
} = this.props;
const {
className,
collapsed,
disabled,
key,
onClick,
selected,
tabIndex,
title,
} = tab;
return {
children: title,
classNames: this.getClassNamesFor('tab', {
className,
collapsed,
disabled,
selected,
tabIndex,
}),
classes,
id: PREFIX_TAB + key,
key: PREFIX_TAB + key,
originalKey: key,
onClick,
onChange: this.onChangeTab,
ref: (ref) => { this.tabRefs[PREFIX_TAB + key] = ref; },
selected,
setSelectedTabDimensions: this.setSelectedTabDimensions,
};
}
getPanelProps(panel, isHidden) {
const {
className,
content,
getContent,
key,
} = panel;
return {
getContent,
children: content,
key: CONTENT_PREFIX + key,
id: CONTENT_PREFIX + key,
tabId: PREFIX_TAB + key,
classNames: this.getClassNamesFor('content', { className, isHidden }),
isHidden,
};
}
getClassNamesFor(type, itemStates) {
const {
classes,
} = this.props;
const {
className = '',
disabled,
selected,
tabIndex,
} = itemStates;
switch (type) {
case 'tab':
return ClassNames(
classes.sectionalTab,
`${BEM_NAVIGATION_TAB_ROOT_CLASS}`,
className,
{
[`${BEM_NAVIGATION_TAB_ROOT_CLASS}--first`]: !tabIndex,
[`${BEM_NAVIGATION_TAB_ROOT_CLASS}--selected`]: selected,
[`${BEM_NAVIGATION_TAB_ROOT_CLASS}--disabled`]: disabled,
},
);
case 'content':
return ClassNames(
classes.sectionalTabsPanelContent,
`${BEM_NAVIGATION_SECTIONAL_TABS}--content`,
className,
);
default:
return '';
}
}
getSelectedTabKey() {
const {
items,
} = this.props;
const {
selectedTabKey,
} = this.state;
if (!selectedTabKey) {
if (!items[0]) {
return undefined;
}
return items[0].key;
}
return selectedTabKey;
}
getExpandedTabs(panels, selectedTabKey) {
if (panels[selectedTabKey]) {
// eslint-disable-next-line react/jsx-props-no-spreading
return <SectionalTabContent {...this.getPanelProps(panels[selectedTabKey])} />;
}
return null;
}
render() {
const {
classes,
withContent,
} = this.props;
const {
selectedTabDimensions,
} = this.state;
const {
panels,
tabsHidden,
tabsVisible,
} = this.getTabs();
const selectedTabKey = this.getSelectedTabKey();
const rootClasses = ClassNames(
classes.root,
`${BEM_NAVIGATION_SECTIONAL_TABS}--container`,
);
const tabsClasses = ClassNames(
classes.sectionalTabsPanel,
`${BEM_NAVIGATION_SECTIONAL_TABS}--panel`,
);
const hiddenTabsDropDown = tabsHidden.length > 0 && (
<DropdownButton
iconType="ellipsis-h"
optionsTheme="light"
color="transparent"
>
{tabsHidden.map((tab) => {
const {
children,
onClick,
originalKey,
} = this.getTabProps(tab);
return (
<DropdownButton.Option
id={originalKey}
key={originalKey}
label={children}
onClick={(event, clickedTabKey) => {
if (isFunction(onClick)) {
onClick(tab);
}
this.onChangeTab(clickedTabKey);
}}
>
{children}
</DropdownButton.Option>
);
})}
</DropdownButton>
);
const indicatorStyle = {
left: selectedTabDimensions?.left ?? 0,
width: selectedTabDimensions?.width ?
selectedTabDimensions.width :
0,
};
return (
<div
className={rootClasses}
ref={(ref) => { this.tabsWrapperRef = ref; }}
role="presentation"
>
<div className={tabsClasses}>
{tabsVisible.reduce((result, tab) => {
result.push(
<SectionalTab
// eslint-disable-next-line react/jsx-props-no-spreading
{...this.getTabProps(tab)}
/>,
);
return result;
}, [])}
<span
className={classes.indicator}
style={{
...indicatorStyle,
}}
/>
{hiddenTabsDropDown}
</div>
{withContent && this.getExpandedTabs(panels, selectedTabKey)}
{<ResizeDetector handleWidth onResize={this.onResizeThrottled} />}
</div>
);
}
}
SectionalTabs.propTypes = propTypes;
SectionalTabs.defaultProps = defaultProps;
const SectionalTabsWithStyles = withStyles(styles, { withTheme: true })(SectionalTabs);
export default SectionalTabsWithStyles;