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