From cde52b3f7272fe4850883c3703cb12797c713b35 Mon Sep 17 00:00:00 2001 From: alex289 Date: Thu, 29 Oct 2026 18:10:18 +0100 Subject: [PATCH 1/3] fix(frontend): make info popovers work on touch devices Information tooltips were inaccessible on touch screens because they only opened on hover. Use tap-to-toggle popovers on touch devices and cover desktop and touch behavior with an E2E test. --- e2e/tests/info-tooltips.test.ts | 79 ++++++++++++++++ .../applications/health/availability-bar.tsx | 22 +++-- .../components/badges/agent-status-badge.tsx | 12 +-- .../components/badges/agent-version-badge.tsx | 12 +-- frontend/src/components/info-tooltip.tsx | 94 +++++++++++++++++++ .../overview/deployment-activity-card.tsx | 15 +-- .../applications/$id/details.tsx | 13 ++- .../repositories/$id/settings/signing.tsx | 23 +++-- .../repositories/$id/settings/sync.tsx | 94 ++++++++++++------- 9 files changed, 288 insertions(+), 76 deletions(-) create mode 100644 e2e/tests/info-tooltips.test.ts create mode 100644 frontend/src/components/info-tooltip.tsx diff --git a/e2e/tests/info-tooltips.test.ts b/e2e/tests/info-tooltips.test.ts new file mode 100644 index 00000000..e27fe15a --- /dev/null +++ b/e2e/tests/info-tooltips.test.ts @@ -0,0 +1,79 @@ +import { test, expect, type Page } from "@playwright/test"; + +async function openActivity(page: Page) { + // 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: [{ date: "2026-10-29", 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("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..9fd367a6 --- /dev/null +++ b/frontend/src/components/info-tooltip.tsx @@ -0,0 +1,94 @@ +import { Popover } from "@base-ui/react/popover"; +import { + createContext, + useContext, + useSyncExternalStore, + 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"; + +// Use input capabilities rather than viewport width, including large touch-only tablets. +const touchQuery = "(hover: none), (pointer: coarse)"; +const TouchContext = createContext(false); + +function subscribe(onChange: () => void) { + const query = window.matchMedia(touchQuery); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); +} + +function getSnapshot() { + return window.matchMedia(touchQuery).matches; +} + +function getServerSnapshot() { + return false; +} + +export function InfoTooltip({ children }: { children: ReactNode }) { + const touch = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return ( + + {touch ? {children} : {children}} + + ); +} + +export function InfoTooltipTrigger({ + render, + nativeButton = !render, + ...props +}: Omit, "ref"> & { render?: ReactElement; nativeButton?: boolean }) { + const touch = useContext(TouchContext); + return touch ? ( + + ) : ( + + ); +} + +export function InfoTooltipContent({ + children, + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, +}: { + children: ReactNode; + className?: string; +} & Pick) { + const touch = useContext(TouchContext); + if (!touch) { + return ( + + {children} + + ); + } + return ( + + + + {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()} - - + +