You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Tooltip.tsx 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2022 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import { throttle, uniqueId } from 'lodash';
  21. import * as React from 'react';
  22. import { createPortal, findDOMNode } from 'react-dom';
  23. import { rawSizes } from '../../app/theme';
  24. import EscKeydownHandler from './EscKeydownHandler';
  25. import ScreenPositionFixer from './ScreenPositionFixer';
  26. import './Tooltip.css';
  27. export type Placement = 'bottom' | 'right' | 'left' | 'top';
  28. export interface TooltipProps {
  29. classNameSpace?: string;
  30. children: React.ReactElement<{}>;
  31. mouseEnterDelay?: number;
  32. mouseLeaveDelay?: number;
  33. onShow?: () => void;
  34. onHide?: () => void;
  35. overlay: React.ReactNode;
  36. placement?: Placement;
  37. visible?: boolean;
  38. }
  39. interface Measurements {
  40. height: number;
  41. left: number;
  42. top: number;
  43. width: number;
  44. }
  45. interface OwnState {
  46. flipped: boolean;
  47. placement?: Placement;
  48. visible: boolean;
  49. }
  50. type State = OwnState & Partial<Measurements>;
  51. const FLIP_MAP: { [key in Placement]: Placement } = {
  52. left: 'right',
  53. right: 'left',
  54. top: 'bottom',
  55. bottom: 'top'
  56. };
  57. function isMeasured(state: State): state is OwnState & Measurements {
  58. return state.height !== undefined;
  59. }
  60. export default function Tooltip(props: TooltipProps) {
  61. // `overlay` is a ReactNode, so it can be `undefined` or `null`. This allows to easily
  62. // render a tooltip conditionally. More generally, we avoid rendering empty tooltips.
  63. return props.overlay != null && props.overlay !== '' ? (
  64. <TooltipInner {...props} />
  65. ) : (
  66. props.children
  67. );
  68. }
  69. export class TooltipInner extends React.Component<TooltipProps, State> {
  70. throttledPositionTooltip: () => void;
  71. mouseEnterTimeout?: number;
  72. mouseLeaveTimeout?: number;
  73. tooltipNode?: HTMLElement | null;
  74. mounted = false;
  75. mouseIn = false;
  76. id: string;
  77. static defaultProps = {
  78. mouseEnterDelay: 0.1
  79. };
  80. constructor(props: TooltipProps) {
  81. super(props);
  82. this.state = {
  83. flipped: false,
  84. placement: props.placement,
  85. visible: props.visible !== undefined ? props.visible : false
  86. };
  87. this.id = uniqueId('tooltip-');
  88. this.throttledPositionTooltip = throttle(this.positionTooltip, 10);
  89. }
  90. componentDidMount() {
  91. this.mounted = true;
  92. if (this.props.visible === true) {
  93. this.positionTooltip();
  94. this.addEventListeners();
  95. }
  96. }
  97. componentDidUpdate(prevProps: TooltipProps, prevState: State) {
  98. if (this.props.placement !== prevProps.placement) {
  99. this.setState({ placement: this.props.placement });
  100. // Break. This will trigger a new componentDidUpdate() call, so the below
  101. // positionTooltip() call will be correct. Otherwise, it might not use
  102. // the new state.placement value.
  103. return;
  104. }
  105. if (
  106. // opens
  107. (this.props.visible === true && !prevProps.visible) ||
  108. (this.props.visible === undefined &&
  109. this.state.visible === true &&
  110. prevState.visible === false)
  111. ) {
  112. this.positionTooltip();
  113. this.addEventListeners();
  114. } else if (
  115. // closes
  116. (!this.props.visible && prevProps.visible === true) ||
  117. (this.props.visible === undefined &&
  118. this.state.visible === false &&
  119. prevState.visible === true)
  120. ) {
  121. this.clearPosition();
  122. this.removeEventListeners();
  123. }
  124. }
  125. componentWillUnmount() {
  126. this.mounted = false;
  127. this.removeEventListeners();
  128. this.clearTimeouts();
  129. }
  130. addEventListeners = () => {
  131. window.addEventListener('resize', this.throttledPositionTooltip);
  132. window.addEventListener('scroll', this.throttledPositionTooltip);
  133. };
  134. removeEventListeners = () => {
  135. window.removeEventListener('resize', this.throttledPositionTooltip);
  136. window.removeEventListener('scroll', this.throttledPositionTooltip);
  137. };
  138. clearTimeouts = () => {
  139. window.clearTimeout(this.mouseEnterTimeout);
  140. window.clearTimeout(this.mouseLeaveTimeout);
  141. };
  142. isVisible = () => {
  143. return this.props.visible !== undefined ? this.props.visible : this.state.visible;
  144. };
  145. getPlacement = (): Placement => {
  146. return this.state.placement || 'bottom';
  147. };
  148. tooltipNodeRef = (node: HTMLElement | null) => {
  149. this.tooltipNode = node;
  150. };
  151. adjustArrowPosition = (
  152. placement: Placement,
  153. { leftFix, topFix }: { leftFix: number; topFix: number }
  154. ) => {
  155. switch (placement) {
  156. case 'left':
  157. case 'right':
  158. return { marginTop: -topFix };
  159. default:
  160. return { marginLeft: -leftFix };
  161. }
  162. };
  163. positionTooltip = () => {
  164. // `findDOMNode(this)` will search for the DOM node for the current component.
  165. // First, it will find a React.Fragment (see `render`). It will skip this, and
  166. // it will get the DOM node of the first child, i.e. DOM node of `this.props.children`.
  167. // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components
  168. // eslint-disable-next-line react/no-find-dom-node
  169. const toggleNode = findDOMNode(this);
  170. if (toggleNode && toggleNode instanceof Element && this.tooltipNode) {
  171. const toggleRect = toggleNode.getBoundingClientRect();
  172. const tooltipRect = this.tooltipNode.getBoundingClientRect();
  173. const { width, height } = tooltipRect;
  174. let left = 0;
  175. let top = 0;
  176. switch (this.getPlacement()) {
  177. case 'bottom':
  178. left = toggleRect.left + toggleRect.width / 2 - width / 2;
  179. top = toggleRect.top + toggleRect.height;
  180. break;
  181. case 'top':
  182. left = toggleRect.left + toggleRect.width / 2 - width / 2;
  183. top = toggleRect.top - height;
  184. break;
  185. case 'right':
  186. left = toggleRect.left + toggleRect.width;
  187. top = toggleRect.top + toggleRect.height / 2 - height / 2;
  188. break;
  189. case 'left':
  190. left = toggleRect.left - width;
  191. top = toggleRect.top + toggleRect.height / 2 - height / 2;
  192. break;
  193. }
  194. // Save width and height (and later set in `render`) to avoid resizing the tooltip
  195. // element when it's placed close to the window's edge.
  196. this.setState({
  197. left: window.pageXOffset + left,
  198. top: window.pageYOffset + top,
  199. width,
  200. height
  201. });
  202. }
  203. };
  204. clearPosition = () => {
  205. this.setState({
  206. flipped: false,
  207. left: undefined,
  208. top: undefined,
  209. width: undefined,
  210. height: undefined,
  211. placement: this.props.placement
  212. });
  213. };
  214. handleMouseEnter = () => {
  215. this.mouseEnterTimeout = window.setTimeout(() => {
  216. // For some reason, even after the `this.mouseEnterTimeout` is cleared, it still
  217. // triggers. To workaround this issue, check that its value is not `undefined`
  218. // (if it's `undefined`, it means the timer has been reset).
  219. if (
  220. this.mounted &&
  221. this.props.visible === undefined &&
  222. this.mouseEnterTimeout !== undefined
  223. ) {
  224. this.setState({ visible: true });
  225. }
  226. }, (this.props.mouseEnterDelay || 0) * 1000);
  227. if (this.props.onShow) {
  228. this.props.onShow();
  229. }
  230. };
  231. handleMouseLeave = () => {
  232. if (this.mouseEnterTimeout !== undefined) {
  233. window.clearTimeout(this.mouseEnterTimeout);
  234. this.mouseEnterTimeout = undefined;
  235. }
  236. if (!this.mouseIn) {
  237. this.mouseLeaveTimeout = window.setTimeout(() => {
  238. if (this.mounted && this.props.visible === undefined && !this.mouseIn) {
  239. this.setState({ visible: false });
  240. }
  241. if (this.props.onHide && !this.mouseIn) {
  242. this.props.onHide();
  243. }
  244. }, (this.props.mouseLeaveDelay || 0) * 1000);
  245. }
  246. };
  247. handleFocus = () => {
  248. this.setState({ visible: true });
  249. if (this.props.onShow) {
  250. this.props.onShow();
  251. }
  252. };
  253. handleBlur = () => {
  254. if (this.mounted) {
  255. this.setState({ visible: false });
  256. if (this.props.onHide) {
  257. this.props.onHide();
  258. }
  259. }
  260. };
  261. handleOverlayMouseEnter = () => {
  262. this.mouseIn = true;
  263. };
  264. handleOverlayMouseLeave = () => {
  265. this.mouseIn = false;
  266. this.handleMouseLeave();
  267. };
  268. needsFlipping = (leftFix: number, topFix: number) => {
  269. // We can live with a tooltip that's slightly positioned over the toggle
  270. // node. Only trigger if it really starts overlapping, as the re-positioning
  271. // is quite expensive, needing 2 re-renders.
  272. const threshold = rawSizes.grid;
  273. switch (this.getPlacement()) {
  274. case 'left':
  275. case 'right':
  276. return Math.abs(leftFix) > threshold;
  277. case 'top':
  278. case 'bottom':
  279. return Math.abs(topFix) > threshold;
  280. }
  281. return false;
  282. };
  283. renderActual = ({ leftFix = 0, topFix = 0 }) => {
  284. if (
  285. !this.state.flipped &&
  286. (leftFix !== 0 || topFix !== 0) &&
  287. this.needsFlipping(leftFix, topFix)
  288. ) {
  289. // Update state in a render function... Not a good idea, but we need to
  290. // render in order to know if we need to flip... To prevent React from
  291. // complaining, we update the state using a setTimeout() call.
  292. setTimeout(() => {
  293. this.setState(
  294. ({ placement = 'bottom' }) => ({
  295. flipped: true,
  296. // Set height to undefined to force ScreenPositionFixer to
  297. // re-compute our positioning.
  298. height: undefined,
  299. placement: FLIP_MAP[placement]
  300. }),
  301. () => {
  302. if (this.state.visible) {
  303. // Force a re-positioning, as "only" updating the state doesn't
  304. // recompute the position, only re-renders with the previous
  305. // position (which is no longer correct).
  306. this.positionTooltip();
  307. }
  308. }
  309. );
  310. }, 1);
  311. return null;
  312. }
  313. const { classNameSpace = 'tooltip' } = this.props;
  314. const currentPlacement = this.getPlacement();
  315. const style = isMeasured(this.state)
  316. ? {
  317. left: this.state.left + leftFix,
  318. top: this.state.top + topFix,
  319. width: this.state.width,
  320. height: this.state.height
  321. }
  322. : undefined;
  323. return (
  324. <div
  325. className={`${classNameSpace} ${currentPlacement}`}
  326. onPointerEnter={this.handleOverlayMouseEnter}
  327. onPointerLeave={this.handleOverlayMouseLeave}
  328. ref={this.tooltipNodeRef}
  329. style={style}>
  330. <div className={`${classNameSpace}-inner`} id={this.id}>
  331. {this.props.overlay}
  332. </div>
  333. <div
  334. className={`${classNameSpace}-arrow`}
  335. style={
  336. isMeasured(this.state)
  337. ? this.adjustArrowPosition(currentPlacement, { leftFix, topFix })
  338. : undefined
  339. }
  340. />
  341. </div>
  342. );
  343. };
  344. render() {
  345. const isVisible = this.isVisible();
  346. return (
  347. <>
  348. {React.cloneElement(this.props.children, {
  349. onPointerEnter: this.handleMouseEnter,
  350. onPointerLeave: this.handleMouseLeave,
  351. onFocus: this.handleFocus,
  352. onBlur: this.handleBlur,
  353. tabIndex: 0,
  354. // aria-describedby is the semantically correct property to use, but it's not
  355. // always well supported. As a fallback, we use aria-labelledby as well.
  356. // See https://sarahmhigley.com/writing/tooltips-in-wcag-21/
  357. // See https://css-tricks.com/accessible-svgs/
  358. 'aria-describedby': isVisible ? this.id : undefined,
  359. 'aria-labelledby': isVisible ? this.id : undefined
  360. })}
  361. {isVisible && (
  362. <EscKeydownHandler onKeydown={this.handleBlur}>
  363. <TooltipPortal>
  364. <ScreenPositionFixer ready={isMeasured(this.state)}>
  365. {this.renderActual}
  366. </ScreenPositionFixer>
  367. </TooltipPortal>
  368. </EscKeydownHandler>
  369. )}
  370. </>
  371. );
  372. }
  373. }
  374. class TooltipPortal extends React.Component {
  375. el: HTMLElement;
  376. constructor(props: {}) {
  377. super(props);
  378. this.el = document.createElement('div');
  379. }
  380. componentDidMount() {
  381. document.body.appendChild(this.el);
  382. }
  383. componentWillUnmount() {
  384. document.body.removeChild(this.el);
  385. }
  386. render() {
  387. return createPortal(this.props.children, this.el);
  388. }
  389. }