Merge pull request #33504 from backstage/bui-pluginheader

This commit is contained in:
Charles de Dreuille
2026-03-23 15:48:45 +00:00
committed by GitHub
9 changed files with 354 additions and 312 deletions
@@ -0,0 +1,9 @@
---
'@backstage/ui': minor
---
**BREAKING**: Removed the `toolbarWrapper` element from `PluginHeader` and dropped `toolbarWrapper` from `PluginHeaderDefinition.classNames`. Toolbar layout styles now live on `toolbar` (`.bui-PluginHeaderToolbar`).
**Migration:** Update custom CSS that targeted `.bui-PluginHeaderToolbarWrapper` to use `.bui-PluginHeaderToolbar` instead.
**Affected components:** PluginHeader
+21 -23
View File
@@ -183,21 +183,6 @@
font-weight: var(--bui-font-weight-regular);
}
.bui-PluginHeaderToolbarWrapper {
padding: 0;
height: 32px;
border: none;
background: none;
}
.bui-PluginHeaderTabsWrapper {
margin: 0;
padding: 0;
margin-left: -8px;
border: none;
background: none;
}
.bui-Input {
border-radius: var(--bui-radius-3);
}
@@ -228,14 +213,6 @@
--bui-border-success: #53db83;
--bui-ring: rgba(0, 0, 0, 0.2);
.bui-HeaderToolbarWrapper {
border: 1px solid var(--bui-border-2);
}
.bui-HeaderTabsWrapper {
border: 1px solid var(--bui-border-2);
}
}
[data-theme-mode='dark'][data-theme-name='spotify'] {
@@ -266,3 +243,24 @@
--bui-ring: rgba(255, 255, 255, 0.2);
}
/*
* Plugin header (@backstage/ui) and story shell header — kept at the bottom of
* this file for easier scanning alongside other component overrides above.
*/
[data-theme-name='spotify'] {
.bui-PluginHeaderToolbar {
padding: 0;
height: 32px;
border: none;
background: none;
margin-bottom: var(--bui-space-2);
}
.bui-PluginHeaderTabsWrapper {
padding: 0;
border: none;
background: none;
margin-left: -8px;
}
}
-1
View File
@@ -1949,7 +1949,6 @@ export const PluginHeaderDefinition: {
readonly classNames: {
readonly root: 'bui-PluginHeader';
readonly toolbar: 'bui-PluginHeaderToolbar';
readonly toolbarWrapper: 'bui-PluginHeaderToolbarWrapper';
readonly toolbarContent: 'bui-PluginHeaderToolbarContent';
readonly toolbarControls: 'bui-PluginHeaderToolbarControls';
readonly toolbarIcon: 'bui-PluginHeaderToolbarIcon';
@@ -95,42 +95,6 @@ const withRouter = (Story: StoryFn) => (
</MemoryRouter>
);
// Extract layout decorator as a reusable constant
const layoutDecorator = [
(Story: StoryFn) => (
<>
<div
style={{
width: '250px',
position: 'fixed',
left: 'var(--sb-panel-left)',
top: 'var(--sb-panel-top)',
bottom: 'var(--sb-panel-bottom)',
backgroundColor: 'var(--sb-sidebar-bg)',
borderRadius: 'var(--sb-panel-radius)',
border: 'var(--sb-sidebar-border)',
borderRight: 'var(--sb-sidebar-border-right)',
zIndex: 1,
}}
/>
<div
style={{
paddingLeft: 'var(--sb-content-padding-inline)',
minHeight: '200vh',
}}
>
<Story />
<Container>
<Text>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam,
quos.
</Text>
</Container>
</div>
</>
),
];
export const Default = meta.story({
args: {
title: 'Page Title',
@@ -208,10 +172,6 @@ export const WithEverything = meta.story({
),
});
export const WithLayout = WithEverything.extend({
decorators: [...layoutDecorator],
});
export const WithTabsMatchingStrategies = meta.story({
args: {
title: 'Route Matching Demo',
@@ -17,15 +17,15 @@
@layer tokens, base, components, utilities;
@layer components {
.bui-PluginHeaderToolbarWrapper {
.bui-PluginHeaderToolbar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-inline: var(--bui-space-5);
border-bottom: 1px solid var(--bui-border-1);
color: var(--bui-fg-primary);
height: 52px;
border-bottom: 1px solid var(--bui-border-1);
}
.bui-PluginHeaderToolbarContent {
@@ -46,13 +46,18 @@
}
.bui-PluginHeaderToolbarIcon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: var(--bui-radius-2);
color: var(--bui-fg-primary);
& svg {
width: 100%;
height: 100%;
width: 1rem;
height: 1rem;
}
}
@@ -19,8 +19,6 @@ import type { StoryFn } from '@storybook/react-vite';
import { PluginHeader } from './PluginHeader';
import type { HeaderTab } from './types';
import {
Button,
Header,
Container,
Text,
ButtonIcon,
@@ -36,7 +34,6 @@ import {
RiCloudy2Line,
RiMore2Line,
} from '@remixicon/react';
import { HeaderBreadcrumb } from '../Header/types';
const meta = preview.meta({
title: 'Backstage UI/PluginHeader',
@@ -82,24 +79,6 @@ const tabs: HeaderTab[] = [
},
];
const tabs2: HeaderTab[] = [
{
id: 'Banana',
label: 'Banana',
href: '/banana',
},
{
id: 'Apple',
label: 'Apple',
href: '/apple',
},
{
id: 'Orange',
label: 'Orange',
href: '/orange',
},
];
const menuItems = [
{
label: 'Settings',
@@ -120,86 +99,6 @@ const menuItems = [
},
];
const breadcrumbs: HeaderBreadcrumb[] = [
{
label: 'Home',
href: '/',
},
{
label: 'Dashboard',
href: '/dashboard',
},
{
label: 'Settings',
href: '/settings',
},
];
// Extract layout decorator as a reusable constant
const layoutDecorator = [
(Story: StoryFn) => (
<>
<div
style={{
width: '250px',
position: 'fixed',
left: 'var(--sb-panel-left)',
top: 'var(--sb-panel-top)',
bottom: 'var(--sb-panel-bottom)',
backgroundColor: 'var(--sb-sidebar-bg)',
borderRadius: 'var(--sb-panel-radius)',
border: 'var(--sb-sidebar-border)',
borderRight: 'var(--sb-sidebar-border-right)',
zIndex: 1,
}}
/>
<div
style={{
paddingLeft: 'var(--sb-content-padding-inline)',
minHeight: '200vh',
}}
>
<Story />
<Container>
<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>
</Container>
</div>
</>
),
withRouter,
];
export const Default = meta.story({
args: {},
decorators: [withRouter],
@@ -220,11 +119,11 @@ export const WithCustomActions = meta.story({
{...args}
customActions={
<>
<ButtonIcon variant="tertiary" icon={<RiCloudy2Line />} />
<ButtonIcon variant="tertiary" icon={<RiEmotionHappyLine />} />
<ButtonIcon variant="tertiary" icon={<RiHeartLine />} />
<ButtonIcon variant="secondary" icon={<RiCloudy2Line />} />
<ButtonIcon variant="secondary" icon={<RiEmotionHappyLine />} />
<ButtonIcon variant="secondary" icon={<RiHeartLine />} />
<MenuTrigger>
<ButtonIcon variant="tertiary" icon={<RiMore2Line />} />
<ButtonIcon variant="secondary" icon={<RiMore2Line />} />
<Menu placement="bottom end">
{menuItems.map(option => (
<MenuItem
@@ -249,90 +148,6 @@ export const WithAllOptionsAndTabs = WithCustomActions.extend({
},
});
export const WithHeader = meta.story({
args: {
...WithAllOptionsAndTabs.input.args,
},
decorators: [withRouter],
render: args => (
<>
<PluginHeader
{...args}
customActions={
<>
<ButtonIcon variant="tertiary" icon={<RiCloudy2Line />} />
<ButtonIcon variant="tertiary" icon={<RiEmotionHappyLine />} />
<ButtonIcon variant="tertiary" icon={<RiHeartLine />} />
</>
}
/>
<Header
title="Page title"
tabs={tabs2}
customActions={<Button>Custom action</Button>}
breadcrumbs={breadcrumbs}
/>
</>
),
});
export const WithLayout = meta.story({
decorators: layoutDecorator,
render: args => (
<>
<PluginHeader {...args} tabs={tabs} />
<Header
title="Page title"
tabs={tabs2}
customActions={<Button>Custom action</Button>}
breadcrumbs={breadcrumbs}
/>
</>
),
});
export const WithLayoutNoTabs = meta.story({
decorators: layoutDecorator,
render: args => (
<>
<PluginHeader {...args} />
<Header title="Page title" tabs={tabs2} />
</>
),
});
export const WithEverything = meta.story({
args: {
tabs,
titleLink: '/',
},
decorators: layoutDecorator,
render: args => (
<>
<PluginHeader
{...args}
customActions={
<>
<ButtonIcon variant="tertiary" icon={<RiCloudy2Line />} />
<ButtonIcon variant="tertiary" icon={<RiEmotionHappyLine />} />
<ButtonIcon variant="tertiary" icon={<RiHeartLine />} />
</>
}
/>
<Header
title="Page title"
tabs={tabs2}
customActions={
<>
<Button variant="secondary">Secondary</Button>
<Button variant="primary">Primary</Button>
</>
}
/>
</>
),
});
export const WithMockedURLCampaigns = meta.story({
args: {
tabs,
@@ -341,7 +156,7 @@ export const WithMockedURLCampaigns = meta.story({
<MemoryRouter initialEntries={['/campaigns']}>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Container mt="6">
<Text as="p">
Current URL is mocked to be: <strong>/campaigns</strong>
</Text>
@@ -363,7 +178,7 @@ export const WithMockedURLIntegrations = meta.story({
<MemoryRouter initialEntries={['/integrations']}>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Container mt="6">
<Text as="p">
Current URL is mocked to be: <strong>/integrations</strong>
</Text>
@@ -385,7 +200,7 @@ export const WithMockedURLNoMatch = meta.story({
<MemoryRouter initialEntries={['/some-other-page']}>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Container mt="6">
<Text as="p">
Current URL is mocked to be: <strong>/some-other-page</strong>
</Text>
@@ -435,7 +250,7 @@ export const WithTabsMatchingStrategies = meta.story({
<MemoryRouter initialEntries={['/mentorship/events']}>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Container mt="6">
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
@@ -490,7 +305,7 @@ export const WithTabsExactMatching = meta.story({
<MemoryRouter initialEntries={['/mentorship/events']}>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Container mt="6">
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
@@ -534,7 +349,7 @@ export const WithTabsPrefixMatchingDeep = meta.story({
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<BUIProvider>
<PluginHeader {...args} />
<Container>
<Container mt="6">
<Text as="p">
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
@@ -22,9 +22,10 @@ import { type NavigateOptions } from 'react-router-dom';
import { Children, useMemo, useRef } from 'react';
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
import { Box } from '../Box';
import { Link } from 'react-aria-components';
import { Link } from '../Link';
import { RiShapesLine } from '@remixicon/react';
import { Text } from '../Text';
import { BgReset } from '../../hooks/useBg';
declare module 'react-aria-components' {
interface RouterConfig {
@@ -33,8 +34,9 @@ declare module 'react-aria-components' {
}
/**
* A component that renders a plugin header with icon, title, custom actions,
* and navigation tabs.
* Renders a plugin header with icon, title, custom actions, and optional tabs.
* Always participates in the background context system so descendants (e.g. buttons)
* get the correct `data-on-bg` styling inside the toolbar and tabs.
*
* @public
*/
@@ -52,9 +54,6 @@ export const PluginHeader = (props: PluginHeaderProps) => {
const hasTabs = tabs && tabs.length > 0;
const headerRef = useRef<HTMLElement>(null);
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
const toolbarContentRef = useRef<HTMLDivElement>(null);
const toolbarControlsRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | undefined>(undefined);
const lastAppliedHeightRef = useRef<number | undefined>(undefined);
@@ -123,53 +122,53 @@ export const PluginHeader = (props: PluginHeaderProps) => {
};
}, []);
const titleContent = (
<>
<div className={classes.toolbarIcon} aria-hidden="true">
{icon || <RiShapesLine />}
</div>
<Text variant="body-medium">{title || 'Your plugin'}</Text>
</>
);
const titleText = title || 'Your plugin';
return (
<header ref={headerRef} className={classes.root}>
<div className={classes.toolbar} data-has-tabs={hasTabs}>
<div className={classes.toolbarWrapper} ref={toolbarWrapperRef}>
<div className={classes.toolbarContent} ref={toolbarContentRef}>
<Text as="h1" variant="body-medium">
<BgReset>
<header ref={headerRef} className={classes.root}>
<Box bg="neutral" className={classes.toolbar} data-has-tabs={hasTabs}>
<div className={classes.toolbarContent}>
<Box
bg="neutral"
className={classes.toolbarIcon}
aria-hidden="true"
>
{icon || <RiShapesLine />}
</Box>
<h1 className={classes.toolbarName}>
{titleLink ? (
<Link className={classes.toolbarName} href={titleLink}>
{titleContent}
<Link href={titleLink} standalone variant="body-medium">
{titleText}
</Link>
) : (
<div className={classes.toolbarName}>{titleContent}</div>
<Text as="span" variant="body-medium">
{titleText}
</Text>
)}
</Text>
</h1>
</div>
<div className={classes.toolbarControls} ref={toolbarControlsRef}>
{actionChildren}
</div>
</div>
</div>
{tabs && (
<Box bg="neutral" className={classes.tabs}>
<Tabs onSelectionChange={onTabSelectionChange}>
<TabList>
{tabs?.map(tab => (
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
<div className={classes.toolbarControls}>{actionChildren}</div>
</Box>
)}
</header>
{tabs && (
<Box bg="neutral" className={classes.tabs}>
<Tabs onSelectionChange={onTabSelectionChange}>
<TabList>
{tabs?.map(tab => (
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</Box>
)}
</header>
</BgReset>
);
};
@@ -27,7 +27,6 @@ export const PluginHeaderDefinition = defineComponent<PluginHeaderOwnProps>()({
classNames: {
root: 'bui-PluginHeader',
toolbar: 'bui-PluginHeaderToolbar',
toolbarWrapper: 'bui-PluginHeaderToolbarWrapper',
toolbarContent: 'bui-PluginHeaderToolbarContent',
toolbarControls: 'bui-PluginHeaderToolbarControls',
toolbarIcon: 'bui-PluginHeaderToolbarIcon',
@@ -0,0 +1,258 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import preview from '../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../provider';
import {
PluginHeader,
Header,
Button,
ButtonIcon,
Card,
Container,
Flex,
MenuTrigger,
Menu,
MenuItem,
} from '..';
import {
RiBookOpenLine,
RiBox3Line,
RiCodeSSlashLine,
RiDownloadLine,
RiEdit2Line,
RiGitBranchLine,
RiMore2Line,
RiPlayLine,
RiRefreshLine,
RiSettings4Line,
RiShieldCheckLine,
RiShareBoxLine,
RiTerminalLine,
} from '@remixicon/react';
// ---------------------------------------------------------------------------
// Shared page content placeholder
// ---------------------------------------------------------------------------
const PageContent = () => (
<Container mt="6">
<Flex direction="row" gap="4">
<Card style={{ minHeight: 120, flex: 1 }} />
<Card style={{ minHeight: 120, flex: 1 }} />
<Card style={{ minHeight: 120, flex: 1 }} />
</Flex>
</Container>
);
// ---------------------------------------------------------------------------
// Shared layout decorator
// ---------------------------------------------------------------------------
const withLayout = (Story: StoryFn) => (
<MemoryRouter>
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
);
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta = preview.meta({
title: 'Recipes/PluginHeader and Header',
parameters: {
layout: 'fullscreen',
},
});
export const NoHeader = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader icon={<RiCodeSSlashLine />} title="APIs" />
<PageContent />
</>
),
});
export const SimpleHeader = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader icon={<RiCodeSSlashLine />} title="APIs" />
<Header title="payments-api" />
<PageContent />
</>
),
});
export const WithTabs = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader
icon={<RiBox3Line />}
title="Catalog"
titleLink="/"
tabs={[
{ id: 'catalog', label: 'Catalog', href: '/catalog' },
{ id: 'apis', label: 'APIs', href: '/apis' },
{ id: 'resources', label: 'Resources', href: '/resources' },
{ id: 'templates', label: 'Templates', href: '/templates' },
{ id: 'docs', label: 'Docs', href: '/docs' },
]}
customActions={
<>
<ButtonIcon
variant="secondary"
icon={<RiSettings4Line />}
aria-label="Settings"
/>
<MenuTrigger>
<ButtonIcon
variant="secondary"
icon={<RiMore2Line />}
aria-label="More options"
/>
<Menu placement="bottom end">
<MenuItem href="/catalog/import">Import component</MenuItem>
<MenuItem href="/catalog/register">Register existing</MenuItem>
<MenuItem href="/catalog/docs">View documentation</MenuItem>
</Menu>
</MenuTrigger>
</>
}
/>
<Header
title="payment-service"
customActions={
<>
<Button variant="secondary" iconStart={<RiEdit2Line />}>
Edit
</Button>
<Button variant="primary" iconStart={<RiShareBoxLine />}>
Unregister
</Button>
</>
}
/>
<PageContent />
</>
),
});
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 />
</>
),
});
export const WithSubTabs = 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"
tabs={[
{ id: 'summary', label: 'Summary', href: '/summary' },
{ id: 'steps', label: 'Steps', href: '/steps' },
{ id: 'artifacts', label: 'Artifacts', href: '/artifacts' },
{ id: 'logs', label: 'Logs', href: '/logs' },
]}
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 />
</>
),
});