Skip to content
Snippets Groups Projects
tooltips.js 12.00 KiB
import React, { Component, createContext, createRef, useState } from 'react';
import ReactDOM from 'react-dom';

import { css, before } from 'glamor';

import { styles } from '../style';

export const IntersectionBoundary = createContext();

export function useTooltip() {
  let [isOpen, setIsOpen] = useState(false);

  return {
    getOpenEvents: (events = {}) => ({
      onClick: e => {
        e.stopPropagation();
        events.onClick && events.onClick(e);
        setIsOpen(true);
      },
    }),
    isOpen,
    close: () => setIsOpen(false),
  };
}

export class Tooltip extends Component {
  static contextType = IntersectionBoundary;
  state = { position: null };

  constructor(props) {
    super(props);
    this.position = props.position || 'bottom-right';
    this.contentRef = createRef();
  }

  setup() {
    this.layout();

    let mousedownHandler = e => {
      let node = e.target;

      while (node && node !== document.documentElement) {
        // Allow clicking reach popover that mount outside of
        // tooltips. Might need to think about this more, like what
        // kind of things can be click that shouldn't close a tooltip?
        if (
          node.dataset.testid === 'tooltip' ||
          node.dataset.reachPopover != null
        ) {
          break;
        }

        node = node.parentNode;
      }

      if (node === document.documentElement) {
        this.props.onClose && this.props.onClose();
      }
    };

    let escHandler = e => {
      if (e.key === 'Escape') {
        this.props.onClose && this.props.onClose();
      }
    };

    window.document.addEventListener('mousedown', mousedownHandler, false);
    this.contentRef.current.addEventListener('keydown', escHandler, false);

    this.cleanup = () => {
      window.document.removeEventListener('mousedown', mousedownHandler);
    };
  }

  componentDidMount() {
    if (this.getContainer()) {
      this.setup();
    } else {
      // TODO: write comment :)
      this.forceUpdate(() => {
        if (this.getContainer()) {
          this.setup();
        } else {
          console.log('Warning: could not mount tooltip, container missing');
        }
      });
    }
  }

  componentDidUpdate(prevProps) {
    // If providing the target rect manually, we can dynamically
    // update to it. We can't do this if we are reading directly from
    // the DOM since we don't know when it's updated.
    if (
      prevProps.targetRect !== this.props.targetRect ||
      prevProps.forceTop !== this.props.forceTop ||
      this.props.forceLayout
    ) {
      this.layout();
    }
  }

  getContainer() {
    let { ignoreBoundary = false } = this.props;

    if (!ignoreBoundary && this.context) {
      return this.context.current;
    }
    return document.body;
  }

  getBoundsContainer() {
    // If the container is a scrollable element, we want to do all the
    // bounds checking on the parent DOM element instead
    let container = this.getContainer();

    if (
      container.parentNode &&
      container.parentNode.style.overflow === 'auto'
    ) {
      return container.parentNode;
    }
    return container;
  }

  layout() {
    let { targetRect, offset = 0 } = this.props;
    let contentEl = this.contentRef.current;
    if (!contentEl) {
      return;
    }

    let box = contentEl.getBoundingClientRect();
    let anchorEl = this.target.parentNode;

    let anchorRect = targetRect || anchorEl.getBoundingClientRect();

    // Copy it so we can mutate it
    anchorRect = {
      top: anchorRect.top,
      left: anchorRect.left,
      width: anchorRect.width,
      height: anchorRect.height,
    };

    let container = this.getBoundsContainer();
    if (!container) {
      return;
    }

    let containerRect = container.getBoundingClientRect();
    anchorRect.left -= containerRect.left;
    anchorRect.top -= containerRect.top;

    // This is a hack, but allow consumers to force a top position if
    // they already know it. This allows them to provide consistent
    // updates. We should generalize this and `targetRect`
    if (this.props.forceTop) {
      anchorRect.top = this.props.forceTop;
    } else {
      let boxHeight = box.height + offset;
      let testTop = anchorRect.top - boxHeight;
      let testBottom = anchorRect.top + anchorRect.height + boxHeight;

      if (
        // If it doesn't fit above it, switch it to below
        (this.position.indexOf('top') !== -1 && testTop < containerRect.top) ||
        // If it doesn't fit below it, switch it above only if it does
        // fit above it
        (this.position.indexOf('bottom') !== -1 &&
          testBottom > containerRect.height &&
          testTop > 0)
      ) {
        // Invert the position
        this.position = this.getOppositePosition(this.position);
      }

      anchorRect.top += container.scrollTop;
    }

    const style = this.getStyleForPosition(
      this.position,
      box,
      anchorRect,
      this.getContainer().getBoundingClientRect(),
      offset,
    );

    contentEl.style.top = style.top;
    contentEl.style.bottom = style.bottom;
    contentEl.style.left = style.left;
    contentEl.style.right = style.right;
    contentEl.style.width = style.width;
  }

  componentWillUnmount() {
    if (this.cleanup) {
      this.cleanup();
    }
  }

  getOppositePosition(position) {
    switch (position) {
      case 'top':
      case 'top-left':
        return 'bottom';
      case 'top-right':
        return 'bottom-right';
      case 'bottom':
      case 'bottom-left':
        return 'top';
      case 'bottom-right':
        return 'top-right';
      case 'bottom-stretch':
        return 'top-stretch';
      case 'top-stretch':
        return 'bottom-stretch';
      case 'bottom-center':
        return 'top-center';
      case 'top-center':
        return 'bottom-center';
      case 'right':
        return 'right';
      default:
    }
  }

