fix(gantt): improve parent task bar styling and visual grouping

- Make parent task bars full height (32px) matching regular bars,
  instead of the thin 8px summary bar
- Move collapse/expand chevron to sit right before the bar start
  position so it moves with the task during drag/resize
- Remove fixed-position indent indicator lines
- Add light background band with rounded border to visually group
  parent tasks with their children, sized to fit the bars
This commit is contained in:
kolaente
2026-03-02 13:07:32 +01:00
parent 19d77157e2
commit 62bb4c33cf
2 changed files with 94 additions and 40 deletions

View File

@@ -32,6 +32,18 @@
>
<template #default="{ focusedRow, focusedCell }">
<div class="gantt-rows-container">
<!-- Group background bands for parent-child visual grouping -->
<div
v-for="(band, bandIndex) in parentGroupBands"
:key="`band-${bandIndex}`"
class="gantt-group-band"
:style="{
top: `${band.startIndex * ROW_HEIGHT}px`,
height: `${(band.endIndex - band.startIndex + 1) * ROW_HEIGHT}px`,
left: `${band.left}px`,
width: `${band.width}px`,
}"
/>
<div class="gantt-rows">
<GanttRow
v-for="(rowId, index) in ganttRows"
@@ -52,7 +64,6 @@
:focused-row="focusedRow ?? null"
:focused-cell="focusedCell"
:row-id="rowId"
:indent-level="ganttBars[index]?.[0]?.meta?.indentLevel ?? 0"
:is-parent="ganttBars[index]?.[0]?.meta?.isParent ?? false"
:is-collapsed="collapsedTaskIds.has(Number(ganttBars[index]?.[0]?.id))"
@barPointerDown="handleBarPointerDown"
@@ -393,6 +404,57 @@ const relationArrows = computed<GanttArrow[]>(() => {
const totalHeight = computed(() => ganttRows.value.length * ROW_HEIGHT)
// Compute parent-child group bands for visual grouping background
const GROUP_BAND_PADDING = 12
const parentGroupBands = computed(() => {
const bands: Array<{ startIndex: number; endIndex: number; left: number; width: number }> = []
const bars = ganttBars.value
const positions = barPositions.value
for (let i = 0; i < bars.length; i++) {
const bar = bars[i]?.[0]
if (!bar?.meta?.isParent) continue
const parentLevel = bar.meta.indentLevel ?? 0
let endIndex = i
// Find last consecutive child with deeper indent
for (let j = i + 1; j < bars.length; j++) {
const childBar = bars[j]?.[0]
const childLevel = childBar?.meta?.indentLevel ?? 0
if (childLevel <= parentLevel) break
endIndex = j
}
// Only create a band if there are actual children visible
if (endIndex > i) {
// Compute horizontal extent from bar positions
let minX = Infinity
let maxX = -Infinity
for (let k = i; k <= endIndex; k++) {
const taskId = Number(bars[k]?.[0]?.id)
const pos = positions.get(taskId)
if (!pos) continue
minX = Math.min(minX, pos.x)
maxX = Math.max(maxX, pos.x + pos.width)
}
if (minX < Infinity) {
bands.push({
startIndex: i,
endIndex,
left: minX - GROUP_BAND_PADDING,
width: maxX - minX + GROUP_BAND_PADDING * 2,
})
}
}
}
return bands
})
function updateGanttTask(id: string, newStart: Date, newEnd: Date) {
const task = tasks.value.get(Number(id))
if (!task) return
@@ -701,6 +763,15 @@ onUnmounted(() => {
position: relative;
}
.gantt-group-band {
position: absolute;
background: hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.06);
border: 1px solid hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.12);
border-radius: 6px;
pointer-events: none;
z-index: 1;
}
.gantt-rows {
position: relative;
z-index: 2;

View File

@@ -8,24 +8,11 @@
:aria-label="$t('project.gantt.taskBarsForRow', { rowId })"
:data-row-id="rowId"
>
<!-- Indent indicator: thin vertical lines for nesting depth -->
<line
v-for="level in indentLevel"
:key="`indent-${level}`"
:x1="(level - 1) * 16 + 8"
:y1="0"
:x2="(level - 1) * 16 + 8"
:y2="40"
stroke="var(--grey-300)"
stroke-width="1"
class="gantt-indent-line"
/>
<!-- Collapse/expand chevron for parent tasks -->
<!-- Collapse/expand chevron for parent tasks positioned just before the bar -->
<g
v-if="isParent"
v-if="isParent && bars[0]"
class="gantt-collapse-toggle"
:transform="`translate(${indentLevel * 16 + 2}, 12)`"
:transform="`translate(${getBarX(bars[0]) - 18}, 12)`"
role="button"
:aria-label="isCollapsed
? $t('project.gantt.expandGroup', { task: bars[0]?.meta?.label || '' })
@@ -117,7 +104,7 @@
@pointerdown="handleBarPointerDown(bar, $event)"
/>
<!-- Parent summary bar (thinner with diamond endpoints) -->
<!-- Parent summary bar (full height with diamond endpoints) -->
<g
v-if="bar.meta?.isParent"
class="gantt-bar gantt-parent-bar"
@@ -126,12 +113,12 @@
:aria-pressed="isRowFocused"
@pointerdown="handleBarPointerDown(bar, $event)"
>
<!-- Thin horizontal bar -->
<rect
:x="getBarX(bar) + 6"
:y="16"
:width="Math.max(0, getBarWidth(bar) - 12)"
:height="8"
:x="getBarX(bar)"
:y="4"
:width="getBarWidth(bar)"
:height="32"
:rx="4"
:fill="getBarFillAttr(bar)"
:opacity="bar.meta?.isDone ? 0.5 : 1"
:stroke="getBarStroke(bar)"
@@ -141,18 +128,14 @@
<!-- Left diamond -->
<polygon
:points="getLeftDiamondPoints(bar)"
:fill="getBarFillAttr(bar)"
:fill="getParentDiamondFill(bar)"
:opacity="bar.meta?.isDone ? 0.5 : 1"
:stroke="getBarStroke(bar)"
:stroke-width="getBarStrokeWidth(bar)"
/>
<!-- Right diamond -->
<polygon
:points="getRightDiamondPoints(bar)"
:fill="getBarFillAttr(bar)"
:fill="getParentDiamondFill(bar)"
:opacity="bar.meta?.isDone ? 0.5 : 1"
:stroke="getBarStroke(bar)"
:stroke-width="getBarStrokeWidth(bar)"
/>
</g>
@@ -249,7 +232,6 @@ const props = defineProps<{
focusedRow: string | null
focusedCell: number | null
rowId: string
indentLevel: number
isParent: boolean
isCollapsed: boolean
}>()
@@ -334,17 +316,15 @@ const getBarTextX = computed(() => (bar: GanttBarModel) => {
}
// When the bar starts before the visible range, clamp text to the left edge
// so the title remains visible within the visible portion of the bar.
// For parent bars, offset by the chevron width
const baseOffset = bar.meta?.isParent ? 24 : 8
return Math.max(getBarX.value(bar) + baseOffset, 8)
return Math.max(getBarX.value(bar) + 8, 8)
})
// Diamond endpoint helpers for parent summary bars
const DIAMOND_SIZE = 6
const DIAMOND_SIZE = 5
function getLeftDiamondPoints(bar: GanttBarModel): string {
const x = getBarX.value(bar)
const cy = 20 // vertical center of the thin bar
const cy = 20 // vertical center of the bar
return `${x},${cy} ${x + DIAMOND_SIZE},${cy - DIAMOND_SIZE} ${x + DIAMOND_SIZE * 2},${cy} ${x + DIAMOND_SIZE},${cy + DIAMOND_SIZE}`
}
@@ -354,6 +334,14 @@ function getRightDiamondPoints(bar: GanttBarModel): string {
return `${x - DIAMOND_SIZE * 2},${cy} ${x - DIAMOND_SIZE},${cy - DIAMOND_SIZE} ${x},${cy} ${x - DIAMOND_SIZE},${cy + DIAMOND_SIZE}`
}
function getParentDiamondFill(bar: GanttBarModel): string {
// Use a darker shade for contrast on the full-height bar
if (bar.meta?.color) {
return 'var(--white)'
}
return 'var(--white)'
}
function isPartialDate(bar: GanttBarModel) {
return bar.meta?.dateType === 'startOnly' || bar.meta?.dateType === 'endOnly'
}
@@ -469,11 +457,6 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
}
}
.gantt-indent-line {
pointer-events: none;
opacity: 0.5;
}
.gantt-collapse-toggle {
pointer-events: all;
cursor: pointer;