Merge pull request #30701 from backstage/bui-header

BUI - Move the breadcrumb on the HeaderPage
This commit is contained in:
Charles de Dreuille
2025-08-11 09:39:32 +01:00
committed by GitHub
17 changed files with 342 additions and 307 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
**Breaking change** Move breadcrumb to fit in the `HeaderPage` instead of the `Header` in Backstage UI.
File diff suppressed because one or more lines are too long
@@ -65,6 +65,24 @@ export const propDefs: Record<string, PropDef> = {
},
},
},
breadcrumbs: {
type: 'complex',
complexType: {
name: 'Breadcrumb[]',
properties: {
label: {
type: 'string',
required: true,
description: 'Display text for the breadcrumb',
},
href: {
type: 'string',
required: true,
description: 'URL for the breadcrumb link',
},
},
},
},
...childrenPropDefs,
...classNamePropDefs,
...stylePropDefs,
@@ -18,24 +18,6 @@ export const propDefs: Record<string, PropDef> = {
type: 'string',
default: '/',
},
breadcrumbs: {
type: 'complex',
complexType: {
name: 'Breadcrumb[]',
properties: {
label: {
type: 'string',
required: true,
description: 'Display text for the breadcrumb',
},
href: {
type: 'string',
required: true,
description: 'URL for the breadcrumb link',
},
},
},
},
customActions: {
type: 'enum',
values: ['ReactNode'],
+53 -36
View File
@@ -9593,10 +9593,7 @@
}
.bui-HeaderToolbar {
z-index: 10;
margin-bottom: var(--bui-space-6);
position: sticky;
top: 0;
&:before {
content: "";
@@ -9667,38 +9664,6 @@
transform: translateY(-50%);
}
.bui-HeaderBreadcrumbs {
align-items: center;
gap: var(--bui-space-2);
font-size: var(--bui-font-size-3);
font-weight: var(--bui-font-weight-regular);
flex-direction: row;
display: flex;
}
.bui-HeaderBreadcrumb {
align-items: center;
gap: var(--bui-space-2);
flex-direction: row;
display: flex;
}
.bui-HeaderBreadcrumbLink {
color: var(--bui-fg-secondary);
cursor: pointer;
text-decoration: none;
&[data-active="true"] {
color: var(--bui-fg-primary);
}
}
.bui-HeaderBreadcrumbSeparator {
width: 16px;
height: 16px;
color: var(--bui-fg-secondary);
}
.bui-HeaderTabsWrapper {
margin-bottom: var(--bui-space-4);
padding-inline: var(--bui-space-3);
@@ -9708,7 +9673,6 @@
.bui-HeaderPage {
gap: var(--bui-space-1);
padding-inline: var(--bui-space-5);
margin-top: var(--bui-space-6);
margin-bottom: var(--bui-space-6);
flex-direction: column;
@@ -9732,6 +9696,59 @@
display: flex;
}
.bui-HeaderPageSticky {
z-index: -1;
height: 0;
color: var(--bui-fg-primary);
position: sticky;
top: 0;
}
.bui-HeaderPageStickyWrapper {
background-color: var(--bui-bg-surface-1);
border-bottom: 1px solid var(--bui-border);
opacity: 0;
}
.bui-HeaderPageStickyContent {
justify-content: space-between;
align-items: center;
height: 48px;
display: flex;
}
.bui-HeaderPageBreadcrumbs {
align-items: center;
gap: var(--bui-space-2);
font-size: var(--bui-font-size-3);
font-weight: var(--bui-font-weight-regular);
flex-direction: row;
display: flex;
}
.bui-HeaderPageBreadcrumb {
align-items: center;
gap: var(--bui-space-2);
flex-direction: row;
display: flex;
}
.bui-HeaderPageBreadcrumbLink {
color: var(--bui-fg-secondary);
cursor: pointer;
text-decoration: none;
&[data-active="true"] {
color: var(--bui-fg-primary);
}
}
.bui-HeaderPageBreadcrumbSeparator {
width: 16px;
height: 16px;
color: var(--bui-fg-secondary);
}
.bui-Icon {
width: 1rem;
height: 1rem;
+17 -10
View File
@@ -405,6 +405,13 @@ export const componentDefinitions: {
readonly content: 'bui-HeaderPageContent';
readonly tabsWrapper: 'bui-HeaderPageTabsWrapper';
readonly controls: 'bui-HeaderPageControls';
readonly sticky: 'bui-HeaderPageSticky';
readonly stickyWrapper: 'bui-HeaderPageStickyWrapper';
readonly stickyContent: 'bui-HeaderPageStickyContent';
readonly breadcrumbs: 'bui-HeaderPageBreadcrumbs';
readonly breadcrumb: 'bui-HeaderPageBreadcrumb';
readonly breadcrumbLink: 'bui-HeaderPageBreadcrumbLink';
readonly breadcrumbSeparator: 'bui-HeaderPageBreadcrumbSeparator';
};
};
readonly Heading: {
@@ -980,14 +987,6 @@ export interface GridProps extends SpaceProps {
// @public
export const Header: (props: HeaderProps) => JSX_2.Element;
// @public
export interface HeaderBreadcrumb {
// (undocumented)
href: string;
// (undocumented)
label: string;
}
// @public
export interface HeaderMenuItem {
// (undocumented)
@@ -1001,8 +1000,18 @@ export interface HeaderMenuItem {
// @public
export const HeaderPage: (props: HeaderPageProps) => JSX_2.Element;
// @public
export interface HeaderPageBreadcrumb {
// (undocumented)
href: string;
// (undocumented)
label: string;
}
// @public
export interface HeaderPageProps {
// (undocumented)
breadcrumbs?: HeaderPageBreadcrumb[];
// (undocumented)
customActions?: React.ReactNode;
// (undocumented)
@@ -1015,8 +1024,6 @@ export interface HeaderPageProps {
// @public
export interface HeaderProps {
// (undocumented)
breadcrumbs?: HeaderBreadcrumb[];
// (undocumented)
customActions?: React.ReactNode;
// (undocumented)
@@ -16,7 +16,7 @@
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { Header } from './Header';
import { HeaderBreadcrumb, HeaderMenuItem, HeaderTab } from './types';
import { HeaderMenuItem, HeaderTab } from './types';
import { Button } from '../Button';
import { HeaderPage } from '../HeaderPage';
import { MemoryRouter } from 'react-router-dom';
@@ -28,6 +28,7 @@ import {
RiEmotionHappyLine,
RiCloudy2Line,
} from '@remixicon/react';
import { HeaderPageBreadcrumb } from '../HeaderPage/types';
const meta = {
title: 'Components/Header',
@@ -86,7 +87,18 @@ const tabs2: HeaderTab[] = [
},
];
const breadcrumbs: HeaderBreadcrumb[] = [
const menuItems: HeaderMenuItem[] = [
{
label: 'Settings',
value: 'settings',
},
{
label: 'Invite new members',
value: 'invite-new-members',
},
];
const breadcrumbs: HeaderPageBreadcrumb[] = [
{
label: 'Home',
href: '/',
@@ -101,17 +113,6 @@ const breadcrumbs: HeaderBreadcrumb[] = [
},
];
const menuItems: HeaderMenuItem[] = [
{
label: 'Settings',
value: 'settings',
},
{
label: 'Invite new members',
value: 'invite-new-members',
},
];
// Extract layout decorator as a reusable constant
const layoutDecorator = [
(Story: StoryFn) => (
@@ -138,7 +139,35 @@ const layoutDecorator = [
>
<Story />
<Container>
<Text>
<Text as="p">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
<Text as="p">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
<Text as="p">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
<Text as="p">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
<Text as="p">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
<Text as="p">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
<Text as="p">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
<Text as="p">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
@@ -195,14 +224,6 @@ export const WithAllOptions: Story = {
render: WithCustomActions.render,
};
export const WithBreadcrumbs: Story = {
args: {
breadcrumbs,
tabs,
},
decorators: [withRouter],
};
export const WithAllOptionsAndTabs: Story = {
args: {
...WithAllOptions.args,
@@ -234,6 +255,7 @@ export const WithHeaderPage: Story = {
menuItems={args.menuItems}
tabs={tabs2}
customActions={<Button>Custom action</Button>}
breadcrumbs={breadcrumbs}
/>
</>
),
@@ -242,7 +264,6 @@ export const WithHeaderPage: Story = {
export const WithLayout: Story = {
args: {
menuItems,
breadcrumbs,
},
decorators: layoutDecorator,
render: args => (
@@ -253,6 +274,7 @@ export const WithLayout: Story = {
menuItems={args.menuItems}
tabs={tabs2}
customActions={<Button>Custom action</Button>}
breadcrumbs={breadcrumbs}
/>
</>
),
@@ -261,7 +283,6 @@ export const WithLayout: Story = {
export const WithLayoutNoTabs: Story = {
args: {
menuItems,
breadcrumbs,
},
decorators: layoutDecorator,
render: args => (
@@ -275,7 +296,6 @@ export const WithLayoutNoTabs: Story = {
export const WithEverything: Story = {
args: {
menuItems,
breadcrumbs,
tabs,
titleLink: '/',
},
@@ -15,9 +15,6 @@
*/
.bui-HeaderToolbar {
position: sticky;
top: 0;
z-index: 10;
margin-bottom: var(--bui-space-6);
&::before {
@@ -89,38 +86,6 @@
gap: var(--bui-space-2);
}
.bui-HeaderBreadcrumbs {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
font-size: var(--bui-font-size-3);
font-weight: var(--bui-font-weight-regular);
}
.bui-HeaderBreadcrumb {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
}
.bui-HeaderBreadcrumbLink {
color: var(--bui-fg-secondary);
text-decoration: none;
cursor: pointer;
&[data-active='true'] {
color: var(--bui-fg-primary);
}
}
.bui-HeaderBreadcrumbSeparator {
width: 16px;
height: 16px;
color: var(--bui-fg-secondary);
}
.bui-HeaderTabsWrapper {
margin-bottom: var(--bui-space-4);
padding-inline: var(--bui-space-3);
@@ -38,7 +38,6 @@ export const Header = (props: HeaderProps) => {
title,
titleLink,
menuItems,
breadcrumbs,
customActions,
onTabSelectionChange,
} = props;
@@ -54,7 +53,6 @@ export const Header = (props: HeaderProps) => {
title={title}
titleLink={titleLink}
menuItems={menuItems}
breadcrumbs={breadcrumbs}
customActions={customActions}
hasTabs={hasTabs}
/>
@@ -16,8 +16,8 @@
import { Link, RouterProvider } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useRef, useState, useEffect } from 'react';
import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
import { useRef } from 'react';
import { RiMore2Line, RiShapesLine } from '@remixicon/react';
import type { HeaderToolbarProps } from './types';
import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
@@ -25,7 +25,6 @@ import { Text } from '../Text';
import { useNavigate, useHref } from 'react-router-dom';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger);
@@ -35,15 +34,7 @@ gsap.registerPlugin(ScrollTrigger);
* @internal
*/
export const HeaderToolbar = (props: HeaderToolbarProps) => {
const {
icon,
title,
titleLink,
menuItems,
breadcrumbs,
customActions,
hasTabs,
} = props;
const { icon, title, titleLink, menuItems, customActions, hasTabs } = props;
const { classNames } = useStyles('Header');
let navigate = useNavigate();
@@ -51,66 +42,6 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
const toolbarContentRef = useRef<HTMLDivElement>(null);
const toolbarControlsRef = useRef<HTMLDivElement>(null);
const breadcrumbsRef = useRef<HTMLDivElement>(null);
// State for breadcrumb visibility
const [showBreadcrumbs, setShowBreadcrumbs] = useState(true);
// Set up resize observer
useEffect(() => {
const wrapper = toolbarWrapperRef.current;
if (!wrapper) return;
const resizeObserver = new ResizeObserver(() => {
const wrapper = toolbarWrapperRef.current;
const content = toolbarContentRef.current;
const options = toolbarControlsRef.current;
if (!wrapper || !content) return;
// Get dimensions
const wrapperRect = wrapper.getBoundingClientRect();
const wrapperWidth = wrapperRect.width;
const wrapperPadding = 24; // 12px on each side (var(--bui-space-3))
const availableWidth = wrapperWidth - wrapperPadding;
// Calculate required width for content
const contentRect = content.getBoundingClientRect();
const contentWidth = contentRect?.width || 0;
// Calculate options width (if exists)
const optionsRect = options?.getBoundingClientRect();
const optionsWidth = optionsRect?.width || 0;
// Check if we need to hide breadcrumbs
const shouldShowBreadcrumbs =
contentWidth + optionsWidth + 32 <= availableWidth;
// Only update state if the value actually changed to prevent flickering
setShowBreadcrumbs(prev =>
prev !== shouldShowBreadcrumbs ? shouldShowBreadcrumbs : prev,
);
});
resizeObserver.observe(wrapper);
return () => {
resizeObserver.disconnect();
};
}, []);
useGSAP(() => {
gsap.to(breadcrumbsRef.current, {
scrollTrigger: {
start: '10% 10%',
end: '20% 20%',
scrub: true,
},
opacity: 1,
duration: 1,
ease: 'power2.inOut',
});
});
const titleContent = (
<>
@@ -133,38 +64,6 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
<div className={classNames.toolbarName}>{titleContent}</div>
)}
</Text>
{breadcrumbs && (
<div
ref={breadcrumbsRef}
className={classNames.breadcrumbs}
style={{
opacity: 0,
visibility: showBreadcrumbs ? 'visible' : 'hidden',
}}
>
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
{breadcrumbs.map((breadcrumb, index) => (
<div key={breadcrumb.label} className={classNames.breadcrumb}>
<Link
href={breadcrumb.href}
className={classNames.breadcrumbLink}
data-active={index === breadcrumbs.length - 1}
>
{breadcrumb.label}
</Link>
{index < breadcrumbs.length - 1 && (
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
)}
</div>
))}
</div>
)}
</div>
<div className={classNames.toolbarControls} ref={toolbarControlsRef}>
{customActions}
+1 -6
View File
@@ -15,9 +15,4 @@
*/
export { Header } from './Header';
export type {
HeaderProps,
HeaderTab,
HeaderMenuItem,
HeaderBreadcrumb,
} from './types';
export type { HeaderProps, HeaderTab, HeaderMenuItem } from './types';
@@ -26,7 +26,6 @@ export interface HeaderProps {
icon?: React.ReactNode;
title?: string;
titleLink?: string;
breadcrumbs?: HeaderBreadcrumb[];
customActions?: React.ReactNode;
menuItems?: HeaderMenuItem[];
tabs?: HeaderTab[];
@@ -61,16 +60,6 @@ export interface HeaderMenuItem {
onClick?: () => void;
}
/**
* Represents a breadcrumb item in the header.
*
* @public
*/
export interface HeaderBreadcrumb {
label: string;
href: string;
}
/**
* Props for the HeaderToolbar component.
*
@@ -80,7 +69,6 @@ export interface HeaderToolbarProps {
icon?: HeaderProps['icon'];
title?: HeaderProps['title'];
titleLink?: HeaderProps['titleLink'];
breadcrumbs?: HeaderProps['breadcrumbs'];
customActions?: HeaderProps['customActions'];
menuItems?: HeaderProps['menuItems'];
hasTabs?: boolean;
@@ -18,7 +18,6 @@
display: flex;
flex-direction: column;
gap: var(--bui-space-1);
padding-inline: var(--bui-space-5);
margin-top: var(--bui-space-6);
margin-bottom: var(--bui-space-6);
}
@@ -39,3 +38,56 @@
align-items: center;
gap: var(--bui-space-2);
}
.bui-HeaderPageSticky {
position: sticky;
top: 0;
z-index: -1;
height: 0;
color: var(--bui-fg-primary);
}
.bui-HeaderPageStickyWrapper {
background-color: var(--bui-bg-surface-1);
border-bottom: 1px solid var(--bui-border);
opacity: 0;
}
.bui-HeaderPageStickyContent {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
}
.bui-HeaderPageBreadcrumbs {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
font-size: var(--bui-font-size-3);
font-weight: var(--bui-font-weight-regular);
}
.bui-HeaderPageBreadcrumb {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
}
.bui-HeaderPageBreadcrumbLink {
color: var(--bui-fg-secondary);
text-decoration: none;
cursor: pointer;
&[data-active='true'] {
color: var(--bui-fg-primary);
}
}
.bui-HeaderPageBreadcrumbSeparator {
width: 16px;
height: 16px;
color: var(--bui-fg-secondary);
}
@@ -18,9 +18,17 @@ import type { HeaderPageProps } from './types';
import { Menu } from '../Menu';
import { Text } from '../Text';
import { ButtonIcon } from '../ButtonIcon';
import { RiMore2Line } from '@remixicon/react';
import { RiArrowRightSLine, RiMore2Line } from '@remixicon/react';
import { Tabs, TabList, Tab } from '../Tabs';
import { useStyles } from '../../hooks/useStyles';
import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { Container } from '../Container';
import { Link } from '../Link';
gsap.registerPlugin(ScrollTrigger);
/**
* A component that renders a header page.
@@ -28,65 +36,128 @@ import { useStyles } from '../../hooks/useStyles';
* @public
*/
export const HeaderPage = (props: HeaderPageProps) => {
const { title, menuItems, tabs, customActions } = props;
const { title, menuItems, tabs, customActions, breadcrumbs } = props;
const { classNames } = useStyles('HeaderPage');
const stickyRef = useRef<HTMLDivElement>(null);
const stickyContentRef = useRef<HTMLDivElement>(null);
return (
<div className={classNames.root}>
<div className={classNames.content}>
<Text variant="title-small" weight="bold" as="h2">
{title}
</Text>
<div className={classNames.controls}>
{customActions}
{menuItems && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="tertiary"
/>
)}
useGSAP(() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: stickyRef.current,
start: 'top top',
end: '120px top',
scrub: true,
},
});
tl.to(stickyContentRef.current, {
opacity: 1,
ease: 'power2.inOut',
}).to(
stickyRef.current,
{
zIndex: 10,
ease: 'none',
},
'<',
);
});
const controls = (
<div className={classNames.controls}>
{customActions}
{menuItems && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="tertiary"
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{menuItems.map(menuItem => (
<Menu.Item
key={menuItem.value}
onClick={() => menuItem.onClick?.()}
>
{menuItem.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
</div>
{tabs && (
<div className={classNames.tabsWrapper}>
<Tabs>
<TabList>
{tabs.map(tab => (
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</div>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{menuItems.map(menuItem => (
<Menu.Item
key={menuItem.value}
onClick={() => menuItem.onClick?.()}
>
{menuItem.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
);
return (
<>
<Container className={classNames.root}>
<div className={classNames.content}>
<Text variant="title-small" weight="bold" as="h2">
{title}
</Text>
{controls}
</div>
{tabs && (
<div className={classNames.tabsWrapper}>
<Tabs>
<TabList>
{tabs.map(tab => (
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</div>
)}
</Container>
<div className={classNames.sticky} ref={stickyRef}>
<div className={classNames.stickyWrapper} ref={stickyContentRef}>
<Container className={classNames.stickyContent}>
{breadcrumbs ? (
<div className={classNames.breadcrumbs}>
{breadcrumbs.map((breadcrumb, index) => (
<div key={breadcrumb.label} className={classNames.breadcrumb}>
<Link
href={breadcrumb.href}
className={classNames.breadcrumbLink}
data-active={index === breadcrumbs.length - 1}
>
{breadcrumb.label}
</Link>
{index < breadcrumbs.length - 1 && (
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
)}
</div>
))}
</div>
) : (
<Text variant="body-medium" as="p">
{title}
</Text>
)}
{controls}
</Container>
</div>
</div>
</>
);
};
@@ -15,4 +15,4 @@
*/
export { HeaderPage } from './HeaderPage';
export type { HeaderPageProps } from './types';
export type { HeaderPageProps, HeaderPageBreadcrumb } from './types';
@@ -26,4 +26,15 @@ export interface HeaderPageProps {
customActions?: React.ReactNode;
menuItems?: HeaderMenuItem[];
tabs?: HeaderTab[];
breadcrumbs?: HeaderPageBreadcrumb[];
}
/**
* Represents a breadcrumb item in the header.
*
* @public
*/
export interface HeaderPageBreadcrumb {
label: string;
href: string;
}
@@ -125,6 +125,13 @@ export const componentDefinitions = {
content: 'bui-HeaderPageContent',
tabsWrapper: 'bui-HeaderPageTabsWrapper',
controls: 'bui-HeaderPageControls',
sticky: 'bui-HeaderPageSticky',
stickyWrapper: 'bui-HeaderPageStickyWrapper',
stickyContent: 'bui-HeaderPageStickyContent',
breadcrumbs: 'bui-HeaderPageBreadcrumbs',
breadcrumb: 'bui-HeaderPageBreadcrumb',
breadcrumbLink: 'bui-HeaderPageBreadcrumbLink',
breadcrumbSeparator: 'bui-HeaderPageBreadcrumbSeparator',
},
},
Heading: {