huozhi.im

Positioning Component in React

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.

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

The result might look like this

image

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

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. 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

// 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.

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://react-overlay-trigger.vercel.app

From bundlephobia 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!