fix(web): collapse dockview workflow stepper to current step when cramped (#1309)
* fix(web): collapse dockview workflow stepper to current step when cramped On narrow windows the full workflow step indicator outgrew the top-bar center track and rendered beneath the right-side action buttons. Switch the grid so the breadcrumb takes only its content width and the center track absorbs the real leftover space, then collapse the stepper to a minimal current-step view (via the shared useToolbarCollapsed hook) only when the full version genuinely cannot fit. * fix(web): stabilize stepper test id and drop margin skewing collapse Move data-testid="workflow-stepper" to the persistent outer container so the e2e locator resolves in both expanded and collapsed modes, and remove mx-2 from the w-full measuring container (it overflowed the wrapper and shifted the collapse threshold by ~8px); the grid gap-2 already spaces the columns. * refactor(web): trim verbose stepper collapse comment * fix(web): tag archived minimal badge with stepper test id Give the archived branch of MinimalWorkflowStepper the same workflow-stepper-minimal test id as the current-step branch so collapsed mode is detectable for archived tasks, and tighten two comments. * test(web): collapse multi-line comments in stepper test to single lines * fix(web): drop aria-current on minimal stepper no-current fallback When no step is current the minimal view falls back to the first step; it should not claim aria-current="step", matching the expanded path where no step is marked current in that case.
This commit is contained in:
@@ -70,7 +70,7 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
return (
|
||||
<header
|
||||
data-testid="task-topbar"
|
||||
className="@container/topbar grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_minmax(0,auto)_minmax(0,1fr)] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
|
||||
className="@container/topbar grid h-10 shrink-0 grid-cols-[minmax(0,auto)_minmax(0,1fr)_auto] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
|
||||
>
|
||||
<TopBarLeft
|
||||
taskId={taskId}
|
||||
@@ -79,7 +79,7 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
remoteExecutorType={remoteExecutorType}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
<div className="min-w-0 justify-self-center overflow-hidden">
|
||||
<div className="min-w-0 justify-self-stretch overflow-hidden">
|
||||
{workflowSteps && workflowSteps.length > 0 && (
|
||||
<WorkflowStepper
|
||||
steps={workflowSteps}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkflowStepper, type WorkflowStepperStep } from "./workflow-stepper";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// useToolbarCollapsed is mocked because the test DOM can't measure offsetWidth.
|
||||
const collapsedMock = vi.fn(() => false);
|
||||
vi.mock("@/hooks/use-toolbar-collapsed", () => ({
|
||||
useToolbarCollapsed: () => collapsedMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: () => undefined,
|
||||
}));
|
||||
vi.mock("@/lib/state/context-files-store", () => ({
|
||||
useContextFilesStore: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/state/layout-store", () => ({
|
||||
useLayoutStore: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/state/dockview-store", () => ({
|
||||
useDockviewStore: () => vi.fn(),
|
||||
}));
|
||||
|
||||
const STEPS: WorkflowStepperStep[] = [
|
||||
{ id: "a", name: "Spec", color: "#111", position: 0 },
|
||||
{ id: "b", name: "Work", color: "#222", position: 1 },
|
||||
{ id: "c", name: "Review", color: "#333", position: 2 },
|
||||
];
|
||||
|
||||
describe("WorkflowStepper", () => {
|
||||
it("renders every step when there is room (not collapsed)", () => {
|
||||
collapsedMock.mockReturnValue(false);
|
||||
render(<WorkflowStepper steps={STEPS} currentStepId="b" />);
|
||||
|
||||
expect(screen.getByTestId("workflow-stepper")).toBeTruthy();
|
||||
expect(screen.queryByTestId("workflow-stepper-minimal")).toBeNull();
|
||||
// All steps render under the persistent outer container.
|
||||
expect(screen.getByTestId("workflow-step-Spec")).toBeTruthy();
|
||||
expect(screen.getByTestId("workflow-step-Work")).toBeTruthy();
|
||||
expect(screen.getByTestId("workflow-step-Review")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapses to only the current step when space runs out", () => {
|
||||
collapsedMock.mockReturnValue(true);
|
||||
render(<WorkflowStepper steps={STEPS} currentStepId="b" />);
|
||||
|
||||
// Outer container persists across variants (stable e2e locator); minimal child marks collapsed state.
|
||||
expect(screen.getByTestId("workflow-stepper")).toBeTruthy();
|
||||
expect(screen.getByTestId("workflow-stepper-minimal")).toBeTruthy();
|
||||
|
||||
// Current step keeps its test id + aria-current in either variant.
|
||||
const current = screen.getByTestId("workflow-step-Work");
|
||||
expect(current.getAttribute("aria-current")).toBe("step");
|
||||
expect(screen.queryByTestId("workflow-step-Spec")).toBeNull();
|
||||
expect(screen.queryByTestId("workflow-step-Review")).toBeNull();
|
||||
|
||||
// Position indicator reflects the current step out of the total.
|
||||
expect(screen.getByText("2/3")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the first step when collapsed with no current step", () => {
|
||||
collapsedMock.mockReturnValue(true);
|
||||
render(<WorkflowStepper steps={STEPS} currentStepId={null} />);
|
||||
|
||||
// Fallback step isn't the real current step, so it must not claim aria-current.
|
||||
expect(screen.getByTestId("workflow-step-Spec").getAttribute("aria-current")).toBeNull();
|
||||
expect(screen.getByText("1/3")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the archived badge instead of a step when collapsed and archived", () => {
|
||||
collapsedMock.mockReturnValue(true);
|
||||
render(<WorkflowStepper steps={STEPS} currentStepId="b" isArchived />);
|
||||
|
||||
expect(screen.getByText("Archived")).toBeTruthy();
|
||||
// Archived badge carries the minimal test id for collapsed-mode detection.
|
||||
expect(screen.getByTestId("workflow-stepper-minimal")).toBeTruthy();
|
||||
expect(screen.queryByTestId("workflow-step-Work")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when there are no steps", () => {
|
||||
collapsedMock.mockReturnValue(false);
|
||||
const { container } = render(<WorkflowStepper steps={[]} currentStepId={null} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -11,6 +11,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { useContextFilesStore } from "@/lib/state/context-files-store";
|
||||
import { useLayoutStore } from "@/lib/state/layout-store";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed";
|
||||
import type { KanbanStepEvents } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
type Step = {
|
||||
@@ -102,38 +103,103 @@ const WorkflowStepper = memo(function WorkflowStepper({
|
||||
[taskId, workflowId, disablePlanMode],
|
||||
);
|
||||
|
||||
// Collapse to a minimal view when the full stepper can't fit (w-full keeps the measurement track-driven).
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isCollapsed = useToolbarCollapsed(containerRef);
|
||||
|
||||
if (sortedSteps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="workflow-stepper"
|
||||
className="flex items-center gap-0 overflow-x-auto flex-shrink min-w-0 mx-2"
|
||||
className="flex w-full min-w-0 items-center justify-center gap-0 overflow-hidden"
|
||||
>
|
||||
{sortedSteps.map((step, index) => (
|
||||
<WorkflowStepItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={index}
|
||||
{isCollapsed ? (
|
||||
<MinimalWorkflowStepper
|
||||
sortedSteps={sortedSteps}
|
||||
currentIndex={currentIndex}
|
||||
isArchived={isArchived}
|
||||
taskId={taskId}
|
||||
workflowId={workflowId}
|
||||
movingToStepId={movingToStepId}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
))}
|
||||
{isArchived && (
|
||||
) : (
|
||||
<>
|
||||
<div className="h-px w-6 shrink-0 bg-border" />
|
||||
<span className="text-[11px] font-medium text-amber-500 bg-amber-500/15 px-2 py-0.5 rounded-md whitespace-nowrap">
|
||||
Archived
|
||||
</span>
|
||||
<div className="flex items-center gap-0">
|
||||
{sortedSteps.map((step, index) => (
|
||||
<WorkflowStepItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={index}
|
||||
currentIndex={currentIndex}
|
||||
isArchived={isArchived}
|
||||
taskId={taskId}
|
||||
workflowId={workflowId}
|
||||
movingToStepId={movingToStepId}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isArchived && (
|
||||
<>
|
||||
<div className="h-px w-6 shrink-0 bg-border" />
|
||||
<span className="text-[11px] font-medium text-amber-500 bg-amber-500/15 px-2 py-0.5 rounded-md whitespace-nowrap">
|
||||
Archived
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Minimal stepper: current step only (or archived badge), keeping the per-step test id + aria-current. */
|
||||
function MinimalWorkflowStepper({
|
||||
sortedSteps,
|
||||
currentIndex,
|
||||
isArchived,
|
||||
}: {
|
||||
sortedSteps: Step[];
|
||||
currentIndex: number;
|
||||
isArchived?: boolean;
|
||||
}) {
|
||||
if (isArchived) {
|
||||
return (
|
||||
<span
|
||||
data-testid="workflow-stepper-minimal"
|
||||
className="text-[11px] font-medium text-amber-500 bg-amber-500/15 px-2 py-0.5 rounded-md whitespace-nowrap"
|
||||
>
|
||||
Archived
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const current = currentIndex >= 0 ? sortedSteps[currentIndex] : sortedSteps[0];
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="workflow-stepper-minimal"
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5"
|
||||
>
|
||||
<div
|
||||
data-testid={`workflow-step-${current.name}`}
|
||||
aria-current={currentIndex >= 0 ? "step" : undefined}
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs"
|
||||
>
|
||||
<StepCircleIndicator isCurrent isCompleted={false} />
|
||||
<span className="truncate text-xs font-medium leading-none text-foreground">
|
||||
{current.name}
|
||||
</span>
|
||||
</div>
|
||||
{sortedSteps.length > 1 && (
|
||||
<span className="shrink-0 text-[11px] tabular-nums leading-none text-muted-foreground">
|
||||
{(currentIndex >= 0 ? currentIndex : 0) + 1}/{sortedSteps.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a step can be moved to */
|
||||
function canMoveToStep(params: {
|
||||
isArchived: boolean | undefined;
|
||||
|
||||
Reference in New Issue
Block a user