feat(ui): add automatic active tab detection to Header (#33783)

* feat(ui): widen activeTabId type to accept null

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): add automatic active tab detection to HeaderNav

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): update Header stories to demonstrate auto-detection and explicit activeTabId

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(ui): remove manual useActiveTabId from PluginHeaderAndHeader recipe

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): update Header docs for activeTabId auto-detection

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore(ui): add API report and changeset for activeTabId auto-detection

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(ui): resolve relative hrefs in HeaderNav tabs

Add resolveHref to HeaderNavItemDefinition so tab links with relative
hrefs are resolved against the router context before rendering.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore(ui): add breaking change changeset for HeaderNav resolveHref

Signed-off-by: Johan Persson <johanopersson@gmail.com>

---------

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-04-07 17:22:01 +02:00
committed by GitHub
parent fa232da324
commit b4a187502b
11 changed files with 142 additions and 99 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added automatic active tab detection to the Header component. When `activeTabId` is omitted, the active tab is now auto-detected from the current route using `matchRoutes`. Pass an explicit `activeTabId` to override, or `null` for no active tab.
**Affected components:** Header
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/ui': minor
---
**BREAKING**: Tab `href` values in the Header component are now resolved through the router context instead of being passed raw to the `<a>` tag. This means relative `href` values (e.g. `sub3`, `./sub4`, `../catalog`) are now resolved against the current route, and absolute `href` values may be affected by the router's `basename` configuration.
**Migration:**
Tab navigation should work the same for absolute `href` values in most setups. If you use relative `href` values in tabs, verify they resolve as expected. If your app configures a router `basename`, check that absolute tab `href` values still navigate correctly.
**Affected components:** Header
@@ -30,7 +30,7 @@ const breadcrumbs = [
];
export const WithEverything = () => (
<MemoryRouter>
<MemoryRouter initialEntries={['/overview']}>
<Header
title="Page Title"
tabs={tabs.slice(0, 2)}
@@ -52,7 +52,7 @@ export const WithLongBreadcrumbs = () => (
);
export const WithTabs = () => (
<MemoryRouter>
<MemoryRouter initialEntries={['/overview']}>
<Header title="Page Title" tabs={tabs.slice(0, 3)} />
</MemoryRouter>
);
+1 -1
View File
@@ -47,7 +47,7 @@ Labels are truncated at 240px.
### Tabs
Tabs use React Router and highlight based on the current route.
Tabs auto-detect the active tab from the current route when `activeTabId` is omitted. Pass an explicit `activeTabId` to override, or `null` for no active tab.
<Snippet open preview={<WithTabs />} code={withTabs} />
@@ -42,9 +42,10 @@ export const headerPagePropDefs: Record<string, PropDef> = {
},
},
activeTabId: {
type: 'string',
type: 'enum',
values: ['string', 'null'],
description:
'ID of the currently active tab. Can be a flat tab ID or a child tab ID within a group.',
'ID of the currently active tab. Omit to auto-detect from the current route. Set to null for no active tab.',
},
breadcrumbs: {
type: 'complex',
+10 -3
View File
@@ -1529,9 +1529,7 @@ export const HeaderNavDefinition: {
readonly active: 'bui-HeaderNavActive';
readonly hovered: 'bui-HeaderNavHovered';
};
readonly analytics: true;
readonly propDefs: {
readonly noTrack: {};
readonly tabs: {};
readonly activeTabId: {};
readonly children: {};
@@ -1560,7 +1558,16 @@ export const HeaderNavItemDefinition: {
readonly classNames: {
readonly root: 'bui-HeaderNavItem';
};
readonly analytics: true;
readonly resolveHref: true;
readonly propDefs: {
readonly noTrack: {};
readonly id: {};
readonly label: {};
readonly href: {};
readonly active: {};
readonly registerRef: {};
readonly onHighlight: {};
readonly className: {};
};
};
@@ -1591,7 +1598,7 @@ export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup;
// @public
export interface HeaderOwnProps {
// (undocumented)
activeTabId?: string;
activeTabId?: string | null;
// (undocumented)
breadcrumbs?: HeaderBreadcrumb[];
// (undocumented)
@@ -14,12 +14,11 @@
* 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 { HeaderNavTabItem } from './types';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import { Button, ButtonIcon, MenuTrigger, Menu, MenuItem } from '../../';
import { RiMore2Line } from '@remixicon/react';
@@ -88,26 +87,6 @@ const withRouter = (Story: StoryFn) => (
</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',
@@ -116,9 +95,9 @@ export const Default = meta.story({
export const WithTabs = meta.story({
decorators: [withRouter],
render: () => {
const activeTabId = useActiveTabId(tabs);
return <Header title="Page Title" tabs={tabs} activeTabId={activeTabId} />;
args: {
...Default.input.args,
tabs,
},
});
@@ -175,17 +154,11 @@ export const WithLongBreadcrumbs = meta.story({
export const WithEverything = meta.story({
decorators: [withRouter],
render: () => {
const activeTabId = useActiveTabId(tabs);
return (
<Header
title="Page Title"
tabs={tabs}
activeTabId={activeTabId}
customActions={<Button>Custom action</Button>}
breadcrumbs={[{ label: 'Home', href: '/' }]}
/>
);
args: {
...Default.input.args,
tabs,
customActions: <Button>Custom action</Button>,
breadcrumbs: [{ label: 'Home', href: '/' }],
},
});
@@ -212,10 +185,17 @@ export const WithGroupedTabs = meta.story({
</MemoryRouter>
),
],
render: () => {
const activeTabId = useActiveTabId(groupedTabs);
return (
<Header title="Page Title" tabs={groupedTabs} activeTabId={activeTabId} />
);
args: {
...Default.input.args,
tabs: groupedTabs,
},
});
export const WithExplicitActiveTab = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
tabs,
activeTabId: 'campaigns',
},
});
+55 -26
View File
@@ -16,6 +16,13 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useFocusVisible, useHover, useLink } from 'react-aria';
import {
matchRoutes,
resolvePath,
useInRouterContext,
useLocation,
useResolvedPath,
} from 'react-router-dom';
import { Button as RAButton } from 'react-aria-components';
import { RiArrowDownSLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
@@ -26,9 +33,8 @@ import {
} from './HeaderNavDefinition';
import { HeaderNavIndicators } from './HeaderNavIndicators';
import { MenuTrigger, Menu, MenuItem } from '../Menu';
import type { AnalyticsTracker } from '../../analytics/types';
import type {
HeaderNavTab,
HeaderNavLinkProps,
HeaderNavTabGroup,
HeaderNavTabItem,
} from './types';
@@ -37,29 +43,21 @@ 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 { ownProps, analytics } = useDefinition(HeaderNavItemDefinition, props);
const { id, label, href, active, registerRef, onHighlight } = ownProps;
const linkRef = useRef<HTMLAnchorElement>(null);
const { linkProps } = useLink({ href: tab.href }, linkRef);
const { linkProps } = useLink({ href }, linkRef);
const { hoverProps } = useHover({
onHoverStart: () => onHighlight(tab.id),
onHoverStart: () => onHighlight(id),
onHoverEnd: () => onHighlight(null),
});
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
linkProps.onClick?.(e);
analytics.captureEvent('click', tab.label, {
attributes: { to: tab.href },
analytics.captureEvent('click', label, {
attributes: { to: href },
});
};
@@ -72,16 +70,16 @@ function HeaderNavLink(props: HeaderNavLinkProps) {
(
linkRef as React.MutableRefObject<HTMLAnchorElement | null>
).current = el;
registerRef(tab.id, el);
registerRef(id, el);
}}
href={tab.href}
href={href}
className={ownProps.classes.root}
aria-current={active ? 'page' : undefined}
onClick={handleClick}
onFocus={() => onHighlight(tab.id)}
onFocus={() => onHighlight(id)}
onBlur={() => onHighlight(null)}
>
{tab.label}
{label}
</a>
</li>
);
@@ -136,13 +134,32 @@ function HeaderNavGroupItem(props: HeaderNavGroupItemProps) {
interface HeaderNavProps {
tabs: HeaderNavTabItem[];
activeTabId?: string;
activeTabId?: string | null;
}
/** @internal */
export function HeaderNav(props: HeaderNavProps) {
function useAutoActiveTabId(tabs: HeaderNavTabItem[]): string | undefined {
const basePath = useResolvedPath('.').pathname;
const { pathname } = useLocation();
return useMemo(() => {
const allTabs = tabs.flatMap(tab => (isTabGroup(tab) ? tab.items : [tab]));
const routeObjects = allTabs.map(tab => ({
path: `${resolvePath(tab.href, basePath).pathname}/*`,
id: tab.id,
}));
const matches = matchRoutes(routeObjects, pathname);
return matches?.[0]?.route.id;
}, [tabs, basePath, pathname]);
}
function HeaderNavAutoDetect(props: { tabs: HeaderNavTabItem[] }) {
const activeTabId = useAutoActiveTabId(props.tabs);
return <HeaderNavInner tabs={props.tabs} activeTabId={activeTabId} />;
}
function HeaderNavInner(props: HeaderNavProps) {
const { tabs, activeTabId } = props;
const { ownProps, analytics } = useDefinition(HeaderNavDefinition, {
const { ownProps } = useDefinition(HeaderNavDefinition, {
tabs,
activeTabId,
});
@@ -199,9 +216,10 @@ export function HeaderNav(props: HeaderNavProps) {
) : (
<HeaderNavLink
key={item.id}
tab={item}
id={item.id}
label={item.label}
href={item.href}
active={activeKey === item.id}
analytics={analytics}
registerRef={registerRef}
onHighlight={setHighlightedKey}
/>
@@ -218,3 +236,14 @@ export function HeaderNav(props: HeaderNavProps) {
</nav>
);
}
/** @internal */
export function HeaderNav(props: HeaderNavProps) {
const inRouter = useInRouterContext();
if (props.activeTabId === undefined && inRouter) {
return <HeaderNavAutoDetect tabs={props.tabs} />;
}
return <HeaderNavInner tabs={props.tabs} activeTabId={props.activeTabId} />;
}
@@ -15,14 +15,13 @@
*/
import { defineComponent } from '../../hooks/useDefinition';
import type { HeaderNavTabItem } from './types';
import type { HeaderNavTabItem, HeaderNavLinkProps } from './types';
import styles from './HeaderNav.module.css';
/** @public */
export const HeaderNavDefinition = defineComponent<{
noTrack?: boolean;
tabs: HeaderNavTabItem[];
activeTabId?: string;
activeTabId?: string | null;
children?: React.ReactNode;
className?: string;
}>()({
@@ -33,9 +32,7 @@ export const HeaderNavDefinition = defineComponent<{
active: 'bui-HeaderNavActive',
hovered: 'bui-HeaderNavHovered',
},
analytics: true,
propDefs: {
noTrack: {},
tabs: {},
activeTabId: {},
children: {},
@@ -44,14 +41,21 @@ export const HeaderNavDefinition = defineComponent<{
});
/** @public */
export const HeaderNavItemDefinition = defineComponent<{
className?: string;
}>()({
export const HeaderNavItemDefinition = defineComponent<HeaderNavLinkProps>()({
styles,
classNames: {
root: 'bui-HeaderNavItem',
},
analytics: true,
resolveHref: true,
propDefs: {
noTrack: {},
id: {},
label: {},
href: {},
active: {},
registerRef: {},
onHighlight: {},
className: {},
},
});
+10 -1
View File
@@ -25,6 +25,15 @@ export interface HeaderNavTab {
href: string;
}
/** @internal */
export interface HeaderNavLinkProps extends HeaderNavTab {
noTrack?: boolean;
active: boolean;
registerRef: (key: string, el: HTMLElement | null) => void;
onHighlight: (key: string | null) => void;
className?: string;
}
/**
* Represents a group of navigation tabs rendered as a dropdown menu.
*
@@ -52,7 +61,7 @@ export interface HeaderOwnProps {
title?: string;
customActions?: React.ReactNode;
tabs?: HeaderNavTabItem[];
activeTabId?: string;
activeTabId?: string | null;
breadcrumbs?: HeaderBreadcrumb[];
className?: string;
}
@@ -14,10 +14,9 @@
* limitations under the License.
*/
import { useMemo } from 'react';
import preview from '../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../provider';
import type { HeaderNavTabItem } from '../components/Header/types';
import {
@@ -211,20 +210,17 @@ const subTabs: HeaderNavTabItem[] = [
{ 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],
decorators: [
(Story: StoryFn) => (
<MemoryRouter initialEntries={['/summary']}>
<BUIProvider>
<Story />
</BUIProvider>
</MemoryRouter>
),
],
render: () => {
const activeTabId = useActiveTabId(subTabs);
return (
<>
<PluginHeader
@@ -249,7 +245,6 @@ export const WithSubTabs = meta.story({
/>
<Header
title="main · #842"
activeTabId={activeTabId}
tabs={subTabs}
breadcrumbs={[
{ label: 'Catalog', href: '/catalog' },