feat(ui): add HeaderMetadataUsers component and polish Header metadata/tags

- Add HeaderMetadataUsers component: single user shows avatar + name,
  multiple users show avatar stack with tooltip on hover
- Use Pressable from react-aria for tooltip trigger compatibility
- Switch tags and metadata text to body-medium variant
- Fix metadata item styling: secondary color label, no bold, no colon,
  flex row with gap-2 between label and value
- Update Header gap to space-3
- Update docs with HeaderMetadataUsers example, correct prop types,
  and synced default snippet

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
Made-with: Cursor
This commit is contained in:
Charles de Dreuille
2026-04-19 10:48:09 +02:00
parent fcc8c4d328
commit 0e8edce069
12 changed files with 319 additions and 57 deletions
@@ -1,6 +1,7 @@
'use client';
import { Header } from '../../../../../packages/ui/src/components/Header/Header';
import { HeaderMetadataUsers } from '../../../../../packages/ui/src/components/Header/HeaderMetadataUsers';
import { Button } from '../../../../../packages/ui/src/components/Button/Button';
import { ButtonIcon } from '../../../../../packages/ui/src/components/ButtonIcon/ButtonIcon';
import {
@@ -11,6 +12,16 @@ import {
import { MemoryRouter } from 'react-router-dom';
import { RiMore2Line } from '@remixicon/react';
const users = {
giles: {
name: 'Giles Peyton-Nicoll',
src: 'https://i.pravatar.cc/150?u=giles',
},
alice: { name: 'Alice Johnson', src: 'https://i.pravatar.cc/150?u=alice42' },
bob: { name: 'Bob Smith', src: 'https://i.pravatar.cc/150?u=bob' },
carol: { name: 'Carol Williams', src: 'https://i.pravatar.cc/150?u=carol' },
};
const tabs = [
{ id: 'overview', label: 'Overview', href: '/overview' },
{ id: 'checks', label: 'Checks', href: '/checks' },
@@ -35,10 +46,18 @@ const tags = [
{ label: 'Gold' },
];
const metadata = [
{ label: 'Owner', value: 'platform-team' },
const metadataUsers = [
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
{
label: 'Owner',
value: <HeaderMetadataUsers users={[users.giles]} />,
},
{
label: 'Contributors',
value: (
<HeaderMetadataUsers users={[users.alice, users.bob, users.carol]} />
),
},
];
export const WithEverything = () => (
@@ -47,8 +66,8 @@ export const WithEverything = () => (
title="Page Title"
tags={tags}
description="A short description of this page. Supports [inline links](https://backstage.io) and **bold text**."
metadata={metadataUsers}
tabs={tabs.slice(0, 2)}
breadcrumbs={breadcrumbs.slice(0, 2)}
customActions={
<>
<Button variant="secondary">Secondary</Button>
@@ -59,6 +78,12 @@ export const WithEverything = () => (
</MemoryRouter>
);
export const WithMetadataUsers = () => (
<MemoryRouter>
<Header title="Page Title" metadata={metadataUsers.slice(0, 2)} />
</MemoryRouter>
);
export const WithTags = () => (
<MemoryRouter>
<Header title="Page Title" tags={tags} />
@@ -76,7 +101,14 @@ export const WithDescription = () => (
export const WithMetadata = () => (
<MemoryRouter>
<Header title="Page Title" metadata={metadata} />
<Header
title="Page Title"
metadata={[
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
]}
/>
</MemoryRouter>
);
+8 -8
View File
@@ -3,11 +3,11 @@ import { CodeBlock } from '@/components/CodeBlock';
import { Snippet } from '@/components/Snippet';
import {
WithEverything,
WithLongBreadcrumbs,
WithTabs,
WithTags,
WithDescription,
WithMetadata,
WithMetadataUsers,
WithCustomActions,
WithMenu,
} from './components';
@@ -19,7 +19,7 @@ import {
withTags,
withDescription,
withMetadata,
withBreadcrumbs,
withMetadataUsers,
withCustomActions,
withMenu,
} from './snippets';
@@ -63,6 +63,12 @@ Key-value pairs displayed below the description.
<Snippet open preview={<WithMetadata />} code={withMetadata} />
### Metadata with users
Use `HeaderMetadataUsers` as the metadata value to display users as avatars. A single user shows the avatar with their name beside it. Multiple users show a row of avatars — hover to reveal each name via tooltip.
<Snippet open preview={<WithMetadataUsers />} code={withMetadataUsers} />
### Tabs
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.
@@ -79,12 +85,6 @@ Use `customActions` to add a dropdown menu.
<Snippet open preview={<WithMenu />} code={withMenu} />
### Breadcrumbs (deprecated)
The `breadcrumbs` prop is deprecated and will be removed in a future release. Labels are truncated at 240px.
<Snippet open preview={<WithLongBreadcrumbs />} code={withBreadcrumbs} />
<Theming definition={HeaderDefinition} />
<ChangelogComponent component={['header', 'header-page']} />
@@ -39,12 +39,13 @@ export const headerPagePropDefs: Record<string, PropDef> = {
label: {
type: 'string',
required: true,
description: 'The key label, displayed in bold.',
description: 'The key label, displayed in secondary color.',
},
value: {
type: 'ReactNode',
type: 'string | ReactNode',
required: true,
description: 'The value to display alongside the label.',
description:
'The value to display alongside the label. Pass a string for plain text or a ReactNode for custom content such as HeaderMetadataUsers.',
},
},
},
+47 -2
View File
@@ -2,7 +2,9 @@ export const usage = `import { Header } from '@backstage/ui';
<Header title="Page Title" />`;
export const defaultSnippet = `<Header
export const defaultSnippet = `import { Header, HeaderMetadataUsers } from '@backstage/ui';
<Header
title="Page Title"
tags={[
{ label: 'TypeScript' },
@@ -10,9 +12,28 @@ export const defaultSnippet = `<Header
{ label: 'Gold' },
]}
description="A short description. Supports [inline links](https://backstage.io) and **bold text**."
metadata={[
{ label: 'Type', value: 'website' },
{
label: 'Owner',
value: <HeaderMetadataUsers users={[{ name: 'Giles Peyton-Nicoll', src: '...' }]} />,
},
{
label: 'Contributors',
value: (
<HeaderMetadataUsers
users={[
{ name: 'Alice Johnson', src: '...' },
{ name: 'Bob Smith', src: '...' },
{ name: 'Carol Williams', src: '...' },
]}
/>
),
},
]}
tabs={[
{ id: 'overview', label: 'Overview', href: '/overview' },
{ id: 'settings', label: 'Settings', href: '/settings' },
{ id: 'checks', label: 'Checks', href: '/checks' },
]}
customActions={
<>
@@ -79,3 +100,27 @@ export const withMetadata = `<Header
{ label: 'Tier', value: 'gold' },
]}
/>`;
export const withMetadataUsers = `import { Header, HeaderMetadataUsers } from '@backstage/ui';
<Header
title="Page Title"
metadata={[
{
label: 'Owner',
value: <HeaderMetadataUsers users={[{ name: 'Giles Peyton-Nicoll', src: '...' }]} />,
},
{
label: 'Contributors',
value: (
<HeaderMetadataUsers
users={[
{ name: 'Alice Johnson', src: '...' },
{ name: 'Bob Smith', src: '...' },
{ name: 'Carol Williams', src: '...' },
]}
/>
),
},
]}
/>`;
@@ -71,4 +71,11 @@
gap: 20px;
flex-wrap: wrap;
}
.bui-HeaderMetaItem {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
}
}
@@ -17,6 +17,7 @@
import preview from '../../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { Header } from './Header';
import { HeaderMetadataUsers } from './HeaderMetadataUsers';
import type { HeaderNavTabItem } from './types';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
@@ -180,50 +181,105 @@ export const WithMetadata = meta.story({
metadata: [
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
],
},
});
const users = {
giles: {
name: 'Giles Peyton-Nicoll',
src: 'https://i.pravatar.cc/150?u=giles',
},
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({
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 WithDescriptionTagsAndMetadata = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
description:
'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.',
tags: [
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
{ label: 'Gold' },
],
metadata: [
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
],
},
render: () => (
<Header
{...Default.input.args}
description="This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**."
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],
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) and **bold text**.',
tags: [
{ label: 'TypeScript' },
{ label: 'Platform', href: '/platform' },
{ label: 'Gold' },
],
metadata: [
{ label: 'Owner', value: 'platform-team' },
{ label: 'Type', value: 'website' },
{ label: 'Tier', value: 'gold' },
],
},
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) and **bold text**."
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[] = [
+8 -5
View File
@@ -52,11 +52,11 @@ export const Header = (props: HeaderProps) => {
<Fragment key={tag.label}>
{i > 0 && <span className={classes.tagDivider} aria-hidden />}
{tag.href ? (
<Link href={tag.href} variant="body-small" standalone>
<Link href={tag.href} variant="body-medium" standalone>
{tag.label}
</Link>
) : (
<Text variant="body-small" color="secondary">
<Text variant="body-medium" color="secondary">
{tag.label}
</Text>
)}
@@ -113,9 +113,12 @@ export const Header = (props: HeaderProps) => {
{metadata && metadata.length > 0 && (
<div className={classes.metaRow}>
{metadata.map(item => (
<Text key={item.label} variant="body-small" color="secondary">
<strong>{item.label}:</strong> {item.value}
</Text>
<div key={item.label} className={classes.metaItem}>
<Text variant="body-medium" color="secondary">
{item.label}
</Text>
<Text variant="body-medium">{item.value}</Text>
</div>
))}
</div>
)}
@@ -0,0 +1,33 @@
/*
* 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);
}
}
@@ -0,0 +1,70 @@
/*
* 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 { 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 overlapping avatars with the name revealed on hover via tooltip.
*
* @public
*/
export const HeaderMetadataUsers = ({
users,
}: {
users: HeaderMetadataUser[];
}) => {
if (users.length === 0) return null;
if (users.length === 1) {
const user = users[0];
return (
<div className={styles.single}>
<Avatar
src={user.src ?? ''}
name={user.name}
size="small"
purpose="decoration"
/>
<Text variant="body-medium">{user.name}</Text>
</div>
);
}
return (
<div className={styles.stack}>
{users.map(user => (
<TooltipTrigger key={user.name}>
<Pressable>
<Avatar
src={user.src ?? ''}
name={user.name}
size="small"
purpose="informative"
/>
</Pressable>
<Tooltip>{user.name}</Tooltip>
</TooltipTrigger>
))}
</div>
);
};
@@ -34,6 +34,7 @@ export const HeaderDefinition = defineComponent<HeaderOwnProps>()({
tagDivider: 'bui-HeaderTagDivider',
description: 'bui-HeaderDescription',
metaRow: 'bui-HeaderMetaRow',
metaItem: 'bui-HeaderMetaItem',
},
propDefs: {
title: {},
@@ -20,6 +20,7 @@ export {
HeaderNavItemDefinition,
HeaderNavGroupDefinition,
} from './HeaderNavDefinition';
export { HeaderMetadataUsers } from './HeaderMetadataUsers';
export type {
HeaderNavTab,
HeaderNavTabGroup,
@@ -27,6 +28,9 @@ export type {
HeaderOwnProps,
HeaderProps,
HeaderBreadcrumb,
HeaderTag,
HeaderMetadataItem,
HeaderMetadataUser,
HeaderPageOwnProps,
HeaderPageProps,
HeaderPageBreadcrumb,
@@ -72,6 +72,16 @@ export interface HeaderMetadataItem {
value: React.ReactNode;
}
/**
* Represents a user in the HeaderMetadataUsers component.
*
* @public
*/
export interface HeaderMetadataUser {
name: string;
src?: string;
}
/**
* Own props for the Header component.
*