Merge pull request #34067 from backstage/cursor/header-sticky-prop
BUI - Add sticky Header support
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user