  getStyleForPosition(position, boxRect, anchorRect, containerRect, offset) {
    const style = {
      top: 'inherit',
      bottom: 'inherit',
      left: 'inherit',
      right: 'inherit',
    };

    if (
      position === 'top' ||
      position === 'top-right' ||
      position === 'top-left'
    ) {
      style.top = anchorRect.top - boxRect.height - offset + 'px';
      if (position === 'top-right') {
        style.left =
          anchorRect.left + (anchorRect.width - boxRect.width) + 'px';
      } else {
        style.left = anchorRect.left + 'px';
        // style.right = 0;
      }
    } else if (
      position === 'bottom' ||
      position === 'bottom-right' ||
      position === 'bottom-left'
    ) {
      style.top = anchorRect.top + anchorRect.height + offset + 'px';
      if (position === 'bottom-right') {
        style.left =
          anchorRect.left + (anchorRect.width - boxRect.width) + 'px';
      } else {
        style.left = anchorRect.left + 'px';
        // style.right = 0;
      }
    } else if (position === 'bottom-center') {
      style.top = anchorRect.top + anchorRect.height + offset + 'px';
      style.left =
        anchorRect.left - (boxRect.width - anchorRect.width) / 2 + 'px';
    } else if (position === 'top-center') {
      style.top = anchorRect.top - boxRect.height - offset + 'px';
      style.left =
        anchorRect.left - (boxRect.width - anchorRect.width) / 2 + 'px';
    } else if (position === 'left-center') {
      style.top =
        anchorRect.top - (boxRect.height - anchorRect.height) / 2 + 'px';
      style.left = anchorRect.left - boxRect.width + 'px';
    } else if (position === 'top-stretch') {
      style.bottom = containerRect.height - anchorRect.top + offset + 'px';
      style.left = anchorRect.left + 'px';
      style.width = anchorRect.width + 'px';
    } else if (position === 'bottom-stretch') {
      style.top = anchorRect.top + anchorRect.height + offset + 'px';
      style.left = anchorRect.left + 'px';
      style.width = anchorRect.width + 'px';
    } else if (position === 'right') {
      style.top = anchorRect.top + 'px';
      style.left = anchorRect.left + anchorRect.width + offset + 'px';
    } else {
      throw new Error('Invalid position for Tooltip: ' + position);
    }
    return style;
  }

  render() {
    const { children, width, style } = this.props;

    const contentStyle = {
      position: 'absolute',
      zIndex: 3000,

      padding: 5,
      width,
      ...styles.shadowLarge,
      borderRadius: 4,
      backgroundColor: 'white',
      // opacity: 0,
      // transition: 'transform .1s, opacity .1s',
      // transitionTimingFunction: 'ease-out'
    };

    // const enteredStyle = { opacity: 1, transform: 'none' };

    if (!this.getContainer()) {
      return null;
    }

    return (
      <div ref={el => (this.target = el)}>
        {ReactDOM.createPortal(
          <div
            {...css(contentStyle, style, styles.darkScrollbar)}
            ref={this.contentRef}
            data-testid="tooltip"
            onClick={e => {
              // Click events inside a tooltip (e.g. when selecting a menu item) will bubble up
              // through the portal to parents in the React tree (as opposed to DOM parents).
              // This is undesirable. For example, clicking on a category group on a budget sheet
              // toggles that group, and so would clicking on a menu item in the settings menu
              // of that category group if the click event wasn't stopped from bubbling up.
              // This issue could be handled in different ways, but I think stopping propagation
              // here is sane; I can't see a scenario where you would want to take advantage of
              // click propagation from a tooltip back to its React parent.
              e.stopPropagation();
            }}
          >
            {children}
          </div>,
          this.getContainer(),
        )}
      </div>
    );
  }
}

export function Pointer({
  pointerDirection = 'up',
  pointerPosition = 'left',
  backgroundColor,
  borderColor = '#c0c0c0',
  border = true,
  color,
  style,
  innerStyle,
  pointerStyle,
  children,
}) {
  return (
    <div {...css({ position: 'relative' }, style)}>
      <div
        {...css(
          {
            zIndex: 3000,
            backgroundColor: backgroundColor,
            color: color,
            padding: 10,
            boxShadow: '0 2px 6px rgba(0, 0, 0, .25)',
            border: border && '1px solid ' + borderColor,
            borderRadius: 2,
          },
          before({
            position: 'absolute',
            display: 'inline-block',
            backgroundColor,
            border: border && '1px solid ' + borderColor,
            borderLeft: 0,
            borderBottom: 0,
            width: 7,
            height: 7,
            boxShadow: '1px -1px 1px rgba(0, 0, 0, .05)',
            ...(pointerDirection === 'up'
              ? {
                  transform: 'rotate(-45deg)',
                  top: border ? -4 : -3,
                  // eslint-disable-next-line rulesdir/typography
                  content: '" "',
                  ...(pointerPosition === 'center'
                    ? { left: 'calc(50% - 3.5px)' }
                    : pointerPosition === 'left'
                    ? { left: 40 }
                    : { right: 40 }),
                }
              : pointerDirection === 'down'
              ? {
                  transform: 'rotate(135deg)',
                  bottom: border ? -4 : -3,
                  // eslint-disable-next-line rulesdir/typography
                  content: '" "',
                  ...(pointerPosition === 'center'
                    ? { left: 'calc(50% - 3.5px)' }
                    : pointerPosition === 'left'
                    ? { left: 40 }
                    : { right: 40 }),
                }
              : pointerDirection === 'right'
              ? {
                  transform: 'rotate(45deg)',
                  // eslint-disable-next-line rulesdir/typography
                  content: '" "',
                  top: 'calc(50% - 3.5px)',
                  right: -3,
                }
              : {}),
            ...pointerStyle,
          }),
          innerStyle,
        )}
      >
        {children}
      </div>
    </div>
  );
}