Merge pull request #33997 from backstage/charlesdedreuille/act-355-header-improvements

feat(ui): add description, tags, and metadata props to Header
This commit is contained in:
Charles de Dreuille
2026-04-24 13:59:56 +01:00
committed by GitHub
25 changed files with 1124 additions and 39 deletions
+2
View File
@@ -47,10 +47,12 @@
},
"dependencies": {
"@backstage/version-bridge": "workspace:^",
"@braintree/sanitize-url": "^7.1.2",
"@internationalized/date": "^3.12.0",
"@remixicon/react": ">=4.6.0 <4.9.0",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
"marked": "^15.0.12",
"react-aria": "~3.48.0",
"react-aria-components": "~1.17.0",
"react-stately": "~3.46.0",
+68 -1
View File
@@ -1585,6 +1585,11 @@ export const HeaderDefinition: {
readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
readonly tabsWrapper: 'bui-HeaderTabsWrapper';
readonly controls: 'bui-HeaderControls';
readonly tags: 'bui-HeaderTags';
readonly tag: 'bui-HeaderTag';
readonly description: 'bui-HeaderDescription';
readonly metaRow: 'bui-HeaderMetaRow';
readonly metaItem: 'bui-HeaderMetaItem';
};
readonly propDefs: {
readonly title: {};
@@ -1592,10 +1597,51 @@ export const HeaderDefinition: {
readonly tabs: {};
readonly activeTabId: {};
readonly breadcrumbs: {};
readonly description: {};
readonly tags: {};
readonly metadata: {};
readonly className: {};
};
};
// @public
export interface HeaderMetadataItem {
// (undocumented)
label: string;
// (undocumented)
value: React.ReactNode;
}
// @public
export const HeaderMetadataStatus: (
input: HeaderMetadataStatusProps,
) => JSX_2.Element;
// @public
export interface HeaderMetadataStatusProps {
// (undocumented)
color: 'danger' | 'warning' | 'success' | 'info';
// (undocumented)
href?: string;
// (undocumented)
label: string;
}
// @public
export interface HeaderMetadataUser {
// (undocumented)
href?: string;
// (undocumented)
name: string;
// (undocumented)
src?: string;
}
// @public
export const HeaderMetadataUsers: (input: {
users: HeaderMetadataUser[];
}) => JSX_2.Element | null;
// @public (undocumented)
export const HeaderNavDefinition: {
readonly styles: {
@@ -1676,15 +1722,20 @@ export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup;
export interface HeaderOwnProps {
// (undocumented)
activeTabId?: string | null;
// (undocumented)
// @deprecated (undocumented)
breadcrumbs?: HeaderBreadcrumb[];
// (undocumented)
className?: string;
// (undocumented)
customActions?: React.ReactNode;
description?: string;
// (undocumented)
metadata?: HeaderMetadataItem[];
// (undocumented)
tabs?: HeaderNavTabItem[];
// (undocumented)
tags?: HeaderTag[];
// (undocumented)
title?: string;
}
@@ -1705,6 +1756,11 @@ export const HeaderPageDefinition: {
readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
readonly tabsWrapper: 'bui-HeaderTabsWrapper';
readonly controls: 'bui-HeaderControls';
readonly tags: 'bui-HeaderTags';
readonly tag: 'bui-HeaderTag';
readonly description: 'bui-HeaderDescription';
readonly metaRow: 'bui-HeaderMetaRow';
readonly metaItem: 'bui-HeaderMetaItem';
};
readonly propDefs: {
readonly title: {};
@@ -1712,6 +1768,9 @@ export const HeaderPageDefinition: {
readonly tabs: {};
readonly activeTabId: {};
readonly breadcrumbs: {};
readonly description: {};
readonly tags: {};
readonly metadata: {};
readonly className: {};
};
};
@@ -1736,6 +1795,14 @@ export interface HeaderTab {
matchStrategy?: TabMatchStrategy;
}
// @public
export interface HeaderTag {
// (undocumented)
href?: string;
// (undocumented)
label: string;
}
// @public (undocumented)
export type JustifyContent =
| 'stretch'
@@ -20,7 +20,7 @@
.bui-Header {
display: flex;
flex-direction: column;
gap: var(--bui-space-1);
gap: var(--bui-space-3);
margin-top: var(--bui-space-6);
}
@@ -47,4 +47,50 @@
align-items: center;
gap: var(--bui-space-2);
}
.bui-HeaderTags {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
flex-wrap: wrap;
list-style: none;
margin: 0;
padding: 0;
}
.bui-HeaderTag {
display: flex;
align-items: center;
gap: var(--bui-space-2);
}
.bui-HeaderTag + .bui-HeaderTag::before {
content: '';
width: 3px;
height: 3px;
border-radius: 50%;
background-color: var(--bui-fg-secondary);
flex-shrink: 0;
}
.bui-HeaderMetaRow {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-5);
flex-wrap: wrap;
margin: 0;
}
.bui-HeaderMetaItem {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
dd {
margin: 0;
}
}
}
@@ -17,6 +17,8 @@
import preview from '../../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { Header } from './Header';
import { HeaderMetadataUsers } from './HeaderMetadataUsers';
import { HeaderMetadataStatus } from './HeaderMetadataStatus';
import type { HeaderNavTabItem } from './types';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
@@ -152,16 +154,208 @@ export const WithLongBreadcrumbs = meta.story({
},
});
export const WithEverything = meta.story({
export const WithDescription = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
tabs,
customActions: <Button>Custom action</Button>,
breadcrumbs: [{ label: 'Home', href: '/' }],
description:
'This is a description of the page. It can include [inline links](https://backstage.io).',
},
});
export const WithTags = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
tags: [
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
{ label: 'Gold' },
],
},
});
export const WithMetadata = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
metadata: [
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
],
},
});
const users = {
giles: {
name: 'Giles Peyton-Nicoll',
src: 'https://i.pravatar.cc/150?u=giles',
href: '/users/giles',
},
alice: {
name: 'Alice Johnson',
src: 'https://i.pravatar.cc/150?u=alicej',
href: '/users/alice',
},
bob: {
name: 'Bob Smith',
src: 'https://i.pravatar.cc/150?u=bob',
href: '/users/bob',
},
carol: {
name: 'Carol Williams',
src: 'https://i.pravatar.cc/150?u=carol',
href: '/users/carol',
},
};
export const WithMetadataUsers = meta.story({
decorators: [withRouter],
render: () => (
<Header
{...Default.input.args}
metadata={[
{
label: 'Owner',
value: <HeaderMetadataUsers users={[users.giles]} />,
},
{
label: 'Contributors',
value: (
<HeaderMetadataUsers
users={[users.alice, users.bob, users.carol]}
/>
),
},
]}
/>
),
});
export const WithMetadataUsersNoLinks = meta.story({
decorators: [withRouter],
render: () => (
<Header
{...Default.input.args}
metadata={[
{
label: 'Owner',
value: (
<HeaderMetadataUsers
users={[{ name: users.giles.name, src: users.giles.src }]}
/>
),
},
{
label: 'Contributors',
value: (
<HeaderMetadataUsers
users={[
{ name: users.alice.name, src: users.alice.src },
{ name: users.bob.name, src: users.bob.src },
{ name: users.carol.name, src: users.carol.src },
]}
/>
),
},
]}
/>
),
});
export const WithMetadataStatus = meta.story({
decorators: [withRouter],
render: () => (
<Header
{...Default.input.args}
metadata={[
{
label: 'Status',
value: <HeaderMetadataStatus label="Passing" color="success" />,
},
{
label: 'Build',
value: (
<HeaderMetadataStatus
label="Failed"
color="danger"
href="/builds/123"
/>
),
},
{
label: 'Coverage',
value: <HeaderMetadataStatus label="Warning" color="warning" />,
},
]}
/>
),
});
export const WithDescriptionTagsAndMetadata = meta.story({
decorators: [withRouter],
render: () => (
<Header
{...Default.input.args}
description="This is a description of the page. It can include [inline links](https://backstage.io)."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
{ label: 'Gold' },
]}
metadata={[
{
label: 'Owner',
value: <HeaderMetadataUsers users={[users.giles]} />,
},
{
label: 'Contributors',
value: (
<HeaderMetadataUsers
users={[users.alice, users.bob, users.carol]}
/>
),
},
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
]}
/>
),
});
export const WithEverything = meta.story({
decorators: [withRouter],
render: () => (
<Header
{...Default.input.args}
tabs={tabs}
customActions={<Button>Custom action</Button>}
breadcrumbs={[{ label: 'Home', href: '/' }]}
description="This is a description of the page. It can include [inline links](https://backstage.io)."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
{ label: 'Gold' },
]}
metadata={[
{ label: 'Type', value: 'website' },
{
label: 'Owner',
value: <HeaderMetadataUsers users={[users.giles]} />,
},
{
label: 'Contributors',
value: (
<HeaderMetadataUsers
users={[users.alice, users.bob, users.carol]}
/>
),
},
]}
/>
),
});
const groupedTabs: HeaderNavTabItem[] = [
{ id: 'overview', label: 'Overview', href: '/overview' },
{
+93 -3
View File
@@ -20,9 +20,31 @@ import { RiArrowRightSLine } from '@remixicon/react';
import { HeaderNav } from './HeaderNav';
import { useDefinition } from '../../hooks/useDefinition';
import { HeaderDefinition } from './definition';
import { sanitizeUrl } from '@braintree/sanitize-url';
import { Container } from '../Container';
import { Lexer } from 'marked';
import { Link } from '../Link';
import { Fragment } from 'react/jsx-runtime';
import { Fragment, useMemo } from 'react';
/**
* Parses inline Markdown links in a string and returns an array of React nodes.
* URLs are sanitized via `@braintree/sanitize-url`; unsafe URLs are rendered as
* plain text. Uses `marked` instead of `react-markdown` to avoid ESM issues.
*/
function renderInlineMarkdown(text: string): React.ReactNode[] {
return Lexer.lexInline(text).map((token, i) => {
if (token.type === 'link') {
const href = sanitizeUrl(token.href);
if (href === 'about:blank') return token.text;
return (
<Link key={i} href={href} standalone>
{token.text}
</Link>
);
}
return token.raw;
});
}
/**
* A secondary header with title, breadcrumbs, tabs, and actions.
@@ -31,11 +53,50 @@ import { Fragment } from 'react/jsx-runtime';
*/
export const Header = (props: HeaderProps) => {
const { ownProps } = useDefinition(HeaderDefinition, props);
const { classes, title, tabs, activeTabId, customActions, breadcrumbs } =
ownProps;
const {
classes,
title,
tabs,
activeTabId,
customActions,
breadcrumbs,
description,
tags,
metadata,
} = ownProps;
const descriptionNodes = useMemo(
() => (description ? renderInlineMarkdown(description) : null),
[description],
);
return (
<Container className={classes.root}>
{tags && tags.length > 0 && (
<ul className={classes.tags}>
{tags.map((tag, i) => (
<li
key={`${i}:${tag.label}:${tag.href ?? ''}`}
className={classes.tag}
>
{tag.href ? (
<Link
href={tag.href}
variant="body-medium"
color="secondary"
standalone
>
{tag.label}
</Link>
) : (
<Text variant="body-medium" color="secondary">
{tag.label}
</Text>
)}
</li>
))}
</ul>
)}
<div className={classes.content}>
<div className={classes.breadcrumbs}>
{breadcrumbs &&
@@ -61,6 +122,35 @@ export const Header = (props: HeaderProps) => {
</div>
<div className={classes.controls}>{customActions}</div>
</div>
{description && (
<Text
variant="body-medium"
color="secondary"
className={classes.description}
>
{descriptionNodes}
</Text>
)}
{metadata && metadata.length > 0 && (
<dl className={classes.metaRow}>
{metadata.map((item, i) => (
<div key={`${i}:${item.label}`} className={classes.metaItem}>
<dt>
<Text variant="body-medium" color="secondary">
{item.label}
</Text>
</dt>
<dd>
{typeof item.value === 'string' ? (
<Text variant="body-medium">{item.value}</Text>
) : (
item.value
)}
</dd>
</div>
))}
</dl>
)}
{tabs && (
<div className={classes.tabsWrapper}>
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
@@ -0,0 +1,49 @@
/*
* 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 {
.single {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-danger {
background-color: var(--bui-fg-danger);
}
.dot-warning {
background-color: var(--bui-fg-warning);
}
.dot-success {
background-color: var(--bui-fg-success);
}
.dot-info {
background-color: var(--bui-fg-info);
}
}
@@ -0,0 +1,50 @@
/*
* 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 type { HeaderMetadataStatusProps } from './types';
import { Text } from '../Text';
import { Link } from '../Link';
import styles from './HeaderMetadataStatus.module.css';
/**
* Displays a single status indicator as a coloured dot with a label inside a
* Header metadata value. Optionally renders the label as a link when href is provided.
*
* @public
*/
export const HeaderMetadataStatus = ({
label,
color,
href,
}: HeaderMetadataStatusProps) => {
return (
<div className={styles.single}>
<span
aria-hidden="true"
className={`${styles.dot} ${styles[`dot-${color}`]}`}
/>
<Text variant="body-medium">
{href ? (
<Link href={href} standalone>
{label}
</Link>
) : (
label
)}
</Text>
</div>
);
};
@@ -0,0 +1,41 @@
/*
* 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 {
.single {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
}
.stack {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-1);
list-style: none;
margin: 0;
padding: 0;
}
.avatarLink {
display: flex;
text-decoration: none;
}
}
@@ -0,0 +1,108 @@
/*
* 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 type { HeaderMetadataUser } from './types';
import { Avatar } from '../Avatar';
import { Tooltip, TooltipTrigger } from '../Tooltip';
import { Text } from '../Text';
import { Link } from '../Link';
import { Pressable } from 'react-aria';
import styles from './HeaderMetadataUsers.module.css';
/**
* Displays a list of users as avatars inside a Header metadata value.
* A single user shows the avatar with their name beside it.
* Multiple users show avatars in a row with the name revealed on hover via tooltip.
* When a user has an `href`, the avatar and name become links.
*
* @public
*/
export const HeaderMetadataUsers = ({
users,
}: {
users: HeaderMetadataUser[];
}) => {
if (users.length === 0) return null;
if (users.length === 1) {
const user = users[0];
if (user.href) {
return (
<Link
href={user.href}
variant="body-medium"
standalone
className={styles.single}
>
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="decoration"
/>
{user.name}
</Link>
);
}
return (
<div className={styles.single}>
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="decoration"
/>
<Text variant="body-medium">{user.name}</Text>
</div>
);
}
return (
<ul className={styles.stack}>
{users.map((user, i) => (
<li key={user.href ?? `${i}:${user.name}`}>
<TooltipTrigger>
{user.href ? (
<Link
href={user.href}
aria-label={user.name}
className={styles.avatarLink}
>
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="decoration"
/>
</Link>
) : (
<Pressable>
<Avatar
src={user.src ?? 'data:,'}
name={user.name}
size="small"
purpose="informative"
/>
</Pressable>
)}
<Tooltip>{user.name}</Tooltip>
</TooltipTrigger>
</li>
))}
</ul>
);
};
@@ -30,6 +30,11 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
breadcrumbs: 'bui-HeaderBreadcrumbs',
tabsWrapper: 'bui-HeaderTabsWrapper',
controls: 'bui-HeaderControls',
tags: 'bui-HeaderTags',
tag: 'bui-HeaderTag',
description: 'bui-HeaderDescription',
metaRow: 'bui-HeaderMetaRow',
metaItem: 'bui-HeaderMetaItem',
},
propDefs: {
title: {},
@@ -37,6 +42,9 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
tabs: {},
activeTabId: {},
breadcrumbs: {},
description: {},
tags: {},
metadata: {},
className: {},
},
});
@@ -20,6 +20,8 @@ export {
HeaderNavItemDefinition,
HeaderNavGroupDefinition,
} from './HeaderNavDefinition';
export { HeaderMetadataUsers } from './HeaderMetadataUsers';
export { HeaderMetadataStatus } from './HeaderMetadataStatus';
export type {
HeaderNavTab,
HeaderNavTabGroup,
@@ -27,6 +29,10 @@ export type {
HeaderOwnProps,
HeaderProps,
HeaderBreadcrumb,
HeaderTag,
HeaderMetadataItem,
HeaderMetadataUser,
HeaderMetadataStatusProps,
HeaderPageOwnProps,
HeaderPageProps,
HeaderPageBreadcrumb,
@@ -52,6 +52,48 @@ export interface HeaderNavTabGroup {
*/
export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup;
/**
* Represents a tag item in the header.
*
* @public
*/
export interface HeaderTag {
label: string;
href?: string;
}
/**
* Represents a metadata key-value pair in the header.
*
* @public
*/
export interface HeaderMetadataItem {
label: string;
value: React.ReactNode;
}
/**
* Represents a user in the HeaderMetadataUsers component.
*
* @public
*/
export interface HeaderMetadataUser {
name: string;
src?: string;
href?: string;
}
/**
* Represents a status item in the HeaderMetadataStatus component.
*
* @public
*/
export interface HeaderMetadataStatusProps {
label: string;
color: 'danger' | 'warning' | 'success' | 'info';
href?: string;
}
/**
* Own props for the Header component.
*
@@ -62,7 +104,17 @@ export interface HeaderOwnProps {
customActions?: React.ReactNode;
tabs?: HeaderNavTabItem[];
activeTabId?: string | null;
/**
* @deprecated The breadcrumbs prop will be removed in a future release.
*/
breadcrumbs?: HeaderBreadcrumb[];
/**
* Markdown string rendered below the title. Only inline links are supported.
* Bold, italic, and block-level markdown are not rendered.
*/
description?: string;
tags?: HeaderTag[];
metadata?: HeaderMetadataItem[];
className?: string;
}