Add sticky Header support

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-04-26 09:04:51 +01:00
parent ad7d51bfec
commit 5351d8ac63
9 changed files with 333 additions and 99 deletions
@@ -18,6 +18,8 @@
@layer components {
.bui-Container {
flex: 1;
width: 100%;
max-width: 120rem;
padding-inline: var(--bui-space-4);
margin-inline: auto;
@@ -18,16 +18,87 @@
@layer components {
.bui-Header {
display: flex;
flex-direction: column;
padding-top: var(--bui-space-2);
width: 100%;
padding-inline: var(--bui-space-5);
}
.bui-Header[data-sticky] {
display: contents;
}
.bui-HeaderAfterSticky {
display: flex;
flex-direction: column;
gap: var(--bui-space-3);
margin-top: var(--bui-space-6);
}
.bui-Header[data-sticky] .bui-HeaderBeforeSticky,
.bui-Header[data-sticky] .bui-HeaderStickySentinel,
.bui-Header[data-sticky] .bui-HeaderContent,
.bui-Header[data-sticky] .bui-HeaderAfterSticky {
width: 100%;
padding-inline: var(--bui-space-5);
box-sizing: border-box;
}
.bui-HeaderBeforeSticky {
padding-top: var(--bui-space-4);
}
.bui-Header[data-sticky] .bui-HeaderBeforeSticky {
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-block: var(--bui-space-3);
}
.bui-HeaderContent[data-sticky] {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--bui-bg-app);
.bui-Header[data-on-bg='neutral-1'] & {
background-color: var(--bui-bg-neutral-1);
}
.bui-Header[data-on-bg='neutral-2'] & {
background-color: var(--bui-bg-neutral-2);
}
.bui-Header[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,6 +110,7 @@
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
flex-shrink: 0;
}
.bui-HeaderBreadcrumbs {
@@ -46,6 +118,19 @@
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
min-width: 0;
}
.bui-HeaderTitle {
margin: 0;
padding: 0;
overflow: hidden;
font-family: var(--bui-font-regular);
font-size: var(--bui-font-size-6);
font-weight: var(--bui-font-weight-bold);
line-height: 140%;
text-overflow: ellipsis;
white-space: nowrap;
}
.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/Container';
const meta = preview.meta({
title: 'Backstage UI/Header',
@@ -393,3 +394,92 @@ 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 title bar stick to the top while the tags,
description, and metadata scroll away. 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>
</>
),
});
+125 -63
View File
@@ -21,10 +21,25 @@ 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 { overflowY } = window.getComputedStyle(parent);
if (/(auto|scroll|overlay)/.test(overflowY)) {
return parent;
}
parent = parent.parentElement;
}
return null;
};
/**
* Parses inline Markdown links in a string and returns an array of React nodes.
@@ -52,7 +67,7 @@ function renderInlineMarkdown(text: string): React.ReactNode[] {
* @public
*/
export const Header = (props: HeaderProps) => {
const { ownProps } = useDefinition(HeaderDefinition, props);
const { ownProps, dataAttributes } = useDefinition(HeaderDefinition, props);
const {
classes,
title,
@@ -63,41 +78,86 @@ export const Header = (props: HeaderProps) => {
description,
tags,
metadata,
sticky,
} = ownProps;
const descriptionNodes = useMemo(
() => (description ? renderInlineMarkdown(description) : null),
[description],
);
const stickySentinelRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
useEffect(() => {
if (!sticky) {
setIsStuck(false);
return;
}
const sentinel = stickySentinelRef.current;
if (!sentinel) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
setIsStuck(!entry.isIntersecting);
},
{ root: getScrollParent(sentinel), threshold: 0 },
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, [sticky]);
return (
<Container className={classes.root}>
<header
className={classes.root}
data-sticky={sticky || undefined}
{...dataAttributes}
>
{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.beforeSticky}>
<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>
)}
<div className={classes.content}>
{sticky && (
<div
ref={stickySentinelRef}
className={classes.stickySentinel}
aria-hidden="true"
/>
)}
<div
className={classes.content}
data-sticky={sticky || undefined}
data-stuck={isStuck || undefined}
>
<div className={classes.breadcrumbs}>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
@@ -116,47 +176,49 @@ export const Header = (props: HeaderProps) => {
<RiArrowRightSLine size={16} color="var(--bui-fg-secondary)" />
</Fragment>
))}
<Text variant="title-small" weight="bold" as="h2">
{title}
</Text>
<h2 className={classes.title}>{title}</h2>
</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>
{(description || (metadata && metadata.length > 0) || tabs) && (
<div className={classes.afterSticky}>
{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>
))}
</dl>
)}
{tabs && (
<div className={classes.tabsWrapper}>
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
)}
</div>
)}
</Container>
</header>
);
};
@@ -24,10 +24,15 @@ import styles from './Header.module.css';
*/
export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
styles,
bg: 'consumer',
classNames: {
root: 'bui-Header',
beforeSticky: 'bui-HeaderBeforeSticky',
stickySentinel: 'bui-HeaderStickySentinel',
content: 'bui-HeaderContent',
afterSticky: 'bui-HeaderAfterSticky',
breadcrumbs: 'bui-HeaderBreadcrumbs',
title: 'bui-HeaderTitle',
tabsWrapper: 'bui-HeaderTabsWrapper',
controls: 'bui-HeaderControls',
tags: 'bui-HeaderTags',
@@ -46,6 +51,7 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
tags: {},
metadata: {},
className: {},
sticky: {},
},
});
@@ -116,6 +116,7 @@ export interface HeaderOwnProps {
tags?: HeaderTag[];
metadata?: HeaderMetadataItem[];
className?: string;
sticky?: boolean;
}
/**