From 0e8edce069ffaf1bac8bd2e8681ca4f12ec2ceca Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 10:48:09 +0200 Subject: [PATCH] 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 Made-with: Cursor --- .../src/app/components/header/components.tsx | 42 +++++- docs-ui/src/app/components/header/page.mdx | 16 +-- .../components/header/props-definition.tsx | 7 +- docs-ui/src/app/components/header/snippets.ts | 49 ++++++- .../src/components/Header/Header.module.css | 7 + .../src/components/Header/Header.stories.tsx | 124 +++++++++++++----- packages/ui/src/components/Header/Header.tsx | 13 +- .../Header/HeaderMetadataUsers.module.css | 33 +++++ .../components/Header/HeaderMetadataUsers.tsx | 70 ++++++++++ .../ui/src/components/Header/definition.ts | 1 + packages/ui/src/components/Header/index.tsx | 4 + packages/ui/src/components/Header/types.ts | 10 ++ 12 files changed, 319 insertions(+), 57 deletions(-) create mode 100644 packages/ui/src/components/Header/HeaderMetadataUsers.module.css create mode 100644 packages/ui/src/components/Header/HeaderMetadataUsers.tsx diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 1a34570c0b..45d07f0981 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -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: , + }, + { + label: 'Contributors', + value: ( + + ), + }, ]; 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={ <> @@ -59,6 +78,12 @@ export const WithEverything = () => ( ); +export const WithMetadataUsers = () => ( + +
+ +); + export const WithTags = () => (
@@ -76,7 +101,14 @@ export const WithDescription = () => ( export const WithMetadata = () => ( -
+
); diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx index a38aac2864..cc8dc531c2 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -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. } 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. + +} 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. } code={withMenu} /> -### Breadcrumbs (deprecated) - -The `breadcrumbs` prop is deprecated and will be removed in a future release. Labels are truncated at 240px. - -} code={withBreadcrumbs} /> - diff --git a/docs-ui/src/app/components/header/props-definition.tsx b/docs-ui/src/app/components/header/props-definition.tsx index 3106e0853b..51d6ddee57 100644 --- a/docs-ui/src/app/components/header/props-definition.tsx +++ b/docs-ui/src/app/components/header/props-definition.tsx @@ -39,12 +39,13 @@ export const headerPagePropDefs: Record = { 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.', }, }, }, diff --git a/docs-ui/src/app/components/header/snippets.ts b/docs-ui/src/app/components/header/snippets.ts index d5ded94872..99948e84d6 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -2,7 +2,9 @@ export const usage = `import { Header } from '@backstage/ui';
`; -export const defaultSnippet = `
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} 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 = `
`; + +export const withMetadataUsers = `import { Header, HeaderMetadataUsers } from '@backstage/ui'; + +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} +/>`; diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 2ab1eb0e8f..ccceaf6f58 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -71,4 +71,11 @@ gap: 20px; flex-wrap: wrap; } + + .bui-HeaderMetaItem { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-2); + } } diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index 0cfe6c03bf..ac476c987b 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -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: () => ( +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + ), +}); + 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: () => ( +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, + ]} + /> + ), }); export const WithEverything = meta.story({ decorators: [withRouter], - args: { - ...Default.input.args, - tabs, - customActions: , - 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: () => ( +
Custom action} + 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: , + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + ), }); const groupedTabs: HeaderNavTabItem[] = [ diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 868b084312..0ddb1c6043 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -52,11 +52,11 @@ export const Header = (props: HeaderProps) => { {i > 0 && } {tag.href ? ( - + {tag.label} ) : ( - + {tag.label} )} @@ -113,9 +113,12 @@ export const Header = (props: HeaderProps) => { {metadata && metadata.length > 0 && (
{metadata.map(item => ( - - {item.label}: {item.value} - +
+ + {item.label} + + {item.value} +
))}
)} diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css new file mode 100644 index 0000000000..8f4c3edc84 --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css @@ -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); + } +} diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx new file mode 100644 index 0000000000..8bb972eb6b --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -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 ( +
+ + {user.name} +
+ ); + } + + return ( +
+ {users.map(user => ( + + + + + {user.name} + + ))} +
+ ); +}; diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 29e813974b..7c9878d7e9 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -34,6 +34,7 @@ export const HeaderDefinition = defineComponent()({ tagDivider: 'bui-HeaderTagDivider', description: 'bui-HeaderDescription', metaRow: 'bui-HeaderMetaRow', + metaItem: 'bui-HeaderMetaItem', }, propDefs: { title: {}, diff --git a/packages/ui/src/components/Header/index.tsx b/packages/ui/src/components/Header/index.tsx index 586ee32bfb..6de3b01df3 100644 --- a/packages/ui/src/components/Header/index.tsx +++ b/packages/ui/src/components/Header/index.tsx @@ -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, diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index b2c687fcf5..3baebcf7ba 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -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. *