diff --git a/docs/src/pages/components/tabs/SimpleTabs.js b/docs/src/pages/components/tabs/SimpleTabs.js index 0f3cdc2e04c2de..4f66fbf04aad40 100644 --- a/docs/src/pages/components/tabs/SimpleTabs.js +++ b/docs/src/pages/components/tabs/SimpleTabs.js @@ -7,10 +7,18 @@ import Tab from '@material-ui/core/Tab'; import Typography from '@material-ui/core/Typography'; function TabPanel(props) { - const { children, ...other } = props; + const { children, value, index, ...other } = props; return ( - + ); @@ -18,6 +26,8 @@ function TabPanel(props) { TabPanel.propTypes = { children: PropTypes.node.isRequired, + index: PropTypes.number.isRequired, + value: PropTypes.number.isRequired, }; const useStyles = makeStyles(theme => ({ @@ -35,22 +45,29 @@ export default function SimpleTabs() { setValue(newValue); } + function a11yProps(index) { + return { + id: `simple-tab-${index}`, + 'aria-controls': `simple-tabpanel-${index}`, + }; + } + return (
- - - + + + -
diff --git a/docs/src/pages/components/tabs/SimpleTabs.tsx b/docs/src/pages/components/tabs/SimpleTabs.tsx index 29638d63cace33..0822620027ce63 100644 --- a/docs/src/pages/components/tabs/SimpleTabs.tsx +++ b/docs/src/pages/components/tabs/SimpleTabs.tsx @@ -8,13 +8,23 @@ import Typography from '@material-ui/core/Typography'; interface TabPanelProps { children?: React.ReactNode; + index: number; + value: number; } function TabPanel(props: TabPanelProps) { - const { children, ...other } = props; + const { children, value, index, ...other } = props; return ( - + ); @@ -22,6 +32,8 @@ function TabPanel(props: TabPanelProps) { TabPanel.propTypes = { children: PropTypes.node.isRequired, + index: PropTypes.number.isRequired, + value: PropTypes.number.isRequired, }; const useStyles = makeStyles((theme: Theme) => @@ -41,22 +53,29 @@ export default function SimpleTabs() { setValue(newValue); } + function a11yProps(index) { + return { + id: `simple-tab-${index}`, + 'aria-controls': `simple-tabpanel-${index}`, + }; + } + return (
- - - + + + -
diff --git a/packages/material-ui/src/Tabs/Tabs.js b/packages/material-ui/src/Tabs/Tabs.js index e6d7cc3ba1f0c6..f0ab37115c14ef 100644 --- a/packages/material-ui/src/Tabs/Tabs.js +++ b/packages/material-ui/src/Tabs/Tabs.js @@ -4,15 +4,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import warning from 'warning'; import clsx from 'clsx'; -import EventListener from 'react-event-listener'; -import debounce from 'debounce'; // < 1kb payload overhead when lodash/debounce is > 3kb. +import debounce from '../utils/debounce'; +import ownerWindow from '../utils/ownerWindow'; import { getNormalizedScrollLeft, detectScrollType } from 'normalize-scroll-left'; import animate from '../internal/animate'; import ScrollbarSize from './ScrollbarSize'; import withStyles from '../styles/withStyles'; import TabIndicator from './TabIndicator'; import TabScrollButton from './TabScrollButton'; -import withForwardedRef from '../utils/withForwardedRef'; +import useEventCallback from '../utils/useEventCallback'; export const styles = theme => ({ /* Styles applied to the root element. */ @@ -62,71 +62,148 @@ export const styles = theme => ({ indicator: {}, }); -class Tabs extends React.Component { - constructor() { - super(); - - if (typeof window !== 'undefined') { - this.handleResize = debounce(() => { - this.updateIndicatorState(this.props); - this.updateScrollButtonState(); - }, 166); // Corresponds to 10 frames at 60 Hz. +const Tabs = React.forwardRef(function Tabs(props, ref) { + const { + action, + centered = false, + children: childrenProp, + classes, + className, + component: Component = 'div', + indicatorColor = 'secondary', + onChange, + ScrollButtonComponent = TabScrollButton, + scrollButtons = 'auto', + TabIndicatorProps = {}, + textColor = 'inherit', + theme, + value, + variant = 'standard', + ...other + } = props; + const scrollable = variant === 'scrollable'; + const isRtl = theme.direction === 'rtl'; + + warning( + !centered || !scrollable, + 'Material-UI: you can not use the `centered={true}` and `variant="scrollable"` properties ' + + 'at the same time on a `Tabs` component.', + ); + + const [mounted, setMounted] = React.useState(false); + const [indicatorStyle, setIndicatorStyle] = React.useState({}); + const [displayScroll, setDisplayScroll] = React.useState({ + left: false, + right: false, + }); + const [scrollerStyle, setScrollerStyle] = React.useState({ + overflow: 'hidden', + marginBottom: null, + }); + const valueToIndex = new Map(); + const tabsRef = React.useRef(null); - this.handleTabsScroll = debounce(() => { - this.updateScrollButtonState(); - }, 166); // Corresponds to 10 frames at 60 Hz. + const getTabsMeta = () => { + const tabsNode = tabsRef.current; + let tabsMeta; + if (tabsNode) { + const rect = tabsNode.getBoundingClientRect(); + // create a new object with ClientRect class props + scrollLeft + tabsMeta = { + clientWidth: tabsNode.clientWidth, + scrollLeft: tabsNode.scrollLeft, + scrollLeftNormalized: getNormalizedScrollLeft(tabsNode, theme.direction), + scrollWidth: tabsNode.scrollWidth, + left: rect.left, + right: rect.right, + }; } - } - state = { - indicatorStyle: {}, - scrollerStyle: { - overflow: 'hidden', - marginBottom: null, - }, - showLeftScroll: false, - showRightScroll: false, - mounted: false, + let tabMeta; + if (tabsNode && value !== false) { + const children = tabsNode.children[0].children; + + if (children.length > 0) { + const tab = children[valueToIndex.get(value)]; + warning( + tab, + [ + `Material-UI: the value provided \`${value}\` to the Tabs component is invalid.`, + 'None of the Tabs children have this value.', + valueToIndex.keys + ? `You can provide one of the following values: ${Array.from( + valueToIndex.keys(), + ).join(', ')}.` + : null, + ].join('\n'), + ); + tabMeta = tab ? tab.getBoundingClientRect() : null; + } + } + return { tabsMeta, tabMeta }; }; - componentDidMount() { - this.setState({ mounted: true }); - this.updateIndicatorState(this.props); - this.updateScrollButtonState(); + const updateIndicatorState = useEventCallback(() => { + const { tabsMeta, tabMeta } = getTabsMeta(); + let left = 0; - if (this.props.action) { - this.props.action({ - updateIndicator: this.handleResize, - }); + if (tabMeta && tabsMeta) { + const correction = isRtl + ? tabsMeta.scrollLeftNormalized + tabsMeta.clientWidth - tabsMeta.scrollWidth + : tabsMeta.scrollLeft; + left = Math.round(tabMeta.left - tabsMeta.left + correction); } - } - componentDidUpdate(prevProps, prevState) { - // The index might have changed at the same time. - // We need to check again the right indicator position. - this.updateIndicatorState(this.props); - this.updateScrollButtonState(); + const newIndicatorStyle = { + left, + // May be wrong until the font is loaded. + width: tabMeta ? Math.round(tabMeta.width) : 0, + }; - if (this.state.indicatorStyle !== prevState.indicatorStyle) { - this.scrollSelectedIntoView(); + if ( + (newIndicatorStyle.left !== indicatorStyle.left || + newIndicatorStyle.width !== indicatorStyle.width) && + !isNaN(newIndicatorStyle.left) && + !isNaN(newIndicatorStyle.width) + ) { + setIndicatorStyle(newIndicatorStyle); } - } + }); - componentWillUnmount() { - this.handleResize.clear(); - this.handleTabsScroll.clear(); - } + const scroll = scrollValue => { + animate('scrollLeft', tabsRef.current, scrollValue); + }; + + const moveTabsScroll = delta => { + const multiplier = isRtl ? -1 : 1; + const nextScrollLeft = tabsRef.current.scrollLeft + delta * multiplier; + // Fix for Edge + const invert = isRtl && detectScrollType() === 'reverse' ? -1 : 1; + scroll(invert * nextScrollLeft); + }; - getConditionalElements = () => { - const { classes, ScrollButtonComponent, scrollButtons, theme, variant } = this.props; - const { showLeftScroll, showRightScroll } = this.state; + const handleLeftScrollClick = () => { + moveTabsScroll(-tabsRef.current.clientWidth); + }; + + const handleRightScrollClick = () => { + moveTabsScroll(tabsRef.current.clientWidth); + }; + + const handleScrollbarSizeChange = React.useCallback(scrollbarHeight => { + setScrollerStyle({ + overflow: null, + marginBottom: -scrollbarHeight, + }); + }, []); + + const getConditionalElements = () => { const conditionalElements = {}; - const scrollable = variant === 'scrollable'; conditionalElements.scrollbarSizeListener = scrollable ? ( - + ) : null; - const scrollButtonsActive = showLeftScroll || showRightScroll; + const scrollButtonsActive = displayScroll.left || displayScroll.right; const showScrollButtons = scrollable && ((scrollButtons === 'auto' && scrollButtonsActive) || @@ -135,9 +212,9 @@ class Tabs extends React.Component { conditionalElements.scrollButtonLeft = showScrollButtons ? ( { - let tabsMeta; - if (this.tabsRef) { - const rect = this.tabsRef.getBoundingClientRect(); - // create a new object with ClientRect class props + scrollLeft - tabsMeta = { - clientWidth: this.tabsRef.clientWidth, - scrollLeft: this.tabsRef.scrollLeft, - scrollLeftNormalized: getNormalizedScrollLeft(this.tabsRef, direction), - scrollWidth: this.tabsRef.scrollWidth, - left: rect.left, - right: rect.right, - }; - } - - let tabMeta; - if (this.tabsRef && value !== false) { - const children = this.tabsRef.children[0].children; - - if (children.length > 0) { - const tab = children[this.valueToIndex.get(value)]; - warning( - tab, - [ - `Material-UI: the value provided \`${value}\` to the Tabs component is invalid.`, - 'None of the Tabs children have this value.', - this.valueToIndex.keys - ? `You can provide one of the following values: ${Array.from( - this.valueToIndex.keys(), - ).join(', ')}.` - : null, - ].join('\n'), - ); - tabMeta = tab ? tab.getBoundingClientRect() : null; - } - } - return { tabsMeta, tabMeta }; - }; - - handleLeftScrollClick = () => { - this.moveTabsScroll(-this.tabsRef.clientWidth); - }; - - handleRightScrollClick = () => { - this.moveTabsScroll(this.tabsRef.clientWidth); - }; - - handleScrollbarSizeChange = scrollbarHeight => { - this.setState({ - scrollerStyle: { - overflow: null, - marginBottom: -scrollbarHeight, - }, - }); - }; - - handleTabsRef = ref => { - this.tabsRef = ref; - }; - - moveTabsScroll = delta => { - const { theme } = this.props; - - const multiplier = theme.direction === 'rtl' ? -1 : 1; - const nextScrollLeft = this.tabsRef.scrollLeft + delta * multiplier; - // Fix for Edge - const invert = theme.direction === 'rtl' && detectScrollType() === 'reverse' ? -1 : 1; - this.scroll(invert * nextScrollLeft); - }; - - scrollSelectedIntoView = () => { - const { theme, value } = this.props; - const { tabsMeta, tabMeta } = this.getTabsMeta(value, theme.direction); + const scrollSelectedIntoView = useEventCallback(() => { + const { tabsMeta, tabMeta } = getTabsMeta(); if (!tabMeta || !tabsMeta) { return; @@ -239,175 +245,148 @@ class Tabs extends React.Component { if (tabMeta.left < tabsMeta.left) { // left side of button is out of view const nextScrollLeft = tabsMeta.scrollLeft + (tabMeta.left - tabsMeta.left); - this.scroll(nextScrollLeft); + scroll(nextScrollLeft); } else if (tabMeta.right > tabsMeta.right) { // right side of button is out of view const nextScrollLeft = tabsMeta.scrollLeft + (tabMeta.right - tabsMeta.right); - this.scroll(nextScrollLeft); + scroll(nextScrollLeft); } - }; - - scroll = value => { - animate('scrollLeft', this.tabsRef, value); - }; - - updateScrollButtonState = () => { - const { scrollButtons, theme, variant } = this.props; - const scrollable = variant === 'scrollable'; + }); + const updateScrollButtonState = useEventCallback(() => { if (scrollable && scrollButtons !== 'off') { - const { scrollWidth, clientWidth } = this.tabsRef; - const scrollLeft = getNormalizedScrollLeft(this.tabsRef, theme.direction); + const { scrollWidth, clientWidth } = tabsRef.current; + const scrollLeft = getNormalizedScrollLeft(tabsRef.current, theme.direction); // use 1 for the potential rounding error with browser zooms. - const showLeftScroll = - theme.direction === 'rtl' ? scrollLeft < scrollWidth - clientWidth - 1 : scrollLeft > 1; - const showRightScroll = - theme.direction !== 'rtl' ? scrollLeft < scrollWidth - clientWidth - 1 : scrollLeft > 1; - - if ( - showLeftScroll !== this.state.showLeftScroll || - showRightScroll !== this.state.showRightScroll - ) { - this.setState({ showLeftScroll, showRightScroll }); + const showLeftScroll = isRtl ? scrollLeft < scrollWidth - clientWidth - 1 : scrollLeft > 1; + const showRightScroll = !isRtl ? scrollLeft < scrollWidth - clientWidth - 1 : scrollLeft > 1; + + if (showLeftScroll !== displayScroll.left || showRightScroll !== displayScroll.right) { + setDisplayScroll({ left: showLeftScroll, right: showRightScroll }); } } - }; + }); - updateIndicatorState(props) { - const { theme, value } = props; + React.useEffect(() => { + const handleResize = debounce(() => { + updateIndicatorState(); + updateScrollButtonState(); + }); - const { tabsMeta, tabMeta } = this.getTabsMeta(value, theme.direction); - let left = 0; + const win = ownerWindow(tabsRef.current); + win.addEventListener('resize', handleResize); + return () => { + handleResize.clear(); + win.removeEventListener('resize', handleResize); + }; + }, [updateIndicatorState, updateScrollButtonState]); - if (tabMeta && tabsMeta) { - const correction = - theme.direction === 'rtl' - ? tabsMeta.scrollLeftNormalized + tabsMeta.clientWidth - tabsMeta.scrollWidth - : tabsMeta.scrollLeft; - left = Math.round(tabMeta.left - tabsMeta.left + correction); - } + const handleTabsScroll = React.useCallback( + debounce(() => { + updateScrollButtonState(); + }), + ); - const indicatorStyle = { - left, - // May be wrong until the font is loaded. - width: tabMeta ? Math.round(tabMeta.width) : 0, + React.useEffect(() => { + return () => { + handleTabsScroll.clear(); }; - - if ( - (indicatorStyle.left !== this.state.indicatorStyle.left || - indicatorStyle.width !== this.state.indicatorStyle.width) && - !isNaN(indicatorStyle.left) && - !isNaN(indicatorStyle.width) - ) { - this.setState({ indicatorStyle }); + }, [handleTabsScroll]); + + React.useEffect(() => { + setMounted(true); + }, []); + + React.useEffect(() => { + updateIndicatorState(); + updateScrollButtonState(); + }); + + React.useEffect(() => { + scrollSelectedIntoView(); + }, [scrollSelectedIntoView, indicatorStyle]); + + React.useImperativeHandle( + action, + () => ({ + updateIndicator: updateIndicatorState, + }), + [updateIndicatorState], + ); + + const indicator = ( + + ); + + let childIndex = 0; + const children = React.Children.map(childrenProp, child => { + if (!React.isValidElement(child)) { + return null; } - } - - render() { - const { - action, - centered, - children: childrenProp, - classes, - className, - component: Component, - indicatorColor, - innerRef, - onChange, - ScrollButtonComponent, - scrollButtons, - TabIndicatorProps = {}, - textColor, - theme, - value, - variant, - ...other - } = this.props; - - const scrollable = variant === 'scrollable'; warning( - !centered || !scrollable, - 'Material-UI: you can not use the `centered={true}` and `variant="scrollable"` properties ' + - 'at the same time on a `Tabs` component.', + child.type !== React.Fragment, + [ + "Material-UI: the Tabs component doesn't accept a Fragment as a child.", + 'Consider providing an array instead.', + ].join('\n'), ); - const indicator = ( - - ); - - this.valueToIndex = new Map(); - let childIndex = 0; - const children = React.Children.map(childrenProp, child => { - if (!React.isValidElement(child)) { - return null; - } + const childValue = child.props.value === undefined ? childIndex : child.props.value; + valueToIndex.set(childValue, childIndex); + const selected = childValue === value; - warning( - child.type !== React.Fragment, - [ - "Material-UI: the Tabs component doesn't accept a Fragment as a child.", - 'Consider providing an array instead.', - ].join('\n'), - ); - - const childValue = child.props.value === undefined ? childIndex : child.props.value; - this.valueToIndex.set(childValue, childIndex); - const selected = childValue === value; - - childIndex += 1; - return React.cloneElement(child, { - fullWidth: variant === 'fullWidth', - indicator: selected && !this.state.mounted && indicator, - selected, - onChange, - textColor, - value: childValue, - }); + childIndex += 1; + return React.cloneElement(child, { + fullWidth: variant === 'fullWidth', + indicator: selected && !mounted && indicator, + selected, + onChange, + textColor, + value: childValue, }); - - const conditionalElements = this.getConditionalElements(); - - return ( - - -
- {conditionalElements.scrollButtonLeft} - {conditionalElements.scrollbarSizeListener} + }); + + const conditionalElements = getConditionalElements(); + + return ( + +
+ {conditionalElements.scrollButtonLeft} + {conditionalElements.scrollbarSizeListener} +
-
- {children} -
- {this.state.mounted && indicator} + {children}
- {conditionalElements.scrollButtonRight} + {mounted && indicator}
- - ); - } -} + {conditionalElements.scrollButtonRight} +
+
+ ); +}); Tabs.propTypes = { /** @@ -446,11 +425,6 @@ Tabs.propTypes = { * Determines the color of the indicator. */ indicatorColor: PropTypes.oneOf(['secondary', 'primary']), - /** - * @ignore - * from `withForwardRef` - */ - innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), /** * Callback fired when the value changes. * @@ -500,14 +474,4 @@ Tabs.propTypes = { variant: PropTypes.oneOf(['standard', 'scrollable', 'fullWidth']), }; -Tabs.defaultProps = { - centered: false, - component: 'div', - indicatorColor: 'secondary', - ScrollButtonComponent: TabScrollButton, - scrollButtons: 'auto', - textColor: 'inherit', - variant: 'standard', -}; - -export default withStyles(styles, { name: 'MuiTabs', withTheme: true })(withForwardedRef(Tabs)); +export default withStyles(styles, { name: 'MuiTabs', withTheme: true })(Tabs);