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(); }); });