Skip to content
76 changes: 76 additions & 0 deletions webviews/components/stickyHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as React from 'react';
import { getStatus } from './header';
import { copyIcon } from './icon';
import { PullRequest } from '../../src/github/views';
import PullRequestContext from '../common/context';

export function useStickyHeader(titleRef: React.RefObject<HTMLDivElement | null>): boolean {
const [isStuck, setIsStuck] = React.useState(false);

React.useEffect(() => {
const el = titleRef.current;
if (!el) {
return;
}

const observer = new IntersectionObserver(
([entry]) => setIsStuck(!entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(el);

return () => observer.disconnect();
}, [titleRef]);

return isStuck;
}

export function StickyHeader({ pr, visible }: { pr: PullRequest; visible: boolean }): JSX.Element {
const { text, color, icon } = getStatus(pr.state, !!pr.isDraft, pr.isIssue, pr.stateReason);
const { copyPrLink } = React.useContext(PullRequestContext);

const stickyRef = React.useCallback((node: HTMLDivElement | null) => {
if (node) {
if (visible) {
node.removeAttribute('inert');
} else {
node.setAttribute('inert', '');
}
}
}, [visible]);

return (
<div ref={stickyRef} className={`sticky-header${visible ? ' visible' : ''}`}>
<div className="sticky-header-left">
<div id="sticky-status" className={`status-badge-${color}`}>
<span className="icon">{icon}</span>
<span>{text}</span>
</div>
<span className="sticky-header-title" dangerouslySetInnerHTML={{ __html: pr.titleHTML }} />
<a
className="sticky-header-number"
href={pr.url}
title={pr.url}
data-vscode-context={JSON.stringify({
url: pr.url,
preventDefaultContextMenuItems: true,
owner: pr.owner,
repo: pr.repo,
number: pr.number,
'github:copyMenu': true,
})}
>
#{pr.number}
</a>
<button title="Copy Link" onClick={copyPrLink} className="icon-button sticky-header-copy" aria-label="Copy Pull Request Link">
{copyIcon}
</button>
</div>
</div>
);
}
99 changes: 99 additions & 0 deletions webviews/editorWebview/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,105 @@ textarea:focus,
outline: 1px solid var(--vscode-focusBorder);
}

/* Sticky Header */

.sticky-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 32px;
background: var(--vscode-editor-background);
border-bottom: 1px solid var(--vscode-editorWidget-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
transform: translateY(-100%);
opacity: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
pointer-events: none;
}

.vscode-high-contrast .sticky-header {
box-shadow: none;
}

.sticky-header.visible {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}

.sticky-header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}

.sticky-header-left #sticky-status {
flex-shrink: 0;
box-sizing: border-box;
line-height: 18px;
color: var(--vscode-button-foreground);
border-radius: 18px;
padding: 2px 10px;
font-weight: 600;
font-size: 12px;
display: flex;
gap: 4px;
align-items: center;
}

.sticky-header-left #sticky-status svg path {
fill: var(--vscode-button-foreground);
}

.vscode-high-contrast .sticky-header-left #sticky-status {
border: 1px solid var(--vscode-contrastBorder);
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}

.vscode-high-contrast .sticky-header-left #sticky-status svg path {
fill: var(--vscode-button-foreground);
}

.sticky-header-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}

.sticky-header-number {
flex-shrink: 0;
color: var(--vscode-textLink-foreground);
white-space: nowrap;
}

.sticky-header-number:hover {
color: var(--vscode-textLink-activeForeground);
}

.sticky-header .icon-button {
color: var(--vscode-foreground);
}

.sticky-header .icon-button:hover,
.sticky-header .icon-button:focus {
background-color: var(--vscode-toolbar-hoverBackground);
cursor: pointer;
}

.sticky-header .icon-button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}

.title-text {
margin-right: 5px;
}
Expand Down
6 changes: 5 additions & 1 deletion webviews/editorWebview/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AddComment, CommentView } from '../components/comment';
import { Header } from '../components/header';
import { StatusChecksSection } from '../components/merge';
import Sidebar, { CollapsibleSidebar } from '../components/sidebar';
import { StickyHeader, useStickyHeader } from '../components/stickyHeader';
import { Timeline } from '../components/timeline';

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

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

return <>
<div id="title" className="title">
<StickyHeader pr={pr} visible={isStuck} />
<div id="title" className="title" ref={titleRef}>
<div className="details">
<Header {...pr} />
</div>
Expand Down
5 changes: 5 additions & 0 deletions webviews/editorWebview/test/overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,10 @@ describe('Overview', function () {

// Initial state should not have sticky class
assert(!titleElement.classList.contains('sticky'));

// Sticky header should exist but not be visible initially
const stickyHeader = out.container.querySelector('.sticky-header');
assert(stickyHeader);
assert(!stickyHeader.classList.contains('visible'));
});
});