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:
parent
e20ec33d33
commit
00bd73f8c8
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,6 +14,8 @@ export function StatusDot({
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full shrink-0 ${statusDotClass(status)} ${glowClass}`}
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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%)" }}
|
||||
>
|
||||
|
||||
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user