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 ( <>