UNPKG

18.7 kBJavaScriptView Raw
1import _extends from "@babel/runtime/helpers/esm/extends";
2import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
3import * as React from 'react';
4import * as ReactDOM from 'react-dom';
5import PropTypes from 'prop-types';
6import clsx from 'clsx';
7import { deepmerge, elementAcceptingRef } from '@material-ui/utils';
8import { alpha } from '../styles/colorManipulator';
9import withStyles from '../styles/withStyles';
10import capitalize from '../utils/capitalize';
11import Grow from '../Grow';
12import Popper from '../Popper';
13import useForkRef from '../utils/useForkRef';
14import useId from '../utils/unstable_useId';
15import setRef from '../utils/setRef';
16import useIsFocusVisible from '../utils/useIsFocusVisible';
17import useControlled from '../utils/useControlled';
18import useTheme from '../styles/useTheme';
19
20function round(value) {
21 return Math.round(value * 1e5) / 1e5;
22}
23
24function arrowGenerator() {
25 return {
26 '&[x-placement*="bottom"] $arrow': {
27 top: 0,
28 left: 0,
29 marginTop: '-0.71em',
30 marginLeft: 4,
31 marginRight: 4,
32 '&::before': {
33 transformOrigin: '0 100%'
34 }
35 },
36 '&[x-placement*="top"] $arrow': {
37 bottom: 0,
38 left: 0,
39 marginBottom: '-0.71em',
40 marginLeft: 4,
41 marginRight: 4,
42 '&::before': {
43 transformOrigin: '100% 0'
44 }
45 },
46 '&[x-placement*="right"] $arrow': {
47 left: 0,
48 marginLeft: '-0.71em',
49 height: '1em',
50 width: '0.71em',
51 marginTop: 4,
52 marginBottom: 4,
53 '&::before': {
54 transformOrigin: '100% 100%'
55 }
56 },
57 '&[x-placement*="left"] $arrow': {
58 right: 0,
59 marginRight: '-0.71em',
60 height: '1em',
61 width: '0.71em',
62 marginTop: 4,
63 marginBottom: 4,
64 '&::before': {
65 transformOrigin: '0 0'
66 }
67 }
68 };
69}
70
71export const styles = theme => ({
72 /* Styles applied to the Popper component. */
73 popper: {
74 zIndex: theme.zIndex.tooltip,
75 pointerEvents: 'none' // disable jss-rtl plugin
76
77 },
78
79 /* Styles applied to the Popper component if `interactive={true}`. */
80 popperInteractive: {
81 pointerEvents: 'auto'
82 },
83
84 /* Styles applied to the Popper component if `arrow={true}`. */
85 popperArrow: arrowGenerator(),
86
87 /* Styles applied to the tooltip (label wrapper) element. */
88 tooltip: {
89 backgroundColor: alpha(theme.palette.grey[700], 0.9),
90 borderRadius: theme.shape.borderRadius,
91 color: theme.palette.common.white,
92 fontFamily: theme.typography.fontFamily,
93 padding: '4px 8px',
94 fontSize: theme.typography.pxToRem(10),
95 lineHeight: `${round(14 / 10)}em`,
96 maxWidth: 300,
97 wordWrap: 'break-word',
98 fontWeight: theme.typography.fontWeightMedium
99 },
100
101 /* Styles applied to the tooltip (label wrapper) element if `arrow={true}`. */
102 tooltipArrow: {
103 position: 'relative',
104 margin: '0'
105 },
106
107 /* Styles applied to the arrow element. */
108 arrow: {
109 overflow: 'hidden',
110 position: 'absolute',
111 width: '1em',
112 height: '0.71em'
113 /* = width / sqrt(2) = (length of the hypotenuse) */
114 ,
115 boxSizing: 'border-box',
116 color: alpha(theme.palette.grey[700], 0.9),
117 '&::before': {
118 content: '""',
119 margin: 'auto',
120 display: 'block',
121 width: '100%',
122 height: '100%',
123 backgroundColor: 'currentColor',
124 transform: 'rotate(45deg)'
125 }
126 },
127
128 /* Styles applied to the tooltip (label wrapper) element if the tooltip is opened by touch. */
129 touch: {
130 padding: '8px 16px',
131 fontSize: theme.typography.pxToRem(14),
132 lineHeight: `${round(16 / 14)}em`,
133 fontWeight: theme.typography.fontWeightRegular
134 },
135
136 /* Styles applied to the tooltip (label wrapper) element if `placement` contains "left". */
137 tooltipPlacementLeft: {
138 transformOrigin: 'right center',
139 margin: '0 24px ',
140 [theme.breakpoints.up('sm')]: {
141 margin: '0 14px'
142 }
143 },
144
145 /* Styles applied to the tooltip (label wrapper) element if `placement` contains "right". */
146 tooltipPlacementRight: {
147 transformOrigin: 'left center',
148 margin: '0 24px',
149 [theme.breakpoints.up('sm')]: {
150 margin: '0 14px'
151 }
152 },
153
154 /* Styles applied to the tooltip (label wrapper) element if `placement` contains "top". */
155 tooltipPlacementTop: {
156 transformOrigin: 'center bottom',
157 margin: '24px 0',
158 [theme.breakpoints.up('sm')]: {
159 margin: '14px 0'
160 }
161 },
162
163 /* Styles applied to the tooltip (label wrapper) element if `placement` contains "bottom". */
164 tooltipPlacementBottom: {
165 transformOrigin: 'center top',
166 margin: '24px 0',
167 [theme.breakpoints.up('sm')]: {
168 margin: '14px 0'
169 }
170 }
171});
172let hystersisOpen = false;
173let hystersisTimer = null;
174export function testReset() {
175 hystersisOpen = false;
176 clearTimeout(hystersisTimer);
177}
178const Tooltip = /*#__PURE__*/React.forwardRef(function Tooltip(props, ref) {
179 const {
180 arrow = false,
181 children,
182 classes,
183 disableFocusListener = false,
184 disableHoverListener = false,
185 disableTouchListener = false,
186 enterDelay = 100,
187 enterNextDelay = 0,
188 enterTouchDelay = 700,
189 id: idProp,
190 interactive = false,
191 leaveDelay = 0,
192 leaveTouchDelay = 1500,
193 onClose,
194 onOpen,
195 open: openProp,
196 placement = 'bottom',
197 PopperComponent = Popper,
198 PopperProps,
199 title,
200 TransitionComponent = Grow,
201 TransitionProps
202 } = props,
203 other = _objectWithoutPropertiesLoose(props, ["arrow", "children", "classes", "disableFocusListener", "disableHoverListener", "disableTouchListener", "enterDelay", "enterNextDelay", "enterTouchDelay", "id", "interactive", "leaveDelay", "leaveTouchDelay", "onClose", "onOpen", "open", "placement", "PopperComponent", "PopperProps", "title", "TransitionComponent", "TransitionProps"]);
204
205 const theme = useTheme();
206 const [childNode, setChildNode] = React.useState();
207 const [arrowRef, setArrowRef] = React.useState(null);
208 const ignoreNonTouchEvents = React.useRef(false);
209 const closeTimer = React.useRef();
210 const enterTimer = React.useRef();
211 const leaveTimer = React.useRef();
212 const touchTimer = React.useRef();
213 const [openState, setOpenState] = useControlled({
214 controlled: openProp,
215 default: false,
216 name: 'Tooltip',
217 state: 'open'
218 });
219 let open = openState;
220
221 if (process.env.NODE_ENV !== 'production') {
222 // eslint-disable-next-line react-hooks/rules-of-hooks
223 const {
224 current: isControlled
225 } = React.useRef(openProp !== undefined); // eslint-disable-next-line react-hooks/rules-of-hooks
226
227 React.useEffect(() => {
228 if (childNode && childNode.disabled && !isControlled && title !== '' && childNode.tagName.toLowerCase() === 'button') {
229 console.error(['Material-UI: You are providing a disabled `button` child to the Tooltip component.', 'A disabled element does not fire events.', "Tooltip needs to listen to the child element's events to display the title.", '', 'Add a simple wrapper element, such as a `span`.'].join('\n'));
230 }
231 }, [title, childNode, isControlled]);
232 }
233
234 const id = useId(idProp);
235 React.useEffect(() => {
236 return () => {
237 clearTimeout(closeTimer.current);
238 clearTimeout(enterTimer.current);
239 clearTimeout(leaveTimer.current);
240 clearTimeout(touchTimer.current);
241 };
242 }, []);
243
244 const handleOpen = event => {
245 clearTimeout(hystersisTimer);
246 hystersisOpen = true; // The mouseover event will trigger for every nested element in the tooltip.
247 // We can skip rerendering when the tooltip is already open.
248 // We are using the mouseover event instead of the mouseenter event to fix a hide/show issue.
249
250 setOpenState(true);
251
252 if (onOpen) {
253 onOpen(event);
254 }
255 };
256
257 const handleEnter = (forward = true) => event => {
258 const childrenProps = children.props;
259
260 if (event.type === 'mouseover' && childrenProps.onMouseOver && forward) {
261 childrenProps.onMouseOver(event);
262 }
263
264 if (ignoreNonTouchEvents.current && event.type !== 'touchstart') {
265 return;
266 } // Remove the title ahead of time.
267 // We don't want to wait for the next render commit.
268 // We would risk displaying two tooltips at the same time (native + this one).
269
270
271 if (childNode) {
272 childNode.removeAttribute('title');
273 }
274
275 clearTimeout(enterTimer.current);
276 clearTimeout(leaveTimer.current);
277
278 if (enterDelay || hystersisOpen && enterNextDelay) {
279 event.persist();
280 enterTimer.current = setTimeout(() => {
281 handleOpen(event);
282 }, hystersisOpen ? enterNextDelay : enterDelay);
283 } else {
284 handleOpen(event);
285 }
286 };
287
288 const {
289 isFocusVisible,
290 onBlurVisible,
291 ref: focusVisibleRef
292 } = useIsFocusVisible();
293 const [childIsFocusVisible, setChildIsFocusVisible] = React.useState(false);
294
295 const handleBlur = () => {
296 if (childIsFocusVisible) {
297 setChildIsFocusVisible(false);
298 onBlurVisible();
299 }
300 };
301
302 const handleFocus = (forward = true) => event => {
303 // Workaround for https://github.com/facebook/react/issues/7769
304 // The autoFocus of React might trigger the event before the componentDidMount.
305 // We need to account for this eventuality.
306 if (!childNode) {
307 setChildNode(event.currentTarget);
308 }
309
310 if (isFocusVisible(event)) {
311 setChildIsFocusVisible(true);
312 handleEnter()(event);
313 }
314
315 const childrenProps = children.props;
316
317 if (childrenProps.onFocus && forward) {
318 childrenProps.onFocus(event);
319 }
320 };
321
322 const handleClose = event => {
323 clearTimeout(hystersisTimer);
324 hystersisTimer = setTimeout(() => {
325 hystersisOpen = false;
326 }, 800 + leaveDelay);
327 setOpenState(false);
328
329 if (onClose) {
330 onClose(event);
331 }
332
333 clearTimeout(closeTimer.current);
334 closeTimer.current = setTimeout(() => {
335 ignoreNonTouchEvents.current = false;
336 }, theme.transitions.duration.shortest);
337 };
338
339 const handleLeave = (forward = true) => event => {
340 const childrenProps = children.props;
341
342 if (event.type === 'blur') {
343 if (childrenProps.onBlur && forward) {
344 childrenProps.onBlur(event);
345 }
346
347 handleBlur();
348 }
349
350 if (event.type === 'mouseleave' && childrenProps.onMouseLeave && event.currentTarget === childNode) {
351 childrenProps.onMouseLeave(event);
352 }
353
354 clearTimeout(enterTimer.current);
355 clearTimeout(leaveTimer.current);
356 event.persist();
357 leaveTimer.current = setTimeout(() => {
358 handleClose(event);
359 }, leaveDelay);
360 };
361
362 const detectTouchStart = event => {
363 ignoreNonTouchEvents.current = true;
364 const childrenProps = children.props;
365
366 if (childrenProps.onTouchStart) {
367 childrenProps.onTouchStart(event);
368 }
369 };
370
371 const handleTouchStart = event => {
372 detectTouchStart(event);
373 clearTimeout(leaveTimer.current);
374 clearTimeout(closeTimer.current);
375 clearTimeout(touchTimer.current);
376 event.persist();
377 touchTimer.current = setTimeout(() => {
378 handleEnter()(event);
379 }, enterTouchDelay);
380 };
381
382 const handleTouchEnd = event => {
383 if (children.props.onTouchEnd) {
384 children.props.onTouchEnd(event);
385 }
386
387 clearTimeout(touchTimer.current);
388 clearTimeout(leaveTimer.current);
389 event.persist();
390 leaveTimer.current = setTimeout(() => {
391 handleClose(event);
392 }, leaveTouchDelay);
393 };
394
395 const handleUseRef = useForkRef(setChildNode, ref);
396 const handleFocusRef = useForkRef(focusVisibleRef, handleUseRef); // can be removed once we drop support for non ref forwarding class components
397
398 const handleOwnRef = React.useCallback(instance => {
399 // #StrictMode ready
400 setRef(handleFocusRef, ReactDOM.findDOMNode(instance));
401 }, [handleFocusRef]);
402 const handleRef = useForkRef(children.ref, handleOwnRef); // There is no point in displaying an empty tooltip.
403
404 if (title === '') {
405 open = false;
406 } // For accessibility and SEO concerns, we render the title to the DOM node when
407 // the tooltip is hidden. However, we have made a tradeoff when
408 // `disableHoverListener` is set. This title logic is disabled.
409 // It's allowing us to keep the implementation size minimal.
410 // We are open to change the tradeoff.
411
412
413 const shouldShowNativeTitle = !open && !disableHoverListener;
414
415 const childrenProps = _extends({
416 'aria-describedby': open ? id : null,
417 title: shouldShowNativeTitle && typeof title === 'string' ? title : null
418 }, other, children.props, {
419 className: clsx(other.className, children.props.className),
420 onTouchStart: detectTouchStart,
421 ref: handleRef
422 });
423
424 const interactiveWrapperListeners = {};
425
426 if (!disableTouchListener) {
427 childrenProps.onTouchStart = handleTouchStart;
428 childrenProps.onTouchEnd = handleTouchEnd;
429 }
430
431 if (!disableHoverListener) {
432 childrenProps.onMouseOver = handleEnter();
433 childrenProps.onMouseLeave = handleLeave();
434
435 if (interactive) {
436 interactiveWrapperListeners.onMouseOver = handleEnter(false);
437 interactiveWrapperListeners.onMouseLeave = handleLeave(false);
438 }
439 }
440
441 if (!disableFocusListener) {
442 childrenProps.onFocus = handleFocus();
443 childrenProps.onBlur = handleLeave();
444
445 if (interactive) {
446 interactiveWrapperListeners.onFocus = handleFocus(false);
447 interactiveWrapperListeners.onBlur = handleLeave(false);
448 }
449 }
450
451 if (process.env.NODE_ENV !== 'production') {
452 if (children.props.title) {
453 console.error(['Material-UI: You have provided a `title` prop to the child of <Tooltip />.', `Remove this title prop \`${children.props.title}\` or the Tooltip component.`].join('\n'));
454 }
455 }
456
457 const mergedPopperProps = React.useMemo(() => {
458 return deepmerge({
459 popperOptions: {
460 modifiers: {
461 arrow: {
462 enabled: Boolean(arrowRef),
463 element: arrowRef
464 }
465 }
466 }
467 }, PopperProps);
468 }, [arrowRef, PopperProps]);
469 return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.cloneElement(children, childrenProps), /*#__PURE__*/React.createElement(PopperComponent, _extends({
470 className: clsx(classes.popper, interactive && classes.popperInteractive, arrow && classes.popperArrow),
471 placement: placement,
472 anchorEl: childNode,
473 open: childNode ? open : false,
474 id: childrenProps['aria-describedby'],
475 transition: true
476 }, interactiveWrapperListeners, mergedPopperProps), ({
477 placement: placementInner,
478 TransitionProps: TransitionPropsInner
479 }) => /*#__PURE__*/React.createElement(TransitionComponent, _extends({
480 timeout: theme.transitions.duration.shorter
481 }, TransitionPropsInner, TransitionProps), /*#__PURE__*/React.createElement("div", {
482 className: clsx(classes.tooltip, classes[`tooltipPlacement${capitalize(placementInner.split('-')[0])}`], ignoreNonTouchEvents.current && classes.touch, arrow && classes.tooltipArrow)
483 }, title, arrow ? /*#__PURE__*/React.createElement("span", {
484 className: classes.arrow,
485 ref: setArrowRef
486 }) : null))));
487});
488process.env.NODE_ENV !== "production" ? Tooltip.propTypes = {
489 // ----------------------------- Warning --------------------------------
490 // | These PropTypes are generated from the TypeScript type definitions |
491 // | To update them edit the d.ts file and run "yarn proptypes" |
492 // ----------------------------------------------------------------------
493
494 /**
495 * If `true`, adds an arrow to the tooltip.
496 */
497 arrow: PropTypes.bool,
498
499 /**
500 * Tooltip reference element.
501 */
502 children: elementAcceptingRef.isRequired,
503
504 /**
505 * Override or extend the styles applied to the component.
506 * See [CSS API](#css) below for more details.
507 */
508 classes: PropTypes.object,
509
510 /**
511 * @ignore
512 */
513 className: PropTypes.string,
514
515 /**
516 * Do not respond to focus events.
517 */
518 disableFocusListener: PropTypes.bool,
519
520 /**
521 * Do not respond to hover events.
522 */
523 disableHoverListener: PropTypes.bool,
524
525 /**
526 * Do not respond to long press touch events.
527 */
528 disableTouchListener: PropTypes.bool,
529
530 /**
531 * The number of milliseconds to wait before showing the tooltip.
532 * This prop won't impact the enter touch delay (`enterTouchDelay`).
533 */
534 enterDelay: PropTypes.number,
535
536 /**
537 * The number of milliseconds to wait before showing the tooltip when one was already recently opened.
538 */
539 enterNextDelay: PropTypes.number,
540
541 /**
542 * The number of milliseconds a user must touch the element before showing the tooltip.
543 */
544 enterTouchDelay: PropTypes.number,
545
546 /**
547 * This prop is used to help implement the accessibility logic.
548 * If you don't provide this prop. It falls back to a randomly generated id.
549 */
550 id: PropTypes.string,
551
552 /**
553 * Makes a tooltip interactive, i.e. will not close when the user
554 * hovers over the tooltip before the `leaveDelay` is expired.
555 */
556 interactive: PropTypes.bool,
557
558 /**
559 * The number of milliseconds to wait before hiding the tooltip.
560 * This prop won't impact the leave touch delay (`leaveTouchDelay`).
561 */
562 leaveDelay: PropTypes.number,
563
564 /**
565 * The number of milliseconds after the user stops touching an element before hiding the tooltip.
566 */
567 leaveTouchDelay: PropTypes.number,
568
569 /**
570 * Callback fired when the component requests to be closed.
571 *
572 * @param {object} event The event source of the callback.
573 */
574 onClose: PropTypes.func,
575
576 /**
577 * Callback fired when the component requests to be open.
578 *
579 * @param {object} event The event source of the callback.
580 */
581 onOpen: PropTypes.func,
582
583 /**
584 * If `true`, the tooltip is shown.
585 */
586 open: PropTypes.bool,
587
588 /**
589 * Tooltip placement.
590 */
591 placement: PropTypes.oneOf(['bottom-end', 'bottom-start', 'bottom', 'left-end', 'left-start', 'left', 'right-end', 'right-start', 'right', 'top-end', 'top-start', 'top']),
592
593 /**
594 * The component used for the popper.
595 */
596 PopperComponent: PropTypes.elementType,
597
598 /**
599 * Props applied to the [`Popper`](/api/popper/) element.
600 */
601 PopperProps: PropTypes.object,
602
603 /**
604 * Tooltip title. Zero-length titles string are never displayed.
605 */
606 title: PropTypes
607 /* @typescript-to-proptypes-ignore */
608 .node.isRequired,
609
610 /**
611 * The component used for the transition.
612 * [Follow this guide](/components/transitions/#transitioncomponent-prop) to learn more about the requirements for this component.
613 */
614 TransitionComponent: PropTypes.elementType,
615
616 /**
617 * Props applied to the [`Transition`](http://reactcommunity.org/react-transition-group/transition#Transition-props) element.
618 */
619 TransitionProps: PropTypes.object
620} : void 0;
621export default withStyles(styles, {
622 name: 'MuiTooltip',
623 flip: false
624})(Tooltip);
\No newline at end of file