How to create an accessible tooltip using React
Tags: react, a11y, webdev, javascript
Published: 2024-01-15 01:51:55

Happy 2024, everyone!!! I hope you had a great starting. On this side, we're going to celebrate the beginning of the year with a new entry for this wonderful series of accessible components. This time, I'm going to share with you how to create an accessible dynamic tooltip and how to use the clipboard API in JavaScript. Let's get started because things are getting exciting!

Project repository: https://github.com/micaavigliano/accessible-tooltip
Project link: https://accessible-tooltip.vercel.app/

First and foremost, let's discuss a bit about what we want to achieve to make our clipboard copy button accessible. We need:

1) The button to receive focus and have an accessible name.
2) The copy button to have a tooltip that appears when the user hovers over it or when it receives focus.
3) The tooltip content to be dynamic, and that this content is announced by the screen reader each time it changes.
4) The content we want to copy should indeed be copied to the clipboard.
5) The copy component should be reusable and can be either static text or an input.
6) The tooltip should close when the user presses the Esc key.


Our specifications are clear and straightforward. Let's start coding!

### Tooltip.tsx

```javascript
interface TooltipProps {
  text: string;
  children: ReactNode;
  direction: "top" | "bottom" | "left" | "right";
  id: string;
}
```

- **Text**: It will be the text that our tooltip will contain.
- **Children**: The element that will contain the tooltip.
- **Direction**: The direction in which we want our tooltip to appear.
- **ID**: An ID to relate it to the aria-describedby attribute that the children will have.

Let's start breaking down the component:

1. We'll need to set a state to manage the visibility of the tooltip:

```javascript
const [showTooltip, setShowTooltip] = useState<boolean>(false);
```

2. Next, let's create two functions to handle this state: tooltipOn and tooltipOff:

```javascript
const tooltipOn = () => {
  setShowTooltip(true);
};

const tooltipOff = () => {
  setShowTooltip(false);
};
```
These functions will be passed to our container element to show the tooltip when hovered using the onMouseEnter function, hide the tooltip when the mouse leaves that element with onMouseLeave, display the tooltip again when the element receives focus with onFocus, and hide the tooltip when focus leaves that element with onBlur. Additionally, to ensure our tooltip is 100% functional and accessible, we will create a function to make it disappear when the user presses the Escape key.

```javascript
const closeTooltip = (ev: KeyboardEvent) => {
    if (ev.key === "Escape") {
      setShowTooltip(false);
    }
};
```
and handle the event in a `useEffect`

```javascript
 useEffect(() => {
    document.addEventListener("keydown", closeTooltip);

    return () => {
      document.removeEventListener("keydown", closeTooltip);
    };
  }, []);
```
Let's see how our component is going to look:

``` javascript
<div
   className="relative inline-block justify-center text-center"
   onMouseEnter={tooltipOn}
   onMouseLeave={tooltipOff}
   onFocus={tooltipOn}
   onBlur={tooltipOff}
>
  {showTooltip && (
    <div
      className={`bg-black text-white text-center rounded p-3 absolute z-10 transition-opacity duration-300 ease-in-out w-fit outline outline-offset-0 ${
      direction === "top" ? "bottom-[calc(100%+1px)] left-10 transform translate-x-[-60%] mb-2"
              : ""
          }
          ${
            direction === "bottom"
              ? "top-[calc(100%+1px)] left-10 transform translate-x-[-60%] mt-2"
              : ""
          }
          ${
            direction === "left"
              ? "-left-100 top-1/2 transform -translate-y-1/2 mr-2"
              : ""
          }
          ${
            direction === "right"
              ? "-right-100 top-1/2 transform -translate-y-1/2 ml-2"
              : ""
          }`}
          data-placement={direction}
          role="tooltip"
          id={id}
          style={getTooltipStyle()}
        >
          {text}
        </div>
      )}
      {children}
    </div>
```

To make our Tooltip accessible, we'll need to pass it a series of attributes:

1. **role="tooltip"**: While semantically it may not represent a significant change, it does so in reference terms by helping screen readers identify and associate the tooltip with its related element. What do I mean by this? Well, any element that contains role="tooltip" must be related to another element that contains aria-labelledby (in this case, the children should have it). This is because the tooltip provides additional information about the element.

2. **id**: The ID of the element to be related through aria-describedby.

3. **data-placement**: It will receive the direction property, which will be the direction of our tooltip.

### CopyToClipboard.tsx

Now, let's move on to one of the most enjoyable components I've had the pleasure of creating. I don't know why, I just grew very fond of it.

Let's start with its properties; in this case, we have one optional and one mandatory:

```javascript
interface ICopyToClipboard {
  text?: string;
  type: "text" | "input";
}
```

1. **Text**: The text that our component will receive in the case of being of type text.
2. **Type**: It can only take two values, either text or input. Text will be a static value, while input will be a dynamic one.

In this case, our copy button will be wrapped in the `Tooltip` component. The button will have an onClick attribute that will receive the `handleCopyText` function, which we'll discuss a little later, and the `aria-labelledby` attribute to relate it to our tooltip.

```javascript
<Tooltip text={copyText} direction={"bottom"} id={"copyid"}>
 <button onClick={handleCopyText} aria-labelledby="copyid">
  <ContentCopy />
 </button>
</Tooltip>
```
Let's move on to the handleCopyText function.

1. We need to create a state to manage the text: `const [copyText, setCopyText] = useState<string>("Copy to clipboard");`
2. To continue with the `handleCopyText` function, we have to understand what the clipboard API is. The clipboard API allows us to interact with the clipboard of an operating system. It contains methods and functions to access and manipulate the information stored on the clipboard (copy, paste, and cut). In this case, we will make use of the `writeText()` method. This method takes a required text parameter and returns a promise. If the operation is successful, the then() method will be executed, changing the value of our `copyText` state. After 5 seconds, the text will revert to `"Copy to clipboard"`. In this case, the writeText method parameter will receive either `text` or `inputValue`, depending on which is not null. This is because if we pass `type="input"` to our component, we won't be providing a default static text.

```javascript
const handleCopyText = () => {
    navigator.clipboard.writeText(text || inputValue).then(() => {
      setCopyText("Copiado");
      setTimeout(() => {
        setCopyText("Copiar al portapapeles");
      }, 5000);
    });
  };
```

And... there you have it! As simple as that, we now have our accessible text copy button with an accessible tooltip as well. Finally, I'd like to show you how the screen reader announces the content of our tooltip:

1. Initial state:

![Image of the screen reader voice-over announcing: "Copy to clipboard, button"](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2wazrd343u5pnpvssew2.png)

2. Clicked the copy button

![Image of the screen reader voice-over announcing: "Copied"](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4cw1edvz3wus2yw1ea9h.png)

3. Back to initial state after 5 seconds

![Image of the screen reader voice-over announcing: "Copy to clipboard"](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cv17uvzcsjfl2wq1fmi1.png)


Now, I hope you enjoyed this component as much as I did, and please share if you've ever come across a tooltip and thought it could be made accessible. Finally, here's the list of resources I used to gather information:

- MDN web docs tooltips: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tooltip_role
- MDN web Clipboard API: https://developer.mozilla.org/es/docs/Web/API/Clipboard_API