Why Positioning a Tooltip Accurately with React So Complicated?
Tags: react, javascript, webdev, ux
Published: 2019-12-22 06:45:43

## Positioning Problem

You might heard lots of sayings of positioned component, such as popup, tooltip, popover, overlay...
they have the common way that you need to position it when you trigger it.

To generalize the problem, thinking deeper, we can encapsulate the components into 2 things: a trigger which you might press or hover in; and a overlay which positioning relatively to the trigger. it might be a tooltip, a popped dialog.

Since I'm using React.js, so I'm going to design it as a react component to solve my positioning problem, and share it as foundation among the overlay-like components. Pure logic, without any styling.

Then I came out the basic idea of the API. The single child component is the trigger, the we pass the overlay component as a prop to `OverlayTrigger` with the `placement` position in string literal. It will get rendered with accurate position once hover or focus on the button.

```js
<OverlayTrigger
  placement='top'
  events={['hover', 'focus']}
  overlay={<span>tooltip</span>}
>
  <button>hover to trigger tooltip</button>
</OverlayTrigger>
```

The result might look like this

![image](https://user-images.githubusercontent.com/4800338/70851736-eeb51900-1ed3-11ea-8ca6-6eea6fdad10b.png)
## How Gonna It Work?

1. We got the the `trigger` get mounted on the DOM;
2. We mount the `overlay` to DOM when we interact with it (hover or focus)
3. We position get the position & size by `getBoundingClientRect` API of above components, and change the position of overlay to close to trigger with specified placement.

pseudo code like the following

```js
function position(overlay, trigger) {
  // after both get mounted, get the positions and sizes
  overlaySize, overlayPos = getSizeAndPosition(overlay)
  triggerSize, triggerPos = getSizeAndPosition(trigger)

  // move overlay near to the trigger
  rePositionOverlay(...)
}
```

There's might also have a root element which you want to hook your overlay on, by default, it's `document.body`. 
Then you can position it with `fixed` or `absolute` layout and the `top`, `left` distance.

Sounds easy, with couples line of the code. Then I tried to integrate it with my app...

## Hover is Not Equal to Mouse Enter 🤦‍♂️

We had the very basic usage of the tooltip, show up when your hover on some icons, dismiss when you hover out. I looks pretty well when I test with the desktop devices. When I open the surface, Oh flicking....

- Can we just disable tooltip when touch screen detected?
- No, we can't, if you want to use `navigator.maxTouchPoints` to detect touch screen, you'll get wrong result on Edge.
- Oh, ok, Edge, alright...

Let's try to solve it by browser events. Back to the topic on my previous blog [Universal Scrubbing Experience on Web](/post/universal-scrubbing-experience-on-web/). In a word, if you try to capture hover actions by **mouseenter** and **mouseleave** events, that's a trap.

Use `PointerEvent` on the browsers supported and use `MouseEvent` on the ones which don't have `PointerEvent`.

The trigger handlers finally become like this

```js
// if `hover` is specified in trigger `events`
onMouseEnter() {
  // match desktop safari behavior
  // mobile safari won't trigger any mouse event while touching
  if (!window.PointerEvent && !window.TouchEvent) {
    this.showOverlay()
  }
  // ...
}

onPointerEnter(event) {
  // match desktop/mobile browsers which support PointerEvent
  if (event.pointerType === 'mouse') {
    this.showOverlay()
  }
}
```

Looks like we're done now? But soon I found there's something wrong...

## Wait, the Size of Trigger and Tooltip Might Change

If just play with hover you won't have this issue, maybe. But triggers' size do change, positioning only on **did mount** phase is not enough, **did update** is required as well.

Then question comes, how do we really know if there's any internal state change happened inside children and overlay components. 
If we pass down any prop like `onSizeUpdate`, that's kind of tricky no one knows the root cause of resizing is class name changing or due to DOM tree updates.

#### react-bootstrap \<OverlayTrigger /\>

After checking how the popular UI components library solving this problem, like react-bootstrap, ant-design, I found that react-bootstrap pass down a function prop called `scheduleUpdate` to trigger, that let trigger be able to forcedly enqueue an repositioning task when it's necessary. It's quite convenient, but we need to omit this function prop on trigger when we don't need it or when we spread all props onto it.

That's kind of inconvenient, since there're still few DOM props like `onMouseEnter` and `onClick`, been passed to trigger implicitly.

#### ant-design \<align /\>

Ant design align component use **ResizeObserver** to track trigger size change. Unfortunately **ResizeObserver** is not widely supported. When I write this post, https://caniuse.com/#feat=resizeobserver shows that ResizeObserver is only supported on latest tech preview version and mobile safari doesn't support it. Ant design included a polyfill for it to get rid fo resize observer usage. 

If we don' care about the bundle size very much, resize observer polyfill could be a choice. However I do care :) ...

Finally I came out a idea, that we use ResizeObserver when it's available, and fallback to **MutationObserver** on some unsupported browsers. With MutationObserver, the approach is to monitor cache the size and invoke callback when size gets changed.

```js
function createObserver(node, onMeasure) {
  if (window.ResizeObserver) {
    const ro = new ResizeObserver(() => onMeasure())
    ro.observe(node)
    return ro
  } else {
    const cachedSize = {width: 0, height: 0}
    function handleMutate() {
      const {width, height} = node.getBoundingClientRect()
      if (cachedSize.width !== width || cachedSize.height !== height) {
        cachedSize.width = width
        cachedSize.height = height
        onMeasure()
      }
    }
    const mob = new MutationObserver(handleMutate)
    mob.observe(node, mutationObserverOption)
    return mob
  }
}
```

Now, we keep the API as simple as possible, and make the implementation as small as possible. I think we solve the most annoying issue :)

## Repo & Demo

Checkout the source code on https://github.com/huozhi/react-overlay-trigger or use it directly with `npm install --save react-overlay-trigger`.
I also provide a playground that you can try it with different devices/browsers. https://huozhi.github.io/react-overlay-trigger/

From [bundlephobia](https://bundlephobia.com/result?p=react-overlay-trigger) we can see it's only 2kb after minimized and gzipped. Small enough, and fit for general situations. Whatever you want to pop with your trigger components.

Hope you'll like it, issues & PRs are welcomed!