mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 08:42:39 +00:00
fix(app): support keyboard tooltip controls
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user