The bug
The Tooltip component wrapped its children in a div[role="presentation"]
that handled the mouse and focus events, and the aria-describedby attribute
lived on that wrapper, pointing at the tooltip's id. In the DOM inspector
everything looked correct.
Screen readers never announced the tooltip content, though. A VoiceOver user
focusing a button with a tooltip heard "Save, button" and nothing else,
because aria-describedby sat on the role="presentation" wrapper rather
than the <button> inside it. Assistive technology skips role="presentation"
elements when it builds the accessibility tree, so the association went
nowhere.
The fix
The tooltip needs to inject aria-describedby onto whatever element the
consumer passes as children, which means cloning the child element:
const child = isValidElement(children)
? cloneElement(children as ReactElement<Record<string, unknown>>, {
'aria-describedby': tooltipId,
})
: children;
return (
<div className='relative inline-block'>
<div
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
role='presentation'
>
{child}
</div>
<div id={tooltipId} role='tooltip' aria-hidden={!isVisible}>
{content}
</div>
</div>
);The wrapper div still handles the events, but aria-describedby now lands on
the actual interactive element: the <button>, <a>, or whatever the child
happens to be. The tooltip div also picks up aria-hidden={!isVisible} so
screen readers don't read its content as regular page text while it's sitting
at opacity 0.
This meant changing the children type from ReactNode to ReactElement,
since cloneElement needs an element, not a string or a fragment. That's a
reasonable constraint, since a tooltip without an interactive trigger element
isn't much use anyway.
Testing the right thing
The original test asserted that the wrapper had aria-describedby, and it
passed because the wrapper did. The test was right about the implementation
and wrong about the spec.
The updated test checks the child element instead:
it('applies aria-describedby to the child element, not the wrapper', () => {
const { container } = render(
<Tooltip content='Tooltip text'>
<button>Hover me</button>
</Tooltip>
);
const button = screen.getByRole('button', { name: 'Hover me' });
const tooltip = container.querySelector('[role="tooltip"]');
const wrapper = container.querySelector('[role="presentation"]');
expect(button).toHaveAttribute(
'aria-describedby',
tooltip?.getAttribute('id')
);
expect(wrapper).not.toHaveAttribute('aria-describedby');
});The negative assertion is the part that matters, because it documents that the wrapper should not carry the attribute and stops a future change from re-adding it to the wrong element.
The takeaway
Testing for the presence of an ARIA attribute is only half the job; the other half is testing which element it sits on. The wrapper had the right attribute, but the child element was the one that needed it. Screen readers don't care about your wrapper divs.
