Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Tabs] optimization of tabs for static label elements #11703

Closed
wants to merge 11 commits into from
59 changes: 59 additions & 0 deletions docs/src/pages/demos/tabs/TabsStaticLabel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Tabs from '@material-ui/core/Tabs';
import Tab from '@material-ui/core/Tab';
import Typography from '@material-ui/core/Typography';

function TabContainer(props) {
return (
<Typography component="div" style={{ padding: 8 * 3 }}>
{props.children}
</Typography>
);
}

TabContainer.propTypes = {
children: PropTypes.node.isRequired,
};

const styles = theme => ({
root: {
flexGrow: 1,
backgroundColor: theme.palette.background.paper,
},
});

class StaticLabelTabs extends React.Component {
state = {
value: 0,
};

handleChange = (event, value) => {
this.setState({ value });
};

render() {
const { classes } = this.props;
const { value } = this.state;

return (
<div className={classes.root}>
<AppBar position="static">
<Tabs staticLabel value={value} onChange={this.handleChange}>
<Tab label="Item One" />
<Tab label="Item Two" />
<Tab label="Item Three" href="#basic-tabs" />
</Tabs>
</AppBar>
</div>
);
}
}

StaticLabelTabs.propTypes = {
classes: PropTypes.object.isRequired,
};

export default withStyles(styles)(StaticLabelTabs);
6 changes: 6 additions & 0 deletions docs/src/pages/demos/tabs/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,9 @@ If you have read the [overrides documentation page](/customization/overrides)
but aren't confident jumping in, here's an example of how you can change the main color of the Tabs. The following demo matches the [Ant Design UI](https://ant.design/components/tabs/).

{{"demo": "pages/demos/tabs/CustomizedTabs.js"}}

### Static Labels

Static labels will ignore resize events and label text dimension changes, to provide a slight performance boost. If you're targetting a mobile device and your labels aren't changing, this may decrease rerender cost.

{{"demo": "pages/demos/tabs/TabsStaticLabel.js"}}
1 change: 1 addition & 0 deletions packages/material-ui/src/Tab/Tab.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface TabProps extends StandardProps<ButtonBaseProps, TabClassKey, 'o
onChange?: (event: React.ChangeEvent<{ checked: boolean }>, value: any) => void;
onClick?: React.EventHandler<any>;
selected?: boolean;
staticLabel?: boolean;
style?: React.CSSProperties;
textColor?: string | 'secondary' | 'primary' | 'inherit';
}
Expand Down
7 changes: 7 additions & 0 deletions packages/material-ui/src/Tab/Tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class Tab extends React.Component {
}

