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
@@ -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.
*