FN-6118: make workflow lane headers tappable

Replace the empty lane toggle button with an accessible tappable header for workflow rows on mobile.

- make the full workflow lane header act as the collapse toggle
- add keyboard support plus hover and focus-visible states for the header control
- update Lane tests to cover header click, keyboard toggles, and removal of the empty icon button

Files changed:
 packages/dashboard/app/components/Lane.css         | 11 +++++++
 packages/dashboard/app/components/Lane.tsx         | 30 +++++++++++--------
 packages/dashboard/app/components/__tests__/Lane.test.tsx | 35 +++++++++++++++++++---
 3 files changed, 60 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-6118

Fusion-Task-Lineage: 0cc1b632-1e59-4d1d-b274-f1da53a503f4
This commit is contained in:
gsxdsm
2026-06-09 12:44:25 -07:00
parent 34d889f046
commit 17312c9fae
3 changed files with 60 additions and 16 deletions

View File

@@ -95,6 +95,17 @@
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
}
.lane-header:hover {
background: var(--surface-hover, var(--surface));
}
.lane-header:focus-visible {
outline: 2px solid var(--focus-ring, var(--color-primary));
outline-offset: -2px;
}
.lane-name {

View File

@@ -1,5 +1,5 @@
import "./Lane.css";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
import { Column } from "./Column";
@@ -134,6 +134,11 @@ function LaneComponent(props: LaneProps) {
}, []);
const handleToggle = useCallback(() => onToggleCollapse(workflow.id), [onToggleCollapse, workflow.id]);
const handleHeaderKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
handleToggle();
}, [handleToggle]);
const makeCanDrop = useCallback(
(targetColumnId: string) => (taskId: string) => props.canDropTask(taskId, targetColumnId, workflow.id),
@@ -142,17 +147,18 @@ function LaneComponent(props: LaneProps) {
return (
<section className="lane" data-lane={workflow.id} aria-label={workflow.name}>
<div className="lane-header">
<button
type="button"
className="lane-collapse-toggle btn btn-icon btn-sm"
onClick={handleToggle}
aria-expanded={!collapsed}
aria-label={collapsed
? t("lane.expand", "Expand {{name}} lane", { name: workflow.name })
: t("lane.collapse", "Collapse {{name}} lane", { name: workflow.name })}
data-testid={`lane-toggle-${workflow.id}`}
/>
<div
className="lane-header"
role="button"
tabIndex={0}
onClick={handleToggle}
onKeyDown={handleHeaderKeyDown}
aria-expanded={!collapsed}
aria-label={collapsed
? t("lane.expand", "Expand {{name}} lane", { name: workflow.name })
: t("lane.collapse", "Collapse {{name}} lane", { name: workflow.name })}
data-testid={`lane-header-${workflow.id}`}
>
<h2 className="lane-name">{workflow.name}</h2>
<span className="lane-count" data-testid={`lane-count-${workflow.id}`}>{tasks.length}</span>
</div>

View File

@@ -113,16 +113,43 @@ describe("Lane", () => {
expect(screen.queryByText("Triage")).toBeNull();
});
it("invokes onToggleCollapse with the workflow id", () => {
it("invokes onToggleCollapse with the workflow id from the lane header", () => {
const props = baseProps();
render(<Lane {...props} />);
fireEvent.click(screen.getByTestId("lane-toggle-builtin:coding"));
fireEvent.click(screen.getByTestId("lane-header-builtin:coding"));
expect(props.onToggleCollapse).toHaveBeenCalledWith("builtin:coding");
});
it("does not render a chevron icon in the lane toggle", () => {
it("exposes the lane header as the accessible collapse toggle", () => {
render(<Lane {...baseProps()} />);
expect(screen.getByTestId("lane-toggle-builtin:coding").querySelector("svg")).toBeNull();
const header = screen.getByRole("button", { name: "Collapse Coding (built-in) lane" });
expect(header.getAttribute("aria-expanded")).toBe("true");
expect(header.getAttribute("tabindex")).toBe("0");
});
it("toggles the lane header with Enter and Space", () => {
const props = baseProps();
render(<Lane {...props} />);
const header = screen.getByTestId("lane-header-builtin:coding");
fireEvent.keyDown(header, { key: "Enter" });
fireEvent.keyDown(header, { key: " " });
expect(props.onToggleCollapse).toHaveBeenCalledTimes(2);
expect(props.onToggleCollapse).toHaveBeenNthCalledWith(1, "builtin:coding");
expect(props.onToggleCollapse).toHaveBeenNthCalledWith(2, "builtin:coding");
});
it("does not render a chevron icon in the lane header", () => {
render(<Lane {...baseProps()} />);
expect(screen.getByTestId("lane-header-builtin:coding").querySelector("svg")).toBeNull();
});
it("does not render an empty btn-icon toggle in the lane header", () => {
render(<Lane {...baseProps()} />);
const header = screen.getByTestId("lane-header-builtin:coding");
expect(header.querySelector(".btn-icon")).toBeNull();
expect(screen.queryByTestId("lane-toggle-builtin:coding")).toBeNull();
});
it("shows the auto-merge toggle for human-review workflow columns", () => {