Skip to content

Commit 067acc0

Browse files
authored
Merge pull request #748 from pennlabs/feature/stick-header2.0
recreate sticky headers changes
2 parents cc9f114 + cf3f2dd commit 067acc0

File tree

2 files changed

+144
-48
lines changed

2 files changed

+144
-48
lines changed

frontend/degree-plan/components/Requirements/ReqPanel.tsx

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
import { useMemo, useState } from 'react';
2+
import { useMemo, useState, useContext, useRef, useLayoutEffect } from 'react';
33
import RuleComponent, { SkeletonRule } from './Rule';
44
import { Degree as DegreeType, DegreePlan, Fulfillment, Rule, Degree as DegreeD } from '@/types';
55
import styled from '@emotion/styled';
@@ -44,6 +44,9 @@ const DegreeHeaderContainer = styled.div`
4444
color: #FFF;
4545
padding: 0.75rem 1.25rem;
4646
border-radius: var(--req-item-radius);
47+
position: sticky;
48+
top: 0;
49+
z-index: 1000;
4750
`
4851

4952
const ReqPanelTitle = styled.div`
@@ -52,9 +55,7 @@ const ReqPanelTitle = styled.div`
5255
`
5356

5457
const DegreeBody = styled.div`
55-
margin-top: .5rem;
56-
overflow-y: auto;
57-
overflow-x: hidden;
58+
overflow-y: none;
5859
`
5960

6061
export const DegreeYear = styled.span`
@@ -84,6 +85,27 @@ const AddButton = styled.div`
8485
const ReqPanelBody = styled(PanelBody)`
8586
overflow-y: scroll;
8687
padding: 1.5rem;
88+
gap: 0;
89+
`
90+
91+
// pr, mr for scroll bar
92+
const ReqContent = styled.div`
93+
padding: 0;
94+
padding-right: 12px;
95+
margin-right: -12px;
96+
display: flex;
97+
flex-direction: column;
98+
gap: .5rem;
99+
overflow-y: scroll;
100+
`
101+
102+
export const HEADER_DEFAULT_BUFFER = 8;
103+
export const WhiteSpace = styled.div<{ $headerHeight: number, $zIndex: number }>`
104+
height: ${HEADER_DEFAULT_BUFFER}px;
105+
background-color: white;
106+
z-index: ${(props) => props.$zIndex || 500};
107+
position: sticky;
108+
top: ${(props) => props.$headerHeight}px;
87109
`
88110

89111
interface DegreeHeaderProps {
@@ -93,12 +115,21 @@ interface DegreeHeaderProps {
93115
collapsed: boolean,
94116
editMode: boolean,
95117
skeleton?: boolean,
118+
containerRef?: React.Ref<HTMLDivElement>
96119
}
97120

98-
const DegreeHeader = ({ degree, remove, setCollapsed, collapsed, editMode, skeleton }: DegreeHeaderProps) => {
121+
const DegreeHeader = ({
122+
degree,
123+
remove,
124+
setCollapsed,
125+
collapsed,
126+
editMode,
127+
skeleton,
128+
containerRef
129+
}: DegreeHeaderProps) => {
99130
const degreeName = !skeleton ? `${degree.degree} in ${degree.major_name} ${degree.concentration ? `(${degree.concentration_name})` : ''}` : <DarkBlueBackgroundSkeleton width="10em" />;
100131
return (
101-
<DegreeHeaderContainer onClick={() => setCollapsed(!collapsed)}>
132+
<DegreeHeaderContainer ref={containerRef} onClick={() => setCollapsed(!collapsed)}>
102133
<DegreeTitleWrapper>
103134
<div>
104135
{degreeName}
@@ -167,8 +198,37 @@ const computeRuleTree = ({activeDegreePlanId, rule, rulesToFulfillments, rulesTo
167198
}
168199

169200

170-
const Degree = ({ allRuleLeaves, degree, rulesToFulfillments, rulesToUnselectedFulfillments, activeDegreeplan, editMode, setModalKey, setModalObject, isLoading }: any) => {
201+
const Degree = ({
202+
allRuleLeaves,
203+
degree,
204+
rulesToFulfillments,
205+
rulesToUnselectedFulfillments,
206+
activeDegreeplan,
207+
editMode,
208+
setModalKey,
209+
setModalObject,
210+
isLoading
211+
}: any) => {
171212
const [collapsed, setCollapsed] = useState(false);
213+
214+
const headerRef = useRef<HTMLDivElement>(null);
215+
const [headerHeight, setHeaderHeight] = useState<number>(0);
216+
217+
useLayoutEffect(() => {
218+
if (!headerRef.current) return;
219+
220+
const resizeObserver = new ResizeObserver((entries) => {
221+
for (const entry of entries) {
222+
setHeaderHeight(entry.target.clientHeight);
223+
}
224+
});
225+
resizeObserver.observe(headerRef.current);
226+
227+
return () => {
228+
resizeObserver.disconnect();
229+
}
230+
}, []);
231+
172232
if (isLoading) {
173233
return (
174234
<div>
@@ -206,6 +266,7 @@ const Degree = ({ allRuleLeaves, degree, rulesToFulfillments, rulesToUnselectedF
206266
return (
207267
<div>
208268
<DegreeHeader
269+
containerRef={headerRef}
209270
degree={degree}
210271
key={degree.id}
211272
remove={() => {
@@ -217,16 +278,22 @@ const Degree = ({ allRuleLeaves, degree, rulesToFulfillments, rulesToUnselectedF
217278
editMode={editMode}
218279
skeleton={false}
219280
/>
220-
{!collapsed && !editMode &&
221-
<DegreeBody>
222-
{degree && degree.rules.map((rule: any) => {
223-
return (
224-
<RuleComponent
225-
{...computeRuleTree({activeDegreePlanId: activeDegreeplan.id, rule, rulesToFulfillments, rulesToUnselectedFulfillments, degree })}
226-
/>
227-
)}
228-
)}
229-
</DegreeBody>}
281+
<WhiteSpace $headerHeight={headerHeight} $zIndex={999} />
282+
{!collapsed && !editMode &&
283+
<>
284+
<DegreeBody>
285+
{degree && degree.rules.map((rule: any) => {
286+
return (
287+
<RuleComponent
288+
headerHeight={headerHeight}
289+
zIndex={999}
290+
{...computeRuleTree({activeDegreePlanId: activeDegreeplan.id, rule, rulesToFulfillments, rulesToUnselectedFulfillments, degree })}
291+
/>
292+
)}
293+
)}
294+
</DegreeBody>
295+
</>
296+
}
230297
</div>
231298
)
232299
}
@@ -287,33 +354,36 @@ const ReqPanel = ({ setModalKey, setModalObject, activeDegreeplan, isLoading }:
287354
<>
288355
{activeDegreeplanDetail &&
289356
<ReqPanelBody>
290-
{activeDegreeplanDetail.degrees.length == 0 && !editMode && <EmptyPanel />}
291-
{activeDegreeplanDetail.degrees.map(degree => (
292-
<Degree
293-
allRuleLeaves={allRuleLeaves}
294-
degree={degree}
295-
rulesToFulfillments={rulesToFulfillments}
296-
rulesToUnselectedFulfillments={rulesToUnselectedFulfillments}
297-
activeDegreeplan={activeDegreeplan}
298-
editMode={editMode}
299-
setModalKey={setModalKey}
300-
setModalObject={setModalObject}
301-
isLoading={isLoading}
302-
/>
303-
))}
304-
{editMode && <AddButton role="button" onClick={() => {
305-
setModalObject(activeDegreeplan);
306-
setModalKey("degree-add");
307-
}}>
308-
<i className="fa fa-plus" />
309-
<div>
310-
Add Degree
311-
</div>
312-
</AddButton>}
357+
<ReqContent>
358+
{activeDegreeplanDetail.degrees.length == 0 && !editMode && <EmptyPanel />}
359+
{activeDegreeplanDetail.degrees.map(degree => (
360+
<Degree
361+
key={degree.id}
362+
allRuleLeaves={allRuleLeaves}
363+
degree={degree}
364+
rulesToFulfillments={rulesToFulfillments}
365+
rulesToUnselectedFulfillments={rulesToUnselectedFulfillments}
366+
activeDegreeplan={activeDegreeplan}
367+
editMode={editMode}
368+
setModalKey={setModalKey}
369+
setModalObject={setModalObject}
370+
isLoading={isLoading}
371+
/>
372+
))}
373+
{editMode && <AddButton role="button" onClick={() => {
374+
setModalObject(activeDegreeplan);
375+
setModalKey("degree-add");
376+
}}>
377+
<i className="fa fa-plus" />
378+
<div>
379+
Add Degree
380+
</div>
381+
</AddButton>}
382+
</ReqContent>
313383
</ReqPanelBody>
314384
}
315385
</>}
316386
</PanelContainer>
317387
);
318388
}
319-
export default ReqPanel;
389+
export default ReqPanel;

frontend/degree-plan/components/Requirements/Rule.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useContext, useState } from "react";
1+
import React, { useContext, useEffect, useState, useLayoutEffect, useRef } from "react";
22
import RuleLeaf, { SkeletonRuleLeaf } from "./QObject";
33
import { DnDCourse, Fulfillment } from "@/types";
44
import styled from "@emotion/styled";
@@ -7,15 +7,17 @@ import { useSWRCrud } from "@/hooks/swrcrud";
77
import { useDrop } from "react-dnd";
88
import { ItemTypes } from "../Dock/dnd/constants";
99
import { DarkBlueBackgroundSkeleton } from "../FourYearPlan/PanelCommon";
10-
import { DegreeYear, RuleTree } from "./ReqPanel";
10+
import { DegreeYear, RuleTree, WhiteSpace, HEADER_DEFAULT_BUFFER } from "./ReqPanel";
1111
import SatisfiedCheck from "../FourYearPlan/SatisfiedCheck";
1212
import { ExpandedCoursesPanelContext } from "@/components/ExpandedBox/ExpandedCoursesPanelTrigger";
1313
import { parseQJson } from "./ruleUtils";
1414

15-
const RuleTitleWrapper = styled.div`
15+
const RuleTitleWrapper = styled.div<{ $headerHeight?: number, $zIndex?: number }>`
1616
background-color: var(--primary-color);
17-
position: relative;
17+
position: sticky;
1818
border-radius: var(--req-item-radius);
19+
top: ${(props) => (props.$headerHeight || 0) + HEADER_DEFAULT_BUFFER}px;
20+
z-index: ${(props) => props.$zIndex || 995};
1921
`;
2022

