Files
kandev/apps/web/components/github/multi-pr-ci-popover.tsx
T
José Almeida 42556971ef feat(web): tabbed multi-PR CI popover for topbar button and chat status chip (#1356)
* feat(web): tabbed multi-PR CI popover for topbar button and chat status chip

* test(e2e): cover multi-PR CI popover tabs, chip aggregate, and dropdown

* fix(web): address multi-PR popover review feedback

- suppress hover popover while the PR dropdown is open (no stacked overlays)
- repo-scope tab testids so same-number PRs on different repos stay unique
- guard non-array taskPRs store value in PRStatusChip (same as PRTaskIcon)
- add tablist/tab/aria-selected semantics to the popover tabs

* fix(web): warm default PR only, skip terminal PRs, add tabpanel ARIA

- chip background-sync warms pickDefaultPR (the tab the popover opens), not list head
- PRFeedbackWarmer skips merged/closed PRs (no wasted fetches in topbar path)
- tabpanel role + aria-controls/labelledby wire tabs to the panel

* fix(web): complete ARIA tabs keyboard model in multi-PR popover

- roving tabindex: only the active tab in the page tab order
- ArrowLeft/ArrowRight move selection + focus within the tablist (wrapping)
- stable useId-scoped tabpanel id so every tab's aria-controls resolves
- document topbar-only scoping of prCheckGroup e2e helpers

* refactor(web): skip duplicate rank call for first PR in pickDefaultPR
2026-06-12 19:34:27 +01:00

139 lines
4.7 KiB
TypeScript

"use client";
import { useId, useState, type KeyboardEvent } from "react";
import { IconGitPullRequest } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { PRCIPopover } from "@/components/github/pr-ci-popover";
import { getPRStatusColor, pickDefaultPR } from "@/components/github/pr-task-icon";
import { usePRFeedbackBackgroundSync } from "@/hooks/domains/github/use-pr-ci-popover";
import type { TaskPR } from "@/lib/types/github";
/**
* Renders nothing — just keeps one PR's feedback cache warm while the popover
* is mounted, so switching tabs shows fresh data immediately. One instance per
* PR (keyed by id) so the hook count stays stable as the list changes.
*/
function PRFeedbackWarmer({ pr }: { pr: TaskPR }) {
usePRFeedbackBackgroundSync(pr);
return null;
}
function prTabDomId(uid: string, pr: TaskPR): string {
return `${uid}-pr-tab-${pr.repo}-${pr.pr_number}`;
}
function PRTab({
pr,
uid,
panelId,
active,
onSelect,
}: {
pr: TaskPR;
uid: string;
panelId: string;
active: boolean;
onSelect: (pr: TaskPR) => void;
}) {
return (
<button
type="button"
role="tab"
id={prTabDomId(uid, pr)}
// Roving tabindex (WAI-ARIA tabs pattern): only the active tab is in the
// page tab order; ArrowLeft/ArrowRight move between tabs.
tabIndex={active ? 0 : -1}
aria-selected={active}
aria-controls={panelId}
data-testid={`pr-popover-tab-${pr.repo}-${pr.pr_number}`}
data-active={active ? "true" : "false"}
onClick={() => onSelect(pr)}
className={cn(
"flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs whitespace-nowrap transition-colors",
active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
>
<IconGitPullRequest className={cn("h-3.5 w-3.5", getPRStatusColor(pr))} />
<span className="font-medium">
{pr.repo} #{pr.pr_number}
</span>
</button>
);
}
/**
* Multi-PR variant of the CI popover. A segmented header lists every PR linked
* to the task (one chip per PR, coloured by its status); the body reuses the
* single-PR PRCIPopover for whichever PR is selected. Defaults to the
* worst-status open PR so problems surface first.
*/
export function MultiPRCIPopover({
prs,
enabled,
onOpenDetailPanel,
}: {
prs: TaskPR[];
enabled: boolean;
onOpenDetailPanel?: (pr: TaskPR) => void;
}) {
// `overrideId` is only set when the user activates a tab. The displayed PR is
// derived: honour the override while it still exists, otherwise fall back to
// the worst-status PR. This keeps the selection valid as the list changes
// (PR closed, new PR opened, task switch) without a setState-in-effect.
const [overrideId, setOverrideId] = useState<string | null>(null);
// useId-scoped DOM ids so two mounted popovers (chip + topbar) can't collide.
// The panel id is stable across tab switches so every tab's aria-controls
// always references an element that exists in the DOM.
const uid = useId();
const panelId = `${uid}-pr-ci-tabpanel`;
const selected = prs.find((p) => p.id === overrideId) ?? pickDefaultPR(prs);
if (!selected) return null;
// ArrowLeft/ArrowRight move selection (and focus) within the tablist,
// wrapping at the ends — selection follows focus, per the ARIA tabs pattern.
const onTablistKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const idx = prs.findIndex((p) => p.id === selected.id);
const delta = e.key === "ArrowRight" ? 1 : -1;
const next = prs[(idx + delta + prs.length) % prs.length];
setOverrideId(next.id);
document.getElementById(prTabDomId(uid, next))?.focus();
};
return (
<div data-testid="pr-multi-popover" className="flex flex-col gap-2">
{prs
.filter((p) => p.state === "open")
.map((pr) => (
<PRFeedbackWarmer key={pr.id} pr={pr} />
))}
<div
role="tablist"
aria-label="Pull requests"
data-testid="pr-multi-popover-tabs"
className="flex gap-1 overflow-x-auto border-b border-border/50 pb-2"
onKeyDown={onTablistKeyDown}
>
{prs.map((pr) => (
<PRTab
key={pr.id}
pr={pr}
uid={uid}
panelId={panelId}
active={pr.id === selected.id}
onSelect={(p) => setOverrideId(p.id)}
/>
))}
</div>
<div role="tabpanel" id={panelId} aria-labelledby={prTabDomId(uid, selected)}>
<PRCIPopover
pr={selected}
enabled={enabled}
onOpenDetailPanel={onOpenDetailPanel ? () => onOpenDetailPanel(selected) : undefined}
/>
</div>
</div>
);
}