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:
@@ -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.',
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user