diff --git a/e2e/tests/info-tooltips.test.ts b/e2e/tests/info-tooltips.test.ts new file mode 100644 index 00000000..c25a1db0 --- /dev/null +++ b/e2e/tests/info-tooltips.test.ts @@ -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 = { + "(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(); + }); +}); diff --git a/frontend/src/components/applications/health/availability-bar.tsx b/frontend/src/components/applications/health/availability-bar.tsx index 7f979e03..f31c0b80 100644 --- a/frontend/src/components/applications/health/availability-bar.tsx +++ b/frontend/src/components/applications/health/availability-bar.tsx @@ -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, @@ -44,10 +44,18 @@ export function AvailabilityBar({ stats }: { stats: HealthStats }) { -
+
{stats.buckets.map((bucket) => ( - - + } /> - +

{tooltipFormatter.formatRange(new Date(bucket.start), new Date(bucket.end))}

@@ -80,8 +88,8 @@ export function AvailabilityBar({ stats }: { stats: HealthStats }) { {formatHealthDuration(entry.seconds(bucket))}

))} -
-
+ + ))}
diff --git a/frontend/src/components/badges/agent-status-badge.tsx b/frontend/src/components/badges/agent-status-badge.tsx index c83b535e..12090601 100644 --- a/frontend/src/components/badges/agent-status-badge.tsx +++ b/frontend/src/components/badges/agent-status-badge.tsx @@ -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 }) { @@ -16,15 +16,15 @@ export function AgentStatusBadge({ status }: { status: AgentStatus }) { return (
{isError ? ( - - + } > {statusDot} {statusLabel} - - {m.agentStatusErrorDescription()} - + + {m.agentStatusErrorDescription()} + ) : ( {statusDot} diff --git a/frontend/src/components/badges/agent-version-badge.tsx b/frontend/src/components/badges/agent-version-badge.tsx index dc53f51e..86ef0401 100644 --- a/frontend/src/components/badges/agent-version-badge.tsx +++ b/frontend/src/components/badges/agent-version-badge.tsx @@ -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< @@ -48,12 +48,12 @@ export function AgentVersionBadge({ const text = version ? `${version} · ${label()}` : label(); return ( - - }> + + }> - {description()} - + + {description()} + ); } diff --git a/frontend/src/components/info-tooltip.tsx b/frontend/src/components/info-tooltip.tsx new file mode 100644 index 00000000..fc5e8015 --- /dev/null +++ b/frontend/src/components/info-tooltip.tsx @@ -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 ( + + {children} + + ); +} + +export function InfoTooltipTrigger({ + render, + nativeButton = !render, + ...props +}: Omit, "ref"> & { render?: ReactElement; nativeButton?: boolean }) { + return ( + } + tabIndex={0} + {...props} + /> + ); +} + +export function InfoTooltipContent({ + children, + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, +}: { + children: ReactNode; + className?: string; +} & Pick) { + return ( + <> + + {children} + + + + + {children} + + + + + ); +} diff --git a/frontend/src/components/overview/deployment-activity-card.tsx b/frontend/src/components/overview/deployment-activity-card.tsx index 816e1b37..42d25aa1 100644 --- a/frontend/src/components/overview/deployment-activity-card.tsx +++ b/frontend/src/components/overview/deployment-activity-card.tsx @@ -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"; @@ -44,8 +44,9 @@ export function DeploymentActivityCard() { {data.days.map((day) => { const dayTotal = day.succeeded + day.failed; return ( - - + } @@ -63,8 +64,8 @@ export function DeploymentActivityCard() { style={{ height: `${(day.succeeded / maxPerDay) * 100}%` }} /> ) : null} - - + +

{dayFormatter.format(new Date(`${day.date}T00:00`))}

@@ -74,8 +75,8 @@ export function DeploymentActivityCard() { failed: day.failed, })}

-
-
+ + ); })}
diff --git a/frontend/src/routes/_authenticated/applications/$id/details.tsx b/frontend/src/routes/_authenticated/applications/$id/details.tsx index 18d24e36..d193c81d 100644 --- a/frontend/src/routes/_authenticated/applications/$id/details.tsx +++ b/frontend/src/routes/_authenticated/applications/$id/details.tsx @@ -51,6 +51,7 @@ import ErrorAlert from "@/components/alerts/error-alert"; import { getSyncTypeIcon, getSyncTypeLabel } from "@/components/badges/repository-sync-type-badge"; import { ApplicationEventSource } from "@/lib/application-events"; import { syncRepository } from "@/lib/repositories"; +import { InfoTooltip, InfoTooltipContent, InfoTooltipTrigger } from "@/components/info-tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { CodeBlock, @@ -494,12 +495,14 @@ function ApplicationDetailsPage() { loading={!data} > {data?.lastSyncedAt ? ( - - }> + + }> {formatRelativeTime(data.lastSyncedAt)} - - {new Date(data.lastSyncedAt).toLocaleString()} - + + + {new Date(data.lastSyncedAt).toLocaleString()} + + ) : ( {m.never()} )} diff --git a/frontend/src/routes/_authenticated/repositories/$id/settings/signing.tsx b/frontend/src/routes/_authenticated/repositories/$id/settings/signing.tsx index 279906c2..22aa1243 100644 --- a/frontend/src/routes/_authenticated/repositories/$id/settings/signing.tsx +++ b/frontend/src/routes/_authenticated/repositories/$id/settings/signing.tsx @@ -12,7 +12,7 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; import { toast } from "@/components/ui/toast"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { InfoTooltip, InfoTooltipContent, InfoTooltipTrigger } from "@/components/info-tooltip"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import ErrorAlert from "@/components/alerts/error-alert"; import { updateRepository, type CommitSigningKey, type Repository } from "@/lib/repositories"; @@ -117,14 +117,19 @@ function RepositorySigningPage() { - - - } - /> - {m.requireSignedCommitsTooltip()} - + + + + {m.requireSignedCommitsTooltip()} +
{m.githubActionsOIDCEnabled()} - - - } - /> - {m.githubActionsOIDCEnabledTooltip()} - + + + + + {m.githubActionsOIDCEnabledTooltip()} + +
{m.githubActionsOIDCAllowedActions()} - - - } - /> - + + + + {m.githubActionsOIDCAllowedActionsTooltip()} - - + +