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
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:
- We'll need to set a state to manage the visibility of the tooltip:
const [showTooltip, setShowTooltip] = useState<boolean>(false);
- Next, let's create two functions to handle this state: tooltipOn and tooltipOff:
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.
const closeTooltip = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
setShowTooltip(false);
}
};
and handle the event in a useEffect
useEffect(() => {
document.addEventListener("keydown", closeTooltip);
return () => {
document.removeEventListener("keydown", closeTooltip);
};
}, []);
Let's see how our component is going to look:
<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:
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.
id: The ID of the element to be related through aria-describedby.
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:
interface ICopyToClipboard {
text?: string;
type: "text" | "input";
}
- Text: The text that our component will receive in the case of being of type text.
- 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.
<Tooltip text={copyText} direction={"bottom"} id={"copyid"}>
<button onClick={handleCopyText} aria-labelledby="copyid">
<ContentCopy />
</button>
</Tooltip>
Let's move on to the handleCopyText function.
- We need to create a state to manage the text:
const [copyText, setCopyText] = useState<string>("Copy to clipboard");
- 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 thewriteText()
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 ourcopyText
state. After 5 seconds, the text will revert to"Copy to clipboard"
. In this case, the writeText method parameter will receive eithertext
orinputValue
, depending on which is not null. This is because if we passtype="input"
to our component, we won't be providing a default static text.
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:
- Initial state:
- Clicked the copy button
- Back to initial state after 5 seconds
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
Top comments (2)
That case it is covered