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