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:
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user