fix(canvas/Tooltip): make aria-describedby conditional (show ? id : undefined)

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 <noreply@anthropic.com>
This commit is contained in:
Molecule AI · core-uiux 2026-05-11 04:14:07 +00:00 committed by Molecule AI Core-FE
parent 98f6039f52
commit a71e4e6aa9
2 changed files with 52 additions and 8 deletions

View File

@ -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(

View File

@ -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(
<Tooltip text="Associated tip">
<Tooltip text="Hidden tip">
<button type="button">Hover me</button>
</Tooltip>
);
// 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(
<Tooltip text="Shown tip">
<button type="button">Hover me</button>
</Tooltip>
);
// 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(
<Tooltip text="Focus tip">
<button type="button">Focus me</button>
</Tooltip>
);
// 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();
});
});