Merge pull request #30874 from backstage/bui-header-page

BUI - Update breacrumb on HeaderPage
This commit is contained in:
Charles de Dreuille
2025-08-13 13:57:42 +01:00
committed by GitHub
14 changed files with 186 additions and 273 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': minor
---
Backstage UI - HeaderPage - We are updating the breadcrumb to be more visible and accessible.
File diff suppressed because one or more lines are too long
+49 -2
View File
@@ -9,7 +9,8 @@ import {
defaultSnippet,
withTabs,
withBreadcrumbs,
withHeaderPage,
withCustomActions,
withMenuItems,
} from './header-page.props';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
@@ -35,6 +36,52 @@ import { ChangelogComponent } from '@/components/ChangelogComponent';
<PropsTable data={propDefs} />
<Theming component="Header" />
## Examples
### With Breadcrumbs
You can add breadcrumbs to the header page to help users navigate to the previous page. The `breadcrumbs`
prop is an array of objects with a `label` and `href` property. By default we truncate the breadcrumb label to 240px.
<Snippet
open
preview={<HeaderPageSnippet story="WithLongBreadcrumbs" />}
code={withBreadcrumbs}
/>
### With Tabs
You can add tabs to the header page to help users navigate to the different sections of the page. The `tabs`
prop is an array of objects with a `label` and `href` property.
<Snippet
open
preview={<HeaderPageSnippet story="WithTabs" />}
code={withTabs}
/>
### With Custom Actions
You can add custom actions to the header page to help users navigate to the different sections of the page. The `customActions`
prop is a React node.
<Snippet
open
preview={<HeaderPageSnippet story="WithCustomActions" />}
code={withCustomActions}
/>
### With Menu Items
You can add menu items to the header page to help users navigate to the different sections of the page. The `menuItems`
prop is an array of objects with a `label`, `value` and `onClick` property.
<Snippet
open
preview={<HeaderPageSnippet story="WithMenuItems" />}
code={withMenuItems}
/>
<Theming component="HeaderPage" />
<ChangelogComponent component="Header" />
@@ -93,7 +93,7 @@ export const usage = `import { HeaderPage } from '@backstage/ui';
<HeaderPage />`;
export const defaultSnippet = `<HeaderPage
title="My plugin"
title="Page Title"
tabs={[
{ id: 'overview', label: 'Overview' },
{ id: 'checks', label: 'Checks' },
@@ -107,3 +107,35 @@ export const defaultSnippet = `<HeaderPage
]}
customActions={<Button>Custom action</Button>}
/>`;
export const withBreadcrumbs = `<HeaderPage
title="Page Title"
breadcrumbs={[
{ label: 'Home', href: '/' },
{ label: 'Long Breadcrumb Name', href: '/long-breadcrumb' },
]}
/>`;
export const withTabs = `<HeaderPage
title="Page Title"
tabs={[
{ id: 'overview', label: 'Overview', href: '/overview' },
{ id: 'checks', label: 'Checks', href: '/checks' },
{ id: 'tracks', label: 'Tracks', href: '/tracks' },
{ id: 'campaigns', label: 'Campaigns', href: '/campaigns' },
{ id: 'integrations', label: 'Integrations', href: '/integrations' },
]}
/>`;
export const withCustomActions = `<HeaderPage
title="Page Title"
customActions={<Button>Custom action</Button>}
/>`;
export const withMenuItems = `<HeaderPage
title="Page Title"
menuItems={[
{ label: 'Settings', value: 'settings', onClick: () => {} },
{ label: 'Invite new members', value: 'invite-new-members', onClick: () => {} },
]}
/>`;
+1 -54
View File
@@ -9659,66 +9659,13 @@
margin-left: -8px;
}
.bui-HeaderPageControls {
.bui-HeaderPageControls, .bui-HeaderPageBreadcrumbs {
align-items: center;
gap: var(--bui-space-2);
flex-direction: row;
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;
-2
View File
@@ -42,11 +42,9 @@
},
"dependencies": {
"@base-ui-components/react": "1.0.0-alpha.7",
"@gsap/react": "^2.1.2",
"@remixicon/react": "^4.6.0",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
"gsap": "^3.13.0",
"react-aria-components": "^1.10.1"
},
"devDependencies": {
+1 -11
View File
@@ -437,10 +437,6 @@ export const componentDefinitions: {
readonly toolbarControls: 'bui-HeaderToolbarControls';
readonly toolbarIcon: 'bui-HeaderToolbarIcon';
readonly toolbarName: 'bui-HeaderToolbarName';
readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
readonly breadcrumb: 'bui-HeaderBreadcrumb';
readonly breadcrumbLink: 'bui-HeaderBreadcrumbLink';
readonly breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator';
readonly tabsWrapper: 'bui-HeaderTabsWrapper';
};
};
@@ -448,15 +444,9 @@ export const componentDefinitions: {
readonly classNames: {
readonly root: 'bui-HeaderPage';
readonly content: 'bui-HeaderPageContent';
readonly breadcrumbs: 'bui-HeaderPageBreadcrumbs';
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: {
@@ -23,10 +23,6 @@ import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
import { Text } from '../Text';
import { useNavigate, useHref } from 'react-router-dom';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
/**
* A component that renders a toolbar.
@@ -111,7 +111,7 @@ const layoutDecorator = [
export const Default: Story = {
args: {
title: 'Header Page',
title: 'Page Title',
},
};
@@ -134,12 +134,30 @@ export const WithCustomActions: Story = {
render: () => (
<HeaderPage
{...Default.args}
menuItems={menuItems}
customActions={<Button>Custom action</Button>}
/>
),
};
export const WithBreadcrumbs: Story = {
decorators: [withRouter],
args: {
...Default.args,
breadcrumbs: [{ label: 'Home', href: '/' }],
},
};
export const WithLongBreadcrumbs: Story = {
decorators: [withRouter],
args: {
...Default.args,
breadcrumbs: [
{ label: 'Home', href: '/' },
{ label: 'Long Breadcrumb Name', href: '/long-breadcrumb' },
],
},
};
export const WithEverything: Story = {
decorators: [withRouter],
render: () => (
@@ -148,6 +166,7 @@ export const WithEverything: Story = {
menuItems={menuItems}
tabs={tabs}
customActions={<Button>Custom action</Button>}
breadcrumbs={[{ label: 'Home', href: '/' }]}
/>
),
};
@@ -39,55 +39,9 @@
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);
}
@@ -21,14 +21,9 @@ import { ButtonIcon } from '../ButtonIcon';
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);
import { Fragment } from 'react/jsx-runtime';
/**
* A component that renders a header page.
@@ -38,126 +33,81 @@ gsap.registerPlugin(ScrollTrigger);
export const HeaderPage = (props: HeaderPageProps) => {
const { title, menuItems, tabs, customActions, breadcrumbs } = props;
const { classNames } = useStyles('HeaderPage');
const stickyRef = useRef<HTMLDivElement>(null);
const stickyContentRef = useRef<HTMLDivElement>(null);
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>
);
return (
<>
<Container className={classNames.root}>
<div className={classNames.content}>
<Container className={classNames.root}>
<div className={classNames.content}>
<div className={classNames.breadcrumbs}>
{breadcrumbs &&
breadcrumbs.map(breadcrumb => (
<Fragment key={breadcrumb.label}>
<Link
href={breadcrumb.href}
variant="title-small"
weight="bold"
color="secondary"
truncate
style={{ maxWidth: '240px' }}
>
{breadcrumb.label}
</Link>
<RiArrowRightSLine size={16} color="var(--bui-fg-secondary)" />
</Fragment>
))}
<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 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>
)}
</Container>
);
};
@@ -27,7 +27,7 @@ import {
useTable,
} from '.';
import { MemoryRouter } from 'react-router-dom';
import { data as data1 } from './mocked-data1';
import { data as data1Raw } from './mocked-data1';
import { data as data2 } from './mocked-data2';
import { data as data3 } from './mocked-data3';
import { data as data4 } from './mocked-data4';
@@ -45,6 +45,10 @@ const meta = {
],
} satisfies Meta;
// Added this fix to fix Chromatic timeout error. This bug is due to rerendering the table with too many rows.
// Work in progress to fix it here - https://github.com/backstage/backstage/pull/30687
const data1 = data1Raw.slice(0, 25);
export default meta;
type Story = StoryObj<typeof meta>;
+1 -11
View File
@@ -112,10 +112,6 @@ export const componentDefinitions = {
toolbarControls: 'bui-HeaderToolbarControls',
toolbarIcon: 'bui-HeaderToolbarIcon',
toolbarName: 'bui-HeaderToolbarName',
breadcrumbs: 'bui-HeaderBreadcrumbs',
breadcrumb: 'bui-HeaderBreadcrumb',
breadcrumbLink: 'bui-HeaderBreadcrumbLink',
breadcrumbSeparator: 'bui-HeaderBreadcrumbSeparator',
tabsWrapper: 'bui-HeaderTabsWrapper',
},
},
@@ -123,15 +119,9 @@ export const componentDefinitions = {
classNames: {
root: 'bui-HeaderPage',
content: 'bui-HeaderPageContent',
breadcrumbs: 'bui-HeaderPageBreadcrumbs',
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: {
-19
View File
@@ -7473,7 +7473,6 @@ __metadata:
dependencies:
"@backstage/cli": "workspace:^"
"@base-ui-components/react": "npm:1.0.0-alpha.7"
"@gsap/react": "npm:^2.1.2"
"@remixicon/react": "npm:^4.6.0"
"@storybook/addon-essentials": "npm:^8.6.12"
"@storybook/addon-interactions": "npm:^8.6.12"
@@ -7492,7 +7491,6 @@ __metadata:
eslint-plugin-storybook: "npm:^0.12.0"
glob: "npm:^11.0.1"
globals: "npm:^15.11.0"
gsap: "npm:^3.13.0"
lightningcss: "npm:^1.29.1"
mini-css-extract-plugin: "npm:^2.9.2"
react: "npm:^18.0.2"
@@ -9367,16 +9365,6 @@ __metadata:
languageName: node
linkType: hard
"@gsap/react@npm:^2.1.2":
version: 2.1.2
resolution: "@gsap/react@npm:2.1.2"
peerDependencies:
gsap: ^3.12.5
react: ">=17"
checksum: 10/48e78bbb31a4ec5a75aab6ea69ec3f4df0bb7cff0c8ebfcdc14553cb8e35f54b7fcf94f933e2aa65fd977135aaac87b855007d376504f1bb93a74328ba1dc0cb
languageName: node
linkType: hard
"@headlessui/react@npm:^1.7.15":
version: 1.7.17
resolution: "@headlessui/react@npm:1.7.17"
@@ -32336,13 +32324,6 @@ __metadata:
languageName: node
linkType: hard
"gsap@npm:^3.13.0":
version: 3.13.0
resolution: "gsap@npm:3.13.0"
checksum: 10/1908d51cbdf8f0bec8a69919af352d743a06529639a89f750f1c02e9a165d3ea816f867569df222ee7bfc14e23e268ebeb51dc5df0687fa7918ddd5d3b3efaec
languageName: node
linkType: hard
"gtoken@npm:^7.0.0":
version: 7.0.1
resolution: "gtoken@npm:7.0.1"