feat(ui): replace Header tabs with nav-based grouped navigation

Replace the RA Tabs/TabList/Tab rendering in the Header component
with a nav-based approach that supports grouped dropdown items via
BUI Menu. Active state is consumer-controlled via a new activeTabId
prop. The indicator system follows the TabsIndicators CSS custom
property pattern for animated active/hover/focus states.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-20 17:31:28 +01:00
parent 6bfcd9967b
commit 8659f3331c
13 changed files with 827 additions and 270 deletions
+24
View File
@@ -0,0 +1,24 @@
---
'@backstage/ui': minor
---
**BREAKING**: The `Header` component's `tabs` prop now uses `HeaderNavTabItem[]` instead of `HeaderTab[]`. Tabs render as a `<nav>` element with links and optional dropdown menus instead of `role="tablist"`. A new `activeTabId` prop controls which tab is highlighted.
**Migration:**
```diff
- import { Header, type HeaderTab } from '@backstage/ui';
+ import { Header, type HeaderNavTabItem } from '@backstage/ui';
// Tabs no longer support matchStrategy — active state is controlled via activeTabId
- const tabs: HeaderTab[] = [
- { id: 'overview', label: 'Overview', href: '/overview', matchStrategy: 'prefix' },
+ const tabs: HeaderNavTabItem[] = [
+ { id: 'overview', label: 'Overview', href: '/overview' },
];
- <Header title="My Page" tabs={tabs} />
+ <Header title="My Page" tabs={tabs} activeTabId="overview" />
```
**Affected components:** Header
@@ -1,5 +1,4 @@
import { classNamePropDefs, type PropDef } from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
export const headerPagePropDefs: Record<string, PropDef> = {
title: {
@@ -13,9 +12,9 @@ export const headerPagePropDefs: Record<string, PropDef> = {
},
tabs: {
type: 'complex',
description: 'Navigation tabs displayed below the title.',
description: 'Navigation items displayed below the title.',
complexType: {
name: 'HeaderTab[]',
name: 'HeaderNavTabItem[]',
properties: {
id: {
type: 'string',
@@ -29,23 +28,24 @@ export const headerPagePropDefs: Record<string, PropDef> = {
},
href: {
type: 'string',
required: true,
description: 'URL to navigate to when tab is clicked.',
},
matchStrategy: {
type: "'exact' | 'prefix'",
required: false,
default: "'exact'",
description: (
<>
Route matching strategy. Use <Chip>exact</Chip> for exact path
match, <Chip>prefix</Chip> if pathname starts with href.
</>
),
description:
'URL to navigate to when tab is clicked. Present on flat tabs, absent on groups.',
},
items: {
type: 'HeaderNavTab[]',
required: false,
description:
'Child tabs rendered as a dropdown menu. Present on groups, absent on flat tabs.',
},
},
},
},
activeTabId: {
type: 'string',
description:
'ID of the currently active tab. Can be a flat tab ID or a child tab ID within a group.',
},
breadcrumbs: {
type: 'complex',
description: 'Breadcrumb trail displayed above the title.',
+75 -1
View File
@@ -1420,13 +1420,86 @@ export const HeaderDefinition: {
readonly title: {};
readonly customActions: {};
readonly tabs: {};
readonly activeTabId: {};
readonly breadcrumbs: {};
readonly className: {};
};
};
// @public (undocumented)
export const HeaderNavDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-HeaderNav';
readonly list: 'bui-HeaderNavList';
readonly active: 'bui-HeaderNavActive';
readonly hovered: 'bui-HeaderNavHovered';
};
readonly analytics: true;
readonly propDefs: {
readonly noTrack: {};
readonly tabs: {};
readonly activeTabId: {};
readonly children: {};
readonly className: {};
};
};
// @public (undocumented)
export const HeaderNavGroupDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-HeaderNavGroup';
};
readonly propDefs: {
readonly className: {};
};
};
// @public (undocumented)
export const HeaderNavItemDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-HeaderNavItem';
};
readonly propDefs: {
readonly className: {};
};
};
// @public
export interface HeaderNavTab {
// (undocumented)
href: string;
// (undocumented)
id: string;
// (undocumented)
label: string;
}
// @public
export interface HeaderNavTabGroup {
// (undocumented)
id: string;
// (undocumented)
items: HeaderNavTab[];
// (undocumented)
label: string;
}
// @public
export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup;
// @public
export interface HeaderOwnProps {
// (undocumented)
activeTabId?: string;
// (undocumented)
breadcrumbs?: HeaderBreadcrumb[];
// (undocumented)
@@ -1434,7 +1507,7 @@ export interface HeaderOwnProps {
// (undocumented)
customActions?: React.ReactNode;
// (undocumented)
tabs?: HeaderTab[];
tabs?: HeaderNavTabItem[];
// (undocumented)
title?: string;
}
@@ -1461,6 +1534,7 @@ export const HeaderPageDefinition: {
readonly title: {};
readonly customActions: {};
readonly tabs: {};
readonly activeTabId: {};
readonly breadcrumbs: {};
readonly className: {};
};
@@ -14,21 +14,14 @@
* limitations under the License.
*/
import { useMemo } from 'react';
import preview from '../../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { Header } from './Header';
import type { HeaderTab } from '../PluginHeader/types';
import { MemoryRouter } from 'react-router-dom';
import type { HeaderNavTabItem } from './types';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import {
Button,
Container,
Text,
ButtonIcon,
MenuTrigger,
Menu,
MenuItem,
} from '../../';
import { Button, ButtonIcon, MenuTrigger, Menu, MenuItem } from '../../';
import { RiMore2Line } from '@remixicon/react';
const meta = preview.meta({
@@ -39,7 +32,7 @@ const meta = preview.meta({
},
});
const tabs: HeaderTab[] = [
const tabs: HeaderNavTabItem[] = [
{
id: 'overview',
label: 'Overview',
@@ -88,13 +81,33 @@ const menuItems = [
];
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<MemoryRouter initialEntries={['/overview']}>
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
);
/**
* Derives activeTabId from the current router location by matching
* the pathname against all tab hrefs (including group children).
*/
function useActiveTabId(items: HeaderNavTabItem[]): string | undefined {
const location = useLocation();
return useMemo(() => {
for (const item of items) {
if ('items' in item) {
for (const child of item.items) {
if (child.href === location.pathname) return child.id;
}
} else if (item.href === location.pathname) {
return item.id;
}
}
return undefined;
}, [items, location.pathname]);
}
export const Default = meta.story({
args: {
title: 'Page Title',
@@ -102,11 +115,11 @@ export const Default = meta.story({
});
export const WithTabs = meta.story({
args: {
...Default.input.args,
tabs,
},
decorators: [withRouter],
render: () => {
const activeTabId = useActiveTabId(tabs);
return <Header title="Page Title" tabs={tabs} activeTabId={activeTabId} />;
},
});
export const WithCustomActions = meta.story({
@@ -162,175 +175,47 @@ export const WithLongBreadcrumbs = meta.story({
export const WithEverything = meta.story({
decorators: [withRouter],
render: () => (
<Header
{...Default.input.args}
tabs={tabs}
customActions={<Button>Custom action</Button>}
breadcrumbs={[{ label: 'Home', href: '/' }]}
/>
),
render: () => {
const activeTabId = useActiveTabId(tabs);
return (
<Header
title="Page Title"
tabs={tabs}
activeTabId={activeTabId}
customActions={<Button>Custom action</Button>}
breadcrumbs={[{ label: 'Home', href: '/' }]}
/>
);
},
});
export const WithTabsMatchingStrategies = meta.story({
args: {
title: 'Route Matching Demo',
tabs: [
{
id: 'home',
label: 'Home',
href: '/home',
},
{
id: 'mentorship',
label: 'Mentorship',
href: '/mentorship',
matchStrategy: 'prefix',
},
{
id: 'catalog',
label: 'Catalog',
href: '/catalog',
matchStrategy: 'prefix',
},
{
id: 'settings',
label: 'Settings',
href: '/settings',
},
const groupedTabs: HeaderNavTabItem[] = [
{ id: 'overview', label: 'Overview', href: '/overview' },
{
id: 'docs-group',
label: 'Documentation',
items: [
{ id: 'docs', label: 'TechDocs', href: '/docs' },
{ id: 'api-docs', label: 'API Reference', href: '/api-docs' },
],
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<BUIProvider>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
Notice how the "Mentorship" tab is active even though we're on a
nested route. This is because it uses{' '}
<code>matchStrategy="prefix"</code>.
</Text>
<br />
<Text>
<strong>Home</strong>: exact matching (default) - not active
</Text>
<Text>
<strong>Mentorship</strong>: prefix matching - IS active (URL
starts with /mentorship)
</Text>
<Text>
<strong>Catalog</strong>: prefix matching - not active
</Text>
<Text>
<strong>Settings</strong>: exact matching (default) - not active
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
{ id: 'ci', label: 'CI/CD', href: '/ci' },
];
export const WithTabsExactMatching = meta.story({
args: {
title: 'Exact Matching Demo',
tabs: [
{
id: 'mentorship',
label: 'Mentorship',
href: '/mentorship',
},
{
id: 'events',
label: 'Events',
href: '/mentorship/events',
},
{
id: 'mentors',
label: 'Mentors',
href: '/mentorship/mentors',
},
],
export const WithGroupedTabs = meta.story({
decorators: [
(Story: StoryFn) => (
<MemoryRouter initialEntries={['/docs']}>
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
render: () => {
const activeTabId = useActiveTabId(groupedTabs);
return (
<Header title="Page Title" tabs={groupedTabs} activeTabId={activeTabId} />
);
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<BUIProvider>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
With default exact matching, only the "Events" tab is active because
it exactly matches the current URL. The "Mentorship" tab is not
active even though the URL is under /mentorship.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
export const WithTabsPrefixMatchingDeep = meta.story({
args: {
title: 'Deep Nesting Demo',
tabs: [
{
id: 'catalog',
label: 'Catalog',
href: '/catalog',
matchStrategy: 'prefix',
},
{
id: 'users',
label: 'Users',
href: '/catalog/users',
matchStrategy: 'prefix',
},
{
id: 'components',
label: 'Components',
href: '/catalog/components',
matchStrategy: 'prefix',
},
],
},
render: args => (
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<BUIProvider>
<Header {...args} />
<Container>
<Text as="p">
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text as="p">
Active tab is <strong>Users</strong> because:
</Text>
<ul>
<li>
<strong>Catalog</strong>: Matches since URL starts with /catalog
</li>
<li>
<strong>Users</strong>: Is active since URL starts with
/catalog/users, and is more specific (has more url segments) than
"Catalog"
</li>
<li>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</li>
</ul>
<Text as="p">
This demonstrates how prefix matching works with deeply nested
routes.
</Text>
</Container>
</BUIProvider>
</MemoryRouter>
),
});
+4 -16
View File
@@ -17,7 +17,7 @@
import type { HeaderProps } from './types';
import { Text } from '../Text';
import { RiArrowRightSLine } from '@remixicon/react';
import { Tabs, TabList, Tab } from '../Tabs';
import { HeaderNav } from './HeaderNav';
import { useDefinition } from '../../hooks/useDefinition';
import { HeaderDefinition } from './definition';
import { Container } from '../Container';
@@ -31,7 +31,8 @@ import { Fragment } from 'react/jsx-runtime';
*/
export const Header = (props: HeaderProps) => {
const { ownProps } = useDefinition(HeaderDefinition, props);
const { classes, title, tabs, customActions, breadcrumbs } = ownProps;
const { classes, title, tabs, activeTabId, customActions, breadcrumbs } =
ownProps;
return (
<Container className={classes.root}>
@@ -62,20 +63,7 @@ export const Header = (props: HeaderProps) => {
</div>
{tabs && (
<div className={classes.tabsWrapper}>
<Tabs>
<TabList>
{tabs.map(tab => (
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
</div>
)}
</Container>
@@ -0,0 +1,115 @@
/*
* Copyright 2026 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.
*/
@layer tokens, base, components, utilities;
@layer components {
.bui-HeaderNav {
position: relative;
/* CSS custom properties for indicator positioning — set by HeaderNavIndicators */
--active-tab-left: 0px;
--active-tab-right: 0px;
--active-tab-top: 0px;
--active-tab-bottom: 0px;
--active-tab-width: 0px;
--active-tab-height: 0px;
--active-tab-opacity: 0;
--active-transition-duration: 0s;
--hovered-tab-left: 0px;
--hovered-tab-right: 0px;
--hovered-tab-top: 0px;
--hovered-tab-bottom: 0px;
--hovered-tab-width: 0px;
--hovered-tab-height: 0px;
--hovered-tab-opacity: 0;
--hovered-transition-duration: 0s;
}
.bui-HeaderNavList {
display: flex;
flex-direction: row;
list-style: none;
margin: 0;
padding: 0;
}
.bui-HeaderNavItem,
.bui-HeaderNavGroup {
font-family: var(--bui-font-family);
font-size: var(--bui-font-size-3);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-secondary);
height: 36px;
display: flex;
gap: var(--bui-space-1);
align-items: center;
padding-inline: var(--bui-space-2);
text-decoration: none;
cursor: pointer;
border: none;
background: none;
outline: none;
}
.bui-HeaderNavItem[aria-current='page'],
.bui-HeaderNavGroup[aria-current='page'] {
color: var(--bui-fg-primary);
}
.bui-HeaderNavActive {
pointer-events: none;
position: absolute;
bottom: -1px;
left: calc(var(--active-tab-left) + var(--bui-space-2));
width: calc(var(--active-tab-width) - var(--bui-space-4));
height: 1px;
background-color: var(--bui-fg-primary);
border-radius: 4px;
opacity: var(--active-tab-opacity);
transition: left var(--active-transition-duration) ease-out,
width var(--active-transition-duration) ease-out, opacity 0.15s;
}
.bui-HeaderNavHovered {
pointer-events: none;
position: absolute;
left: var(--hovered-tab-left);
top: calc(var(--hovered-tab-top) + 4px);
width: var(--hovered-tab-width);
height: calc(var(--hovered-tab-height) - 8px);
background-color: var(--bui-bg-neutral-2);
border-radius: 4px;
opacity: var(--hovered-tab-opacity);
transition: left var(--hovered-transition-duration) ease-out,
top var(--hovered-transition-duration) ease-out,
width var(--hovered-transition-duration) ease-out,
height var(--hovered-transition-duration) ease-out, opacity 0.15s;
[data-focus-visible] & {
outline: 2px solid var(--bui-ring);
outline-offset: -2px;
}
}
@media (prefers-reduced-motion: reduce) {
.bui-HeaderNavActive,
.bui-HeaderNavHovered {
transition: none;
}
}
}
@@ -0,0 +1,220 @@
/*
* Copyright 2026 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 { useCallback, useMemo, useRef, useState } from 'react';
import { useFocusVisible, useHover, useLink } from 'react-aria';
import { Button as RAButton } from 'react-aria-components';
import { RiArrowDownSLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
import {
HeaderNavDefinition,
HeaderNavItemDefinition,
HeaderNavGroupDefinition,
} from './HeaderNavDefinition';
import { HeaderNavIndicators } from './HeaderNavIndicators';
import { MenuTrigger, Menu, MenuItem } from '../Menu';
import type { AnalyticsTracker } from '../../analytics/types';
import type {
HeaderNavTab,
HeaderNavTabGroup,
HeaderNavTabItem,
} from './types';
function isTabGroup(tab: HeaderNavTabItem): tab is HeaderNavTabGroup {
return 'items' in tab;
}
interface HeaderNavLinkProps {
tab: HeaderNavTab;
active: boolean;
analytics: AnalyticsTracker;
registerRef: (key: string, el: HTMLElement | null) => void;
onHighlight: (key: string | null) => void;
}
function HeaderNavLink(props: HeaderNavLinkProps) {
const { tab, active, analytics, registerRef, onHighlight } = props;
const { ownProps } = useDefinition(HeaderNavItemDefinition, {});
const linkRef = useRef<HTMLAnchorElement>(null);
const { linkProps } = useLink({ href: tab.href }, linkRef);
const { hoverProps } = useHover({
onHoverStart: () => onHighlight(tab.id),
onHoverEnd: () => onHighlight(null),
});
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
linkProps.onClick?.(e);
analytics.captureEvent('click', tab.label, {
attributes: { to: tab.href },
});
};
return (
<li>
<a
{...linkProps}
{...hoverProps}
ref={el => {
(
linkRef as React.MutableRefObject<HTMLAnchorElement | null>
).current = el;
registerRef(tab.id, el);
}}
href={tab.href}
className={ownProps.classes.root}
aria-current={active ? 'page' : undefined}
onClick={handleClick}
onFocus={() => onHighlight(tab.id)}
onBlur={() => onHighlight(null)}
>
{tab.label}
</a>
</li>
);
}
interface HeaderNavGroupItemProps {
group: HeaderNavTabGroup;
active: boolean;
activeChildId?: string;
registerRef: (key: string, el: HTMLElement | null) => void;
onHighlight: (key: string | null) => void;
}
function HeaderNavGroupItem(props: HeaderNavGroupItemProps) {
const { group, active, activeChildId, registerRef, onHighlight } = props;
const { ownProps } = useDefinition(HeaderNavGroupDefinition, {});
const { hoverProps } = useHover({
onHoverStart: () => onHighlight(group.id),
onHoverEnd: () => onHighlight(null),
});
return (
<li>
<MenuTrigger>
<RAButton
ref={el => {
registerRef(group.id, el);
}}
className={ownProps.classes.root}
aria-current={active ? 'page' : undefined}
{...hoverProps}
onFocus={() => onHighlight(group.id)}
onBlur={() => onHighlight(null)}
>
{group.label}
<RiArrowDownSLine size={16} />
</RAButton>
<Menu
selectionMode="single"
selectedKeys={new Set(activeChildId ? [activeChildId] : [])}
>
{group.items.map(item => (
<MenuItem key={item.id} id={item.id} href={item.href}>
{item.label}
</MenuItem>
))}
</Menu>
</MenuTrigger>
</li>
);
}
interface HeaderNavProps {
tabs: HeaderNavTabItem[];
activeTabId?: string;
}
/** @internal */
export function HeaderNav(props: HeaderNavProps) {
const { tabs, activeTabId } = props;
const { ownProps, analytics } = useDefinition(HeaderNavDefinition, {
tabs,
activeTabId,
});
const { classes } = ownProps;
const { isFocusVisible } = useFocusVisible();
const navRef = useRef<HTMLElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const [highlightedKey, setHighlightedKey] = useState<string | null>(null);
// Resolve activeTabId to a top-level key (groups own their children's active state)
const { activeKey, activeChildId } = useMemo(() => {
if (!activeTabId) return { activeKey: undefined, activeChildId: undefined };
for (const item of tabs) {
if (isTabGroup(item)) {
const child = item.items.find(c => c.id === activeTabId);
if (child) {
return { activeKey: item.id, activeChildId: child.id };
}
} else if (item.id === activeTabId) {
return { activeKey: item.id, activeChildId: undefined };
}
}
return { activeKey: undefined, activeChildId: undefined };
}, [activeTabId, tabs]);
const registerRef = useCallback((key: string, el: HTMLElement | null) => {
if (el) {
itemRefs.current.set(key, el);
} else {
itemRefs.current.delete(key);
}
}, []);
return (
<nav
ref={navRef}
aria-label="Content navigation"
className={classes.root}
data-focus-visible={isFocusVisible || undefined}
>
<ul role="list" className={classes.list}>
{tabs.map(item =>
isTabGroup(item) ? (
<HeaderNavGroupItem
key={item.id}
group={item}
active={activeKey === item.id}
activeChildId={activeChildId}
registerRef={registerRef}
onHighlight={setHighlightedKey}
/>
) : (
<HeaderNavLink
key={item.id}
tab={item}
active={activeKey === item.id}
analytics={analytics}
registerRef={registerRef}
onHighlight={setHighlightedKey}
/>
),
)}
</ul>
<HeaderNavIndicators
navRef={navRef}
itemRefs={itemRefs}
activeKey={activeKey}
highlightedKey={highlightedKey}
classes={{ active: classes.active, hovered: classes.hovered }}
/>
</nav>
);
}
@@ -0,0 +1,70 @@
/*
* Copyright 2026 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 { defineComponent } from '../../hooks/useDefinition';
import type { HeaderNavTabItem } from './types';
import styles from './HeaderNav.module.css';
/** @public */
export const HeaderNavDefinition = defineComponent<{
noTrack?: boolean;
tabs: HeaderNavTabItem[];
activeTabId?: string;
children?: React.ReactNode;
className?: string;
}>()({
styles,
classNames: {
root: 'bui-HeaderNav',
list: 'bui-HeaderNavList',
active: 'bui-HeaderNavActive',
hovered: 'bui-HeaderNavHovered',
},
analytics: true,
propDefs: {
noTrack: {},
tabs: {},
activeTabId: {},
children: {},
className: {},
},
});
/** @public */
export const HeaderNavItemDefinition = defineComponent<{
className?: string;
}>()({
styles,
classNames: {
root: 'bui-HeaderNavItem',
},
propDefs: {
className: {},
},
});
/** @public */
export const HeaderNavGroupDefinition = defineComponent<{
className?: string;
}>()({
styles,
classNames: {
root: 'bui-HeaderNavGroup',
},
propDefs: {
className: {},
},
});
@@ -0,0 +1,130 @@
/*
* Copyright 2026 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 { useCallback, useEffect, useRef } from 'react';
interface HeaderNavIndicatorsProps {
navRef: React.RefObject<HTMLElement | null>;
itemRefs: React.MutableRefObject<Map<string, HTMLElement>>;
activeKey: string | undefined;
highlightedKey: string | null;
classes: {
active: string;
hovered: string;
};
}
export function HeaderNavIndicators(props: HeaderNavIndicatorsProps) {
const { navRef, itemRefs, activeKey, highlightedKey, classes } = props;
const prevActiveKey = useRef<string | null>(null);
const prevHoveredKey = useRef<string | null>(null);
const resetTimer = useRef<number | null>(null);
const updateCSSVariables = useCallback(() => {
const container = navRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
// Active indicator
if (activeKey) {
const el = itemRefs.current.get(activeKey);
if (el) {
const rect = el.getBoundingClientRect();
const relativeLeft = rect.left - containerRect.left;
const relativeTop = rect.top - containerRect.top;
if (prevActiveKey.current === null) {
container.style.setProperty('--active-transition-duration', '0s');
requestAnimationFrame(() => {
container.style.setProperty(
'--active-transition-duration',
'0.25s',
);
});
} else {
container.style.setProperty('--active-transition-duration', '0.25s');
}
container.style.setProperty('--active-tab-left', `${relativeLeft}px`);
container.style.setProperty('--active-tab-width', `${rect.width}px`);
container.style.setProperty('--active-tab-height', `${rect.height}px`);
container.style.setProperty('--active-tab-top', `${relativeTop}px`);
container.style.setProperty('--active-tab-opacity', '1');
prevActiveKey.current = activeKey;
}
} else {
container.style.setProperty('--active-tab-opacity', '0');
prevActiveKey.current = null;
}
// Highlight indicator (follows whichever interaction happened last — hover or focus)
if (highlightedKey) {
if (resetTimer.current !== null) {
cancelAnimationFrame(resetTimer.current);
resetTimer.current = null;
}
const el = itemRefs.current.get(highlightedKey);
if (el) {
const rect = el.getBoundingClientRect();
const relativeLeft = rect.left - containerRect.left;
const relativeTop = rect.top - containerRect.top;
if (prevHoveredKey.current === null) {
container.style.setProperty('--hovered-transition-duration', '0s');
requestAnimationFrame(() => {
container.style.setProperty(
'--hovered-transition-duration',
'0.2s',
);
});
} else {
container.style.setProperty('--hovered-transition-duration', '0.2s');
}
container.style.setProperty('--hovered-tab-left', `${relativeLeft}px`);
container.style.setProperty('--hovered-tab-top', `${relativeTop}px`);
container.style.setProperty('--hovered-tab-width', `${rect.width}px`);
container.style.setProperty('--hovered-tab-height', `${rect.height}px`);
container.style.setProperty('--hovered-tab-opacity', '1');
prevHoveredKey.current = highlightedKey;
}
} else {
container.style.setProperty('--hovered-tab-opacity', '0');
resetTimer.current = requestAnimationFrame(() => {
prevHoveredKey.current = null;
resetTimer.current = null;
});
}
}, [activeKey, highlightedKey, navRef, itemRefs]);
useEffect(() => {
updateCSSVariables();
}, [updateCSSVariables]);
useEffect(() => {
const handleResize = () => updateCSSVariables();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [updateCSSVariables]);
return (
<>
<div className={classes.active} />
<div className={classes.hovered} />
</>
);
}
@@ -35,6 +35,7 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
title: {},
customActions: {},
tabs: {},
activeTabId: {},
breadcrumbs: {},
className: {},
},
+9 -2
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2026 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.
@@ -13,10 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Header, HeaderPage } from './Header';
export { HeaderDefinition, HeaderPageDefinition } from './definition';
export {
HeaderNavDefinition,
HeaderNavItemDefinition,
HeaderNavGroupDefinition,
} from './HeaderNavDefinition';
export type {
HeaderNavTab,
HeaderNavTabGroup,
HeaderNavTabItem,
HeaderOwnProps,
HeaderProps,
HeaderBreadcrumb,
+30 -2
View File
@@ -14,7 +14,34 @@
* limitations under the License.
*/
import type { HeaderTab } from '../PluginHeader/types';
/**
* Represents a single navigation tab in the header.
*
* @public
*/
export interface HeaderNavTab {
id: string;
label: string;
href: string;
}
/**
* Represents a group of navigation tabs rendered as a dropdown menu.
*
* @public
*/
export interface HeaderNavTabGroup {
id: string;
label: string;
items: HeaderNavTab[];
}
/**
* A navigation tab item either a flat link or a dropdown group.
*
* @public
*/
export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup;
/**
* Own props for the Header component.
@@ -24,7 +51,8 @@ import type { HeaderTab } from '../PluginHeader/types';
export interface HeaderOwnProps {
title?: string;
customActions?: React.ReactNode;
tabs?: HeaderTab[];
tabs?: HeaderNavTabItem[];
activeTabId?: string;
breadcrumbs?: HeaderBreadcrumb[];
className?: string;
}
@@ -14,10 +14,12 @@
* limitations under the License.
*/
import { useMemo } from 'react';
import preview from '../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { BUIProvider } from '../provider';
import type { HeaderNavTabItem } from '../components/Header/types';
import {
PluginHeader,
Header,
@@ -31,7 +33,6 @@ import {
MenuItem,
} from '..';
import {
RiBookOpenLine,
RiBox3Line,
RiCodeSSlashLine,
RiDownloadLine,
@@ -41,9 +42,7 @@ import {
RiPlayLine,
RiRefreshLine,
RiSettings4Line,
RiShieldCheckLine,
RiShareBoxLine,
RiTerminalLine,
} from '@remixicon/react';
// ---------------------------------------------------------------------------
@@ -205,54 +204,70 @@ export const WithBreadcrumb = meta.story({
),
});
const subTabs: HeaderNavTabItem[] = [
{ id: 'summary', label: 'Summary', href: '/summary' },
{ id: 'steps', label: 'Steps', href: '/steps' },
{ id: 'artifacts', label: 'Artifacts', href: '/artifacts' },
{ id: 'logs', label: 'Logs', href: '/logs' },
];
function useActiveTabId(items: HeaderNavTabItem[]): string | undefined {
const location = useLocation();
return useMemo(() => {
for (const item of items) {
if ('href' in item && item.href === location.pathname) return item.id;
}
return undefined;
}, [items, location.pathname]);
}
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 />
</>
),
render: () => {
const activeTabId = useActiveTabId(subTabs);
return (
<>
<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"
activeTabId={activeTabId}
tabs={subTabs}
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 />
</>
);
},
});