/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * GanttTaskTree — left pane showing the hierarchical task list with * expand/collapse chevrons, milestone diamond markers, and duration. */ import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react'; import { ChevronRight, ChevronDown, Diamond, CircleDot, Flag, GripVertical } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { FlattenedTask } from './schedule-utils'; import { formatDurationShort } from './schedule-utils'; export const GANTT_ROW_HEIGHT = 28; /** * Height of the sticky column-header row. MUST match the timeline's tick * header so scroll-sync between the two panes lands on the same row — * otherwise the highlight band / task bars drift by one row. */ export const GANTT_HEADER_HEIGHT = 28; interface GanttTaskTreeProps { rows: FlattenedTask[]; selectedGlobalIds: Set; hoveredGlobalId: string | null; onToggleExpand: (globalId: string) => void; onSelect: (globalId: string, multi: boolean) => void; /** Click on empty-space below the rows clears the selection. */ onBackgroundClick?: () => void; /** User finished a drag — move the source row to the index of the target row. */ onReorder?: (sourceGlobalId: string, targetIndex: number) => void; onHover: (globalId: string | null) => void; scrollTop: number; onScroll: (scrollTop: number) => void; } export const GanttTaskTree = memo(function GanttTaskTree({ rows, selectedGlobalIds, hoveredGlobalId, onToggleExpand, onSelect, onBackgroundClick, onReorder, onHover, scrollTop, onScroll, }: GanttTaskTreeProps) { // Drag-to-reorder state. Uses native HTML5 drag-and-drop for // accessibility (screen-readers can speak the cursor transitions) // and cross-browser reliability. `dropIndex` drives the horizontal // drop-indicator line between rows. const dragSourceRef = useRef(null); const [dropIndex, setDropIndex] = useState(null); const containerRef = useRef(null); const handleScroll = useCallback((e: React.UIEvent) => { onScroll(e.currentTarget.scrollTop); }, [onScroll]); // Sync externally-controlled scrollTop (e.g. timeline → task tree alignment). useLayoutEffect(() => { const el = containerRef.current; if (!el) return; if (el.scrollTop !== scrollTop) { el.scrollTop = scrollTop; } }, [scrollTop]); /** * Click on the scroll container itself (not a row/cell/button) clears * the Gantt selection. Uses `e.currentTarget === e.target` so clicks * that bubble up from a row don't also fire deselect. */ const handleContainerClick = useCallback((e: React.MouseEvent) => { if (e.currentTarget === e.target) onBackgroundClick?.(); }, [onBackgroundClick]); return (
{/* Sticky column header — mirrors the timeline's tick header so both scroll containers have identical content layouts and `scrollTop` sync lands on the same row. */}
Task Duration
{/* ARIA grid semantics: the table is a grid, each keeps its native/`row` role (so `aria-selected` is valid), and the focusable primary cell carries `tabIndex` + keyboard handlers. This keeps the chevron
); });