diff --git a/.changeset/cold-ways-double.md b/.changeset/cold-ways-double.md
new file mode 100644
index 0000000000..a187c1433e
--- /dev/null
+++ b/.changeset/cold-ways-double.md
@@ -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
diff --git a/.changeset/header-nav-resolve-href.md b/.changeset/header-nav-resolve-href.md
new file mode 100644
index 0000000000..2e4872cddc
--- /dev/null
+++ b/.changeset/header-nav-resolve-href.md
@@ -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 `` 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
diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx
index dc32bdd6cb..26dc715a5d 100644
--- a/docs-ui/src/app/components/header/components.tsx
+++ b/docs-ui/src/app/components/header/components.tsx
@@ -30,7 +30,7 @@ const breadcrumbs = [
];
export const WithEverything = () => (
-
+
(
);
export const WithTabs = () => (
-
+
);
diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx
index 0ae9b045aa..9e02b70a70 100644
--- a/docs-ui/src/app/components/header/page.mdx
+++ b/docs-ui/src/app/components/header/page.mdx
@@ -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.
} code={withTabs} />
diff --git a/docs-ui/src/app/components/header/props-definition.tsx b/docs-ui/src/app/components/header/props-definition.tsx
index 4caacf1c13..0acc7d30ae 100644
--- a/docs-ui/src/app/components/header/props-definition.tsx
+++ b/docs-ui/src/app/components/header/props-definition.tsx
@@ -42,9 +42,10 @@ export const headerPagePropDefs: Record = {
},
},
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',
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index f56959fb36..e30a81a874 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -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)
diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx
index 78169dee92..c30534a993 100644
--- a/packages/ui/src/components/Header/Header.stories.tsx
+++ b/packages/ui/src/components/Header/Header.stories.tsx
@@ -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) => (
);
-/**
- * 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 ;
+ 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 (
- Custom action}
- breadcrumbs={[{ label: 'Home', href: '/' }]}
- />
- );
+ args: {
+ ...Default.input.args,
+ tabs,
+ customActions: ,
+ breadcrumbs: [{ label: 'Home', href: '/' }],
},
});
@@ -212,10 +185,17 @@ export const WithGroupedTabs = meta.story({
),
],
- render: () => {
- const activeTabId = useActiveTabId(groupedTabs);
- return (
-
- );
+ args: {
+ ...Default.input.args,
+ tabs: groupedTabs,
+ },
+});
+
+export const WithExplicitActiveTab = meta.story({
+ decorators: [withRouter],
+ args: {
+ ...Default.input.args,
+ tabs,
+ activeTabId: 'campaigns',
},
});
diff --git a/packages/ui/src/components/Header/HeaderNav.tsx b/packages/ui/src/components/Header/HeaderNav.tsx
index 22d2eb5b58..1a4ba77c2d 100644
--- a/packages/ui/src/components/Header/HeaderNav.tsx
+++ b/packages/ui/src/components/Header/HeaderNav.tsx
@@ -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(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) => {
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
).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}
);
@@ -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 ;
+}
+
+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) {
) : (
@@ -218,3 +236,14 @@ export function HeaderNav(props: HeaderNavProps) {
);
}
+
+/** @internal */
+export function HeaderNav(props: HeaderNavProps) {
+ const inRouter = useInRouterContext();
+
+ if (props.activeTabId === undefined && inRouter) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/packages/ui/src/components/Header/HeaderNavDefinition.ts b/packages/ui/src/components/Header/HeaderNavDefinition.ts
index 3b1007972a..bbf3ce172f 100644
--- a/packages/ui/src/components/Header/HeaderNavDefinition.ts
+++ b/packages/ui/src/components/Header/HeaderNavDefinition.ts
@@ -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()({
styles,
classNames: {
root: 'bui-HeaderNavItem',
},
+ analytics: true,
+ resolveHref: true,
propDefs: {
+ noTrack: {},
+ id: {},
+ label: {},
+ href: {},
+ active: {},
+ registerRef: {},
+ onHighlight: {},
className: {},
},
});
diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts
index e215688b2c..1d4acc45ea 100644
--- a/packages/ui/src/components/Header/types.ts
+++ b/packages/ui/src/components/Header/types.ts
@@ -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;
}
diff --git a/packages/ui/src/recipes/PluginHeaderAndHeader.stories.tsx b/packages/ui/src/recipes/PluginHeaderAndHeader.stories.tsx
index b45c25268e..a43252a9b7 100644
--- a/packages/ui/src/recipes/PluginHeaderAndHeader.stories.tsx
+++ b/packages/ui/src/recipes/PluginHeaderAndHeader.stories.tsx
@@ -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) => (
+
+
+
+
+
+ ),
+ ],
render: () => {
- const activeTabId = useActiveTabId(subTabs);
return (
<>