From 631079055677d71ec95a79ce94f18a043394da89 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 11:41:55 +0200 Subject: [PATCH] feat(ui): add href support to HeaderMetadataUsers, remove bold from description, full-width header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 `` root with a plain `
` 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 Made-with: Cursor --- .../src/app/components/header/components.tsx | 77 ++++++++++++++-- docs-ui/src/app/components/header/page.mdx | 16 +++- .../components/header/props-definition.tsx | 32 ++++++- docs-ui/src/app/components/header/snippets.ts | 49 +++++++--- .../src/components/Header/Header.stories.tsx | 89 +++++++++++++++++-- packages/ui/src/components/Header/Header.tsx | 14 +-- .../Header/HeaderMetadataStatus.module.css | 49 ++++++++++ .../Header/HeaderMetadataStatus.tsx | 47 ++++++++++ .../Header/HeaderMetadataUsers.module.css | 5 ++ .../components/Header/HeaderMetadataUsers.tsx | 66 ++++++++++---- packages/ui/src/components/Header/index.tsx | 2 + packages/ui/src/components/Header/types.ts | 16 +++- 12 files changed, 406 insertions(+), 56 deletions(-) create mode 100644 packages/ui/src/components/Header/HeaderMetadataStatus.module.css create mode 100644 packages/ui/src/components/Header/HeaderMetadataStatus.tsx diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 45d07f0981..2a583e6c80 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -2,6 +2,7 @@ import { Header } from '../../../../../packages/ui/src/components/Header/Header'; import { HeaderMetadataUsers } from '../../../../../packages/ui/src/components/Header/HeaderMetadataUsers'; +import { HeaderMetadataStatus } from '../../../../../packages/ui/src/components/Header/HeaderMetadataStatus'; import { Button } from '../../../../../packages/ui/src/components/Button/Button'; import { ButtonIcon } from '../../../../../packages/ui/src/components/ButtonIcon/ButtonIcon'; import { @@ -16,10 +17,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=alice42', + 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=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 = [ @@ -43,11 +57,14 @@ const breadcrumbs = [ const tags = [ { label: 'TypeScript' }, { label: 'Platform', href: '/platform' }, - { label: 'Gold' }, ]; const metadataUsers = [ { label: 'Type', value: 'website' }, + { + label: 'Status', + value: , + }, { label: 'Owner', value: , @@ -65,7 +82,7 @@ export const WithEverything = () => (
( export const WithMetadataUsers = () => ( -
+
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> ); @@ -94,7 +128,7 @@ export const WithDescription = () => (
); @@ -106,7 +140,34 @@ export const WithMetadata = () => ( metadata={[ { label: 'Owner', value: 'platform-team' }, { label: 'Type', value: 'website' }, - { label: 'Tier', value: 'gold' }, + ]} + /> + +); + +export const WithMetadataStatus = () => ( + +
, + }, + { + label: 'Build', + value: ( + + ), + }, + { + label: 'Coverage', + value: , + }, ]} /> diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx index cc8dc531c2..0a31a10f29 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -8,10 +8,11 @@ import { WithDescription, WithMetadata, WithMetadataUsers, + WithMetadataStatus, WithCustomActions, WithMenu, } from './components'; -import { headerPagePropDefs } from './props-definition'; +import { headerPagePropDefs, headerMetadataUsersPropDefs } from './props-definition'; import { usage, defaultSnippet, @@ -20,6 +21,7 @@ import { withDescription, withMetadata, withMetadataUsers, + withMetadataStatus, withCustomActions, withMenu, } from './snippets'; @@ -53,7 +55,7 @@ Tags are rendered above the title. Each tag with an `href` renders as a link; ta ### Description -The description accepts a markdown string. Only inline elements are supported — links, bold, and italic. +The description accepts a markdown string with support for inline links. Bold, italic, and block-level markdown are not rendered. } code={withDescription} /> @@ -65,10 +67,18 @@ Key-value pairs displayed below the description. ### 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. +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. When a user has an `href`, the avatar and name become links. } code={withMetadataUsers} /> + + +### Metadata with status + +Use `HeaderMetadataStatus` as the metadata value to display a status indicator. The dot colour is driven by the `color` prop which maps to BUI status tokens. Pass an `href` to make the label a link. + +} code={withMetadataStatus} /> + ### 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. diff --git a/docs-ui/src/app/components/header/props-definition.tsx b/docs-ui/src/app/components/header/props-definition.tsx index 51d6ddee57..f4e7c2b670 100644 --- a/docs-ui/src/app/components/header/props-definition.tsx +++ b/docs-ui/src/app/components/header/props-definition.tsx @@ -28,7 +28,7 @@ export const headerPagePropDefs: Record = { description: { type: 'string', description: - 'Markdown string rendered below the title. Only inline elements are supported: links, bold, and italic. Block-level markdown such as headings or lists is not rendered.', + 'Markdown string rendered below the title. Only inline links are supported. Bold, italic, and block-level markdown are not rendered.', }, metadata: { type: 'complex', @@ -114,3 +114,33 @@ export const headerPagePropDefs: Record = { }, ...classNamePropDefs, }; + +export const headerMetadataUsersPropDefs: Record = { + users: { + type: 'complex', + description: + 'List of users to display. A single user shows the avatar with their name beside it. Multiple users show a row of avatars with names revealed on hover via tooltip.', + complexType: { + name: 'HeaderMetadataUser[]', + properties: { + name: { + type: 'string', + required: true, + description: + 'Display name shown beside the avatar (single) or in the tooltip (multiple).', + }, + src: { + type: 'string', + required: false, + description: 'URL for the avatar image.', + }, + href: { + type: 'string', + required: false, + description: + 'When provided, the avatar becomes a link and the name is rendered as a Link component.', + }, + }, + }, + }, +}; diff --git a/docs-ui/src/app/components/header/snippets.ts b/docs-ui/src/app/components/header/snippets.ts index 99948e84d6..ac38d6d7eb 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -2,30 +2,33 @@ export const usage = `import { Header } from '@backstage/ui';
`; -export const defaultSnippet = `import { Header, HeaderMetadataUsers } from '@backstage/ui'; +export const defaultSnippet = `import { Header, HeaderMetadataUsers, HeaderMetadataStatus } from '@backstage/ui';
, + }, { label: 'Owner', - value: , + value: , }, { label: 'Contributors', value: ( ), @@ -89,7 +92,7 @@ export const withTags = `
`; export const withMetadata = `
`; + +export const withMetadataStatus = `import { Header, HeaderMetadataStatus } from '@backstage/ui'; + +
, + }, + { + label: 'Build', + value: , + }, + { + label: 'Coverage', + value: , + }, ]} />`; @@ -106,18 +128,19 @@ export const withMetadataUsers = `import { Header, HeaderMetadataUsers } from '@
, + value: , }, { label: 'Contributors', value: ( ), diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index ac476c987b..77994ab4d9 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -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: () => ( +
+ ), + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + ), +}); + +export const WithMetadataStatus = meta.story({ + decorators: [withRouter], + render: () => ( +
, + }, + { + label: 'Build', + value: ( + + ), + }, + { + label: 'Coverage', + value: , + }, + ]} + /> + ), +}); + export const WithDescriptionTagsAndMetadata = meta.story({ decorators: [withRouter], 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**." + description="This is a description of the page. It can include [inline links](https://backstage.io)." tags={[ { label: 'TypeScript' }, { label: 'Platform', href: '/platform' }, diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 0ddb1c6043..2518a6716d 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -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 ( - +
{tags && tags.length > 0 && (
{tags.map((tag, i) => ( {i > 0 && } {tag.href ? ( - + {tag.label} ) : ( @@ -92,7 +96,7 @@ export const Header = (props: HeaderProps) => { {description && ( ( @@ -127,7 +131,7 @@ export const Header = (props: HeaderProps) => {
)} - +
); }; diff --git a/packages/ui/src/components/Header/HeaderMetadataStatus.module.css b/packages/ui/src/components/Header/HeaderMetadataStatus.module.css new file mode 100644 index 0000000000..ec4a9c9fd7 --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.module.css @@ -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); + } +} diff --git a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx new file mode 100644 index 0000000000..75c9ea778f --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx @@ -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 ( +
+ + + {href ? ( + + {label} + + ) : ( + label + )} + +
+ ); +}; diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css index 8f4c3edc84..745e768d56 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css @@ -30,4 +30,9 @@ align-items: center; gap: var(--bui-space-1); } + + .avatarLink { + display: flex; + text-decoration: none; + } } diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx index 8bb972eb6b..3f68398ca5 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -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 = ( + + ); return (
- - {user.name} + {user.href ? ( + + {avatar} + + ) : ( + avatar + )} + {user.href ? ( + + {user.name} + + ) : ( + {user.name} + )}
); } @@ -54,14 +75,29 @@ export const HeaderMetadataUsers = ({
{users.map(user => ( - - - + {user.href ? ( + + + + ) : ( + + + + )} {user.name} ))} diff --git a/packages/ui/src/components/Header/index.tsx b/packages/ui/src/components/Header/index.tsx index 6de3b01df3..3cdced383d 100644 --- a/packages/ui/src/components/Header/index.tsx +++ b/packages/ui/src/components/Header/index.tsx @@ -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, diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index 3baebcf7ba..db03c6968c 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -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[];