Merge pull request #34067 from backstage/cursor/header-sticky-prop

BUI - Add sticky Header support
This commit is contained in:
Charles de Dreuille
2026-04-27 14:20:23 +01:00
committed by GitHub
11 changed files with 665 additions and 180 deletions
+25 -2
View File
@@ -1579,10 +1579,20 @@ export const HeaderDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly bg: 'consumer';
readonly classNames: {
readonly root: 'bui-Header';
readonly headerTop: 'bui-HeaderTop';
readonly stickySentinel: 'bui-HeaderStickySentinel';
readonly content: 'bui-HeaderContent';
readonly headerBottom: 'bui-HeaderBottom';
readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
readonly breadcrumbsSmall: 'bui-HeaderBreadcrumbsSmall';
readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink';
readonly breadcrumbLinkSmall: 'bui-HeaderBreadcrumbLinkSmall';
readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator';
readonly titleStack: 'bui-HeaderTitleStack';
readonly title: 'bui-HeaderTitle';
readonly titleSmall: 'bui-HeaderTitleSmall';
readonly tabsWrapper: 'bui-HeaderTabsWrapper';
readonly controls: 'bui-HeaderControls';
readonly tags: 'bui-HeaderTags';
@@ -1601,6 +1611,7 @@ export const HeaderDefinition: {
readonly tags: {};
readonly metadata: {};
readonly className: {};
readonly sticky: {};
};
};
@@ -1731,6 +1742,7 @@ export interface HeaderOwnProps {
description?: string;
// (undocumented)
metadata?: HeaderMetadataItem[];
sticky?: boolean;
// (undocumented)
tabs?: HeaderNavTabItem[];
// (undocumented)
@@ -1750,10 +1762,20 @@ export const HeaderPageDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly bg: 'consumer';
readonly classNames: {
readonly root: 'bui-Header';
readonly headerTop: 'bui-HeaderTop';
readonly stickySentinel: 'bui-HeaderStickySentinel';
readonly content: 'bui-HeaderContent';
readonly headerBottom: 'bui-HeaderBottom';
readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
readonly breadcrumbsSmall: 'bui-HeaderBreadcrumbsSmall';
readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink';
readonly breadcrumbLinkSmall: 'bui-HeaderBreadcrumbLinkSmall';
readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator';
readonly titleStack: 'bui-HeaderTitleStack';
readonly title: 'bui-HeaderTitle';
readonly titleSmall: 'bui-HeaderTitleSmall';
readonly tabsWrapper: 'bui-HeaderTabsWrapper';
readonly controls: 'bui-HeaderControls';
readonly tags: 'bui-HeaderTags';
@@ -1772,6 +1794,7 @@ export const HeaderPageDefinition: {
readonly tags: {};
readonly metadata: {};
readonly className: {};
readonly sticky: {};
};
};
@@ -18,6 +18,8 @@
@layer components {
.bui-Container {
flex: 1;
width: 100%;
max-width: 120rem;
padding-inline: var(--bui-space-4);
margin-inline: auto;
@@ -17,17 +17,77 @@
@layer tokens, base, components, utilities;
@layer components {
.bui-Header {
.bui-HeaderBottom {
display: flex;
flex-direction: column;
gap: var(--bui-space-3);
margin-top: var(--bui-space-6);
margin-bottom: var(--bui-space-5);
}
.bui-HeaderTop,
.bui-HeaderContent,
.bui-HeaderBottom {
width: 100%;
padding-inline: var(--bui-space-5);
box-sizing: border-box;
}
.bui-HeaderTop {
padding-top: var(--bui-space-6);
}
.bui-HeaderStickySentinel {
height: 1px;
margin-bottom: -1px;
pointer-events: none;
}
.bui-HeaderContent {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--bui-space-3);
padding-top: var(--bui-space-3);
padding-bottom: var(--bui-space-3);
}
.bui-HeaderContent[data-has-tags] {
padding-top: var(--bui-space-2);
}
.bui-HeaderContent[data-sticky] {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--bui-bg-app);
&[data-on-bg='neutral-1'] {
background-color: var(--bui-bg-neutral-1);
}
&[data-on-bg='neutral-2'] {
background-color: var(--bui-bg-neutral-2);
}
&[data-on-bg='neutral-3'] {
background-color: var(--bui-bg-neutral-3);
}
}
.bui-HeaderContent[data-sticky]::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
content: '';
background-color: var(--bui-border-1);
opacity: 0;
transition: opacity 200ms ease;
}
.bui-HeaderContent[data-stuck]::after {
opacity: 1;
}
.bui-HeaderTabsWrapper {
@@ -39,13 +99,79 @@
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
flex-shrink: 0;
}
.bui-HeaderBreadcrumbs {
.bui-HeaderTitleStack {
position: relative;
flex: 1 1 auto;
height: 1lh;
min-width: 0;
font-size: var(--bui-font-size-6);
line-height: 140%;
overflow: hidden;
}
.bui-HeaderBreadcrumbs,
.bui-HeaderBreadcrumbsSmall {
position: absolute;
inset-inline: 0;
top: 50%;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
min-width: 0;
transform: translateY(-50%);
}
.bui-HeaderBreadcrumbsSmall {
gap: var(--bui-space-1);
}
.bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink,
.bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall {
max-width: 240px;
overflow: hidden;
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-bold);
line-height: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
.bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink {
font-size: inherit;
}
.bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall {
font-size: var(--bui-font-size-4);
}
.bui-HeaderBreadcrumbSeparator {
flex-shrink: 0;
}
.bui-HeaderTitle,
.bui-HeaderTitleSmall {
margin: 0;
padding: 0;
flex: 1 1 auto;
min-width: 0;
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-bold);
line-height: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bui-HeaderTitle {
font-size: inherit;
}
.bui-HeaderTitleSmall {
font-size: var(--bui-font-size-4);
}
.bui-HeaderTags {
@@ -24,6 +24,7 @@ import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import { Button, ButtonIcon, MenuTrigger, Menu, MenuItem } from '../../';
import { RiMore2Line } from '@remixicon/react';
import { Container } from '../Container';
const meta = preview.meta({
title: 'Backstage UI/Header',
@@ -393,3 +394,127 @@ export const WithExplicitActiveTab = meta.story({
activeTabId: 'campaigns',
},
});
export const NonSticky = meta.story({
decorators: [withRouter],
render: () => (
<>
<Header
title="Sticky Page Title"
description="This is a description of the page that scrolls away when you scroll down."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
]}
metadata={[
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
]}
customActions={<Button>Custom action</Button>}
/>
<Container pb="3">
{Array.from({ length: 60 }, (_, i) => (
<p key={i} style={{ marginBottom: '16px' }}>
Scroll down to see the entire header scroll away with the rest of
the page content. Line {i + 1}.
</p>
))}
</Container>
</>
),
});
export const Sticky = meta.story({
decorators: [withRouter],
render: () => (
<>
<Header
title="Sticky Page Title"
sticky
description="This is a description of the page that scrolls away when you scroll down."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
]}
metadata={[
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
]}
customActions={<Button>Custom action</Button>}
/>
<Container pb="3">
{Array.from({ length: 60 }, (_, i) => (
<p key={i} style={{ marginBottom: '16px' }}>
Scroll down to see the title bar stick to the top while the tags,
description, and metadata scroll away. Line {i + 1}.
</p>
))}
</Container>
</>
),
});
export const StickyWithLongTitle = meta.story({
decorators: [withRouter],
render: () => (
<>
<Header
title="This is a very long page title that should demonstrate how the sticky Header behaves when the title takes up significantly more horizontal space than usual"
sticky
description="This is a description of the page that scrolls away when you scroll down."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
]}
metadata={[
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
]}
customActions={<Button>Custom action</Button>}
/>
<Container pb="3">
{Array.from({ length: 60 }, (_, i) => (
<p key={i} style={{ marginBottom: '16px' }}>
Scroll down to see the long title bar stick to the top while the
tags, description, and metadata scroll away. Line {i + 1}.
</p>
))}
</Container>
</>
),
});
export const StickyWithBreadcrumbsAndLongTitle = meta.story({
decorators: [withRouter],
render: () => (
<>
<Header
title="This is a very long page title that should demonstrate how sticky breadcrumbs and titles behave when both need to fit in the compact stuck state"
sticky
breadcrumbs={[
{ label: 'Home', href: '/' },
{ label: 'Very Long Breadcrumb Name', href: '/long-breadcrumb' },
]}
description="This is a description of the page that scrolls away when you scroll down."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
]}
metadata={[
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
]}
customActions={<Button>Custom action</Button>}
/>
<Container pb="3">
{Array.from({ length: 60 }, (_, i) => (
<p key={i} style={{ marginBottom: '16px' }}>
Scroll down to see the breadcrumb and long title bar stick to the
top while the tags, description, and metadata scroll away. Line{' '}
{i + 1}.
</p>
))}
</Container>
</>
),
});
+208 -85
View File
@@ -21,10 +21,37 @@ import { HeaderNav } from './HeaderNav';
import { useDefinition } from '../../hooks/useDefinition';
import { HeaderDefinition } from './definition';
import { sanitizeUrl } from '@braintree/sanitize-url';
import { Container } from '../Container';
import { Lexer } from 'marked';
import { Link } from '../Link';
import { Fragment, useMemo } from 'react';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
const getScrollParent = (element: HTMLElement | null): Element | null => {
let parent = element?.parentElement;
while (parent) {
const { overflow, overflowX, overflowY } = window.getComputedStyle(parent);
if (
/(auto|scroll|overlay|hidden)/.test(`${overflow}${overflowX}${overflowY}`)
) {
return parent;
}
parent = parent.parentElement;
}
return null;
};
const isStickySentinelOutOfView = (
sentinel: HTMLElement,
root: Element | null,
) => {
const sentinelRect = sentinel.getBoundingClientRect();
const rootTop = root ? root.getBoundingClientRect().top : 0;
return sentinelRect.bottom <= rootTop;
};
/**
* Parses inline Markdown links in a string and returns an array of React nodes.
@@ -52,7 +79,9 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
* @public
*/
export const Header = (props: HeaderProps) => {
const { ownProps } = useDefinition(HeaderDefinition, props);
const { ownProps, dataAttributes } = useDefinition(HeaderDefinition, props, {
classNameTarget: 'content',
});
const {
classes,
title,
@@ -63,6 +92,7 @@ export const Header = (props: HeaderProps) => {
description,
tags,
metadata,
sticky,
} = ownProps;
const descriptionNodes = useMemo(
@@ -70,93 +100,186 @@ export const Header = (props: HeaderProps) => {
[description],
);
// The sentinel sits directly before the sticky content and leaves the
// viewport when the content becomes stuck, letting us toggle stuck styling.
const stickySentinelRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
useEffect(() => {
if (!sticky) {
setIsStuck(false);
return;
}
const sentinel = stickySentinelRef.current;
if (!sentinel) {
return;
}
const root = getScrollParent(sentinel);
if (typeof IntersectionObserver === 'undefined') {
const updateStuckState = () => {
setIsStuck(isStickySentinelOutOfView(sentinel, root));
};
const scrollTarget = root ?? window;
updateStuckState();
scrollTarget.addEventListener('scroll', updateStuckState, {
passive: true,
});
window.addEventListener('resize', updateStuckState);
return () => {
scrollTarget.removeEventListener('scroll', updateStuckState);
window.removeEventListener('resize', updateStuckState);
};
}
const observer = new IntersectionObserver(
([entry]) => {
setIsStuck(!entry.isIntersecting);
},
{ root, threshold: 0 },
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, [sticky]);
return (
<Container className={classes.root}>
{tags && tags.length > 0 && (
<ul className={classes.tags}>
{tags.map((tag, i) => (
<li
key={`${i}:${tag.label}:${tag.href ?? ''}`}
className={classes.tag}
>
{tag.href ? (
<Link
href={tag.href}
variant="body-medium"
color="secondary"
standalone
>
{tag.label}
</Link>
) : (
<Text variant="body-medium" color="secondary">
{tag.label}
</Text>
)}
</li>
))}
</ul>
)}
<div className={classes.content}>
<div className={classes.breadcrumbs}>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
<Fragment key={breadcrumb.label}>
<Link
href={breadcrumb.href}
variant="title-small"
weight="bold"
color="secondary"
truncate
style={{ maxWidth: '240px' }}
standalone
>
{breadcrumb.label}
</Link>
<RiArrowRightSLine size={16} color="var(--bui-fg-secondary)" />
</Fragment>
<>
<div className={classes.headerTop} data-sticky={sticky || undefined}>
{tags && tags.length > 0 && (
<ul className={classes.tags}>
{tags.map((tag, i) => (
<li
key={`${i}:${tag.label}:${tag.href ?? ''}`}
className={classes.tag}
>
{tag.href ? (
<Link
href={tag.href}
variant="body-medium"
color="secondary"
standalone
>
{tag.label}
</Link>
) : (
<Text variant="body-medium" color="secondary">
{tag.label}
</Text>
)}
</li>
))}
<Text variant="title-small" weight="bold" as="h2">
{title}
</Text>
</ul>
)}
</div>
{sticky && (
<div
ref={stickySentinelRef}
className={classes.stickySentinel}
data-sticky=""
aria-hidden="true"
/>
)}
<div
className={classes.content}
data-sticky={sticky || undefined}
data-stuck={isStuck || undefined}
data-has-tags={tags && tags.length > 0 ? '' : undefined}
{...dataAttributes}
>
<div className={classes.titleStack}>
{isStuck ? (
<div className={classes.breadcrumbsSmall}>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
<Fragment key={breadcrumb.label}>
<Link
href={breadcrumb.href}
color="secondary"
className={classes.breadcrumbLinkSmall}
standalone
>
{breadcrumb.label}
</Link>
<RiArrowRightSLine
className={classes.breadcrumbSeparator}
size={16}
color="var(--bui-fg-secondary)"
/>
</Fragment>
))}
<h2 className={classes.titleSmall}>{title}</h2>
</div>
) : (
<div className={classes.breadcrumbs}>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
<Fragment key={breadcrumb.label}>
<Link
href={breadcrumb.href}
color="secondary"
className={classes.breadcrumbLink}
standalone
>
{breadcrumb.label}
</Link>
<RiArrowRightSLine
className={classes.breadcrumbSeparator}
size={16}
color="var(--bui-fg-secondary)"
/>
</Fragment>
))}
<h2 className={classes.title}>{title}</h2>
</div>
)}
</div>
<div className={classes.controls}>{customActions}</div>
</div>
{description && (
<Text
variant="body-medium"
color="secondary"
className={classes.description}
>
{descriptionNodes}
</Text>
)}
{metadata && metadata.length > 0 && (
<dl className={classes.metaRow}>
{metadata.map((item, i) => (
<div key={`${i}:${item.label}`} className={classes.metaItem}>
<dt>
<Text variant="body-medium" color="secondary">
{item.label}
</Text>
</dt>
<dd>
{typeof item.value === 'string' ? (
<Text variant="body-medium">{item.value}</Text>
) : (
item.value
)}
</dd>
</div>
))}
</dl>
)}
{tabs && (
<div className={classes.tabsWrapper}>
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
</div>
)}
</Container>
<div className={classes.headerBottom} data-sticky={sticky || undefined}>
{description && (
<Text
variant="body-medium"
color="secondary"
className={classes.description}
>
{descriptionNodes}
</Text>
)}
{metadata && metadata.length > 0 && (
<dl className={classes.metaRow}>
{metadata.map((item, i) => (
<div key={`${i}:${item.label}`} className={classes.metaItem}>
<dt>
<Text variant="body-medium" color="secondary">
{item.label}
</Text>
</dt>
<dd>
{typeof item.value === 'string' ? (
<Text variant="body-medium">{item.value}</Text>
) : (
item.value
)}
</dd>
</div>
))}
</dl>
)}
{tabs && (
<div className={classes.tabsWrapper}>
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
</div>
)}
</div>
</>
);
};
@@ -24,10 +24,20 @@ import styles from './Header.module.css';
*/
export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
styles,
bg: 'consumer',
classNames: {
root: 'bui-Header',
headerTop: 'bui-HeaderTop',
stickySentinel: 'bui-HeaderStickySentinel',
content: 'bui-HeaderContent',
headerBottom: 'bui-HeaderBottom',
breadcrumbs: 'bui-HeaderBreadcrumbs',
breadcrumbsSmall: 'bui-HeaderBreadcrumbsSmall',
breadcrumbLink: 'bui-HeaderBreadcrumbLink',
breadcrumbLinkSmall: 'bui-HeaderBreadcrumbLinkSmall',
breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator',
titleStack: 'bui-HeaderTitleStack',
title: 'bui-HeaderTitle',
titleSmall: 'bui-HeaderTitleSmall',
tabsWrapper: 'bui-HeaderTabsWrapper',
controls: 'bui-HeaderControls',
tags: 'bui-HeaderTags',
@@ -46,6 +56,7 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
tags: {},
metadata: {},
className: {},
sticky: {},
},
});
@@ -116,6 +116,11 @@ export interface HeaderOwnProps {
tags?: HeaderTag[];
metadata?: HeaderMetadataItem[];
className?: string;
/**
* Makes the title-and-actions row stick to the top of its nearest scroll
* container while the rest of the header content scrolls away.
*/
sticky?: boolean;
}
/**
@@ -58,6 +58,19 @@ const PageContent = () => (
</Container>
);
const LongPageContent = () => (
<Container mt="6" pb="3">
<Flex direction="row" gap="4" mb="4">
<Card style={{ minHeight: 200, flex: 1 }} />
<Card style={{ minHeight: 200, flex: 1 }} />
<Card style={{ minHeight: 200, flex: 1 }} />
</Flex>
{Array.from({ length: 40 }, (_, i) => (
<Card key={i} style={{ minHeight: 200, marginBottom: 16 }} />
))}
</Container>
);
// ---------------------------------------------------------------------------
// Shared layout decorator
// ---------------------------------------------------------------------------
@@ -157,52 +170,6 @@ export const WithTabs = meta.story({
),
});
export const WithBreadcrumb = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader
icon={<RiGitBranchLine />}
title="CI/CD"
titleLink="/"
tabs={[
{ id: 'builds', label: 'Builds', href: '/builds' },
{ id: 'pipelines', label: 'Pipelines', href: '/pipelines' },
{ id: 'deployments', label: 'Deployments', href: '/deployments' },
{ id: 'settings', label: 'Settings', href: '/settings' },
]}
customActions={
<>
<ButtonIcon
variant="secondary"
icon={<RiRefreshLine />}
aria-label="Refresh"
/>
</>
}
/>
<Header
title="main · #842"
breadcrumbs={[
{ label: 'Catalog', href: '/catalog' },
{ label: 'Services', href: '/catalog?kind=Component' },
]}
customActions={
<>
<Button variant="secondary" iconStart={<RiDownloadLine />}>
Download logs
</Button>
<Button variant="primary" iconStart={<RiPlayLine />}>
Re-run pipeline
</Button>
</>
}
/>
<PageContent />
</>
),
});
const subTabs: HeaderNavTabItem[] = [
{ id: 'summary', label: 'Summary', href: '/summary' },
{ id: 'steps', label: 'Steps', href: '/steps' },
@@ -244,11 +211,65 @@ export const WithSubTabs = meta.story({
}
/>
<Header
title="main · #842"
title="Production deployment pipeline run"
tabs={subTabs}
breadcrumbs={[
{ label: 'Catalog', href: '/catalog' },
{ label: 'Services', href: '/catalog?kind=Component' },
customActions={
<>
<Button variant="secondary" iconStart={<RiDownloadLine />}>
Download logs
</Button>
<Button variant="primary" iconStart={<RiPlayLine />}>
Re-run pipeline
</Button>
</>
}
/>
<PageContent />
</>
);
},
});
export const WithSubTabsAndTags = meta.story({
decorators: [
(Story: StoryFn) => (
<MemoryRouter initialEntries={['/summary']}>
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
render: () => {
return (
<>
<PluginHeader
icon={<RiGitBranchLine />}
title="CI/CD"
titleLink="/"
tabs={[
{ id: 'builds', label: 'Builds', href: '/builds' },
{ id: 'pipelines', label: 'Pipelines', href: '/pipelines' },
{ id: 'deployments', label: 'Deployments', href: '/deployments' },
{ id: 'settings', label: 'Settings', href: '/settings' },
]}
customActions={
<>
<ButtonIcon
variant="secondary"
icon={<RiRefreshLine />}
aria-label="Refresh"
/>
</>
}
/>
<Header
title="Production deployment pipeline run"
tabs={subTabs}
tags={[
{ label: 'Production' },
{ label: 'Owner: Platform', href: '/catalog?owner=platform' },
{ label: 'Tier 1' },
]}
customActions={
<>
@@ -266,3 +287,57 @@ export const WithSubTabs = meta.story({
);
},
});
export const WithStickyHeader = meta.story({
decorators: [
(Story: StoryFn) => (
<MemoryRouter initialEntries={['/summary']}>
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
render: () => {
return (
<>
<PluginHeader
icon={<RiGitBranchLine />}
title="CI/CD"
titleLink="/"
tabs={[
{ id: 'builds', label: 'Builds', href: '/builds' },
{ id: 'pipelines', label: 'Pipelines', href: '/pipelines' },
{ id: 'deployments', label: 'Deployments', href: '/deployments' },
{ id: 'settings', label: 'Settings', href: '/settings' },
]}
customActions={
<>
<ButtonIcon
variant="secondary"
icon={<RiRefreshLine />}
aria-label="Refresh"
/>
</>
}
/>
<Header
title="Production deployment pipeline run"
sticky
tabs={subTabs}
customActions={
<>
<Button variant="secondary" iconStart={<RiDownloadLine />}>
Download logs
</Button>
<Button variant="primary" iconStart={<RiPlayLine />}>
Re-run pipeline
</Button>
</>
}
/>
<LongPageContent />
</>
);
},
});