diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index d54bf977fc..6d37157d84 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -89,7 +89,9 @@ test.describe("context window meter", () => { } }); - test("shows the host's plan limits in the context tooltip", async ({ page }) => { + test("shows host plan limits and lets keyboard users enter the context tooltip", async ({ + page, + }) => { test.setTimeout(180_000); const usage = await installUsageReportsFixture(page, { lists: [[contextUsageReport()]] }); const session = await seedMockAgentWorkspace({ @@ -102,22 +104,32 @@ test.describe("context window meter", () => { await expectComposerVisible(page); const meter = page.getByTestId("context-window-meter"); await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 }); - await meter.hover(); await usage.waitForListRequests(1); + // The preceding Tab marks this as keyboard focus; tooltips deliberately ignore restored focus. + await page.keyboard.press("Tab"); + await meter.focus(); + const tooltip = page.getByTestId("context-window-meter-tooltip"); await expect(tooltip.getByText("Mock plan", { exact: true })).toBeVisible(); await expect(tooltip.getByText("42% used", { exact: true })).toBeVisible(); - const pin = tooltip.getByRole("checkbox", { name: /Pin Mock plan Session/ }); - await pin.click(); - await expect(pin).toBeChecked(); - - await tooltip.getByRole("button", { name: "Refresh Mock plan" }).click(); + const refresh = tooltip.getByRole("button", { name: "Refresh Mock plan" }); + await page.keyboard.press("Tab"); + await expect(refresh).toBeFocused(); + await page.keyboard.press("Enter"); await usage.waitForListRequests(2); expect(usage.listRequests()[1]).toMatchObject({ forceRefresh: true, reportIds: ["mock:account"], }); + + const pin = tooltip.getByRole("checkbox", { name: /Pin Mock plan Session/ }); + await page.keyboard.press("Tab"); + await expect(pin).toBeFocused(); + await page.keyboard.press("Space"); + await expect(pin).toBeChecked(); + await pin.click(); + await expect(pin).not.toBeChecked(); } finally { await session.cleanup(); } diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 84b780cbda..d2a3332fd1 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -47,6 +47,8 @@ interface TooltipContextValue { cancelClose: () => void; scheduleClose: () => void; triggerRef: React.RefObject; + contentRef: React.RefObject; + focusContent: () => boolean; enabled: boolean; openOnPress: boolean; delayDuration: number; @@ -101,6 +103,17 @@ function composeEventHandlers( }; } +function focusFirstContentControl(content: View | null): boolean { + if (!isWeb || typeof document === "undefined" || !content) return false; + const root = content as unknown as HTMLElement; + const control = root.querySelector( + '[role="button"][tabindex]:not([aria-disabled="true"]), [role="checkbox"][tabindex]:not([aria-disabled="true"]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])', + ); + if (!control) return false; + control.focus(); + return document.activeElement === control; +} + function useControllableOpenState({ open, defaultOpen, @@ -247,6 +260,7 @@ export function Tooltip({ interactive?: boolean; }>): ReactElement { const triggerRef = useRef(null); + const contentRef = useRef(null); const closeTimerRef = useRef | null>(null); const [isOpen, setIsOpen] = useControllableOpenState({ open, @@ -274,6 +288,7 @@ export function Tooltip({ closeTimerRef.current = null; }, 100); }, [cancelClose, interactive, setIsOpen]); + const focusContent = useCallback(() => focusFirstContentControl(contentRef.current), []); useEffect(() => cancelClose, [cancelClose]); @@ -284,6 +299,8 @@ export function Tooltip({ cancelClose, scheduleClose, triggerRef, + contentRef, + focusContent, enabled, openOnPress: opensOnPress, delayDuration, @@ -294,6 +311,7 @@ export function Tooltip({ setIsOpen, cancelClose, scheduleClose, + focusContent, enabled, opensOnPress, delayDuration, @@ -311,6 +329,7 @@ export function TooltipTrigger({ onHoverOut, onFocus, onBlur, + onKeyDown, onPress, asChild = false, triggerRefProp = "ref", @@ -318,6 +337,7 @@ export function TooltipTrigger({ }: PressableProps & { asChild?: boolean; triggerRefProp?: string; + onKeyDown?: (event: unknown) => void; }): ReactElement { const ctx = useTooltipContext("TooltipTrigger"); const openTimerRef = useRef | null>(null); @@ -388,6 +408,21 @@ export function TooltipTrigger({ [close, onBlur], ); + const handleKeyDown = useCallback( + (event: unknown) => { + if (isCallable(onKeyDown)) onKeyDown(event); + if (!ctx.interactive || !ctx.open) return; + const keyboardEvent = event as { + key?: unknown; + shiftKey?: unknown; + preventDefault?: () => void; + }; + if (keyboardEvent.key !== "Tab" || keyboardEvent.shiftKey) return; + if (ctx.focusContent()) keyboardEvent.preventDefault?.(); + }, + [ctx, onKeyDown], + ); + const handlePress = useCallback( (e: unknown) => { if (isCallable(onPress)) onPress(e); @@ -412,6 +447,7 @@ export function TooltipTrigger({ onHoverOut: handleHoverOut, onFocus: handleFocus, onBlur: handleBlur, + onKeyDown: handleKeyDown, onPress: handlePress, ...(isWeb ? ({ @@ -442,6 +478,7 @@ export function TooltipTrigger({ onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut), onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus), onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur), + onKeyDown: composeEventHandlers(Reflect.get(rawProps, "onKeyDown"), handleKeyDown), onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress), onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn), onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut), @@ -561,6 +598,7 @@ export function TooltipContent({