From ec804501c613d1bb6bef74d6556d7c518cbf8231 Mon Sep 17 00:00:00 2001 From: "hedwig.doets" Date: Fri, 24 Apr 2026 11:24:03 +0200 Subject: [PATCH 1/5] fix: a11y on interactive elements with tooltip with sr-only element --- .../pluggableWidgets/tooltip-web/CHANGELOG.md | 4 + .../pluggableWidgets/tooltip-web/package.json | 2 +- .../tooltip-web/src/components/Tooltip.tsx | 52 ++++++++++- .../src/components/__tests__/Tooltip.spec.tsx | 93 ++++++++++++++++++- .../__snapshots__/Tooltip.spec.tsx.snap | 12 ++- .../tooltip-web/src/package.xml | 2 +- .../tooltip-web/src/ui/Tooltip.scss | 13 +++ 7 files changed, 170 insertions(+), 8 deletions(-) diff --git a/packages/pluggableWidgets/tooltip-web/CHANGELOG.md b/packages/pluggableWidgets/tooltip-web/CHANGELOG.md index af5fada629..9956dd181c 100644 --- a/packages/pluggableWidgets/tooltip-web/CHANGELOG.md +++ b/packages/pluggableWidgets/tooltip-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- 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. + ## [1.5.1] - 2026-02-10 ### Added diff --git a/packages/pluggableWidgets/tooltip-web/package.json b/packages/pluggableWidgets/tooltip-web/package.json index 80ee4e7dbe..3c23cf7b4e 100644 --- a/packages/pluggableWidgets/tooltip-web/package.json +++ b/packages/pluggableWidgets/tooltip-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/tooltip-web", "widgetName": "Tooltip", - "version": "1.5.1", + "version": "1.5.2", "description": "Shows a value inside a colored tooltip or label", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx b/packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx index 13637494c6..1101948ad1 100644 --- a/packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx +++ b/packages/pluggableWidgets/tooltip-web/src/components/Tooltip.tsx @@ -1,6 +1,6 @@ import { Placement } from "@floating-ui/react"; import classNames from "classnames"; -import { CSSProperties, ReactElement, ReactNode, useState } from "react"; +import { CSSProperties, ReactElement, ReactNode, useCallback, useEffect, useId, useRef, useState } from "react"; import { OpenOnEnum, RenderMethodEnum } from "../../typings/TooltipProps"; import { useFloatingUI } from "../utils/useFloatingUI"; @@ -22,6 +22,10 @@ export const Tooltip = (props: TooltipProps): ReactElement => { const { trigger, htmlMessage, textMessage, openOn, position, preview, renderMethod } = props; const [showTooltip, setShowTooltip] = useState(preview ?? false); const [arrowElement, setArrowElement] = useState(null); + const contentId = useId(); + const hasTooltipContent = !!(textMessage || htmlMessage); + const triggerWrapperRef = useRef(null); + const { arrowStyles, blurFocusEvents, floatingStyles, getFloatingProps, getReferenceProps, refs, staticSide } = useFloatingUI({ position, @@ -31,18 +35,60 @@ export const Tooltip = (props: TooltipProps): ReactElement => { openOn }); + // Apply aria-describedby to all focusable elements inside the trigger wrapper + useEffect(() => { + if (!hasTooltipContent || !triggerWrapperRef.current) { + return; + } + + // Find all focusable elements (button, a, input, select, textarea, etc.) + const focusableElements = triggerWrapperRef.current.querySelectorAll( + 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length > 0) { + focusableElements.forEach(element => { + element.setAttribute("aria-describedby", contentId); + }); + + return () => { + focusableElements.forEach(element => { + element.removeAttribute("aria-describedby"); + }); + }; + } + }, [hasTooltipContent, contentId]); + + // Merge our ref with floating-ui's ref + const setTriggerRef = useCallback( + (node: HTMLDivElement | null) => { + triggerWrapperRef.current = node; + if (refs?.setReference) { + refs.setReference(node); + } + }, + [refs] + ); + return (
{trigger}
- {showTooltip && (textMessage || htmlMessage) ? ( + {/* Hidden content for screen readers - always in DOM, only accessible via aria-describedby */} + {hasTooltipContent && ( + + )} + {/* Visible content for sighted users */} + {showTooltip && hasTooltipContent ? (
{ const triggerElement = screen.getByTestId("tooltip-trigger"); await user.click(triggerElement); - expect(screen.queryByText("Simple Tooltip")).toBeInTheDocument(); + expect(screen.queryByRole("tooltip")).toHaveTextContent("Simple Tooltip"); }); it("close onOutsideClick if tooltip is visible", async () => { @@ -154,4 +154,95 @@ describe("Tooltip", () => { await user.click(document.body); expect(screen.queryByRole("tooltip")).toBeNull(); }); + + describe("accessibility", () => { + it("adds aria-describedby to trigger element", () => { + render(); + const triggerElement = screen.getByTestId("tooltip-trigger"); + + expect(triggerElement).toHaveAttribute("aria-describedby"); + }); + + it("renders sr-only content for screen readers before tooltip is shown", () => { + render(); + const srOnlyContent = document.querySelector(".sr-only"); + + expect(srOnlyContent).toBeInTheDocument(); + expect(srOnlyContent).toHaveTextContent(defaultTooltipProps.textMessage as string); + }); + + it("sr-only content is always in DOM even when tooltip is not visible", () => { + render(); + const srOnlyContent = document.querySelector(".sr-only"); + + // Content should exist before tooltip is shown + expect(srOnlyContent).toBeInTheDocument(); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("sr-only content matches the text message", () => { + const customMessage = "Custom accessibility message"; + render(); + const srOnlyContent = document.querySelector(".sr-only"); + + expect(srOnlyContent).toHaveTextContent(customMessage); + }); + + it("sr-only content matches the HTML message", () => { + const htmlContent =
Custom HTML Content
; + render( + + ); + const srOnlyContent = document.querySelector(".sr-only"); + + expect(srOnlyContent).toHaveTextContent("Custom HTML Content"); + }); + + it("does not add aria-describedby when no tooltip content is provided", () => { + render(); + const triggerElement = screen.getByTestId("tooltip-trigger"); + const parentElement = triggerElement.parentElement; + + expect(parentElement).not.toHaveAttribute("aria-describedby"); + }); + + it("adds aria-describedby to all focusable elements in trigger", () => { + render( + + + + + Link + +
+ } + /> + ); + + const button1 = screen.getByTestId("button1"); + const button2 = screen.getByTestId("button2"); + const link = screen.getByTestId("link1"); + + // All focusable elements should have aria-describedby + expect(button1).toHaveAttribute("aria-describedby"); + expect(button2).toHaveAttribute("aria-describedby"); + expect(link).toHaveAttribute("aria-describedby"); + + // All should point to the same sr-only content + const ariaDescribedBy1 = button1.getAttribute("aria-describedby"); + const ariaDescribedBy2 = button2.getAttribute("aria-describedby"); + const ariaDescribedBy3 = link.getAttribute("aria-describedby"); + + expect(ariaDescribedBy1).toBe(ariaDescribedBy2); + expect(ariaDescribedBy2).toBe(ariaDescribedBy3); + }); + }); }); diff --git a/packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap b/packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap index 21d786034a..156735bc95 100644 --- a/packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap +++ b/packages/pluggableWidgets/tooltip-web/src/components/__tests__/__snapshots__/Tooltip.spec.tsx.snap @@ -6,20 +6,28 @@ exports[`Tooltip render DOM structure 1`] = ` class="widget-tooltip widget-tooltip-right" >
Trigger
+