FN-6133: fix workflow toolbar toggle accessibility

Replace the empty workflow toolbar icon toggle with an accessible text control.

- move workflow toolbar collapse/expand handling into shared click and keyboard callbacks
- make the expanded toolbar itself clickable while rendering a focusable "Workflow" label in collapsed mode
- update toolbar styling and Board tests to cover keyboard toggling, single-workflow cases, and the absence of empty icon buttons

Files changed:
 packages/dashboard/app/components/Board.tsx        |  51 ++++++++---
 packages/dashboard/app/components/Lane.css         |  19 +++-
 .../app/components/__tests__/Board.test.tsx        | 100 +++++++++++++++++++--
 3 files changed, 147 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-6133

Fusion-Task-Lineage: 6dd217a0-ede0-424a-8ab7-cdde39d2b6ce
This commit is contained in:
gsxdsm
2026-06-09 15:05:25 -07:00
parent 0b589e1f2b
commit dfb037edd8
3 changed files with 147 additions and 23 deletions

View File

@@ -4,7 +4,7 @@ import { sortTasksForDisplayColumn } from "./taskSorting";
import { Column } from "./Column";
import "./Lane.css";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useState, useMemo, useEffect, useCallback, useRef, type KeyboardEvent, type MouseEvent } from "react";
import { Pencil, Plus } from "lucide-react";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
@@ -117,6 +117,23 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
}
}, [workflowToolbarCollapsed, projectId]);
const toggleWorkflowToolbarCollapsed = useCallback(() => {
setWorkflowToolbarCollapsed((collapsed) => !collapsed);
}, []);
const handleWorkflowToolbarKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleWorkflowToolbarCollapsed();
}
}, [toggleWorkflowToolbarCollapsed]);
const handleExpandedWorkflowToolbarClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
toggleWorkflowToolbarCollapsed();
}
}, [toggleWorkflowToolbarCollapsed]);
useEffect(() => {
recordResumeEvent({
view: "Board",
@@ -473,17 +490,29 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
return (
<div className="board-workflow-view">
{(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && (
<div className="board-workflow-toolbar" data-collapsed={workflowToolbarCollapsed || undefined}>
<button
type="button"
className="btn btn-icon btn-sm board-workflow-collapse-toggle"
onClick={() => setWorkflowToolbarCollapsed((c) => !c)}
aria-expanded={!workflowToolbarCollapsed}
aria-label={workflowToolbarCollapsed ? "Expand workflow toolbar" : "Collapse workflow toolbar"}
data-testid="board-workflow-collapse-toggle"
/>
<div
className="board-workflow-toolbar"
data-collapsed={workflowToolbarCollapsed || undefined}
onClick={workflowToolbarCollapsed ? undefined : handleExpandedWorkflowToolbarClick}
onKeyDown={workflowToolbarCollapsed ? undefined : handleWorkflowToolbarKeyDown}
tabIndex={workflowToolbarCollapsed ? undefined : 0}
aria-expanded={workflowToolbarCollapsed ? undefined : true}
aria-label={workflowToolbarCollapsed ? undefined : "Collapse workflow toolbar"}
data-testid={workflowToolbarCollapsed ? undefined : "board-workflow-collapse-toggle"}
>
{workflowToolbarCollapsed ? (
<span className="board-workflow-collapsed-label">Workflow</span>
<span
className="board-workflow-collapsed-label"
role="button"
tabIndex={0}
onClick={toggleWorkflowToolbarCollapsed}
onKeyDown={handleWorkflowToolbarKeyDown}
aria-expanded={false}
aria-label="Expand workflow toolbar"
data-testid="board-workflow-collapse-toggle"
>
Workflow
</span>
) : (
<>
{workflowOptions.length > 1 && (

View File

@@ -42,13 +42,26 @@
padding: var(--space-xs) var(--space-md);
}
.board-workflow-collapse-toggle {
flex: 0 0 auto;
.board-workflow-toolbar:not([data-collapsed]),
.board-workflow-collapsed-label {
cursor: pointer;
}
.board-workflow-toolbar:focus-visible,
.board-workflow-collapsed-label:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.board-workflow-collapsed-label {
font-size: 0.8rem;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.8rem;
}
.board-workflow-collapsed-label:hover,
.board-workflow-collapsed-label:focus-visible {
color: var(--text);
}
.board-workflow-selector {

View File

@@ -935,7 +935,6 @@ describe("Board", () => {
const toolbar = document.querySelector(".board-workflow-toolbar");
expect(toolbar).not.toBeNull();
return Array.from(toolbar?.querySelectorAll("button") ?? [])
.filter((button) => !button.classList.contains("board-workflow-collapse-toggle"))
.map((button) => button.getAttribute("aria-label"));
}
@@ -1064,6 +1063,15 @@ describe("Board", () => {
return { onCreateWorkflow, onOpenWorkflowEditor };
}
function expectNoEmptyWorkflowToolbarIconButtons() {
const toolbar = document.querySelector(".board-workflow-toolbar");
expect(toolbar).not.toBeNull();
const emptyIconButtons = Array.from(toolbar?.querySelectorAll("button.btn-icon") ?? [])
.filter((button) => !button.querySelector("svg") && button.textContent?.trim() === "");
expect(emptyIconButtons).toHaveLength(0);
expect(toolbar?.querySelector("button.board-workflow-collapse-toggle")).toBeNull();
}
it("defaults to expanded (not collapsed)", async () => {
renderWorkflowToolbarBoard();
@@ -1077,7 +1085,19 @@ describe("Board", () => {
renderWorkflowToolbarBoard();
await screen.findByLabelText("Select workflow");
expect(screen.getByTestId("board-workflow-collapse-toggle").querySelector("svg")).toBeNull();
const toolbar = document.querySelector(".board-workflow-toolbar");
expect(toolbar?.querySelector(".lucide-chevron-left, .lucide-chevron-right")).toBeNull();
});
it("does not render an empty btn-icon button in the workflow toolbar", async () => {
renderWorkflowToolbarBoard();
await screen.findByLabelText("Select workflow");
expectNoEmptyWorkflowToolbarIconButtons();
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(screen.getByText("Workflow")).toBeDefined();
expectNoEmptyWorkflowToolbarIconButtons();
});
it("clicking the toggle collapses the toolbar", async () => {
@@ -1096,27 +1116,38 @@ describe("Board", () => {
it("clicking the toggle again expands the toolbar", async () => {
renderWorkflowToolbarBoard();
await screen.findByLabelText("Select workflow");
const toggle = screen.getByTestId("board-workflow-collapse-toggle");
fireEvent.click(toggle);
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(screen.queryByLabelText("Select workflow")).toBeNull();
fireEvent.click(toggle);
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(screen.getByLabelText("Select workflow")).toBeDefined();
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("true");
});
it("toggles the workflow toolbar from the keyboard", async () => {
renderWorkflowToolbarBoard();
await screen.findByLabelText("Select workflow");
fireEvent.keyDown(screen.getByTestId("board-workflow-collapse-toggle"), { key: "Enter" });
expect(screen.queryByLabelText("Select workflow")).toBeNull();
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(screen.getByTestId("board-workflow-collapse-toggle"), { key: " " });
expect(screen.getByLabelText("Select workflow")).toBeDefined();
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("true");
});
it("persists collapsed state to localStorage", async () => {
renderWorkflowToolbarBoard();
await screen.findByLabelText("Select workflow");
const toggle = screen.getByTestId("board-workflow-collapse-toggle");
fireEvent.click(toggle);
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(window.localStorage.getItem(BOARD_WORKFLOW_COLLAPSED_KEY)).toBe("1");
fireEvent.click(toggle);
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(window.localStorage.getItem(BOARD_WORKFLOW_COLLAPSED_KEY)).toBe("0");
});
@@ -1132,6 +1163,57 @@ describe("Board", () => {
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("false");
expect(screen.getByText("Workflow")).toBeDefined();
});
it("collapses and expands a single-workflow toolbar with action callbacks", async () => {
const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn();
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW]);
renderBoard({
tasks: [mkTask({ id: "FN-1", column: "triage" })],
onCreateWorkflow,
onOpenWorkflowEditor,
});
await waitFor(() => expect(screen.getByTestId("column-triage")).toBeDefined());
expect(screen.queryByLabelText("Select workflow")).toBeNull();
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
expectNoEmptyWorkflowToolbarIconButtons();
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(screen.queryByRole("button", { name: "New workflow" })).toBeNull();
expect(screen.queryByRole("button", { name: "Edit workflows" })).toBeNull();
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("false");
expectNoEmptyWorkflowToolbarIconButtons();
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
});
it("collapses and expands a multi-workflow toolbar without action callbacks", async () => {
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
);
renderBoard({
tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })],
onCreateWorkflow: undefined,
onOpenWorkflowEditor: undefined,
});
expect(await screen.findByLabelText("Select workflow")).toBeDefined();
expect(screen.queryByRole("button", { name: "New workflow" })).toBeNull();
expect(screen.queryByRole("button", { name: "Edit workflows" })).toBeNull();
expectNoEmptyWorkflowToolbarIconButtons();
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(screen.queryByLabelText("Select workflow")).toBeNull();
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("false");
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(screen.getByLabelText("Select workflow")).toBeDefined();
});
});
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {