Skip to content

Commit 98e8b9a

Browse files
mrleemurraymrleemurrayCopilot
authored
Implement sticky header for PR overview with tests (#8528)
* Implement sticky header for PR overview and add related tests * Refactor sticky header functionality into separate component and hook * Update webviews/editorWebview/index.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add hidden props to StickyHeader for improved accessibility * Add spacing after sticky header status for improved layout * Remove box-shadow from sticky header in high contrast mode * Update webviews/components/stickyHeader.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor StickyHeader to manage inert attribute and aria-hidden for improved accessibility --------- Co-authored-by: mrleemurray <lee.murray@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent e810f7f commit 98e8b9a

File tree

4 files changed

+185
-1
lines changed

4 files changed

+185
-1
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as React from 'react';
7+
import { getStatus } from './header';
8+
import { copyIcon } from './icon';
9+
import { PullRequest } from '../../src/github/views';
10+
import PullRequestContext from '../common/context';
11+
12+
export function useStickyHeader(titleRef: React.RefObject<HTMLDivElement | null>): boolean {
13+
const [isStuck, setIsStuck] = React.useState(false);
14+
15+
React.useEffect(() => {
16+
const el = titleRef.current;
17+
if (!el) {
18+
return;
19+
}
20+
21+
const observer = new IntersectionObserver(
22+
([entry]) => setIsStuck(!entry.isIntersecting),
23+
{ threshold: 0 },
24+
);
25+
observer.observe(el);
26+
27+
return () => observer.disconnect();
28+
}, [titleRef]);
29+
30+
return isStuck;
31+
}
32+
33+
export function StickyHeader({ pr, visible }: { pr: PullRequest; visible: boolean }): JSX.Element {
34+
const { text, color, icon } = getStatus(pr.state, !!pr.isDraft, pr.isIssue, pr.stateReason);
35+
const { copyPrLink } = React.useContext(PullRequestContext);
36+
37+
const stickyRef = React.useCallback((node: HTMLDivElement | null) => {
38+
if (node) {
39+
if (visible) {
40+
node.removeAttribute('inert');
41+
} else {
42+
node.setAttribute('inert', '');
43+
}
44+
}
45+
}, [visible]);
46+
47+
return (
48+
<div ref={stickyRef} className={`sticky-header${visible ? ' visible' : ''}`}>
49+
<div className="sticky-header-left">
50+
<div id="sticky-status" className={`status-badge-${color}`}>
51+
<span className="icon">{icon}</span>
52+
<span>{text}</span>
53+
</div>
54+
<span className="sticky-header-title" dangerouslySetInnerHTML={{ __html: pr.titleHTML }} />
55+
<a
56+
className="sticky-header-number"
57+
href={pr.url}
58+
title={pr.url}
59+
data-vscode-context={JSON.stringify({
60+
url: pr.url,
61+
preventDefaultContextMenuItems: true,
62+
owner: pr.owner,
63+
repo: pr.repo,
64+
number: pr.number,
65+
'github:copyMenu': true,
66+
})}
67+
>
68+
#{pr.number}
69+
</a>
70+
<button title="Copy Link" onClick={copyPrLink} className="icon-button sticky-header-copy" aria-label="Copy Pull Request Link">
71+
{copyIcon}
72+
</button>
73+
</div>
74+
</div>
75+
);
76+
}

webviews/editorWebview/index.css

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,105 @@ 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+
.vscode-high-contrast .sticky-header {
68+
box-shadow: none;
69+
}
70+
71+
.sticky-header.visible {
72+
transform: translateY(0);
73+
opacity: 1;
74+
pointer-events: auto;
75+
}
76+
77+
.sticky-header-left {
78+
display: flex;
79+
align-items: center;
80+
gap: 8px;
81+
min-width: 0;
82+
}
83+
84+
.sticky-header-left #sticky-status {
85+
flex-shrink: 0;
86+
box-sizing: border-box;
87+
line-height: 18px;
88+
color: var(--vscode-button-foreground);
89+
border-radius: 18px;
90+
padding: 2px 10px;
91+
font-weight: 600;
92+
font-size: 12px;
93+
display: flex;
94+
gap: 4px;
95+
align-items: center;
96+
}
97+
98+
.sticky-header-left #sticky-status svg path {
99+
fill: var(--vscode-button-foreground);
100+
}
101+
102+
.vscode-high-contrast .sticky-header-left #sticky-status {
103+
border: 1px solid var(--vscode-contrastBorder);
104+
background-color: var(--vscode-button-background);
105+
color: var(--vscode-button-foreground);
106+
}
107+
108+
.vscode-high-contrast .sticky-header-left #sticky-status svg path {
109+
fill: var(--vscode-button-foreground);
110+
}
111+
112+
.sticky-header-title {
113+
font-weight: 600;
114+
white-space: nowrap;
115+
overflow: hidden;
116+
text-overflow: ellipsis;
117+
min-width: 0;
118+
}
119+
120+
.sticky-header-number {
121+
flex-shrink: 0;
122+
color: var(--vscode-textLink-foreground);
123+
white-space: nowrap;
124+
}
125+
126+
.sticky-header-number:hover {
127+
color: var(--vscode-textLink-activeForeground);
128+
}
129+
130+
.sticky-header .icon-button {
131+
color: var(--vscode-foreground);
132+
}
133+
134+
.sticky-header .icon-button:hover,
135+
.sticky-header .icon-button:focus {
136+
background-color: var(--vscode-toolbar-hoverBackground);
137+
cursor: pointer;
138+
}
139+
140+
.sticky-header .icon-button:focus {
141+
outline: 1px solid var(--vscode-focusBorder);
142+
outline-offset: 1px;
143+
}
144+
46145
.title-text {
47146
margin-right: 5px;
48147
}

webviews/editorWebview/overview.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AddComment, CommentView } from '../components/comment';
1010
import { Header } from '../components/header';
1111
import { StatusChecksSection } from '../components/merge';
1212
import Sidebar, { CollapsibleSidebar } from '../components/sidebar';
13+
import { StickyHeader, useStickyHeader } from '../components/stickyHeader';
1314
import { Timeline } from '../components/timeline';
1415

1516
const useMediaQuery = (query: string) => {
@@ -31,9 +32,12 @@ const useMediaQuery = (query: string) => {
3132

3233
export const Overview = (pr: PullRequest) => {
3334
const isSingleColumnLayout = useMediaQuery('(max-width: 768px)');
35+
const titleRef = React.useRef<HTMLDivElement>(null);
36+
const isStuck = useStickyHeader(titleRef);
3437

3538
return <>
36-
<div id="title" className="title">
39+
<StickyHeader pr={pr} visible={isStuck} />
40+
<div id="title" className="title" ref={titleRef}>
3741
<div className="details">
3842
<Header {...pr} />
3943
</div>

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)