componentDidUpdate(prevProps, prevState) {
if (this.props.staticLabel) return;
if (this.state.labelWrapped === prevState.labelWrapped) {
/**
* At certain text and tab lengths, a larger font size may wrap to two lines while the smaller
Expand Down Expand Up @@ -141,6 +142,7 @@ class Tab extends React.Component {
icon,
indicator,
label: labelProp,
staticLabel,
onChange,
selected,
textColor,
Expand Down Expand Up @@ -243,6 +245,10 @@ Tab.propTypes = {
* @ignore
*/
selected: PropTypes.bool,
/**
* @ignore
*/
staticLabel: PropTypes.bool,
/**
* @ignore
*/
Expand All @@ -255,6 +261,7 @@ Tab.propTypes = {

Tab.defaultProps = {
disabled: false,
staticLabel: false,
textColor: 'inherit',
};

Expand Down
1 change: 1 addition & 0 deletions packages/material-ui/src/Tabs/Tabs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface TabsProps
scrollable?: boolean;
ScrollButtonComponent?: React.ReactType;
scrollButtons?: 'auto' | 'on' | 'off';
staticLabel?: boolean;
TabIndicatorProps?: Partial<TabIndicatorProps>;
textColor?: 'secondary' | 'primary' | 'inherit' | string;
value: any;
Expand Down
101 changes: 72 additions & 29 deletions packages/material-ui/src/Tabs/Tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ class Tabs extends React.Component {
showLeftScroll: false,
showRightScroll: false,
mounted: false,
tabsMeta: null,
tabMeta: null,
};

componentDidMount() {
Expand Down Expand Up @@ -91,8 +93,8 @@ class Tabs extends React.Component {
const conditionalElements = {};
conditionalElements.scrollbarSizeListener = scrollable ? (
<ScrollbarSize
onLoad={this.handleScrollbarSizeChange}
onChange={this.handleScrollbarSizeChange}
onLoad={this.handleScrollbarSizeChange}
/>
) : null;

Expand Down Expand Up @@ -123,32 +125,70 @@ class Tabs extends React.Component {
return conditionalElements;
};

getTabsMeta = (value, direction) => {
let tabsMeta;
if (this.tabs) {
const rect = this.tabs.getBoundingClientRect();
// create a new object with ClientRect class props + scrollLeft
tabsMeta = {
clientWidth: this.tabs ? this.tabs.clientWidth : 0,
scrollLeft: this.tabs ? this.tabs.scrollLeft : 0,
scrollLeftNormalized: this.tabs ? getNormalizedScrollLeft(this.tabs, direction) : 0,
scrollWidth: this.tabs ? this.tabs.scrollWidth : 0,
left: rect.left,
right: rect.right,
};
}
getMeta = refreshTabMeta => {
if (this.props.staticLabel) return this.getMetaStaticLabel(refreshTabMeta);
return {
tabMeta: this.getTabMeta(),
tabsMeta: this.getTabsMeta(),
};
};

let tabMeta;
if (this.tabs && value !== false) {
const children = this.tabs.children[0].children;
getMetaStaticLabel = refreshTabMeta => {
/*
Get meta static label only thrashes the DOM at two times:
1. On mount, getting all tabs dimensions
2. On mount and value change, getting the active tab dimensions
*/
const state = this.state;
const { value } = this.props;
const tabsMeta = state.tabsMeta || this.getTabsMeta();
const takeTabMeta = refreshTabMeta || !state.tabMeta || state.tabMeta.value !== value;
const tabMeta = takeTabMeta ? this.getTabMeta() : state.tabMeta;
const tabMetaUpdated = tabMeta && (tabMeta !== state.tabMeta || value !== tabMeta.value);
if (tabsMeta !== state.tabsMeta || tabMetaUpdated) {
this.setState({ tabMeta, tabsMeta });
}
return state;
};

getTabMeta = () => {
const { props, tabs } = this;
const { value } = props;
if (tabs && value !== false) {
const children = tabs.children[0].children;
if (children.length > 0) {
const tab = children[this.valueToIndex[value]];
warning(tab, `Material-UI: the value provided \`${value}\` is invalid`);
tabMeta = tab ? tab.getBoundingClientRect() : null;
if (tab) {
const rect = tab.getBoundingClientRect();
return {
left: rect.left,
right: rect.right,
value,
width: rect.width,
};
}
}
}
return { tabsMeta, tabMeta };
return undefined;
};

getTabsMeta = () => {
const { props, tabs } = this;
const { theme } = props;
if (tabs) {
const rect = this.tabs.getBoundingClientRect();
// create a new object with ClientRect class props + scrollLeft
return {
clientWidth: tabs.clientWidth,
scrollLeft: tabs.scrollLeft,
scrollLeftNormalized: getNormalizedScrollLeft(tabs, theme.direction),
scrollWidth: tabs.scrollWidth,
left: rect.left,
right: rect.right,
};
}
return undefined;
};

tabs = undefined;
Expand Down Expand Up @@ -195,10 +235,9 @@ class Tabs extends React.Component {
}
};

updateIndicatorState(props) {
const { theme, value } = props;
updateIndicatorState({ theme }) {
const { tabsMeta, tabMeta } = this.getMeta(false);

const { tabsMeta, tabMeta } = this.getTabsMeta(value, theme.direction);
let left = 0;

if (tabMeta && tabsMeta) {
Expand Down Expand Up @@ -226,13 +265,9 @@ class Tabs extends React.Component {
}

scrollSelectedIntoView = () => {
const { theme, value } = this.props;
const { tabsMeta, tabMeta } = this.getTabsMeta(value, theme.direction);

if (!tabMeta || !tabsMeta) {
return;
}
const { tabsMeta, tabMeta } = this.getMeta(true);

if (!tabMeta || !tabsMeta) return;
if (tabMeta.left < tabsMeta.left) {
// left side of button is out of view
const nextScrollLeft = tabsMeta.scrollLeft + (tabMeta.left - tabsMeta.left);
Expand Down Expand Up @@ -279,6 +314,7 @@ class Tabs extends React.Component {
scrollable,
ScrollButtonComponent,
scrollButtons,
staticLabel,
TabIndicatorProps = {},
textColor,
theme,
Expand Down Expand Up @@ -330,6 +366,7 @@ class Tabs extends React.Component {
indicator: selected && !this.state.mounted && indicator,
selected,
onChange,
staticLabel,
textColor,
value: childValue,
});
Expand Down Expand Up @@ -422,6 +459,12 @@ Tabs.propTypes = {
* `off` will never present them
*/
scrollButtons: PropTypes.oneOf(['auto', 'on', 'off']),
/**
* Prevents resizing on the labels after the first query.
* This improves performance, but leads to broken UX on resize or label change.
* As a result, it works best with mobile devices, where widths are fixed.
*/
staticLabel: PropTypes.bool,
/**
* Properties applied to the `TabIndicator` element.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/material-ui/src/Tabs/Tabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ describe('<Tabs />', () => {
it('should scroll left tab into view', () => {
metaStub.returns({
tabsMeta: { left: 0, right: 100, scrollLeft: 10 },
tabMeta: { left: -10, right: 10 },
tabMeta: { left: -10, right: 10, value: 0 },
});

instance.scrollSelectedIntoView();
Expand All @@ -589,7 +589,7 @@ describe('<Tabs />', () => {
it('should scroll right tab into view', () => {
metaStub.returns({
tabsMeta: { left: 0, right: 100, scrollLeft: 0 },
tabMeta: { left: 90, right: 110 },
tabMeta: { left: 90, right: 110, value: 1 },
});

instance.scrollSelectedIntoView();
Expand Down
7 changes: 7 additions & 0 deletions pages/demos/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ function Page() {
raw: preval`
module.exports = require('fs')
.readFileSync(require.resolve('docs/src/pages/demos/tabs/SimpleTabs'), 'utf8')
`,
},
'pages/demos/tabs/TabsStaticLabel.js': {
js: require('docs/src/pages/demos/tabs/TabsStaticLabel').default,
raw: preval`
module.exports = require('fs')
.readFileSync(require.resolve('docs/src/pages/demos/tabs/TabsStaticLabel'), 'utf8')
`,
},
'pages/demos/tabs/TabsWrappedLabel.js': {
Expand Down