2123
const ProgressBar = styled.div<{ $progress: number }>`
@@ -152,11 +154,34 @@ export const SkeletonRule: React.FC<React.PropsWithChildren> = ({
152154
/**
153155
* Recursive component to represent a rule.
154156
*/
155-
const RuleComponent = (ruleTree: RuleTree) => {
157+
const RuleComponent = (ruleTree: RuleTree & { headerHeight?: number, zIndex?: number }) => {
156158
const { setCourses, courses } = useContext(ExpandedCoursesPanelContext);
157159
const { type, activeDegreePlanId, rule, progress } = ruleTree;
158160
const satisfied = progress === 1;
159161

162+
const headerHeight = ruleTree.headerHeight;
163+
const zIndex = ruleTree.zIndex || -1;
164+
165+
const myHeaderRef = useRef<HTMLDivElement>(null);
166+
const [myHeight, setMyHeight] = useState(0);
167+
168+
useLayoutEffect(() => {
169+
if (!myHeaderRef.current) return;
170+
171+
const resizeObserver = new ResizeObserver((entries) => {
172+
for (const entry of entries) {
173+
if (entry.target instanceof HTMLElement) {
174+
setMyHeight(entry.target.clientHeight);
175+
}
176+
}
177+
});
178+
resizeObserver.observe(myHeaderRef.current);
179+
180+
return () => {
181+
resizeObserver.disconnect();
182+
};
183+
}, [rule.id]);
184+
160185
// state for INTERNAL_NODEs
161186
const [collapsed, setCollapsed] = useState(false);
162187

@@ -322,7 +347,7 @@ const RuleComponent = (ruleTree: RuleTree) => {
322347

323348
return (
324349
<>
325-
<RuleTitleWrapper onClick={() => setCollapsed(!collapsed)}>
350+
<RuleTitleWrapper $headerHeight={headerHeight} $zIndex={zIndex} onClick={() => setCollapsed(!collapsed)} ref={myHeaderRef}>
326351
<ProgressBar $progress={progress}></ProgressBar>
327352
<RuleTitle>
328353
<div>
@@ -335,12 +360,13 @@ const RuleComponent = (ruleTree: RuleTree) => {
335360
)}
336361
</RuleTitle>
337362
</RuleTitleWrapper>
363+
<WhiteSpace $headerHeight={myHeight + (headerHeight ||0) + HEADER_DEFAULT_BUFFER} $zIndex={500} />
338364
{!collapsed && (
339365
<Indented>
340366
<Column>
341367
{children.map((ruleTree) => (
342368
<div>
343-
<RuleComponent {...ruleTree} />
369+
<RuleComponent headerHeight={myHeight + (headerHeight || 0) + HEADER_DEFAULT_BUFFER} zIndex={zIndex - 1} {...ruleTree} />
344370
</div>
345371
))}
346372
</Column>

0 commit comments

Comments
 (0)