UNPKG

18.9 kBJavaScriptView Raw
1import _extends from "@babel/runtime/helpers/esm/extends";
2import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
3import * as React from 'react';
4import { isFragment } from 'react-is';
5import PropTypes from 'prop-types';
6import clsx from 'clsx';
7import { refType } from '@material-ui/utils';
8import debounce from '../utils/debounce';
9import ownerWindow from '../utils/ownerWindow';
10import { getNormalizedScrollLeft, detectScrollType } from '../utils/scrollLeft';
11import animate from '../internal/animate';
12import ScrollbarSize from './ScrollbarSize';
13import withStyles from '../styles/withStyles';
14import TabIndicator from './TabIndicator';
15import TabScrollButton from '../TabScrollButton';
16import useEventCallback from '../utils/useEventCallback';
17import useTheme from '../styles/useTheme';
18export const styles = theme => ({
19 /* Styles applied to the root element. */
20 root: {
21 overflow: 'hidden',
22 minHeight: 48,
23 WebkitOverflowScrolling: 'touch',
24 // Add iOS momentum scrolling.
25 display: 'flex'
26 },
27
28 /* Styles applied to the root element if `orientation="vertical"`. */
29 vertical: {
30 flexDirection: 'column'
31 },
32
33 /* Styles applied to the flex container element. */
34 flexContainer: {
35 display: 'flex'
36 },
37
38 /* Styles applied to the flex container element if `orientation="vertical"`. */
39 flexContainerVertical: {
40 flexDirection: 'column'
41 },
42
43 /* Styles applied to the flex container element if `centered={true}` & `!variant="scrollable"`. */
44 centered: {
45 justifyContent: 'center'
46 },
47
48 /* Styles applied to the tablist element. */
49 scroller: {
50 position: 'relative',
51 display: 'inline-block',
52 flex: '1 1 auto',
53 whiteSpace: 'nowrap'
54 },
55
56 /* Styles applied to the tablist element if `!variant="scrollable"`. */
57 fixed: {
58 overflowX: 'hidden',
59 width: '100%'
60 },
61
62 /* Styles applied to the tablist element if `variant="scrollable"`. */
63 scrollable: {
64 overflowX: 'scroll',
65 // Hide dimensionless scrollbar on MacOS
66 scrollbarWidth: 'none',
67 // Firefox
68 '&::-webkit-scrollbar': {
69 display: 'none' // Safari + Chrome
70
71 }
72 },
73
74 /* Styles applied to the `ScrollButtonComponent` component. */
75 scrollButtons: {},
76
77 /* Styles applied to the `ScrollButtonComponent` component if `scrollButtons="auto"` or scrollButtons="desktop"`. */
78 scrollButtonsDesktop: {
79 [theme.breakpoints.down('xs')]: {
80 display: 'none'
81 }
82 },
83
84 /* Styles applied to the `TabIndicator` component. */
85 indicator: {}
86});
87const Tabs = /*#__PURE__*/React.forwardRef(function Tabs(props, ref) {
88 const {
89 'aria-label': ariaLabel,
90 'aria-labelledby': ariaLabelledBy,
91 action,
92 centered = false,
93 children: childrenProp,
94 classes,
95 className,
96 component: Component = 'div',
97 indicatorColor = 'secondary',
98 onChange,
99 orientation = 'horizontal',
100 ScrollButtonComponent = TabScrollButton,
101 scrollButtons = 'auto',
102 selectionFollowsFocus,
103 TabIndicatorProps = {},
104 TabScrollButtonProps,
105 textColor = 'inherit',
106 value,
107 variant = 'standard'
108 } = props,
109 other = _objectWithoutPropertiesLoose(props, ["aria-label", "aria-labelledby", "action", "centered", "children", "classes", "className", "component", "indicatorColor", "onChange", "orientation", "ScrollButtonComponent", "scrollButtons", "selectionFollowsFocus", "TabIndicatorProps", "TabScrollButtonProps", "textColor", "value", "variant"]);
110
111 const theme = useTheme();
112 const scrollable = variant === 'scrollable';
113 const isRtl = theme.direction === 'rtl';
114 const vertical = orientation === 'vertical';
115 const scrollStart = vertical ? 'scrollTop' : 'scrollLeft';
116 const start = vertical ? 'top' : 'left';
117 const end = vertical ? 'bottom' : 'right';
118 const clientSize = vertical ? 'clientHeight' : 'clientWidth';
119 const size = vertical ? 'height' : 'width';
120
121 if (process.env.NODE_ENV !== 'production') {
122 if (centered && scrollable) {
123 console.error('Material-UI: You can not use the `centered={true}` and `variant="scrollable"` properties ' + 'at the same time on a `Tabs` component.');
124 }
125 }
126
127 const [mounted, setMounted] = React.useState(false);
128 const [indicatorStyle, setIndicatorStyle] = React.useState({});
129 const [displayScroll, setDisplayScroll] = React.useState({
130 start: false,
131 end: false
132 });
133 const [scrollerStyle, setScrollerStyle] = React.useState({
134 overflow: 'hidden',
135 marginBottom: null
136 });
137 const valueToIndex = new Map();
138 const tabsRef = React.useRef(null);
139 const tabListRef = React.useRef(null);
140
141 const getTabsMeta = () => {
142 const tabsNode = tabsRef.current;
143 let tabsMeta;
144
145 if (tabsNode) {
146 const rect = tabsNode.getBoundingClientRect(); // create a new object with ClientRect class props + scrollLeft
147
148 tabsMeta = {
149 clientWidth: tabsNode.clientWidth,
150 scrollLeft: tabsNode.scrollLeft,
151 scrollTop: tabsNode.scrollTop,
152 scrollLeftNormalized: getNormalizedScrollLeft(tabsNode, theme.direction),
153 scrollWidth: tabsNode.scrollWidth,
154 top: rect.top,
155 bottom: rect.bottom,
156 left: rect.left,
157 right: rect.right
158 };
159 }
160
161 let tabMeta;
162
163 if (tabsNode && value !== false) {
164 const children = tabListRef.current.children;
165
166 if (children.length > 0) {
167 const tab = children[valueToIndex.get(value)];
168
169 if (process.env.NODE_ENV !== 'production') {
170 if (!tab) {
171 console.error([`Material-UI: The value provided to the Tabs component is invalid.`, `None of the Tabs' children match with \`${value}\`.`, valueToIndex.keys ? `You can provide one of the following values: ${Array.from(valueToIndex.keys()).join(', ')}.` : null].join('\n'));
172 }
173 }
174
175 tabMeta = tab ? tab.getBoundingClientRect() : null;
176 }
177 }
178
179 return {
180 tabsMeta,
181 tabMeta
182 };
183 };
184
185 const updateIndicatorState = useEventCallback(() => {
186 const {
187 tabsMeta,
188 tabMeta
189 } = getTabsMeta();
190 let startValue = 0;
191
192 if (tabMeta && tabsMeta) {
193 if (vertical) {
194 startValue = tabMeta.top - tabsMeta.top + tabsMeta.scrollTop;
195 } else {
196 const correction = isRtl ? tabsMeta.scrollLeftNormalized + tabsMeta.clientWidth - tabsMeta.scrollWidth : tabsMeta.scrollLeft;
197 startValue = tabMeta.left - tabsMeta.left + correction;
198 }
199 }
200
201 const newIndicatorStyle = {
202 [start]: startValue,
203 // May be wrong until the font is loaded.
204 [size]: tabMeta ? tabMeta[size] : 0
205 };
206
207 if (isNaN(indicatorStyle[start]) || isNaN(indicatorStyle[size])) {
208 setIndicatorStyle(newIndicatorStyle);
209 } else {
210 const dStart = Math.abs(indicatorStyle[start] - newIndicatorStyle[start]);
211 const dSize = Math.abs(indicatorStyle[size] - newIndicatorStyle[size]);
212
213 if (dStart >= 1 || dSize >= 1) {
214 setIndicatorStyle(newIndicatorStyle);
215 }
216 }
217 });
218
219 const scroll = scrollValue => {
220 animate(scrollStart, tabsRef.current, scrollValue);
221 };
222
223 const moveTabsScroll = delta => {
224 let scrollValue = tabsRef.current[scrollStart];
225
226 if (vertical) {
227 scrollValue += delta;
228 } else {
229 scrollValue += delta * (isRtl ? -1 : 1); // Fix for Edge
230
231 scrollValue *= isRtl && detectScrollType() === 'reverse' ? -1 : 1;
232 }
233
234 scroll(scrollValue);
235 };
236
237 const handleStartScrollClick = () => {
238 moveTabsScroll(-tabsRef.current[clientSize]);
239 };
240
241 const handleEndScrollClick = () => {
242 moveTabsScroll(tabsRef.current[clientSize]);
243 };
244
245 const handleScrollbarSizeChange = React.useCallback(scrollbarHeight => {
246 setScrollerStyle({
247 overflow: null,
248 marginBottom: -scrollbarHeight
249 });
250 }, []);
251
252 const getConditionalElements = () => {
253 const conditionalElements = {};
254 conditionalElements.scrollbarSizeListener = scrollable ? /*#__PURE__*/React.createElement(ScrollbarSize, {
255 className: classes.scrollable,
256 onChange: handleScrollbarSizeChange
257 }) : null;
258 const scrollButtonsActive = displayScroll.start || displayScroll.end;
259 const showScrollButtons = scrollable && (scrollButtons === 'auto' && scrollButtonsActive || scrollButtons === 'desktop' || scrollButtons === 'on');
260 conditionalElements.scrollButtonStart = showScrollButtons ? /*#__PURE__*/React.createElement(ScrollButtonComponent, _extends({
261 orientation: orientation,
262 direction: isRtl ? 'right' : 'left',
263 onClick: handleStartScrollClick,
264 disabled: !displayScroll.start,
265 className: clsx(classes.scrollButtons, scrollButtons !== 'on' && classes.scrollButtonsDesktop)
266 }, TabScrollButtonProps)) : null;
267 conditionalElements.scrollButtonEnd = showScrollButtons ? /*#__PURE__*/React.createElement(ScrollButtonComponent, _extends({
268 orientation: orientation,
269 direction: isRtl ? 'left' : 'right',
270 onClick: handleEndScrollClick,
271 disabled: !displayScroll.end,
272 className: clsx(classes.scrollButtons, scrollButtons !== 'on' && classes.scrollButtonsDesktop)
273 }, TabScrollButtonProps)) : null;
274 return conditionalElements;
275 };
276
277 const scrollSelectedIntoView = useEventCallback(() => {
278 const {
279 tabsMeta,
280 tabMeta
281 } = getTabsMeta();
282
283 if (!tabMeta || !tabsMeta) {
284 return;
285 }
286
287 if (tabMeta[start] < tabsMeta[start]) {
288 // left side of button is out of view
289 const nextScrollStart = tabsMeta[scrollStart] + (tabMeta[start] - tabsMeta[start]);
290 scroll(nextScrollStart);
291 } else if (tabMeta[end] > tabsMeta[end]) {
292 // right side of button is out of view
293 const nextScrollStart = tabsMeta[scrollStart] + (tabMeta[end] - tabsMeta[end]);
294 scroll(nextScrollStart);
295 }
296 });
297 const updateScrollButtonState = useEventCallback(() => {
298 if (scrollable && scrollButtons !== 'off') {
299 const {
300 scrollTop,
301 scrollHeight,
302 clientHeight,
303 scrollWidth,
304 clientWidth
305 } = tabsRef.current;
306 let showStartScroll;
307 let showEndScroll;
308
309 if (vertical) {
310 showStartScroll = scrollTop > 1;
311 showEndScroll = scrollTop < scrollHeight - clientHeight - 1;
312 } else {
313 const scrollLeft = getNormalizedScrollLeft(tabsRef.current, theme.direction); // use 1 for the potential rounding error with browser zooms.
314
315 showStartScroll = isRtl ? scrollLeft < scrollWidth - clientWidth - 1 : scrollLeft > 1;
316 showEndScroll = !isRtl ? scrollLeft < scrollWidth - clientWidth - 1 : scrollLeft > 1;
317 }
318
319 if (showStartScroll !== displayScroll.start || showEndScroll !== displayScroll.end) {
320 setDisplayScroll({
321 start: showStartScroll,
322 end: showEndScroll
323 });
324 }
325 }
326 });
327 React.useEffect(() => {
328 const handleResize = debounce(() => {
329 updateIndicatorState();
330 updateScrollButtonState();
331 });
332 const win = ownerWindow(tabsRef.current);
333 win.addEventListener('resize', handleResize);
334 return () => {
335 handleResize.clear();
336 win.removeEventListener('resize', handleResize);
337 };
338 }, [updateIndicatorState, updateScrollButtonState]);
339 const handleTabsScroll = React.useCallback(debounce(() => {
340 updateScrollButtonState();
341 }));
342 React.useEffect(() => {
343 return () => {
344 handleTabsScroll.clear();
345 };
346 }, [handleTabsScroll]);
347 React.useEffect(() => {
348 setMounted(true);
349 }, []);
350 React.useEffect(() => {
351 updateIndicatorState();
352 updateScrollButtonState();
353 });
354 React.useEffect(() => {
355 scrollSelectedIntoView();
356 }, [scrollSelectedIntoView, indicatorStyle]);
357 React.useImperativeHandle(action, () => ({
358 updateIndicator: updateIndicatorState,
359 updateScrollButtons: updateScrollButtonState
360 }), [updateIndicatorState, updateScrollButtonState]);
361 const indicator = /*#__PURE__*/React.createElement(TabIndicator, _extends({
362 className: classes.indicator,
363 orientation: orientation,
364 color: indicatorColor
365 }, TabIndicatorProps, {
366 style: _extends({}, indicatorStyle, TabIndicatorProps.style)
367 }));
368 let childIndex = 0;
369 const children = React.Children.map(childrenProp, child => {
370 if (! /*#__PURE__*/React.isValidElement(child)) {
371 return null;
372 }
373
374 if (process.env.NODE_ENV !== 'production') {
375 if (isFragment(child)) {
376 console.error(["Material-UI: The Tabs component doesn't accept a Fragment as a child.", 'Consider providing an array instead.'].join('\n'));
377 }
378 }
379
380 const childValue = child.props.value === undefined ? childIndex : child.props.value;
381 valueToIndex.set(childValue, childIndex);
382 const selected = childValue === value;
383 childIndex += 1;
384 return /*#__PURE__*/React.cloneElement(child, {
385 fullWidth: variant === 'fullWidth',
386 indicator: selected && !mounted && indicator,
387 selected,
388 selectionFollowsFocus,
389 onChange,
390 textColor,
391 value: childValue
392 });
393 });
394
395 const handleKeyDown = event => {
396 const {
397 target
398 } = event; // Keyboard navigation assumes that [role="tab"] are siblings
399 // though we might warn in the future about nested, interactive elements
400 // as a a11y violation
401
402 const role = target.getAttribute('role');
403
404 if (role !== 'tab') {
405 return;
406 }
407
408 let newFocusTarget = null;
409 let previousItemKey = orientation !== "vertical" ? 'ArrowLeft' : 'ArrowUp';
410 let nextItemKey = orientation !== "vertical" ? 'ArrowRight' : 'ArrowDown';
411
412 if (orientation !== "vertical" && theme.direction === 'rtl') {
413 // swap previousItemKey with nextItemKey
414 previousItemKey = 'ArrowRight';
415 nextItemKey = 'ArrowLeft';
416 }
417
418 switch (event.key) {
419 case previousItemKey:
420 newFocusTarget = target.previousElementSibling || tabListRef.current.lastChild;
421 break;
422
423 case nextItemKey:
424 newFocusTarget = target.nextElementSibling || tabListRef.current.firstChild;
425 break;
426
427 case 'Home':
428 newFocusTarget = tabListRef.current.firstChild;
429 break;
430
431 case 'End':
432 newFocusTarget = tabListRef.current.lastChild;
433 break;
434
435 default:
436 break;
437 }
438
439 if (newFocusTarget !== null) {
440 newFocusTarget.focus();
441 event.preventDefault();
442 }
443 };
444
445 const conditionalElements = getConditionalElements();
446 return /*#__PURE__*/React.createElement(Component, _extends({
447 className: clsx(classes.root, className, vertical && classes.vertical),
448 ref: ref
449 }, other), conditionalElements.scrollButtonStart, conditionalElements.scrollbarSizeListener, /*#__PURE__*/React.createElement("div", {
450 className: clsx(classes.scroller, scrollable ? classes.scrollable : classes.fixed),
451 style: scrollerStyle,
452 ref: tabsRef,
453 onScroll: handleTabsScroll
454 }, /*#__PURE__*/React.createElement("div", {
455 "aria-label": ariaLabel,
456 "aria-labelledby": ariaLabelledBy,
457 className: clsx(classes.flexContainer, vertical && classes.flexContainerVertical, centered && !scrollable && classes.centered),
458 onKeyDown: handleKeyDown,
459 ref: tabListRef,
460 role: "tablist"
461 }, children), mounted && indicator), conditionalElements.scrollButtonEnd);
462});
463process.env.NODE_ENV !== "production" ? Tabs.propTypes = {
464 // ----------------------------- Warning --------------------------------
465 // | These PropTypes are generated from the TypeScript type definitions |
466 // | To update them edit the d.ts file and run "yarn proptypes" |
467 // ----------------------------------------------------------------------
468
469 /**
470 * Callback fired when the component mounts.
471 * This is useful when you want to trigger an action programmatically.
472 * It supports two actions: `updateIndicator()` and `updateScrollButtons()`
473 *
474 * @param {object} actions This object contains all possible actions
475 * that can be triggered programmatically.
476 */
477 action: refType,
478
479 /**
480 * The label for the Tabs as a string.
481 */
482 'aria-label': PropTypes.string,
483
484 /**
485 * An id or list of ids separated by a space that label the Tabs.
486 */
487 'aria-labelledby': PropTypes.string,
488
489 /**
490 * If `true`, the tabs will be centered.
491 * This property is intended for large views.
492 */
493 centered: PropTypes.bool,
494
495 /**
496 * The content of the component.
497 */
498 children: PropTypes.node,
499
500 /**
501 * Override or extend the styles applied to the component.
502 * See [CSS API](#css) below for more details.
503 */
504 classes: PropTypes.object,
505
506 /**
507 * @ignore
508 */
509 className: PropTypes.string,
510
511 /**
512 * The component used for the root node.
513 * Either a string to use a HTML element or a component.
514 */
515 component: PropTypes
516 /* @typescript-to-proptypes-ignore */
517 .elementType,
518
519 /**
520 * Determines the color of the indicator.
521 */
522 indicatorColor: PropTypes.oneOf(['primary', 'secondary']),
523
524 /**
525 * Callback fired when the value changes.
526 *
527 * @param {object} event The event source of the callback
528 * @param {any} value We default to the index of the child (number)
529 */
530 onChange: PropTypes.func,
531
532 /**
533 * The tabs orientation (layout flow direction).
534 */
535 orientation: PropTypes.oneOf(['horizontal', 'vertical']),
536
537 /**
538 * The component used to render the scroll buttons.
539 */
540 ScrollButtonComponent: PropTypes.elementType,
541
542 /**
543 * Determine behavior of scroll buttons when tabs are set to scroll:
544 *
545 * - `auto` will only present them when not all the items are visible.
546 * - `desktop` will only present them on medium and larger viewports.
547 * - `on` will always present them.
548 * - `off` will never present them.
549 */
550 scrollButtons: PropTypes.oneOf(['auto', 'desktop', 'off', 'on']),
551
552 /**
553 * If `true` the selected tab changes on focus. Otherwise it only
554 * changes on activation.
555 */
556 selectionFollowsFocus: PropTypes.bool,
557
558 /**
559 * Props applied to the tab indicator element.
560 */
561 TabIndicatorProps: PropTypes.object,
562
563 /**
564 * Props applied to the [`TabScrollButton`](/api/tab-scroll-button/) element.
565 */
566 TabScrollButtonProps: PropTypes.object,
567
568 /**
569 * Determines the color of the `Tab`.
570 */
571 textColor: PropTypes.oneOf(['inherit', 'primary', 'secondary']),
572
573 /**
574 * The value of the currently selected `Tab`.
575 * If you don't want any selected `Tab`, you can set this property to `false`.
576 */
577 value: PropTypes.any,
578
579 /**
580 * Determines additional display behavior of the tabs:
581 *
582 * - `scrollable` will invoke scrolling properties and allow for horizontally
583 * scrolling (or swiping) of the tab bar.
584 * -`fullWidth` will make the tabs grow to use all the available space,
585 * which should be used for small views, like on mobile.
586 * - `standard` will render the default state.
587 */
588 variant: PropTypes.oneOf(['fullWidth', 'scrollable', 'standard'])
589} : void 0;
590export default withStyles(styles, {
591 name: 'MuiTabs'
592})(Tabs);
\No newline at end of file