;
+}
+```
+
+**Why:** Screen readers need content to be in DOM before interaction, but visual tooltip appears on-demand.
+
+**DO NOT:**
+
+- ❌ Remove sr-only content (breaks screen reader support)
+- ❌ Use only `aria-live` (wrong pattern, see aria-live-vs-aria-describedby-analysis.md)
+- ❌ Try to conditionally render sr-only content (defeats its purpose)
+
+### 2. DOM Manipulation for aria-describedby (REQUIRED)
+
+We use `useEffect` + `querySelectorAll` to apply `aria-describedby`:
+
+```tsx
+useEffect(() => {
+ const focusableElements = triggerWrapperRef.current.querySelectorAll(
+ 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ focusableElements.forEach(element => {
+ element.setAttribute("aria-describedby", contentId);
+ });
+}, [hasTooltipContent, contentId]);
+```
+
+**Why:** Trigger can be ANY Mendix widget (ActionButton, custom components). We can't use `cloneElement` because we don't control the widget's internals.
+
+**DO NOT:**
+
+- ❌ Try to use `cloneElement` to pass props to trigger (doesn't work with complex components)
+- ❌ Apply `aria-describedby` to wrapper div (doesn't inherit to focusable children)
+- ❌ Target only first focusable element (must handle multiple buttons/links)
+
+### 3. Floating-UI Integration
+
+Uses `@floating-ui/react` for positioning with these hooks:
+
+- `useFloating` - position calculation
+- `useHover`, `useFocus`, `useClick` - interaction modes
+- `useRole` - sets `role="tooltip"` on visible tooltip
+- `useDismiss` - handles outside clicks and escape key
+
+**DO NOT:**
+
+- ❌ Remove floating-ui integration (positioning will break)
+- ❌ Modify `useRole` to set custom IDs (we override aria-describedby manually)
+
+## Common Tasks
+
+### Adding a New Trigger Mode
+
+1. Add enum value to `OpenOnEnum` in typings
+2. Update `useFloatingUI.ts` to add new interaction hook
+3. Add tests in `Tooltip.spec.tsx`
+4. Update XML with new enum value
+
+### Modifying Tooltip Content Rendering
+
+**CRITICAL:** Always maintain dual rendering (sr-only + visual).
+
+```tsx
+// ✅ Correct: Both elements get the same content
+const content = renderMethod === "text" ? textMessage : htmlMessage;
+
+
{content}
;
+{
+ showTooltip &&
{content}
;
+}
+
+// ❌ Wrong: Different content or conditional sr-only
+{
+ showTooltip &&
{content}
;
+} // NO!
+```
+
+### Styling Changes
+
+- Visual tooltip styles: `.widget-tooltip-content` in `Tooltip.scss`
+- Sr-only styles: `.sr-only` class (DO NOT MODIFY - standard pattern)
+- Trigger wrapper: `.widget-tooltip-trigger`
+
+**DO NOT:**
+
+- ❌ Remove or modify `.sr-only` class (breaks accessibility)
+- ❌ Add `display: none` or `visibility: hidden` to sr-only (screen readers won't read it)
+
+## Accessibility Requirements (NON-NEGOTIABLE)
+
+### Must Maintain
+
+1. **Sr-only content always in DOM** with `aria-hidden="true"`
+2. **All focusable elements** get `aria-describedby` (not just first)
+3. **useId() for stable IDs** across renders
+4. **Clean up aria-describedby** in useEffect return
+
+### Testing Checklist
+
+Before any commit affecting accessibility:
+
+```bash
+cd packages/pluggableWidgets/tooltip-web
+pnpm run test # Must pass all 7 accessibility tests
+```
+
+**Required tests:**
+
+- ✅ aria-describedby on trigger element
+- ✅ Sr-only content always in DOM
+- ✅ Content matches (text and HTML)
+- ✅ No aria-describedby when no content
+- ✅ Multiple focusable elements all get aria-describedby
+
+### Screen Reader Testing
+
+If changing accessibility logic, manually test with:
+
+- **NVDA** (Windows + Firefox/Chrome)
+- **VoiceOver** (macOS + Safari)
+
+**Expected behavior:**
+
+1. Tab to trigger → Announces: "Button, [tooltip text]"
+2. Browse mode → Sr-only div NOT reachable
+3. Multiple triggers → Each announces tooltip text
+
+## Common Pitfalls
+
+### ❌ Anti-Pattern: Removing Duplication
+
+```tsx
+// WRONG: Trying to DRY by removing sr-only
+{
+ showTooltip ?
{content}
:
{content}
;
+}
+```
+
+**Problem:** Sr-only must ALWAYS be in DOM, not conditionally rendered.
+
+### ❌ Anti-Pattern: Using aria-live
+
+```tsx
+// WRONG: Trying to use aria-live instead of aria-describedby
+
+ {content}
+
+```
+
+**Problem:** See `aria-live-vs-aria-describedby-analysis.md` for detailed explanation. Short version: wrong pattern, causes interruptions, doesn't work with all trigger modes.
+
+### ❌ Anti-Pattern: Simplifying Query Selector
+
+```tsx
+// WRONG: Only finding first element
+const focusableElement = wrapper.querySelector("button");
+focusableElement.setAttribute("aria-describedby", id);
+```
+
+**Problem:** Trigger can contain multiple buttons. Must use `querySelectorAll` and loop.
+
+## Debugging Tips
+
+### Issue: Screen reader not announcing tooltip
+
+**Check:**
+
+1. Does trigger element have `aria-describedby` attribute? (inspect DOM)
+2. Does `aria-describedby` value match sr-only div's `id`?
+3. Is sr-only div in DOM? (should always be present)
+4. Does sr-only div have `aria-hidden="true"`?
+
+**Common causes:**
+
+- useEffect not running (check dependencies)
+- querySelector not finding elements (check selector)
+- Content ID mismatch (check useId() value)
+
+### Issue: Tooltip not appearing visually
+
+**Check:**
+
+1. Is `showTooltip` state true? (React DevTools)
+2. Are floating-ui refs attached? (check `refs.setReference`, `refs.setFloating`)
+3. Is `getReferenceProps` spread correctly?
+4. Check browser console for floating-ui errors
+
+**Common causes:**
+
+- Trigger wrapper missing ref
+- OpenOn mode not matching user interaction
+- Floating-ui middleware error
+
+## References
+
+### Detailed Documentation
+
+- **`tooltip-accessibility-implementation.md`** - Complete implementation guide
+ - Problem statement and root cause
+ - Solution architecture with code walkthroughs
+ - WCAG compliance details
+ - Browser/screen reader compatibility
+ - Testing recommendations
+
+- **`aria-live-vs-aria-describedby-analysis.md`** - Why we chose aria-describedby
+ - Detailed comparison of approaches
+ - Limitations of aria-live for tooltips
+ - Industry standards (GitHub, Bootstrap, Material-UI)
+ - Real-world testing results
+
+### External Resources
+
+- [ARIA 1.2 - aria-describedby](https://www.w3.org/TR/wai-aria-1.2/#aria-describedby)
+- [Floating UI - Tooltip](https://floating-ui.com/docs/tooltip)
+- [WCAG 2.1 - 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)
+
+## Quick Decision Tree
+
+**"Should I modify the sr-only content rendering?"**
+→ Only if also modifying visual tooltip. Keep them in sync.
+
+**"Can I remove the DOM manipulation useEffect?"**
+→ No. Required for complex trigger components.
+
+**"Should I switch to aria-live?"**
+→ No. Read `aria-live-vs-aria-describedby-analysis.md` first.
+
+**"Can I optimize by caching querySelector results?"**
+→ No. Elements can change between renders.
+
+**"Should I apply aria-describedby to the wrapper instead?"**
+→ No. Focusable elements inside need the attribute directly.
+
+## Version History
+
+See `CHANGELOG.md` for release notes.
+
+**Current Status:**
+
+- ✅ Screen reader accessible (aria-describedby pattern)
+- ✅ Supports multiple focusable elements
+- ✅ Works with all trigger modes
+- ✅ WCAG 2.1 Level A compliant
+- ✅ Compatible with all modern browsers + screen readers
+
+## Code Review Checklist
+
+Before approving changes to this widget:
+
+- [ ] Sr-only content still always in DOM
+- [ ] All focusable elements get aria-describedby (not just first)
+- [ ] useEffect cleanup removes aria-describedby
+- [ ] All 7 accessibility tests pass
+- [ ] No use of aria-live for tooltip content
+- [ ] floating-ui integration intact
+- [ ] CHANGELOG.md updated for user-facing changes
+
+## Emergency Contacts
+
+If you need to make changes but are unsure:
+
+1. Read `tooltip-accessibility-implementation.md` (comprehensive guide)
+2. Check `aria-live-vs-aria-describedby-analysis.md` (explains key decisions)
+3. Run tests: `cd packages/pluggableWidgets/tooltip-web && pnpm run test`
+4. Test manually with screen reader if accessibility affected
+
+**Remember:** This widget's complexity is justified by accessibility requirements. Simplifications often break screen reader support.
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/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 && (
+