Skip to content

Commit 60673c7

Browse files
committed
#4009 Add rendering conditions and switch Collapse to modern auto-friendly css
1 parent fa87568 commit 60673c7

3 files changed

Lines changed: 148 additions & 41 deletions

File tree

src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx

Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { eachDayOfInterval, isMonday, differenceInDays } from 'date-fns';
1212
import { getMonday } from '../../../utils/datetime.utils';
1313
import { toDateString } from 'shared';
1414
import { GANTT_CHART_CELL_SIZE, GANTT_CHART_GAP_SIZE } from '../../../utils/gantt.utils';
15+
import { useRef, useCallback, useEffect } from 'react';
16+
1517
export interface GanttEditability<E, T> {
1618
highlightTaskComparator: HighlightTaskComparator<T>;
1719
highlightSubtaskComparator: HighlightTaskComparator<T>;
@@ -38,38 +40,103 @@ const GanttChart = <E, T>({ startDate, endDate, collections, editability }: Gant
3840

3941
const today = new Date(new Date().setHours(0, 0, 0, 0));
4042
const currentWeekCol = days.findIndex((day) => toDateString(day) === toDateString(getMonday(today))) + 1;
41-
4243
const daysIntoWeek = differenceInDays(today, getMonday(today));
4344
const dailyOffset = daysIntoWeek * (parseFloat(GANTT_CHART_CELL_SIZE) / 7);
4445

46+
const scrollContainerRef = useRef<HTMLDivElement>(null);
47+
48+
const validCollections = collections.filter((c) => c.tasks);
49+
50+
const sectionDataRef = useRef<
51+
{ sectionEl: HTMLDivElement | null; placeholderEl: HTMLDivElement | null; height: number }[]
52+
>([]);
53+
54+
if (sectionDataRef.current.length !== validCollections.length) {
55+
sectionDataRef.current = validCollections.map((_, i) => ({
56+
sectionEl: sectionDataRef.current[i]?.sectionEl ?? null,
57+
placeholderEl: sectionDataRef.current[i]?.placeholderEl ?? null,
58+
height: sectionDataRef.current[i]?.height ?? 0
59+
}));
60+
}
61+
62+
const updateVisibility = useCallback(() => {
63+
const container = scrollContainerRef.current;
64+
if (!container) return;
65+
66+
const containerRect = container.getBoundingClientRect();
67+
const viewportBottom = containerRect.bottom;
68+
69+
for (const data of sectionDataRef.current) {
70+
const { sectionEl, placeholderEl } = data;
71+
if (!sectionEl || !placeholderEl) continue;
72+
73+
const el = sectionEl.style.display === 'none' ? placeholderEl : sectionEl;
74+
const elTop = el.getBoundingClientRect().top;
75+
const measuredHeight = sectionEl.offsetHeight || placeholderEl.offsetHeight;
76+
77+
const isVisible = elTop <= viewportBottom;
78+
79+
sectionEl.style.display = isVisible ? '' : 'none';
80+
placeholderEl.style.display = isVisible ? 'none' : '';
81+
placeholderEl.style.height = `${measuredHeight}px`;
82+
}
83+
}, []);
84+
85+
useEffect(() => {
86+
const container = scrollContainerRef.current;
87+
if (!container) return;
88+
89+
container.addEventListener('scroll', updateVisibility, { passive: true });
90+
// Also re-check on container resize
91+
const ro = new ResizeObserver(updateVisibility);
92+
ro.observe(container);
93+
updateVisibility(); // initial pass
94+
95+
return () => {
96+
container.removeEventListener('scroll', updateVisibility);
97+
ro.disconnect();
98+
};
99+
}, [updateVisibility]);
100+
45101
return (
46102
<Box
103+
ref={scrollContainerRef}
47104
sx={{
48105
width: '100%',
49-
height: { xs: 'calc(100vh - 9.5rem )', md: 'calc(100vh - 6.25rem)' },
106+
height: { xs: 'calc(100vh - 9.5rem)', md: 'calc(100vh - 6.25rem)' },
50107
overflow: 'scroll',
51108
position: 'relative',
52-
'&::-webkit-scrollbar': {
53-
display: 'none'
54-
},
55-
scrollbarWidth: 'none', // Firefox
56-
msOverflowStyle: 'none' // IE and Edge
109+
'&::-webkit-scrollbar': { display: 'none' },
110+
scrollbarWidth: 'none',
111+
msOverflowStyle: 'none'
57112
}}
58113
>
59114
<GanttChartTimeline start={startDate} end={endDate} />
60115
<Box sx={{ position: 'relative' }}>
61-
{collections.map((collection) => {
62-
return collection.tasks ? (
63-
<GanttChartCollectionSection
64-
startDate={startDate}
65-
endDate={endDate}
66-
collection={collection}
67-
editability={editability}
116+
{validCollections.map((collection, idx) => (
117+
<Box key={idx}>
118+
{/* Real */}
119+
<Box
120+
ref={(el: HTMLDivElement | null) => {
121+
if (sectionDataRef.current[idx]) sectionDataRef.current[idx].sectionEl = el;
122+
}}
123+
>
124+
<GanttChartCollectionSection
125+
startDate={startDate}
126+
endDate={endDate}
127+
collection={collection}
128+
editability={editability}
129+
/>
130+
</Box>
131+
{/* Placeholder */}
132+
<Box
133+
ref={(el: HTMLDivElement | null) => {
134+
if (sectionDataRef.current[idx]) sectionDataRef.current[idx].placeholderEl = el;
135+
}}
136+
style={{ display: 'none', height: 0 }}
68137
/>
69-
) : (
70-
<></>
71-
);
72-
})}
138+
</Box>
139+
))}
73140

74141
{currentWeekCol > 0 && (
75142
<Box

src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,27 @@ import { Edit } from '@mui/icons-material';
22
import { Box, Chip, IconButton, Typography, useTheme } from '@mui/material';
33
import GanttChartSection from './GanttChartSection';
44
import { GanttCollection } from '../../../utils/gantt.utils';
5-
import { useState } from 'react';
5+
import { useEffect, useRef, useState } from 'react';
66
import { GanttEditability } from './GanttChart';
77

88
interface GanttChartCollectionSectionProps<E, T> {
99
startDate: Date;
1010
endDate: Date;
1111
collection: GanttCollection<E, T>;
1212
editability?: GanttEditability<E, T>;
13+
onHeightChange?: (height: number) => void;
1314
}
1415

1516
const GanttChartCollectionSection = <E, T>({
1617
startDate,
1718
endDate,
1819
collection,
19-
editability
20+
editability,
21+
onHeightChange
2022
}: GanttChartCollectionSectionProps<E, T>) => {
2123
const theme = useTheme();
2224
const [isEditMode, setIsEditMode] = useState(false);
25+
const sectionRef = useRef<HTMLDivElement>(null);
2326

2427
const collectionSectionBackgroundStyle = {
2528
mt: 1,
@@ -61,8 +64,21 @@ const GanttChartCollectionSection = <E, T>({
6164

6265
const ignoreBool = () => false;
6366

67+
useEffect(() => {
68+
const el = sectionRef.current;
69+
if (!el || !onHeightChange) return;
70+
71+
const ro = new ResizeObserver(() => {
72+
onHeightChange(el.getBoundingClientRect().height);
73+
});
74+
ro.observe(el);
75+
// Fire once immediately so the parent has a height before the first scroll
76+
onHeightChange(el.getBoundingClientRect().height);
77+
return () => ro.disconnect();
78+
}, [onHeightChange]);
79+
6480
return (
65-
<Box sx={collectionSectionBackgroundStyle}>
81+
<Box ref={sectionRef} sx={collectionSectionBackgroundStyle}>
6682
<Box sx={collectionDescriptionContainerStyle}>
6783
<Typography variant="h6" fontWeight={400}>
6884
{collection.title}

src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import {
44
HighlightTaskComparator,
55
OnMouseOverOptions
66
} from '../../../../../utils/gantt.utils';
7-
import { Collapse } from '@mui/material';
7+
import { Box } from '@mui/material';
88
import GanttTaskBar from './GanttTaskBar';
99
import GanttTaskBarDisplay from './GanttTaskBarDisplay';
10-
import { useState } from 'react';
10+
import { useEffect, useRef, useState } from 'react';
1111

1212
interface GanttTaskBarViewProps<T> {
1313
days: Date[];
@@ -37,11 +37,19 @@ const GanttTaskBarView = <T,>({
3737
onToggle
3838
}: GanttTaskBarViewProps<T>) => {
3939
const [showChildren, setShowChildren] = useState(false);
40+
const animationRef = useRef<number | null>(null);
4041

4142
const handleToggle = () => {
4243
setShowChildren((prev) => !prev);
4344
};
4445

46+
// Fire onToggle after the grid animation completes (200ms matches transition below)
47+
useEffect(() => {
48+
if (animationRef.current) cancelAnimationFrame(animationRef.current);
49+
const timeout = setTimeout(() => onToggle?.(), 200);
50+
return () => clearTimeout(timeout);
51+
}, [showChildren, onToggle]);
52+
4553
return (
4654
<>
4755
<GanttTaskBarDisplay
@@ -58,24 +66,40 @@ const GanttTaskBarView = <T,>({
5866
highlightTaskComparator={highlightTaskComparator}
5967
/>
6068

61-
<Collapse in={showChildren} unmountOnExit onEntered={onToggle} onExited={onToggle}>
62-
{task.children.map((child) => (
63-
<GanttTaskBar
64-
key={child.id}
65-
days={days}
66-
task={child}
67-
isEditMode={false}
68-
createChange={() => {}}
69-
handleOnMouseOver={handleOnMouseOver}
70-
handleOnMouseLeave={handleOnMouseLeave}
71-
highlightedChange={highlightedChange}
72-
onAddTaskPressed={onAddTaskPressed}
73-
highlightSubtaskComparator={highlightSubtaskComparator}
74-
highlightTaskComparator={highlightTaskComparator}
75-
onToggle={onToggle}
76-
/>
77-
))}
78-
</Collapse>
69+
{/*
70+
The grid trick: animate grid-template-rows from 0fr to 1fr.
71+
The inner div needs to be a single grid child — its natural height
72+
determines the expanded size, so no explicit height is ever needed.
73+
This never triggers layout reflow unlike height/max-height transitions.
74+
*/}
75+
<Box
76+
sx={{
77+
display: 'grid',
78+
gridTemplateRows: showChildren ? '1fr' : '0fr',
79+
transition: 'grid-template-rows 200ms ease',
80+
overflow: 'hidden'
81+
}}
82+
>
83+
{/* This inner div must have no min-height so it can collapse to 0 */}
84+
<Box sx={{ minHeight: 0 }}>
85+
{task.children.map((child) => (
86+
<GanttTaskBar
87+
key={child.id}
88+
days={days}
89+
task={child}
90+
isEditMode={false}
91+
createChange={() => {}}
92+
handleOnMouseOver={handleOnMouseOver}
93+
handleOnMouseLeave={handleOnMouseLeave}
94+
highlightedChange={highlightedChange}
95+
onAddTaskPressed={onAddTaskPressed}
96+
highlightSubtaskComparator={highlightSubtaskComparator}
97+
highlightTaskComparator={highlightTaskComparator}
98+
onToggle={onToggle}
99+
/>
100+
))}
101+
</Box>
102+
</Box>
79103
</>
80104
);
81105
};

0 commit comments

Comments
 (0)