Skip to content

Commit ec80450

Browse files
committed
fix: a11y on interactive elements with tooltip with sr-only element
1 parent 3b8878f commit ec80450

7 files changed

Lines changed: 170 additions & 8 deletions

File tree

packages/pluggableWidgets/tooltip-web/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- We fixed an accessibility issue where tooltip content was not announced by screen readers. Tooltip text is now immediately accessible when focusing or hovering over trigger elements through the use of `aria-describedby` pointing to always-present sr-only content.
12+
913
## [1.5.1] - 2026-02-10
1014

1115
### Added

packages/pluggableWidgets/tooltip-web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@mendix/tooltip-web",
33
"widgetName": "Tooltip",
4-
"version": "1.5.1",
4+
"version": "1.5.2",
55
"description": "Shows a value inside a colored tooltip or label",
66
"copyright": "© Mendix Technology BV 2025. All rights reserved.",
77
"license": "Apache-2.0",

packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Placement } from "@floating-ui/react";
22
import classNames from "classnames";
3-
import { CSSProperties, ReactElement, ReactNode, useState } from "react";
3+
import { CSSProperties, ReactElement, ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
44
import { OpenOnEnum, RenderMethodEnum } from "../../typings/TooltipProps";
55
import { useFloatingUI } from "../utils/useFloatingUI";
66

@@ -22,6 +22,10 @@ export const Tooltip = (props: TooltipProps): ReactElement => {
2222
const { trigger, htmlMessage, textMessage, openOn, position, preview, renderMethod } = props;
2323
const [showTooltip, setShowTooltip] = useState(preview ?? false);
2424
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null);
25+
const contentId = useId();
26+
const hasTooltipContent = !!(textMessage || htmlMessage);
27+
const triggerWrapperRef = useRef<HTMLDivElement>(null);
28+
2529
const { arrowStyles, blurFocusEvents, floatingStyles, getFloatingProps, getReferenceProps, refs, staticSide } =
2630
useFloatingUI({
2731
position,
@@ -31,18 +35,60 @@ export const Tooltip = (props: TooltipProps): ReactElement => {
3135
openOn
3236
});
3337

38+
// Apply aria-describedby to all focusable elements inside the trigger wrapper
39+
useEffect(() => {
40+
if (!hasTooltipContent || !triggerWrapperRef.current) {
41+
return;
42+
}
43+
44+
// Find all focusable elements (button, a, input, select, textarea, etc.)
45+
const focusableElements = triggerWrapperRef.current.querySelectorAll<HTMLElement>(
46+
'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
47+
);
48+
49+
if (focusableElements.length > 0) {
50+
focusableElements.forEach(element => {
51+
element.setAttribute("aria-describedby", contentId);
52+
});
53+
54+
return () => {
55+
focusableElements.forEach(element => {
56+
element.removeAttribute("aria-describedby");
57+
});
58+
};
59+
}
60+
}, [hasTooltipContent, contentId]);
61+
62+
// Merge our ref with floating-ui's ref
63+
const setTriggerRef = useCallback(
64+
(node: HTMLDivElement | null) => {
65+
triggerWrapperRef.current = node;
66+
if (refs?.setReference) {
67+
refs.setReference(node);
68+
}
69+
},
70+
[refs]
71+
);
72+
3473
return (
3574
<div className={classNames(props.class, "widget-tooltip", `widget-tooltip-${position}`)}>
3675
<div
3776
className="widget-tooltip-trigger"
38-
ref={refs?.setReference}
77+
ref={setTriggerRef}
3978
{...(preview
4079
? undefined
4180
: getReferenceProps?.({ ...(openOn === "hoverFocus" && !preview ? blurFocusEvents : undefined) }))}
4281
>
4382
{trigger}
4483
</div>
45-
{showTooltip && (textMessage || htmlMessage) ? (
84+
{/* Hidden content for screen readers - always in DOM, only accessible via aria-describedby */}
85+
{hasTooltipContent && (
86+
<div id={contentId} className="sr-only" aria-hidden="true">
87+
{renderMethod === "text" ? textMessage : htmlMessage}
88+
</div>
89+
)}
90+
{/* Visible content for sighted users */}
91+
{showTooltip && hasTooltipContent ? (
4692
<div
4793
className="widget-tooltip-content"
4894
ref={refs?.setFloating}

packages/pluggableWidgets/tooltip-web/src/components/__tests__/Tooltip.spec.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ describe("Tooltip", () => {
141141
const triggerElement = screen.getByTestId("tooltip-trigger");
142142
await user.click(triggerElement);
143143

144-
expect(screen.queryByText("Simple Tooltip")).toBeInTheDocument();
144+
expect(screen.queryByRole("tooltip")).toHaveTextContent("Simple Tooltip");
145145
});
146146

147147
it("close onOutsideClick if tooltip is visible", async () => {
@@ -154,4 +154,95 @@ describe("Tooltip", () => {
154154
await user.click(document.body);
155155
expect(screen.queryByRole("tooltip")).toBeNull();
156156
});
157+
158+
describe("accessibility", () => {
159+
it("adds aria-describedby to trigger element", () => {
160+
render(<Tooltip {...defaultTooltipProps} />);
161+
const triggerElement = screen.getByTestId("tooltip-trigger");
162+
163+
expect(triggerElement).toHaveAttribute("aria-describedby");
164+
});
165+
166+
it("renders sr-only content for screen readers before tooltip is shown", () => {
167+
render(<Tooltip {...defaultTooltipProps} />);
168+
const srOnlyContent = document.querySelector(".sr-only");
169+
170+
expect(srOnlyContent).toBeInTheDocument();
171+
expect(srOnlyContent).toHaveTextContent(defaultTooltipProps.textMessage as string);
172+
});
173+
174+
it("sr-only content is always in DOM even when tooltip is not visible", () => {
175+
render(<Tooltip {...defaultTooltipProps} openOn="click" />);
176+
const srOnlyContent = document.querySelector(".sr-only");
177+
178+
// Content should exist before tooltip is shown
179+
expect(srOnlyContent).toBeInTheDocument();
180+
expect(screen.queryByRole("tooltip")).toBeNull();
181+
});
182+
183+
it("sr-only content matches the text message", () => {
184+
const customMessage = "Custom accessibility message";
185+
render(<Tooltip {...defaultTooltipProps} textMessage={customMessage} />);
186+
const srOnlyContent = document.querySelector(".sr-only");
187+
188+
expect(srOnlyContent).toHaveTextContent(customMessage);
189+
});
190+
191+
it("sr-only content matches the HTML message", () => {
192+
const htmlContent = <div>Custom HTML Content</div>;
193+
render(
194+
<Tooltip
195+
{...defaultTooltipProps}
196+
renderMethod="custom"
197+
htmlMessage={htmlContent}
198+
textMessage={undefined}
199+
/>
200+
);
201+
const srOnlyContent = document.querySelector(".sr-only");
202+
203+
expect(srOnlyContent).toHaveTextContent("Custom HTML Content");
204+
});
205+
206+
it("does not add aria-describedby when no tooltip content is provided", () => {
207+
render(<Tooltip {...defaultTooltipProps} textMessage={undefined} htmlMessage={undefined} />);
208+
const triggerElement = screen.getByTestId("tooltip-trigger");
209+
const parentElement = triggerElement.parentElement;
210+
211+
expect(parentElement).not.toHaveAttribute("aria-describedby");
212+
});
213+
214+
it("adds aria-describedby to all focusable elements in trigger", () => {
215+
render(
216+
<Tooltip
217+
{...defaultTooltipProps}
218+
trigger={
219+
<div>
220+
<button data-testid="button1">Button 1</button>
221+
<button data-testid="button2">Button 2</button>
222+
<a href="#" data-testid="link1">
223+
Link
224+
</a>
225+
</div>
226+
}
227+
/>
228+
);
229+
230+
const button1 = screen.getByTestId("button1");
231+
const button2 = screen.getByTestId("button2");
232+
const link = screen.getByTestId("link1");
233+
234+
// All focusable elements should have aria-describedby
235+
expect(button1).toHaveAttribute("aria-describedby");
236+
expect(button2).toHaveAttribute("aria-describedby");
237+
expect(link).toHaveAttribute("aria-describedby");
238+
239+
// All should point to the same sr-only content
240+
const ariaDescribedBy1 = button1.getAttribute("aria-describedby");
241+
const ariaDescribedBy2 = button2.getAttribute("aria-describedby");
242+
const ariaDescribedBy3 = link.getAttribute("aria-describedby");
243+
244+
expect(ariaDescribedBy1).toBe(ariaDescribedBy2);
245+
expect(ariaDescribedBy2).toBe(ariaDescribedBy3);
246+
});
247+
});
157248
});

packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,28 @@ exports[`Tooltip render DOM structure 1`] = `
66
class="widget-tooltip widget-tooltip-right"
77
>
88
<div
9-
aria-describedby=":r0:"
9+
aria-describedby=":r1:"
1010
class="widget-tooltip-trigger"
1111
>
1212
<div
13+
aria-describedby=":r0:"
1314
data-testid="tooltip-trigger"
1415
tabindex="0"
1516
>
1617
Trigger
1718
</div>
1819
</div>
20+
<div
21+
aria-hidden="true"
22+
class="sr-only"
23+
id=":r0:"
24+
>
25+
Tooltip text
26+
</div>
1927
<div
2028
class="widget-tooltip-content"
2129
data-floating-ui-focusable=""
22-
id=":r0:"
30+
id=":r1:"
2331
role="tooltip"
2432
style="position: fixed; left: 0px; top: 0px; transform: translate(0px, 0px);"
2533
tabindex="-1"

packages/pluggableWidgets/tooltip-web/src/package.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="utf-8" ?>
22
<package xmlns="http://www.mendix.com/package/1.0/">
3-
<clientModule name="Tooltip" version="1.5.1" xmlns="http://www.mendix.com/clientModule/1.0/">
3+
<clientModule name="Tooltip" version="1.5.2" xmlns="http://www.mendix.com/clientModule/1.0/">
44
<widgetFiles>
55
<widgetFile path="Tooltip.xml" />
66
</widgetFiles>

packages/pluggableWidgets/tooltip-web/src/ui/Tooltip.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@
44
}
55
}
66

7+
// Screen reader only - visually hidden but accessible to assistive technology
8+
.sr-only {
9+
position: absolute;
10+
width: 1px;
11+
height: 1px;
12+
padding: 0;
13+
margin: -1px;
14+
overflow: hidden;
15+
clip: rect(0, 0, 0, 0);
16+
white-space: nowrap;
17+
border-width: 0;
18+
}
19+
720
.widget-tooltip-content {
821
color: #24276c;
922
display: inline-block;

0 commit comments

Comments
 (0)