feat(ui): add href support to HeaderMetadataUsers, remove bold from description, full-width header

- Add `href` to `HeaderMetadataUser`: avatar becomes a link and name renders as a primary `Link` with `standalone` (no underline at rest)
- Add `WithMetadataUsersNoLinks` story alongside `WithMetadataUsers` to cover both link and non-link variants
- Remove `strong`/`em` from ReactMarkdown `allowedElements` in Header description — only plain text and inline links are now supported
- Replace `<Container>` root with a plain `<div>` so the Header spans its full parent width
- Add `HeaderMetadataStatus` component and CSS (new files)
- Update docs: fixtures, snippets, props-definition, and page.mdx to reflect all changes

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
Made-with: Cursor
This commit is contained in:
Charles de Dreuille
2026-04-19 11:41:55 +02:00
parent 0e8edce069
commit 6310790556
12 changed files with 406 additions and 56 deletions
@@ -18,6 +18,7 @@ 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';
@@ -27,9 +28,6 @@ import { RiMore2Line } from '@remixicon/react';
const meta = preview.meta({
title: 'Backstage UI/Header',
component: Header,
parameters: {
layout: 'fullscreen',
},
});
const tabs: HeaderNavTabItem[] = [
@@ -158,7 +156,7 @@ export const WithDescription = meta.story({
args: {
...Default.input.args,
description:
'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.',
'This is a description of the page. It can include [inline links](https://backstage.io).',
},
});
@@ -189,10 +187,23 @@ 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',
},
alice: { name: 'Alice Johnson', src: 'https://i.pravatar.cc/150?u=alicej' },
bob: { name: 'Bob Smith', src: 'https://i.pravatar.cc/150?u=bob' },
carol: { name: 'Carol Williams', src: 'https://i.pravatar.cc/150?u=carol' },
};
export const WithMetadataUsers = meta.story({
@@ -218,12 +229,72 @@ export const WithMetadataUsers = meta.story({
),
});
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) and **bold text**."
description="This is a description of the page. It can include [inline links](https://backstage.io)."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
@@ -257,7 +328,7 @@ export const WithEverything = meta.story({
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) and **bold text**."
description="This is a description of the page. It can include [inline links](https://backstage.io)."
tags={[
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
+9 -5
View File
@@ -20,7 +20,6 @@ import { RiArrowRightSLine } from '@remixicon/react';
import { HeaderNav } from './HeaderNav';
import { useDefinition } from '../../hooks/useDefinition';
import { HeaderDefinition } from './definition';
import { Container } from '../Container';
import { Link } from '../Link';
import { Fragment } from 'react/jsx-runtime';
import ReactMarkdown from 'react-markdown';
@@ -45,14 +44,19 @@ export const Header = (props: HeaderProps) => {
} = ownProps;
return (
<Container className={classes.root}>
<div className={classes.root}>
{tags && tags.length > 0 && (
<div className={classes.tags}>
{tags.map((tag, i) => (
<Fragment key={tag.label}>
{i > 0 && <span className={classes.tagDivider} aria-hidden />}
{tag.href ? (
<Link href={tag.href} variant="body-medium" standalone>
<Link
href={tag.href}
variant="body-medium"
color="secondary"
standalone
>
{tag.label}
</Link>
) : (
@@ -92,7 +96,7 @@ export const Header = (props: HeaderProps) => {
{description && (
<ReactMarkdown
className={classes.description}
allowedElements={['p', 'a', 'strong', 'em']}
allowedElements={['p', 'a']}
unwrapDisallowed
components={{
p: ({ children }) => (
@@ -127,7 +131,7 @@ export const Header = (props: HeaderProps) => {
<HeaderNav tabs={tabs} activeTabId={activeTabId} />
</div>
)}
</Container>
</div>
);
};
@@ -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,47 @@
/*
* 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 { HeaderMetadataStatusItem } 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,
}: HeaderMetadataStatusItem) => {
return (
<div className={styles.single}>
<span className={`${styles.dot} ${styles[`dot-${color}`]}`} />
<Text variant="body-medium">
{href ? (
<Link href={href} standalone>
{label}
</Link>
) : (
label
)}
</Text>
</div>
);
};
@@ -30,4 +30,9 @@
align-items: center;
gap: var(--bui-space-1);
}
.avatarLink {
display: flex;
text-decoration: none;
}
}
@@ -18,6 +18,7 @@ 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';
@@ -25,6 +26,7 @@ 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 overlapping avatars with the name revealed on hover via tooltip.
* When a user has an `href`, the avatar and name become links.
*
* @public
*/
@@ -37,15 +39,34 @@ export const HeaderMetadataUsers = ({
if (users.length === 1) {
const user = users[0];
const avatar = (
<Avatar
src={user.src ?? ''}
name={user.name}
size="small"
purpose="decoration"
/>
);
return (
<div className={styles.single}>
<Avatar
src={user.src ?? ''}
name={user.name}
size="small"
purpose="decoration"
/>
<Text variant="body-medium">{user.name}</Text>
{user.href ? (
<Link
href={user.href}
aria-label={user.name}
className={styles.avatarLink}
>
{avatar}
</Link>
) : (
avatar
)}
{user.href ? (
<Link href={user.href} variant="body-medium" standalone>
{user.name}
</Link>
) : (
<Text variant="body-medium">{user.name}</Text>
)}
</div>
);
}
@@ -54,14 +75,29 @@ export const HeaderMetadataUsers = ({
<div className={styles.stack}>
{users.map(user => (
<TooltipTrigger key={user.name}>
<Pressable>
<Avatar
src={user.src ?? ''}
name={user.name}
size="small"
purpose="informative"
/>
</Pressable>
{user.href ? (
<Link
href={user.href}
aria-label={user.name}
className={styles.avatarLink}
>
<Avatar
src={user.src ?? ''}
name={user.name}
size="small"
purpose="decoration"
/>
</Link>
) : (
<Pressable>
<Avatar
src={user.src ?? ''}
name={user.name}
size="small"
purpose="informative"
/>
</Pressable>
)}
<Tooltip>{user.name}</Tooltip>
</TooltipTrigger>
))}
@@ -21,6 +21,7 @@ export {
HeaderNavGroupDefinition,
} from './HeaderNavDefinition';
export { HeaderMetadataUsers } from './HeaderMetadataUsers';
export { HeaderMetadataStatus } from './HeaderMetadataStatus';
export type {
HeaderNavTab,
HeaderNavTabGroup,
@@ -31,6 +32,7 @@ export type {
HeaderTag,
HeaderMetadataItem,
HeaderMetadataUser,
HeaderMetadataStatusItem,
HeaderPageOwnProps,
HeaderPageProps,
HeaderPageBreadcrumb,
+14 -2
View File
@@ -80,6 +80,18 @@ export interface HeaderMetadataItem {
export interface HeaderMetadataUser {
name: string;
src?: string;
href?: string;
}
/**
* Represents a status item in the HeaderMetadataStatus component.
*
* @public
*/
export interface HeaderMetadataStatusItem {
label: string;
color: 'danger' | 'warning' | 'success' | 'info';
href?: string;
}
/**
@@ -97,8 +109,8 @@ export interface HeaderOwnProps {
*/
breadcrumbs?: HeaderBreadcrumb[];
/**
* Markdown string rendered below the title. Only inline elements are
* supported (links, bold, italic). Block-level markdown is not rendered.
* Markdown string rendered below the title. Only inline links are supported.
* Bold, italic, and block-level markdown are not rendered.
*/
description?: string;
tags?: HeaderTag[];