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:
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user