mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): task-detail active tab highlight — tooltip wrapper clobbered Radix Tabs data-state
TooltipTrigger asChild spreads its own data-state (closed) through the Slot merge onto the TabsTrigger, overriding Tabs' active/inactive attribute, so no tab ever matched the data-[state=active] styles. Re-asserting data-state in the trigger's own props survives the merge; derived from the same activeTab that drives the controlled Tabs.
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
import { TaskStatus, Team, TaskType, type Task } from "@/types";
|
||||||
|
|
||||||
|
// Regression: wrapping each TabsTrigger in a Tooltip (e211a3c1) collides with
|
||||||
|
// Radix Tabs' own data-state — TooltipTrigger's asChild Slot merge injects
|
||||||
|
// the tooltip's own data-state ("closed"/"open") onto the underlying
|
||||||
|
// TabsTrigger, and Radix Tabs' internal render spreads incoming props AFTER
|
||||||
|
// its own literal data-state, so the tooltip's value always wins. Every
|
||||||
|
// trigger ends up data-state="closed" regardless of selection, so the
|
||||||
|
// data-[state=active] highlight in ui/tabs.tsx never fires for any tab.
|
||||||
|
|
||||||
|
const mockReplace = vi.fn();
|
||||||
|
let searchParams = new URLSearchParams();
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ replace: mockReplace }),
|
||||||
|
usePathname: () => "/tasks/t1",
|
||||||
|
useSearchParams: () => searchParams,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-tasks", () => ({
|
||||||
|
useTaskFindings: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The tab panes aren't under test here — stub them so the strip's highlight
|
||||||
|
// behavior can be verified without wiring react-query/task fixtures for
|
||||||
|
// every pane (mirrors tab-findings.test.tsx stubbing CodeSnippet).
|
||||||
|
vi.mock("../tab-overview", () => ({
|
||||||
|
TabOverview: () => <div data-testid="pane-overview" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../tab-plan", () => ({
|
||||||
|
TabPlan: () => <div data-testid="pane-plan" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../tab-progress", () => ({
|
||||||
|
TabProgress: () => <div data-testid="pane-progress" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../tab-commits", () => ({
|
||||||
|
TabCommits: () => <div data-testid="pane-commits" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../tab-notes", () => ({
|
||||||
|
TabNotes: () => <div data-testid="pane-notes" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../tab-dependencies", () => ({
|
||||||
|
TabDependencies: () => <div data-testid="pane-deps" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../tab-findings", () => ({
|
||||||
|
TabFindings: () => <div data-testid="pane-findings" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../tab-collision", () => ({
|
||||||
|
TabCollision: () => <div data-testid="pane-collision" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TaskTabs } from "../task-tabs";
|
||||||
|
|
||||||
|
function buildTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: "t1",
|
||||||
|
title: "Task",
|
||||||
|
description: "d",
|
||||||
|
status: TaskStatus.IN_PROGRESS,
|
||||||
|
team: Team.BACKEND,
|
||||||
|
task_type: TaskType.CODE,
|
||||||
|
acceptance_criteria: [],
|
||||||
|
progress_updates: [],
|
||||||
|
checkpoints: [],
|
||||||
|
commits: [],
|
||||||
|
dependency_ids: [],
|
||||||
|
blocker_ids: [],
|
||||||
|
plan: null,
|
||||||
|
dev_notes: null,
|
||||||
|
qa_notes: null,
|
||||||
|
auditor_notes: null,
|
||||||
|
quick_context: null,
|
||||||
|
...overrides,
|
||||||
|
} as unknown as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TaskTabs active-tab highlight", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchParams = new URLSearchParams();
|
||||||
|
mockReplace.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only the default (Overview) tab's trigger active", () => {
|
||||||
|
render(<TaskTabs task={buildTask()} />);
|
||||||
|
|
||||||
|
const overviewTrigger = screen.getByRole("tab", { name: /Overview/i });
|
||||||
|
expect(overviewTrigger).toHaveAttribute("data-state", "active");
|
||||||
|
|
||||||
|
const others = screen
|
||||||
|
.getAllByRole("tab")
|
||||||
|
.filter((t) => t !== overviewTrigger);
|
||||||
|
expect(others).toHaveLength(7);
|
||||||
|
for (const trigger of others) {
|
||||||
|
expect(trigger).toHaveAttribute("data-state", "inactive");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the active data-state to whichever tab the URL selects", () => {
|
||||||
|
searchParams = new URLSearchParams("tab=commits");
|
||||||
|
render(<TaskTabs task={buildTask()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("tab", { name: /Commits/i })).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"active",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("tab", { name: /Overview/i })).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"inactive",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -140,7 +140,18 @@ export function TaskTabs({ task }: TaskTabsProps) {
|
|||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Tooltip key={tab.value}>
|
<Tooltip key={tab.value}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<TabsTrigger value={tab.value} className="gap-2">
|
{/* TooltipTrigger's asChild Slot merge injects its own
|
||||||
|
data-state ("open"/"closed") onto this trigger, and Radix
|
||||||
|
Tabs' internal render spreads incoming props after its own
|
||||||
|
literal data-state — so the tooltip's value silently wins
|
||||||
|
and the data-[state=active] highlight in ui/tabs.tsx never
|
||||||
|
fires. Re-assert the real selection state explicitly so it
|
||||||
|
survives the merge. */}
|
||||||
|
<TabsTrigger
|
||||||
|
value={tab.value}
|
||||||
|
data-state={tab.value === activeTab ? "active" : "inactive"}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<tab.icon className="h-4 w-4" />
|
<tab.icon className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">{tab.label}</span>
|
<span className="hidden sm:inline">{tab.label}</span>
|
||||||
{tab.count !== undefined && (
|
{tab.count !== undefined && (
|
||||||
|
|||||||
Reference in New Issue
Block a user