fix(canvas): a11y fixes + budget_used TypeScript guard + orgs-page test fix (#1367)

* fix(canvas/a11y): mark StatusDot as aria-hidden — decorative element

StatusDot is purely decorative; the status is already conveyed via
aria-label on parent elements (WorkspaceNode, SidePanel header, etc.).
Marking it aria-hidden="true" prevents screen readers from announcing
the empty div as "img" with no alt text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(canvas): guard budget_used optional field with ?? 0 in progress calc

TypeScript error in CI: 'budget.budget_used' is possibly 'undefined'
when used in the progress percentage calculation. The field is
optional per BudgetData interface, so ?? 0 is the correct guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(canvas/a11y): Tooltip keyboard focus support + ARIA role

- Add role="tooltip" + unique id so assistive tech can find tooltip content
- Add aria-describedby on trigger so screen readers announce tooltip text
- Add onFocus/onBlur handlers so keyboard users (Tab navigation) can see
  tooltips that mouse users see on hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(canvas/test): restore advanceTimersByTime pattern in orgs-page error test

waitFor() + fake timers (vi.useFakeTimers in beforeEach) cause race
conditions: the 5s polling timeout fires before React state updates flush.
Restores the established pattern used by all other tests in this file:
advanceTimersByTimeAsync(50) + runAllTimersAsync().
Also removes the now-unused waitFor import.

Ref: PRs #1360, #1345
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
molecule-ai[bot] 2026-04-21 11:08:24 +00:00 committed by GitHub
parent e20ec33d33
commit 00bd73f8c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 37 additions and 7 deletions

View File

@ -15,7 +15,7 @@
* - Polling: provisioning orgs schedule a 5s refresh (fake timers)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { render, screen, cleanup } from "@testing-library/react";
// ── Hoisted mocks ────────────────────────────────────────────────────────────
// vi.mock factories are hoisted above imports; any captured references must
@ -131,10 +131,9 @@ describe("/orgs — error state", () => {
Promise.reject(new Error("GET /cp/orgs: 500"))
);
render(<OrgsPage />);
// PR #1243 replaced waitFor polling with vi.advanceTimersByTimeAsync(50),
// which fires the timer but does not guarantee React render flush completes
// before the assertion runs. Restores waitFor for the error-state test.
await waitFor(() => expect(screen.getByText(/Error:/)).toBeTruthy());
await vi.advanceTimersByTimeAsync(50);
await vi.runAllTimersAsync();
expect(screen.getByText(/Error:/)).toBeTruthy();
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
});
});

View File

@ -14,6 +14,8 @@ export function StatusDot({
return (
<div
className={`${sizeClass} rounded-full shrink-0 ${statusDotClass(status)} ${glowClass}`}
aria-hidden="true"
role="img"
/>
);
}

View File

@ -3,6 +3,11 @@
import { useState, useRef, useEffect, useCallback, type ReactNode } from "react";
import { createPortal } from "react-dom";
let tooltipIdCounter = 0;
function nextId() {
return ++tooltipIdCounter;
}
interface Props {
text: string;
children: ReactNode;
@ -13,6 +18,7 @@ export function Tooltip({ text, children }: Props) {
const [pos, setPos] = useState({ x: 0, y: 0 });
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const triggerRef = useRef<HTMLDivElement>(null);
const tooltipId = useRef(`tooltip-${nextId()}`);
useEffect(() => () => clearTimeout(timerRef.current), []);
@ -31,11 +37,34 @@ export function Tooltip({ text, children }: Props) {
setShow(false);
}, []);
// Show tooltip on keyboard focus (Tab navigation)
const onFocus = useCallback(() => {
clearTimeout(timerRef.current);
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPos({ x: rect.left, y: rect.top });
}
setShow(true);
}, []);
const onBlur = useCallback(() => {
setShow(false);
}, []);
return (
<div ref={triggerRef} onMouseEnter={enter} onMouseLeave={leave}>
<div
ref={triggerRef}
onMouseEnter={enter}
onMouseLeave={leave}
onFocus={onFocus}
onBlur={onBlur}
aria-describedby={tooltipId.current}
>
{children}
{show && text && createPortal(
<div
id={tooltipId.current}
role="tooltip"
className="fixed z-[9999] max-w-[400px] max-h-[300px] overflow-y-auto px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg shadow-2xl shadow-black/60 pointer-events-none"
style={{ left: pos.x, top: Math.max(8, pos.y - 8), transform: "translateY(-100%)" }}
>

View File

@ -107,7 +107,7 @@ export function BudgetSection({ workspaceId }: Props) {
const progressPct =
budget && budget.budget_limit != null && budget.budget_limit > 0
? Math.min(100, Math.round((budget.budget_used / budget.budget_limit) * 100))
? Math.min(100, Math.round(((budget.budget_used ?? 0) / budget.budget_limit) * 100))
: 0;
// ── Render ────────────────────────────────────────────────────────────────