FN-6894: widen workflow switcher menus
Workflow switcher dropdowns now size open menus to fit long workflow names without widening the collapsed trigger. - Add menu width calculation that combines measured workflow names, option decorations, trigger width, and viewport bounds. - Measure option names with a canvas when opening/repositioning the dropdown. - Cover long-name, viewport clamp, trigger sizing, and CSS overflow behavior in WorkflowSwitcher tests. - Document the open-listbox sizing behavior in the dashboard guide. Files changed: docs/dashboard-guide.md | 2 +- .../dashboard/app/components/WorkflowSwitcher.tsx | 49 +++++++++++++- .../components/__tests__/WorkflowSwitcher.test.tsx | 78 +++++++++++++++++++++- 3 files changed, 123 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6894 Fusion-Task-Lineage: 3f2f8129-3f85-484d-9c0f-b6ebe673b6a9
This commit is contained in:
@@ -89,7 +89,7 @@ Features:
|
||||
- Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs
|
||||
- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback)
|
||||
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
|
||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Each option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
||||
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash.
|
||||
|
||||

|
||||
|
||||
@@ -27,6 +27,36 @@ interface DropdownPosition {
|
||||
}
|
||||
|
||||
const ZERO_COUNTS: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0 };
|
||||
const DEFAULT_MENU_HORIZONTAL_PADDING = 16;
|
||||
const DEFAULT_MENU_MIN_WIDTH = 240;
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowSwitcher 2026-06-21-18:34:
|
||||
* The open listbox must expose full workflow names for comparison while the collapsed trigger remains intentionally narrow and ellipsized.
|
||||
* Size the menu from measured name content plus option decorations, then clamp to the viewport so the trigger width can prevent shrinking but cannot force long names to stay truncated.
|
||||
* OPTION_DECORATIONS_WIDTH budgets the option row padding/gaps, three count badges plus separators, an optional btn-icon edit affordance, and scrollbar allowance from the existing token-sized CSS.
|
||||
*/
|
||||
export const OPTION_DECORATIONS_WIDTH = 200;
|
||||
|
||||
export interface ComputeMenuWidthInput {
|
||||
longestNameWidth: number;
|
||||
triggerWidth: number;
|
||||
viewportWidth: number;
|
||||
horizontalPadding?: number;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export function computeMenuWidth({
|
||||
longestNameWidth,
|
||||
triggerWidth,
|
||||
viewportWidth,
|
||||
horizontalPadding = DEFAULT_MENU_HORIZONTAL_PADDING,
|
||||
minWidth = DEFAULT_MENU_MIN_WIDTH,
|
||||
}: ComputeMenuWidthInput): number {
|
||||
const contentWidth = Math.max(0, longestNameWidth) + OPTION_DECORATIONS_WIDTH;
|
||||
const desired = Math.max(triggerWidth, contentWidth, minWidth);
|
||||
return Math.min(desired, viewportWidth - horizontalPadding * 2);
|
||||
}
|
||||
|
||||
function getCounts(counts: Map<string, WorkflowStatusCounts>, workflowId: string): WorkflowStatusCounts {
|
||||
return counts.get(workflowId) ?? ZERO_COUNTS;
|
||||
@@ -68,12 +98,24 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const measurementCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const onOpenRef = useRef(onOpen);
|
||||
|
||||
const selectedIndex = useMemo(() => Math.max(0, workflows.findIndex((workflow) => workflow.id === value)), [value, workflows]);
|
||||
const selectedWorkflow = workflows[selectedIndex] ?? workflows[0] ?? null;
|
||||
const selectedCounts = selectedWorkflow ? getCounts(counts, selectedWorkflow.id) : ZERO_COUNTS;
|
||||
|
||||
const measureLongestOptionNameWidth = useCallback((names: string[]) => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return 0;
|
||||
const canvas = measurementCanvasRef.current ?? document.createElement("canvas");
|
||||
measurementCanvasRef.current = canvas;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return 0;
|
||||
context.font = getComputedStyle(trigger).font;
|
||||
return names.reduce((longestWidth, name) => Math.max(longestWidth, context.measureText(name).width), 0);
|
||||
}, []);
|
||||
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
@@ -82,7 +124,7 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
const offsetTop = window.visualViewport?.offsetTop ?? 0;
|
||||
const offsetLeft = window.visualViewport?.offsetLeft ?? 0;
|
||||
const horizontalPadding = 16;
|
||||
const horizontalPadding = DEFAULT_MENU_HORIZONTAL_PADDING;
|
||||
const verticalPadding = 16;
|
||||
const gap = 4;
|
||||
const preferredHeight = Math.min(viewportHeight * 0.6, 320);
|
||||
@@ -94,14 +136,15 @@ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, l
|
||||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||||
const availableHeight = Math.max((openUpward ? spaceAbove : spaceBelow) - verticalPadding - gap, 160);
|
||||
const maxHeight = Math.max(Math.min(availableHeight, preferredHeight), 160);
|
||||
const width = Math.min(Math.max(rect.width, 240), viewportWidth - horizontalPadding * 2);
|
||||
const longestNameWidth = measureLongestOptionNameWidth(workflows.map((workflow) => workflow.name));
|
||||
const width = computeMenuWidth({ longestNameWidth, triggerWidth: rect.width, viewportWidth, horizontalPadding });
|
||||
const left = Math.min(Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - width) + offsetLeft;
|
||||
const top = openUpward
|
||||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||||
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - maxHeight);
|
||||
|
||||
setDropdownPosition({ top, left, width, maxHeight });
|
||||
}, []);
|
||||
}, [measureLongestOptionNameWidth, workflows]);
|
||||
|
||||
useEffect(() => {
|
||||
onOpenRef.current = onOpen;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardWorkflowDefinition } from "../../api";
|
||||
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||
import { WorkflowSwitcher } from "../WorkflowSwitcher";
|
||||
import { computeMenuWidth, OPTION_DECORATIONS_WIDTH, WorkflowSwitcher } from "../WorkflowSwitcher";
|
||||
import type { WorkflowStatusCounts } from "../workflowStatusCounts";
|
||||
|
||||
const workflows: BoardWorkflowDefinition[] = [
|
||||
@@ -27,6 +28,39 @@ function cssRuleFor(css: string, selector: string) {
|
||||
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
|
||||
}
|
||||
|
||||
function menuWidth() {
|
||||
const menu = screen.getByRole("listbox", { name: "Workflow" });
|
||||
return Number.parseFloat(menu.style.width);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("computeMenuWidth", () => {
|
||||
it("keeps short-name menus at or above the min width and trigger width", () => {
|
||||
expect(computeMenuWidth({ longestNameWidth: 12, triggerWidth: 180, viewportWidth: 1024 })).toBe(240);
|
||||
expect(computeMenuWidth({ longestNameWidth: 12, triggerWidth: 280, viewportWidth: 1024 })).toBe(280);
|
||||
});
|
||||
|
||||
it("grows with long names plus the option decorations budget", () => {
|
||||
const longestNameWidth = 420;
|
||||
expect(computeMenuWidth({ longestNameWidth, triggerWidth: 180, viewportWidth: 1024 })).toBe(longestNameWidth + OPTION_DECORATIONS_WIDTH);
|
||||
});
|
||||
|
||||
it("caps content-driven width to the padded viewport", () => {
|
||||
expect(computeMenuWidth({ longestNameWidth: 1200, triggerWidth: 180, viewportWidth: 390, horizontalPadding: 16 })).toBe(358);
|
||||
});
|
||||
|
||||
it("uses trigger dominance when the collapsed control is wider than the content budget", () => {
|
||||
expect(computeMenuWidth({ longestNameWidth: 20, triggerWidth: 360, viewportWidth: 1024 })).toBe(360);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowSwitcher", () => {
|
||||
it("renders the active workflow without compact counts while collapsed", () => {
|
||||
render(
|
||||
@@ -58,6 +92,46 @@ describe("WorkflowSwitcher", () => {
|
||||
expect(screen.queryByRole("listbox", { name: "Workflow" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("widens the open listbox for long workflow names without changing the trigger sizing contract", () => {
|
||||
/* Surface Enumeration: this covers short/long populated options through the shared Board/ListView switcher component seam, with CSS assertions for the collapsed trigger and mobile viewport overflow safety net. */
|
||||
const ctxStub = {
|
||||
font: "",
|
||||
measureText: (text: string) => ({ width: text.length * 8 }),
|
||||
};
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctxStub as CanvasRenderingContext2D);
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||
|
||||
const { unmount } = render(<WorkflowSwitcher workflows={workflows} value="coding" onChange={vi.fn()} counts={countMap()} />);
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher"));
|
||||
const shortWidth = menuWidth();
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<WorkflowSwitcher
|
||||
workflows={[
|
||||
workflows[0],
|
||||
{ id: "long", name: "Release Engineering Workflow With Very Long Name", columns: [] },
|
||||
]}
|
||||
value="coding"
|
||||
onChange={vi.fn()}
|
||||
counts={countMap()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("workflow-switcher"));
|
||||
const longWidth = menuWidth();
|
||||
|
||||
expect(shortWidth).toBeGreaterThanOrEqual(240);
|
||||
expect(longWidth).toBeGreaterThan(shortWidth);
|
||||
|
||||
const css = loadAllAppCssBaseOnly();
|
||||
const triggerRule = cssRuleFor(css, ".workflow-switcher-trigger");
|
||||
expect(triggerRule).toMatch(/max-width:\s*calc\(var\(--space-xl\) \* 12\)/);
|
||||
const currentNameRule = cssRuleFor(css, ".workflow-switcher-current-name,\n.workflow-switcher-option-name");
|
||||
expect(currentNameRule).toMatch(/text-overflow:\s*ellipsis/);
|
||||
const switcherCss = readFileSync("app/components/WorkflowSwitcher.css", "utf8");
|
||||
expect(switcherCss).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*max-width:\s*calc\(100vw - var\(--space-xl\)\);/);
|
||||
});
|
||||
|
||||
it("fires onOpen only on click-driven closed-to-open transitions", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<WorkflowSwitcher workflows={workflows} value="coding" onChange={vi.fn()} counts={countMap()} onOpen={onOpen} />);
|
||||
|
||||
Reference in New Issue
Block a user