Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions e2e/tests/info-tooltips.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { test, expect, type Page } from "@playwright/test";

async function openActivity(page: Page, days = 1) {
// The overview shows onboarding instead of activity when there are no applications.
await page.route("**/api/v1/applications", (route) =>
route.fulfill({
json: [
{
id: "tooltip-app",
name: "Tooltip app",
icon: "",
syncStatus: "synced",
healthStatus: "healthy",
repositoryName: "Test",
agentId: "test-agent",
agentName: "Test agent",
branch: "main",
commit: "abc1234",
path: "compose.yaml",
},
],
}),
);
await page.route("**/api/v1/activity?*", (route) =>
route.fulfill({
json: {
days: Array.from({ length: days }, (_, index) => ({
date: `2026-10-${29 - index}`,
succeeded: 3,
failed: 1,
})),
recentDeployments: [],
},
}),
);
await page.goto("/");
const card = page
.locator('[data-slot="card"]')
.filter({ has: page.getByText("Deployments", { exact: true }) });
return card.locator('[data-slot="tooltip-trigger"], [aria-haspopup="dialog"]').first();
}

test("information tooltips open on desktop hover and keyboard focus", async ({ page }) => {
const trigger = await openActivity(page);
const tooltip = page.locator('[data-slot="tooltip-content"]');
await trigger.hover();
await expect(tooltip).toContainText("Succeeded: 3 Β· Failed: 1");
await page.mouse.move(0, 0);
await expect(tooltip).toBeHidden();
await trigger.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
await expect(tooltip).toBeVisible();
await page.keyboard.press("Escape");
await expect(tooltip).toBeHidden();
});

test.describe("hybrid mouse and touch input", () => {
test.use({ hasTouch: true, isMobile: false, viewport: { width: 1280, height: 900 } });

test.beforeEach(async ({ page }) => {
// Playwright's touch emulation normally makes touch the primary input.
// Model a Windows touchscreen laptop that reports a primary mouse instead.
await page.addInitScript(() => {
const matchMedia = window.matchMedia.bind(window);
const capabilities: Record<string, boolean> = {
"(hover: none), (pointer: coarse)": false,
"(hover: none)": false,
"(pointer: coarse)": false,
"(hover: hover)": true,
"(pointer: fine)": true,
"(any-pointer: coarse)": true,
};
window.matchMedia = (query) => {
const result = matchMedia(query);
if (query in capabilities) {
Object.defineProperty(result, "matches", { value: capabilities[query] });
}
return result;
};
});
});

test("hover, touch, and hover again work on the same trigger", async ({ page }) => {
const trigger = await openActivity(page, 7);
await expect
.poll(() =>
page.evaluate(() => ({
hover: matchMedia("(hover: hover)").matches,
fine: matchMedia("(pointer: fine)").matches,
touch: matchMedia("(any-pointer: coarse)").matches,
})),
)
.toEqual({ hover: true, fine: true, touch: true });
const tooltip = page.locator('[data-slot="tooltip-content"]');
const popup = page.getByRole("dialog", { name: "Details" });
await trigger.hover();
await expect(tooltip).toContainText("Succeeded: 3 Β· Failed: 1");
await trigger.tap();
await expect(popup).toContainText("Succeeded: 3 Β· Failed: 1");
await expect(tooltip).toBeHidden();
await trigger.tap();
await expect(popup).toBeHidden();
await trigger.tap();
await expect(popup).toBeVisible();
await page.getByRole("heading", { name: "Overview" }).tap();
await expect(popup).toBeHidden();
await page.mouse.move(0, 0);
await trigger.hover();
await expect(tooltip).toBeVisible();
});
test("form help buttons support both hover and touch", async ({ page }) => {
await page.route("**/api/v1/repositories/info-repo", (route) =>
route.fulfill({
json: {
id: "info-repo",
name: "Info repository",
commitSigningSupported: true,
requireSignedCommits: false,
commitSigningKeys: [],
},
}),
);
await page.goto("/repositories/info-repo/settings/signing");
const trigger = page.getByRole("button", { name: "Details", exact: true });
await trigger.hover();
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveText(
"Reject commits that are not signed by a trusted key.",
);
await trigger.tap();
await expect(page.getByRole("dialog", { name: "Details" })).toHaveText(
"Reject commits that are not signed by a trusted key.",
);
await expect(page.locator('[data-slot="tooltip-content"]')).toBeHidden();
await trigger.tap();
await expect(page.getByRole("dialog", { name: "Details" })).toBeHidden();
await expect(page.getByRole("switch")).not.toBeChecked();
});
});

