From 03bd96ad9d26bfba8b3bca2654fd9be786dcab29 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Mon, 11 May 2026 04:14:07 +0000 Subject: [PATCH] fix(canvas/Tooltip): make aria-describedby conditional (show ? id : undefined) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts PR #299's WCAG-correct approach. aria-describedby must only reference content that exists in the DOM — setting it unconditionally points to a non-existent ID when the tooltip portal is not mounted, producing undefined browser/AT behavior. Changes: - Tooltip.tsx: aria-describedby={show ? tooltipId.current : undefined} - Tooltip.test.tsx: 3 new aria-describedby tests: 1. does NOT set aria-describedby when tooltip is hidden 2. sets aria-describedby when tooltip shown (hover) 3. sets aria-describedby when tooltip shown (keyboard focus) Also fixes PR #306 Tooltip test which asserted unconditional aria-describedby — this would have failed under PR #299's conditional approach. Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/Tooltip.tsx | 5 +- .../src/components/__tests__/Tooltip.test.tsx | 55 ++++++++++++++++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/canvas/src/components/Tooltip.tsx b/canvas/src/components/Tooltip.tsx index d694ec28..17d0a7d5 100644 --- a/canvas/src/components/Tooltip.tsx +++ b/canvas/src/components/Tooltip.tsx @@ -77,7 +77,10 @@ export function Tooltip({ text, children }: Props) { onMouseLeave={leave} onFocus={onFocus} onBlur={onBlur} - aria-describedby={tooltipId.current} + // WCAG / ARIA: only set aria-describedby when the tooltip portal is + // actually mounted in the DOM. This keeps the referenced ID always valid + // and avoids dangling aria-describedby pointing to a non-existent element. + aria-describedby={show ? tooltipId.current : undefined} > {children} {show && text && createPortal( diff --git a/canvas/src/components/__tests__/Tooltip.test.tsx b/canvas/src/components/__tests__/Tooltip.test.tsx index 704bce0c..59af98ee 100644 --- a/canvas/src/components/__tests__/Tooltip.test.tsx +++ b/canvas/src/components/__tests__/Tooltip.test.tsx @@ -221,23 +221,64 @@ describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => { }); }); -describe("Tooltip — aria-describedby", () => { +describe("Tooltip — aria-describedby (WCAG / ARIA best practice)", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); - it("associates tooltip with the trigger via aria-describedby", () => { + it("does NOT set aria-describedby when tooltip is hidden (WCAG 1.4.13)", () => { + // aria-describedby must only reference content that exists in the DOM. + // Setting it unconditionally points to a non-existent ID when the portal + // is not mounted — undefined browser/AT behavior. const { container } = render( - + ); + // Without any hover/focus, the tooltip is not shown const wrapper = container.querySelector("[aria-describedby]"); - const describedBy = wrapper?.getAttribute("aria-describedby"); - expect(describedBy).toBeTruthy(); - // Show the tooltip first so the portal element is in the DOM + expect(wrapper).toBeNull(); + }); + + it("sets aria-describedby when tooltip is shown (hover)", () => { + const { container } = render( + + + + ); + // Before hover: no aria-describedby + const wrapperBefore = container.querySelector("[aria-describedby]"); + expect(wrapperBefore).toBeNull(); + + // Hover → tooltip shows fireEvent.mouseEnter(container.querySelector("button")!); act(() => { vi.advanceTimersByTime(500); }); - // The describedby id must now resolve to the tooltip portal element + + // After hover: aria-describedby is set and references the portal element + const wrapperAfter = container.querySelector("[aria-describedby]"); + const describedBy = wrapperAfter?.getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy!)).toBeTruthy(); + }); + + it("sets aria-describedby when tooltip is shown (keyboard focus)", () => { + const { container } = render( + + + + ); + // Before focus: no aria-describedby + const wrapperBefore = container.querySelector("[aria-describedby]"); + expect(wrapperBefore).toBeNull(); + + // Focus → tooltip shows immediately (no timer) + act(() => { + container.querySelector("button")!.focus(); + }); + + // After focus: aria-describedby is set + const wrapperAfter = container.querySelector("[aria-describedby]"); + const describedBy = wrapperAfter?.getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); expect(document.getElementById(describedBy!)).toBeTruthy(); }); });