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:
parent
8ef7f95ddc
commit
0dc9cdf16c
@ -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(
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user