test.describe("touch information popovers", () => {
test.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } });

test("tap toggles details and tapping outside dismisses them", async ({ page }) => {
const trigger = await openActivity(page);
const popup = page.getByRole("dialog", { name: "Details" });
await trigger.tap();
await expect(popup).toContainText("Succeeded: 3 Β· Failed: 1");
await trigger.tap();
await expect(popup).toBeHidden();
await trigger.tap();
await expect(popup).toBeVisible();
await page.getByRole("heading", { name: "Overview" }).tap();
await expect(popup).toBeHidden();
});

test("large touch screens also use tap and support Escape", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
const trigger = await openActivity(page);
const popup = page.getByRole("dialog", { name: "Details" });
await trigger.tap();
await expect(popup).toBeVisible();
await page.keyboard.press("Escape");
await expect(popup).toBeHidden();
});
});
22 changes: 15 additions & 7 deletions frontend/src/components/applications/health/availability-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { InfoTooltip, InfoTooltipContent, InfoTooltipTrigger } from "@/components/info-tooltip";
import {
formatHealthDuration,
healthLegend,
Expand Down Expand Up @@ -44,10 +44,18 @@ export function AvailabilityBar({ stats }: { stats: HealthStats }) {
</CardAction>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex h-12 gap-0.5 sm:gap-1" role="img" aria-label={m.healthAvailability()}>
<div
className="flex h-12 gap-0.5 sm:gap-1"
role="group"
aria-label={m.healthAvailability()}
>
{stats.buckets.map((bucket) => (
<Tooltip key={bucket.start}>
<TooltipTrigger
<InfoTooltip key={bucket.start}>
<InfoTooltipTrigger
aria-label={tooltipFormatter.formatRange(
new Date(bucket.start),
new Date(bucket.end),
)}
render={
<div
className={cn(
Expand All @@ -59,7 +67,7 @@ export function AvailabilityBar({ stats }: { stats: HealthStats }) {
/>
}
/>
<TooltipContent className="flex-col items-start gap-1">
<InfoTooltipContent className="flex-col items-start gap-1">
<p className="font-medium">
{tooltipFormatter.formatRange(new Date(bucket.start), new Date(bucket.end))}
</p>
Expand All @@ -80,8 +88,8 @@ export function AvailabilityBar({ stats }: { stats: HealthStats }) {
{formatHealthDuration(entry.seconds(bucket))}
</p>
))}
</TooltipContent>
</Tooltip>
</InfoTooltipContent>
</InfoTooltip>
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/components/badges/agent-status-badge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AgentStatus } from "@/lib/agents";
import { Badge } from "../ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { InfoTooltip, InfoTooltipContent, InfoTooltipTrigger } from "@/components/info-tooltip";
import { m } from "@/lib/paraglide/messages";

export function AgentStatusBadge({ status }: { status: AgentStatus }) {
Expand All @@ -16,15 +16,15 @@ export function AgentStatusBadge({ status }: { status: AgentStatus }) {
return (
<div className="flex items-center gap-2">
{isError ? (
<Tooltip>
<TooltipTrigger
<InfoTooltip>
<InfoTooltipTrigger
render={<Badge tabIndex={0} variant={badgeVariant} className="cursor-help" />}
>
{statusDot}
{statusLabel}
</TooltipTrigger>
<TooltipContent>{m.agentStatusErrorDescription()}</TooltipContent>
</Tooltip>
</InfoTooltipTrigger>
<InfoTooltipContent>{m.agentStatusErrorDescription()}</InfoTooltipContent>
</InfoTooltip>
) : (
<Badge variant={badgeVariant}>
{statusDot}
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/components/badges/agent-version-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AgentVersionStatus } from "@/lib/agents";
import { cn } from "@/lib/utils";
import { AlertTriangle, CircleHelp, OctagonAlert } from "lucide-react";
import { Badge } from "../ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { InfoTooltip, InfoTooltipContent, InfoTooltipTrigger } from "@/components/info-tooltip";
import { m } from "@/lib/paraglide/messages";

const statusConfig: Record<
Expand Down Expand Up @@ -48,12 +48,12 @@ export function AgentVersionBadge({
const text = version ? `${version} Β· ${label()}` : label();

return (
<Tooltip>
<TooltipTrigger render={<Badge tabIndex={0} className={cn("cursor-help", className)} />}>
<InfoTooltip>
<InfoTooltipTrigger render={<Badge tabIndex={0} className={cn("cursor-help", className)} />}>
<Icon aria-hidden="true" />
{text}
</TooltipTrigger>
<TooltipContent>{description()}</TooltipContent>
</Tooltip>
</InfoTooltipTrigger>
<InfoTooltipContent>{description()}</InfoTooltipContent>
</InfoTooltip>
);
}
68 changes: 68 additions & 0 deletions frontend/src/components/info-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Popover } from "@base-ui/react/popover";
import { useState, type ComponentProps, type ReactElement, type ReactNode } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { m } from "@/lib/paraglide/messages";
import { cn } from "@/lib/utils";

// Action and navigation controls keep Tooltip so taps still perform their primary action.
export function InfoTooltip({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Tooltip disabled={open}>{children}</Tooltip>
</Popover.Root>
);
}

export function InfoTooltipTrigger({
render,
nativeButton = !render,
...props
}: Omit<ComponentProps<"button">, "ref"> & { render?: ReactElement; nativeButton?: boolean }) {
return (
<TooltipTrigger
render={<Popover.Trigger render={render} nativeButton={nativeButton} />}
tabIndex={0}
{...props}
/>
);
}

export function InfoTooltipContent({
children,
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
}: {
children: ReactNode;
className?: string;
} & Pick<Popover.Positioner.Props, "side" | "sideOffset" | "align" | "alignOffset">) {
return (
<>
<TooltipContent {...{ className, side, sideOffset, align, alignOffset }}>
{children}
</TooltipContent>
<Popover.Portal>
<Popover.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className="isolate z-50"
>
<Popover.Popup
aria-label={m.details()}
className={cn(
"flex w-fit max-w-[min(20rem,calc(100vw-2rem))] flex-col gap-1.5 rounded-md bg-foreground px-3 py-2 text-xs text-background shadow-md outline-none",
className,
)}
>
{children}
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</>
);
}
15 changes: 8 additions & 7 deletions frontend/src/components/overview/deployment-activity-card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { InfoTooltip, InfoTooltipContent, InfoTooltipTrigger } from "@/components/info-tooltip";
import { m } from "@/lib/paraglide/messages";
import { getLocale } from "@/lib/paraglide/runtime";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -44,8 +44,9 @@ export function DeploymentActivityCard() {
{data.days.map((day) => {
const dayTotal = day.succeeded + day.failed;
return (
<Tooltip key={day.date}>
<TooltipTrigger
<InfoTooltip key={day.date}>
<InfoTooltipTrigger
aria-label={dayFormatter.format(new Date(`${day.date}T00:00`))}
render={
<div className="flex h-full flex-1 flex-col justify-end gap-px rounded-sm hover:bg-muted/50" />
}
Expand All @@ -63,8 +64,8 @@ export function DeploymentActivityCard() {
style={{ height: `${(day.succeeded / maxPerDay) * 100}%` }}
/>
) : null}
</TooltipTrigger>
<TooltipContent>
</InfoTooltipTrigger>
<InfoTooltipContent>
<p className="font-medium">
{dayFormatter.format(new Date(`${day.date}T00:00`))}
</p>
Expand All @@ -74,8 +75,8 @@ export function DeploymentActivityCard() {
failed: day.failed,
})}
</p>
</TooltipContent>
</Tooltip>
</InfoTooltipContent>
</InfoTooltip>
);
})}
</div>
Expand Down
Loading
Loading