fix(app): support keyboard tooltip controls

This commit is contained in:
Michael Wu
2026-10-04 05:45:13 +09:00
parent 71faa28997
commit 8ee1f640d3
2 changed files with 59 additions and 7 deletions
@@ -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();
}
@@ -47,6 +47,8 @@ interface TooltipContextValue {
cancelClose: () => void;
scheduleClose: () => void;
triggerRef: React.RefObject<View | null>;
contentRef: React.RefObject<View | null>;
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<HTMLElement>(
'[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<View>(null);
const contentRef = useRef<View>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | 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({
<View pointerEvents={ctx.interactive ? "box-none" : "none"} style={styles.portalOverlay}>
<FloatingSurface
pointerEvents={ctx.interactive ? "auto" : "none"}
ref={ctx.contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
@@ -597,6 +635,7 @@ export function TooltipContent({
/>
<FloatingSurface
pointerEvents="auto"
ref={ctx.contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
@@ -614,6 +653,7 @@ export function TooltipContent({
<Pressable testID="tooltip-dismiss" style={styles.overlay} onPress={handleDismiss}>
<FloatingSurface
pointerEvents="none"
ref={ctx.contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}