import "./WorkflowSwitcher.css"; import { ChevronDown, Pencil, Plus } from "lucide-react"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import type { BoardWorkflowDefinition } from "../api"; import type { WorkflowStatusCounts } from "./workflowStatusCounts"; export interface WorkflowSwitcherProps { workflows: BoardWorkflowDefinition[]; value: string; onChange: (id: string) => void; counts: Map; /** Fired each time the dropdown transitions from closed to open so consumers can refresh count data. */ onOpen?: () => void; label?: string; onEditWorkflow?: (workflowId: string) => void; onCreateWorkflow?: () => void; } interface DropdownPosition { top: number; left: number; width: number; maxHeight: number; } const ZERO_COUNTS: WorkflowStatusCounts = { todo: 0, inProgress: 0, done: 0, merging: 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, workflowId: string): WorkflowStatusCounts { return counts.get(workflowId) ?? ZERO_COUNTS; } /** * FNXC:WorkflowSwitcher 2026-06-20-00:09: * The board/list workflow switcher must be a fully rendered themed dropdown rather than a native select so each workflow option can include compact inline Todo, In Progress, and Done counts. * The component owns only presentation and accessible dropdown behavior; all status-bucket semantics stay in computeWorkflowStatusCounts so Board and ListView cannot drift. * * FNXC:WorkflowSwitcher 2026-06-20-00:31: * Counts are contextual detail, so the collapsed trigger must stay visually and accessibly scoped to the active workflow name plus chevron. * Render Todo, In Progress, and Done counts only while the dropdown is expanded; option rows keep their count text because the listbox is the comparison surface. * * FNXC:WorkflowSwitcher 2026-06-20-15:34: * Workflow edit and creation affordances moved into the shared dropdown so Board and ListView cannot leave separate toolbar icon shells behind. * Each option row owns a sibling edit button, and New workflow remains visible in a non-scrolling footer while long workflow lists scroll. * * FNXC:WorkflowSwitcher 2026-06-21-00:00: * Opening the dropdown must refresh workflow count data because task-to-workflow assignments do not emit board-workflows invalidation events. * Fire onOpen only on closed-to-open transitions so consumers can refetch without close-time calls or render loops. */ export function WorkflowSwitcher({ workflows, value, onChange, counts, onOpen, label: labelProp, onEditWorkflow, onCreateWorkflow }: WorkflowSwitcherProps) { const { t } = useTranslation("app"); const label = labelProp ?? t("workflowSwitcher.label", "Workflow"); const todoLabel = t("workflowSwitcher.todo", "Todo"); const inProgressLabel = t("workflowSwitcher.inProgress", "In Progress"); const doneLabel = t("workflowSwitcher.done", "Done"); const mergingLabel = t("workflowSwitcher.merging", "Merging"); const editWorkflowLabel = t("workflowSwitcher.editWorkflow", "Edit workflow"); const newWorkflowLabel = t("workflowSwitcher.newWorkflow", "New workflow"); const listboxId = useId(); const [isOpen, setIsOpen] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(0); const [dropdownPosition, setDropdownPosition] = useState(null); const [portalRoot, setPortalRoot] = useState(null); const containerRef = useRef(null); const triggerRef = useRef(null); const dropdownRef = useRef(null); const listRef = useRef(null); const measurementCanvasRef = useRef(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; const rect = trigger.getBoundingClientRect(); const viewportWidth = window.visualViewport?.width ?? window.innerWidth; const viewportHeight = window.visualViewport?.height ?? window.innerHeight; const offsetTop = window.visualViewport?.offsetTop ?? 0; const offsetLeft = window.visualViewport?.offsetLeft ?? 0; const horizontalPadding = DEFAULT_MENU_HORIZONTAL_PADDING; const verticalPadding = 16; const gap = 4; const preferredHeight = Math.min(viewportHeight * 0.6, 320); const triggerTop = rect.top - offsetTop; const triggerBottom = rect.bottom - offsetTop; const triggerLeft = rect.left - offsetLeft; const spaceBelow = viewportHeight - triggerBottom; const spaceAbove = triggerTop; 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 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; }, [onOpen]); useEffect(() => { setPortalRoot(document.body); }, []); useEffect(() => { if (!isOpen) return; setHighlightedIndex(selectedIndex); updateDropdownPosition(); }, [isOpen, selectedIndex, updateDropdownPosition]); useEffect(() => { if (!isOpen) return; const handleReposition = () => updateDropdownPosition(); window.addEventListener("resize", handleReposition); window.addEventListener("scroll", handleReposition, true); const visualViewport = window.visualViewport; visualViewport?.addEventListener("resize", handleReposition); visualViewport?.addEventListener("scroll", handleReposition); return () => { window.removeEventListener("resize", handleReposition); window.removeEventListener("scroll", handleReposition, true); visualViewport?.removeEventListener("resize", handleReposition); visualViewport?.removeEventListener("scroll", handleReposition); }; }, [isOpen, updateDropdownPosition]); useEffect(() => { if (!isOpen) return; const handlePointerDown = (event: MouseEvent) => { const target = event.target as Node; if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) return; setIsOpen(false); }; document.addEventListener("mousedown", handlePointerDown); return () => document.removeEventListener("mousedown", handlePointerDown); }, [isOpen]); useEffect(() => { if (!isOpen || !listRef.current) return; const highlightedElement = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`); if (highlightedElement && typeof highlightedElement.scrollIntoView === "function") { highlightedElement.scrollIntoView({ block: "nearest" }); } }, [highlightedIndex, isOpen]); const openDropdown = useCallback(() => { if (isOpen) return; onOpenRef.current?.(); setIsOpen(true); }, [isOpen]); const toggleDropdown = useCallback(() => { if (isOpen) { setIsOpen(false); return; } onOpenRef.current?.(); setIsOpen(true); }, [isOpen]); const selectWorkflow = useCallback((workflowId: string) => { onChange(workflowId); setIsOpen(false); triggerRef.current?.focus(); }, [onChange]); const handleEditWorkflow = useCallback((workflowId: string) => { if (!onEditWorkflow) return; setIsOpen(false); onEditWorkflow(workflowId); }, [onEditWorkflow]); const handleCreateWorkflow = useCallback(() => { if (!onCreateWorkflow) return; setIsOpen(false); onCreateWorkflow(); }, [onCreateWorkflow]); const handleKeyDown = useCallback((event: KeyboardEvent) => { switch (event.key) { case "ArrowDown": event.preventDefault(); if (!isOpen) { openDropdown(); } else { setHighlightedIndex((current) => (workflows.length ? (current + 1) % workflows.length : 0)); } break; case "ArrowUp": event.preventDefault(); if (!isOpen) { openDropdown(); } else { setHighlightedIndex((current) => (workflows.length ? (current - 1 + workflows.length) % workflows.length : 0)); } break; case "Enter": case " ": event.preventDefault(); if (isOpen) { const workflow = workflows[highlightedIndex]; if (workflow) selectWorkflow(workflow.id); } else { openDropdown(); } break; case "Escape": event.preventDefault(); setIsOpen(false); break; case "Tab": setIsOpen(false); break; } }, [highlightedIndex, isOpen, openDropdown, selectWorkflow, workflows]); if (!selectedWorkflow) return null; const renderCountBadges = (workflowCounts: WorkflowStatusCounts, variant: "trigger" | "option") => (