First pass improving the PluginHeader

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-03-21 10:14:44 +00:00
parent d36a6828d9
commit 49ffe8ae6b
8 changed files with 418 additions and 254 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`). Update custom CSS that targeted `.bui-PluginHeaderToolbarWrapper` to use `.bui-PluginHeaderToolbar` instead.
**Affected components:** PluginHeader
`PluginHeader` now establishes a neutral background provider (same rules as `Box` with `bg="neutral"`) so controls in the toolbar and tabs resolve `data-on-bg` correctly.
+31 -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,34 @@
--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 {
margin: 0;
padding: 0;
border: none;
background: none;
}
}
[data-theme-mode='light'][data-theme-name='spotify'] {
.bui-HeaderToolbarWrapper {
border: 1px solid var(--bui-border-2);
}
.bui-HeaderTabsWrapper {
border: 1px solid var(--bui-border-2);
}
}
-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';
@@ -17,15 +17,16 @@
@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;
background-color: var(--bui-bg-neutral-1);
border-bottom: 1px solid var(--bui-border-1);
}
.bui-PluginHeaderToolbarContent {
@@ -46,13 +47,19 @@
}
.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);
background-color: var(--bui-bg-neutral-2);
color: var(--bui-fg-primary);
& svg {
width: 100%;
height: 100%;
width: 1rem;
height: 1rem;
}
}
@@ -66,5 +73,6 @@
.bui-PluginHeaderTabsWrapper {
padding-inline: var(--bui-space-3);
border-bottom: 1px solid var(--bui-border-1);
background-color: var(--bui-bg-neutral-1);
}
}
@@ -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],
@@ -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>
@@ -17,12 +17,13 @@
import type { PluginHeaderProps } from './types';
import { Tabs, TabList, Tab } from '../Tabs';
import { useDefinition } from '../../hooks/useDefinition';
import { useBgProvider, BgProvider } from '../../hooks/useBg';
import { PluginHeaderDefinition } from './definition';
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';
@@ -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
*/
@@ -50,11 +52,9 @@ export const PluginHeader = (props: PluginHeaderProps) => {
onTabSelectionChange,
} = ownProps;
const providerBg = useBgProvider('neutral');
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,37 +123,29 @@ 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}>
const inner = (
<>
<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">
{titleLink ? (
<Link className={classes.toolbarName} href={titleLink}>
{titleContent}
</Link>
) : (
<div className={classes.toolbarName}>{titleContent}</div>
)}
</Text>
</div>
<div className={classes.toolbarControls} ref={toolbarControlsRef}>
{actionChildren}
</div>
<div className={classes.toolbarContent}>
<div className={classes.toolbarIcon}>{icon || <RiShapesLine />}</div>
<h1 className={classes.toolbarName}>
{titleLink ? (
<Link href={titleLink} standalone variant="body-medium">
{titleText}
</Link>
) : (
<Text as="span" variant="body-medium">
{titleText}
</Text>
)}
</h1>
</div>
<div className={classes.toolbarControls}>{actionChildren}</div>
</div>
{tabs && (
<Box bg="neutral" className={classes.tabs}>
<Box className={classes.tabs}>
<Tabs onSelectionChange={onTabSelectionChange}>
<TabList>
{tabs?.map(tab => (
@@ -170,6 +162,14 @@ export const PluginHeader = (props: PluginHeaderProps) => {
</Tabs>
</Box>
)}
</>
);
const { bg: surfaceBg } = providerBg;
return (
<header ref={headerRef} className={classes.root} data-bg={surfaceBg}>
{surfaceBg ? <BgProvider bg={surfaceBg}>{inner}</BgProvider> : inner}
</header>
);
};
@@ -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,326 @@
/*
* 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',
},
});
// ---------------------------------------------------------------------------
// Story: Catalog entity page
// ---------------------------------------------------------------------------
export const CatalogEntityPage = 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"
breadcrumbs={[
{ label: 'Catalog', href: '/catalog' },
{ label: 'Services', href: '/catalog?kind=Component' },
]}
tabs={[
{ id: 'overview', label: 'Overview', href: '/overview' },
{ id: 'ci-cd', label: 'CI/CD', href: '/ci-cd' },
{ id: 'api', label: 'API', href: '/api' },
{ id: 'dependencies', label: 'Dependencies', href: '/dependencies' },
{ id: 'docs', label: 'Docs', href: '/docs' },
]}
customActions={
<>
<Button variant="secondary" iconStart={<RiEdit2Line />}>
Edit
</Button>
<Button variant="primary" iconStart={<RiShareBoxLine />}>
Unregister
</Button>
</>
}
/>
<PageContent />
</>
),
});
// ---------------------------------------------------------------------------
// Story: CI/CD pipeline view
// ---------------------------------------------------------------------------
export const CICDPipelineView = 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="tertiary"
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' },
]}
customActions={
<>
<Button variant="secondary" iconStart={<RiDownloadLine />}>
Download logs
</Button>
<Button variant="primary" iconStart={<RiPlayLine />}>
Re-run pipeline
</Button>
</>
}
/>
<PageContent />
</>
),
});
// ---------------------------------------------------------------------------
// Story: TechDocs page
// ---------------------------------------------------------------------------
export const TechDocsPage = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader
icon={<RiBookOpenLine />}
title="TechDocs"
titleLink="/"
tabs={[
{ id: 'explore', label: 'Explore', href: '/explore' },
{ id: 'owned', label: 'Owned by me', href: '/owned' },
{ id: 'starred', label: 'Starred', href: '/starred' },
]}
/>
<Header
title="Getting started"
tabs={[
{ id: 'overview', label: 'Overview', href: '/overview' },
{
id: 'architecture',
label: 'Architecture',
href: '/architecture',
},
{ id: 'runbooks', label: 'Runbooks', href: '/runbooks' },
{ id: 'adr', label: 'ADRs', href: '/adr' },
]}
customActions={
<MenuTrigger>
<ButtonIcon
variant="tertiary"
icon={<RiMore2Line />}
aria-label="More options"
/>
<Menu placement="bottom end">
<MenuItem iconStart={<RiShareBoxLine />} href="/share">
Share link
</MenuItem>
<MenuItem iconStart={<RiEdit2Line />} href="/edit">
Edit on GitHub
</MenuItem>
</Menu>
</MenuTrigger>
}
/>
<PageContent />
</>
),
});
// ---------------------------------------------------------------------------
// Story: Security / compliance audit page
// ---------------------------------------------------------------------------
export const SecurityAuditPage = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader
icon={<RiShieldCheckLine />}
title="Security"
titleLink="/"
tabs={[
{ id: 'overview', label: 'Overview', href: '/overview' },
{ id: 'vulnerabilities', label: 'Vulnerabilities', href: '/vulns' },
{ id: 'policies', label: 'Policies', href: '/policies' },
{ id: 'audits', label: 'Audits', href: '/audits' },
]}
customActions={
<>
<ButtonIcon
variant="tertiary"
icon={<RiRefreshLine />}
aria-label="Refresh scan"
/>
<Button variant="primary" iconStart={<RiTerminalLine />}>
Run scan
</Button>
</>
}
/>
<Header
title="payment-service"
tabs={[
{ id: 'critical', label: 'Critical', href: '/critical' },
{ id: 'high', label: 'High', href: '/high' },
{ id: 'medium', label: 'Medium', href: '/medium' },
{ id: 'low', label: 'Low', href: '/low' },
]}
customActions={
<>
<Button variant="secondary" iconStart={<RiDownloadLine />}>
Export report
</Button>
</>
}
/>
<PageContent />
</>
),
});
// ---------------------------------------------------------------------------
// Story: Minimal — no tabs, no actions
// ---------------------------------------------------------------------------
export const Minimal = meta.story({
decorators: [withLayout],
render: () => (
<>
<PluginHeader icon={<RiCodeSSlashLine />} title="APIs" titleLink="/" />
<Header title="payments-api" />
<PageContent />
</>
),
});