Add sticky Header support
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
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.
|
||||
+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'] {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user