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
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/ui': minor
---
Added a `sticky` prop to the `Header` component. When `true`, the title-and-actions bar stays fixed to the top of its scroll container while the rest of the header (tags, description, metadata) scrolls away. The sticky bar background color automatically matches the container surface using the bg-consumer system.
**BREAKING**: Removed the main header class from the `Header` component. Custom styles that target this class should be updated to target the component sections that remain.
**Affected components:** Header
+18 -31
View File
@@ -50,26 +50,11 @@ export default definePreview({
dynamicTitle: true,
},
},
background: {
name: 'Background',
description: 'Global background for components',
defaultValue: 'app',
toolbar: {
icon: 'contrast',
items: [
{ value: 'app', title: 'App Background' },
{ value: 'neutral-1', title: 'Neutral 1 Background' },
{ value: 'neutral-2', title: 'Neutral 2 Background' },
{ value: 'neutral-3', title: 'Neutral 3 Background' },
],
},
},
},
initialGlobals: {
themeMode: 'light',
themeName: 'backstage',
background: 'app',
},
parameters: {
@@ -143,7 +128,6 @@ export default definePreview({
globals.themeMode === 'light' ? themes.light : themes.dark;
const selectedThemeMode = globals.themeMode || 'light';
const selectedThemeName = globals.themeName || 'backstage';
const selectedBackground = globals.background || 'app';
const isFullscreen = context.parameters.layout === 'fullscreen';
useEffect(() => {
@@ -155,15 +139,13 @@ export default definePreview({
document.body.removeAttribute('data-theme-mode');
document.body.removeAttribute('data-theme-name');
};
}, [selectedTheme, selectedThemeName]);
}, [selectedThemeMode, selectedThemeName]);
useEffect(() => {
appThemeApi.setActiveThemeId(selectedThemeMode);
}, [selectedThemeMode]);
document.body.style.backgroundColor = 'var(--bui-bg-app)';
document.body.style.padding =
isFullscreen && selectedBackground !== 'app' ? '1rem' : '';
const docsStoryElements = document.getElementsByClassName('docs-story');
Array.from(docsStoryElements).forEach(element => {
(element as HTMLElement).style.backgroundColor = 'var(--bui-bg-app)';
@@ -174,18 +156,23 @@ export default definePreview({
{/* @ts-ignore */}
<TestApiProvider apis={apis}>
<AlertDisplay />
{Array.from({
length:
selectedBackground === 'app'
? 0
: parseInt(selectedBackground.split('-')[1], 10),
}).reduce<React.ReactNode>(
children => (
<Box bg="neutral" p="4">
{children}
</Box>
),
<Story />,
{selectedThemeName === 'spotify' ? (
<Box
bg="neutral"
m={isFullscreen ? '4' : undefined}
style={{
borderRadius: 'var(--bui-radius-3)',
height: isFullscreen
? 'calc(100vh - (var(--bui-space-4) * 2))'
: undefined,
overflow: 'auto',
overscrollBehavior: 'none',
}}
>
<Story />
</Box>
) : (
<Story />
)}
</TestApiProvider>
</UnifiedThemeProvider>
+7 -8
View File
@@ -190,10 +190,6 @@
.bui-Tag {
border-radius: var(--bui-radius-full);
}
.bui-Container {
padding-inline: 0;
}
}
[data-theme-mode='light'][data-theme-name='spotify'] {
@@ -250,17 +246,20 @@
*/
[data-theme-name='spotify'] {
.bui-PluginHeaderToolbar {
padding: 0;
height: 32px;
border: none;
background: none;
margin-bottom: var(--bui-space-2);
margin-top: var(--bui-space-4);
}
.bui-PluginHeaderTabsWrapper {
padding: 0;
border: none;
background: none;
margin-left: -8px;
margin-left: 0;
margin-top: var(--bui-space-2);
}
.bui-HeaderTop {
padding-top: var(--bui-space-4);
}
}
+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 />
</>
);
},
});