Skip to content

Commit 0ab4891

Browse files
author
mrleemurray
committed
Implement sticky header for PR overview and add related tests
1 parent e810f7f commit 0ab4891

File tree

3 files changed

+139
-2
lines changed

3 files changed

+139
-2
lines changed

webviews/editorWebview/index.css

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,91 @@ textarea:focus,
4343
outline: 1px solid var(--vscode-focusBorder);
4444
}
4545

46+
/* Sticky Header */
47+
48+
.sticky-header {
49+
position: fixed;
50+
top: 0;
51+
left: 0;
52+
right: 0;
53+
z-index: 200;
54+
display: flex;
55+
align-items: center;
56+
justify-content: space-between;
57+
padding: 8px 32px;
58+
background: var(--vscode-editor-background);
59+
border-bottom: 1px solid var(--vscode-editorWidget-border);
60+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
61+
transform: translateY(-100%);
62+
opacity: 0;
63+
transition: transform 0.2s ease, opacity 0.2s ease;
64+
pointer-events: none;
65+
}
66+
67+
.sticky-header.visible {
68+
transform: translateY(0);
69+
opacity: 1;
70+
pointer-events: auto;
71+
}
72+
73+
.sticky-header-left {
74+
display: flex;
75+
align-items: center;
76+
gap: 8px;
77+
min-width: 0;
78+
}
79+
80+
.sticky-header-left #sticky-status {
81+
flex-shrink: 0;
82+
box-sizing: border-box;
83+
line-height: 18px;
84+
color: var(--vscode-button-foreground);
85+
border-radius: 18px;
86+
padding: 2px 10px;
87+
font-weight: 600;
88+
font-size: 12px;
89+
display: flex;
90+
gap: 4px;
91+
align-items: center;
92+
}
93+
94+
.sticky-header-left #sticky-status svg path {
95+
fill: var(--vscode-button-foreground);
96+
}
97+
98+
.sticky-header-title {
99+
font-weight: 600;
100+
white-space: nowrap;
101+
overflow: hidden;
102+
text-overflow: ellipsis;
103+
min-width: 0;
104+
}
105+
106+
.sticky-header-number {
107+
flex-shrink: 0;
108+
color: var(--vscode-textLink-foreground);
109+
white-space: nowrap;
110+
}
111+
112+
.sticky-header-number:hover {
113+
color: var(--vscode-textLink-activeForeground);
114+
}
115+
116+
.sticky-header .icon-button {
117+
color: var(--vscode-foreground);
118+
}
119+
120+
.sticky-header .icon-button:hover,
121+
.sticky-header .icon-button:focus {
122+
background-color: var(--vscode-toolbar-hoverBackground);
123+
cursor: pointer;
124+
}
125+
126+
.sticky-header .icon-button:focus {
127+
outline: 1px solid var(--vscode-focusBorder);
128+
outline-offset: 1px;
129+
}
130+
46131
.title-text {
47132
margin-right: 5px;
48133
}

webviews/editorWebview/overview.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
import * as React from 'react';
77
import { PullRequest } from '../../src/github/views';
88

9+
import PullRequestContext from '../common/context';
910
import { AddComment, CommentView } from '../components/comment';
10-
import { Header } from '../components/header';
11+
import { getStatus, Header } from '../components/header';
12+
import { copyIcon } from '../components/icon';
1113
import { StatusChecksSection } from '../components/merge';
1214
import Sidebar, { CollapsibleSidebar } from '../components/sidebar';
1315
import { Timeline } from '../components/timeline';
@@ -29,11 +31,35 @@ const useMediaQuery = (query: string) => {
2931
return matches;
3032
};
3133

34+
function useStickyHeader(titleRef: React.RefObject<HTMLDivElement | null>): boolean {
35+
const [isStuck, setIsStuck] = React.useState(false);
36+
37+
React.useEffect(() => {
38+
const el = titleRef.current;
39+
if (!el) {
40+
return;
41+
}
42+
43+
const observer = new IntersectionObserver(
44+
([entry]) => setIsStuck(!entry.isIntersecting),
45+
{ threshold: 0 },
46+
);
47+
observer.observe(el);
48+
49+
return () => observer.disconnect();
50+
}, [titleRef]);
51+
52+
return isStuck;
53+
}
54+
3255
export const Overview = (pr: PullRequest) => {
3356
const isSingleColumnLayout = useMediaQuery('(max-width: 768px)');
57+
const titleRef = React.useRef<HTMLDivElement>(null);
58+
const isStuck = useStickyHeader(titleRef);
3459

3560
return <>
36-
<div id="title" className="title">
61+
<StickyHeader pr={pr} visible={isStuck} />
62+
<div id="title" className="title" ref={titleRef}>
3763
<div className="details">
3864
<Header {...pr} />
3965
</div>
@@ -52,6 +78,27 @@ export const Overview = (pr: PullRequest) => {
5278
</>;
5379
};
5480

81+
function StickyHeader({ pr, visible }: { pr: PullRequest; visible: boolean }): JSX.Element {
82+
const { text, color, icon } = getStatus(pr.state, !!pr.isDraft, pr.isIssue, pr.stateReason);
83+
const { copyPrLink } = React.useContext(PullRequestContext);
84+
85+
return (
86+
<div className={`sticky-header${visible ? ' visible' : ''}`}>
87+
<div className="sticky-header-left">
88+
<div id="sticky-status" className={`status-badge-${color}`}>
89+
<span className="icon">{icon}</span>
90+
<span>{text}</span>
91+
</div>
92+
<span className="sticky-header-title" dangerouslySetInnerHTML={{ __html: pr.titleHTML }} />
93+
<a className="sticky-header-number" href={pr.url}>#{pr.number}</a>
94+
<button title="Copy Link" onClick={copyPrLink} className="icon-button sticky-header-copy" aria-label="Copy Pull Request Link">
95+
{copyIcon}
96+
</button>
97+
</div>
98+
</div>
99+
);
100+
}
101+
55102
const Main = (pr: PullRequest) => (
56103
<div id="main">
57104
<div id="description">

webviews/editorWebview/test/overview.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,10 @@ describe('Overview', function () {
5353

5454
// Initial state should not have sticky class
5555
assert(!titleElement.classList.contains('sticky'));
56+
57+
// Sticky header should exist but not be visible initially
58+
const stickyHeader = out.container.querySelector('.sticky-header');
59+
assert(stickyHeader);
60+
assert(!stickyHeader.classList.contains('visible'));
5661
});
5762
});

0 commit comments

Comments
 (0)