Reach-ui: Better ergonomics around showing arrows in Tooltips

Created on 5 Mar 2020  路  6Comments  路  Source: reach/reach-ui

馃殌 Feature request

Current Behavior

At the time of writing, it's not entirely intuitive to support arrows on tooltips (which is a fairly common use case). The docs suggest I need to drop down to low-level useTooltip APIs and place the tooltip myself.

The docs tell me to do my own position function to center the tooltips to "keep the positioning logic simpler".

Desired Behavior

But I still want dynamic positioning with collision detection.

I think either

  • the docs should show how to position tooltips with arrows with the current API, or
  • the library itself should support arrows out of the box and handle the positioning (perhaps opt-in through a prop, I don't know).

The latter is preferred, I think.

Suggested Solution

Right now, I inlined the tooltips.tsx source into our app and hacked the Reach Tooltip code to render an arrow div along with the main tooltip.

export type TooltipContentProps = {
    ariaLabel?: string;
    position?: Position;
    /** Provide an optional positioning function for the arrow. */
+   positionArrow?: Position;
    label: React.ReactNode;
    isVisible?: boolean;
    triggerRect: DOMRect | null;
};
function TooltipContent(
   // ...
   position = positionDefault,
+  positionArrow = positionArrowDefault,

We need to let the arrow know about collisions, so we can make it point downwards:

export type Collisions = {
  left: boolean;
  right: boolean;
  bottom: boolean;
  top: boolean;
};

const getCollisions = (
  triggerRect: DOMRect,
  tooltipRect: DOMRect
): Collisions => ({
  top: triggerRect.top - tooltipRect.height < 0,
  right: window.innerWidth < triggerRect.left + tooltipRect.width,
  bottom:
      window.innerHeight < triggerRect.bottom + tooltipRect.height + OFFSET,
  left: triggerRect.left - tooltipRect.width < 0,
});

const positionArrow: Position = (triggerRect, tooltipRect, collisions) => {
    if (!triggerRect || !tooltipRect || !collisions) {
        return {};
    }

    const directionUp = collisions.bottom && !collisions.top;

    return {
        left: triggerRect.left - 10 + triggerRect.width / 2,
        top: directionUp
            ? triggerRect.bottom + window.pageYOffset - tooltipRect.height - 10
            : triggerRect.bottom + window.pageYOffset,
        ...(directionUp
            ? {
                  transform: 'rotate(180deg)',
              }
            : undefined),
    };
};

Then add the arrow below the tooltip in TooltipContent:

const collisions =
   !!triggerRect && !!tooltipRect
      ? getCollisions(triggerRect, tooltipRect)
      : undefined;

return (
  <Fragment>
    <Comp
      data-reach-tooltip
      role={useAriaLabel ? undefined : 'tooltip'}
      id={useAriaLabel ? undefined : id}
      children={label}
      style={{
        ...style,
        ...(!tooltipRect
          ? {
              visibility: 'hidden',
            }
          : position(triggerRect, tooltipRect, collisions)),
      }}
      ref={ref}
      {...rest}
    />
    <div
      data-reach-tooltip-arrow
      style={{
        position: 'absolute',
        width: 0,
        height: 0,
        ...(!tooltipRect
          ? {
              visibility: 'hidden',
            }
          : positionArrow(triggerRect, tooltipRect, collisions)),
      }}
    />
    {useAriaLabel && (
      <VisuallyHidden role="tooltip" id={id}>
        {ariaLabel}
      </VisuallyHidden>
    )}
  </Fragment>
);

See complete code in the gist linked below.


I'm sure this can be made much cleaner, but I just needed to get the job done. I think the authors have a better starting point 馃槃

I imagine the API could be:

<Tooltip label="Foobar" arrow>
  <button>Hey</button>
</Tooltip>

This suggests these additions to TooltipContentProps:

export type TooltipContentProps = {
    ariaLabel?: string;
    position?: Position;
    label: React.ReactNode;
    isVisible?: boolean;
    triggerRect: DOMRect | null;

    // New 馃 
    arrow?: boolean; // Default to false
    positionArrow?: Position; // Default to a nice positioning function, but let users customise
};

Then the library code takes complete care about:

  • positioning the tooltips with collision detection.
  • positioning the arrows accordingly.
  • providing custom positioning functions for both tooltip and arrows as a part of the external API.

Who does this impact? Who is this for?

Most people wanting tooltips in web apps.

Describe alternatives you've considered

Most other tooltip libs provide out-of-the-box solutions for rendering tooltips. See Reakit: https://reakit.io/docs/tooltip/.

Additional context

Complete gist of my modified code: https://gist.github.com/brookback/67bb0de295c8af96f50ce6bec8cf4cd8.


Thanks for a great, hackable library! <3

Stale Enhancement

Most helpful comment

Awesome write-up, thank you! As it stands I think this is still better handled by docs and examples than new props or abstract components. Reach UI is intended to be fairly low-level, and we want you to build your own abstractions on top of ours. I'll take a closer look at your fork code to see if I can't figure out better ways we might handle it either in the docs or some better example code.

All 6 comments

Thank you for opening this issue

Awesome write-up, thank you! As it stands I think this is still better handled by docs and examples than new props or abstract components. Reach UI is intended to be fairly low-level, and we want you to build your own abstractions on top of ours. I'll take a closer look at your fork code to see if I can't figure out better ways we might handle it either in the docs or some better example code.

Thanks for replying 馃檹 !

Whilst I totally understand you don't want to bog down the lib with arrow functionality, I think, as you say, something can be done to clarify how to get the best of both worlds: the built-in positioning and arrows that behave in a good way.

The problem I ran into when positioning my arrows (without resorting to centering the whole tooltip horizontally and disallowing collision detection) was that I needed references to

  • tooltipRect
  • triggerRect

in order to calculate collisions and heights. The current API doesn't expose tooltipRect (if I'm not mistaken).

The current API doesn't expose tooltipRect (if I'm not mistaken).

There's a small chance that you are mistaken! Here's an example of one that I implemented. Mostly just copy/pasted from the docs with a few modifications.

It looks like the the position callback provides both triggerRect and tooltipRect as arguments to the function that you pass in.

@graygilmore Yes, that's correct!

Interesting approach, thanks. No measuring at all of the tooltipRect. Is that code handling where the tooltip is above an element? Like, does it adjust the position and rotation of the triangle?

For this approach we assume that the tooltip always will appear below the hovered item. So the only thing we need to be aware about are left/right boundaries. There's some CSS love happening behind the scenes that doesn't show the whole picture but hopefully having tooltipRect in the position callback gets you what you need!

Was this page helpful?
0 / 5 - 0 ratings