From c96e2b3445a06b7a7da7fba0d0e8f52fde837943 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 09:48:56 +0200 Subject: [PATCH 01/22] feat(ui): add description, tags, and metadata props to Header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three new props to the Header component: - `tags`: renders a row of text/link items above the title, separated by 3×3px circle dividers - `description`: renders a markdown string below the title with inline link support via react-markdown - `metadata`: renders key-value pairs below the description with 20px gaps Also deprecates the `breadcrumbs` prop for future removal. Signed-off-by: Charles de Dreuille Made-with: Cursor --- .changeset/header-improvements.md | 7 ++ packages/ui/package.json | 1 + .../src/components/Header/Header.module.css | 24 +++++++ .../src/components/Header/Header.stories.tsx | 64 +++++++++++++++++++ packages/ui/src/components/Header/Header.tsx | 62 +++++++++++++++++- .../ui/src/components/Header/definition.ts | 7 ++ packages/ui/src/components/Header/types.ts | 30 +++++++++ 7 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 .changeset/header-improvements.md diff --git a/.changeset/header-improvements.md b/.changeset/header-improvements.md new file mode 100644 index 0000000000..961cb3c0c7 --- /dev/null +++ b/.changeset/header-improvements.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added `description`, `tags`, and `metadata` props to the `Header` component. The `description` prop accepts a markdown string with support for inline links. The `tags` prop renders a row of text or link items above the title. The `metadata` prop renders key-value pairs below the description. The `breadcrumbs` prop has been deprecated and will be removed in a future release. + +**Affected components:** Header diff --git a/packages/ui/package.json b/packages/ui/package.json index 1b17bc8744..242d15036c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -52,6 +52,7 @@ "clsx": "^2.1.1", "react-aria": "~3.48.0", "react-aria-components": "~1.17.0", + "react-markdown": "^8.0.0", "react-stately": "~3.46.0", "use-sync-external-store": "^1.4.0" }, diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index d74d3eeb50..c386d5a00e 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -47,4 +47,28 @@ align-items: center; gap: var(--bui-space-2); } + + .bui-HeaderTags { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-2); + flex-wrap: wrap; + } + + .bui-HeaderTagDivider { + width: 3px; + height: 3px; + border-radius: 50%; + background-color: var(--bui-fg-secondary); + flex-shrink: 0; + } + + .bui-HeaderMetaRow { + display: flex; + flex-direction: row; + align-items: center; + gap: 20px; + flex-wrap: wrap; + } } diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index c30534a993..0cfe6c03bf 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -152,6 +152,58 @@ export const WithLongBreadcrumbs = meta.story({ }, }); +export const WithDescription = 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**.', + }, +}); + +export const WithTags = meta.story({ + decorators: [withRouter], + args: { + ...Default.input.args, + tags: [ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, + { label: 'Gold' }, + ], + }, +}); + +export const WithMetadata = meta.story({ + decorators: [withRouter], + args: { + ...Default.input.args, + metadata: [ + { label: 'Owner', value: 'platform-team' }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, + ], + }, +}); + +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' }, + ], + }, +}); + export const WithEverything = meta.story({ decorators: [withRouter], args: { @@ -159,6 +211,18 @@ export const WithEverything = meta.story({ 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' }, + ], }, }); diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index bc8289cdf1..868b084312 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -23,6 +23,7 @@ import { HeaderDefinition } from './definition'; import { Container } from '../Container'; import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; +import ReactMarkdown from 'react-markdown'; /** * A secondary header with title, breadcrumbs, tabs, and actions. @@ -31,11 +32,38 @@ import { Fragment } from 'react/jsx-runtime'; */ export const Header = (props: HeaderProps) => { const { ownProps } = useDefinition(HeaderDefinition, props); - const { classes, title, tabs, activeTabId, customActions, breadcrumbs } = - ownProps; + const { + classes, + title, + tabs, + activeTabId, + customActions, + breadcrumbs, + description, + tags, + metadata, + } = ownProps; return ( + {tags && tags.length > 0 && ( +
+ {tags.map((tag, i) => ( + + {i > 0 && } + {tag.href ? ( + + {tag.label} + + ) : ( + + {tag.label} + + )} + + ))} +
+ )}
{breadcrumbs && @@ -61,6 +89,36 @@ export const Header = (props: HeaderProps) => {
{customActions}
+ {description && ( + ( + + {children} + + ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {description} + + )} + {metadata && metadata.length > 0 && ( +
+ {metadata.map(item => ( + + {item.label}: {item.value} + + ))} +
+ )} {tabs && (
diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 2876770339..29e813974b 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -30,6 +30,10 @@ export const HeaderDefinition = defineComponent()({ breadcrumbs: 'bui-HeaderBreadcrumbs', tabsWrapper: 'bui-HeaderTabsWrapper', controls: 'bui-HeaderControls', + tags: 'bui-HeaderTags', + tagDivider: 'bui-HeaderTagDivider', + description: 'bui-HeaderDescription', + metaRow: 'bui-HeaderMetaRow', }, propDefs: { title: {}, @@ -37,6 +41,9 @@ export const HeaderDefinition = defineComponent()({ tabs: {}, activeTabId: {}, breadcrumbs: {}, + description: {}, + tags: {}, + metadata: {}, className: {}, }, }); diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index 1d4acc45ea..b2c687fcf5 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -52,6 +52,26 @@ export interface HeaderNavTabGroup { */ export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup; +/** + * Represents a tag item in the header. + * + * @public + */ +export interface HeaderTag { + label: string; + href?: string; +} + +/** + * Represents a metadata key-value pair in the header. + * + * @public + */ +export interface HeaderMetadataItem { + label: string; + value: React.ReactNode; +} + /** * Own props for the Header component. * @@ -62,7 +82,17 @@ export interface HeaderOwnProps { customActions?: React.ReactNode; tabs?: HeaderNavTabItem[]; activeTabId?: string | null; + /** + * @deprecated The breadcrumbs prop will be removed in a future release. + */ breadcrumbs?: HeaderBreadcrumb[]; + /** + * Markdown string rendered below the title. Only inline elements are + * supported (links, bold, italic). Block-level markdown is not rendered. + */ + description?: string; + tags?: HeaderTag[]; + metadata?: HeaderMetadataItem[]; className?: string; } From 33ea7fb3017d850044c2c6dc5482ac5de53a9721 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 09:54:19 +0200 Subject: [PATCH 02/22] docs(ui): update Header docs with tags, description, and metadata props Add new props to the Header API reference and examples on ui.backstage.io. Also adds deprecated badge support to the shared PropsTable component so the breadcrumbs prop is visually marked as deprecated. Signed-off-by: Charles de Dreuille Made-with: Cursor --- .../src/app/components/header/components.tsx | 36 +++++++++++++++ docs-ui/src/app/components/header/page.mdx | 32 +++++++++++-- .../components/header/props-definition.tsx | 45 +++++++++++++++++++ docs-ui/src/app/components/header/snippets.ts | 36 +++++++++++++-- docs-ui/src/components/Chip/Chip.tsx | 8 +++- docs-ui/src/components/Chip/styles.module.css | 10 +++++ .../src/components/PropsTable/PropsTable.tsx | 7 ++- docs-ui/src/utils/propDefs.ts | 1 + 8 files changed, 166 insertions(+), 9 deletions(-) diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 26dc715a5d..4eb911e143 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -29,10 +29,25 @@ const breadcrumbs = [ }, ]; +const tags = [ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, + { label: 'Gold' }, +]; + +const metadata = [ + { label: 'Owner', value: 'platform-team' }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, +]; + export const WithEverything = () => (
( ); +export const WithTags = () => ( + +
+ +); + +export const WithDescription = () => ( + +
+ +); + +export const WithMetadata = () => ( + +
+ +); + export const WithLongBreadcrumbs = () => (
diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx index 9e02b70a70..a38aac2864 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -5,6 +5,9 @@ import { WithEverything, WithLongBreadcrumbs, WithTabs, + WithTags, + WithDescription, + WithMetadata, WithCustomActions, WithMenu, } from './components'; @@ -13,6 +16,9 @@ import { usage, defaultSnippet, withTabs, + withTags, + withDescription, + withMetadata, withBreadcrumbs, withCustomActions, withMenu, @@ -24,7 +30,7 @@ import { ChangelogComponent } from '@/components/ChangelogComponent'; } code={defaultSnippet} /> @@ -39,11 +45,23 @@ import { ChangelogComponent } from '@/components/ChangelogComponent'; ## Examples -### Breadcrumbs +### Tags -Labels are truncated at 240px. +Tags are rendered above the title. Each tag with an `href` renders as a link; tags without `href` render as plain text. Tags are separated by a small circle divider. -} code={withBreadcrumbs} /> +} code={withTags} /> + +### Description + +The description accepts a markdown string. Only inline elements are supported — links, bold, and italic. + +} code={withDescription} /> + +### Metadata + +Key-value pairs displayed below the description. + +} code={withMetadata} /> ### Tabs @@ -61,6 +79,12 @@ 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 0acc7d30ae..3106e0853b 100644 --- a/docs-ui/src/app/components/header/props-definition.tsx +++ b/docs-ui/src/app/components/header/props-definition.tsx @@ -5,6 +5,50 @@ export const headerPagePropDefs: Record = { type: 'string', description: 'Page heading displayed in the header.', }, + tags: { + type: 'complex', + description: + 'Items displayed above the title. Each tag renders as a link when href is provided, or as plain text otherwise. Tags are separated by a small circle divider.', + complexType: { + name: 'HeaderTag[]', + properties: { + label: { + type: 'string', + required: true, + description: 'Display text for the tag.', + }, + href: { + type: 'string', + required: false, + description: 'URL to navigate to when the tag is clicked.', + }, + }, + }, + }, + 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.', + }, + metadata: { + type: 'complex', + description: 'Key-value pairs displayed below the description.', + complexType: { + name: 'HeaderMetadataItem[]', + properties: { + label: { + type: 'string', + required: true, + description: 'The key label, displayed in bold.', + }, + value: { + type: 'ReactNode', + required: true, + description: 'The value to display alongside the label.', + }, + }, + }, + }, customActions: { type: 'enum', values: ['ReactNode'], @@ -49,6 +93,7 @@ export const headerPagePropDefs: Record = { }, breadcrumbs: { type: 'complex', + deprecated: true, description: 'Breadcrumb trail displayed above the title.', complexType: { name: 'HeaderBreadcrumb[]', diff --git a/docs-ui/src/app/components/header/snippets.ts b/docs-ui/src/app/components/header/snippets.ts index 8dfef0e69e..f0803ae7c7 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -4,9 +4,16 @@ export const usage = `import { Header } from '@backstage/ui'; export const defaultSnippet = `
} />`; + +export const withTags = `
`; + +export const withDescription = `
`; + +export const withMetadata = `
`; diff --git a/docs-ui/src/components/Chip/Chip.tsx b/docs-ui/src/components/Chip/Chip.tsx index b348b46c32..a1d9d75e62 100644 --- a/docs-ui/src/components/Chip/Chip.tsx +++ b/docs-ui/src/components/Chip/Chip.tsx @@ -4,12 +4,18 @@ import styles from './styles.module.css'; export const Chip = ({ children, head = false, + deprecated = false, }: { children: ReactNode; head?: boolean; + deprecated?: boolean; }) => { return ( - + {children} ); diff --git a/docs-ui/src/components/Chip/styles.module.css b/docs-ui/src/components/Chip/styles.module.css index d752ae2602..2446e46388 100644 --- a/docs-ui/src/components/Chip/styles.module.css +++ b/docs-ui/src/components/Chip/styles.module.css @@ -14,6 +14,11 @@ color: #2563eb; } +.deprecated { + background-color: #fff4e5; + color: #b45309; +} + [data-theme-mode='dark'] .chip { background-color: #2c2c2c; color: #fff; @@ -22,3 +27,8 @@ [data-theme-mode='dark'] .chip.head { background-color: #33405b; } + +[data-theme-mode='dark'] .chip.deprecated { + background-color: #3d2a10; + color: #fbbf24; +} diff --git a/docs-ui/src/components/PropsTable/PropsTable.tsx b/docs-ui/src/components/PropsTable/PropsTable.tsx index a8af2ad116..db5759d0e3 100644 --- a/docs-ui/src/components/PropsTable/PropsTable.tsx +++ b/docs-ui/src/components/PropsTable/PropsTable.tsx @@ -52,7 +52,12 @@ export const PropsTable = >({ switch (column) { case 'prop': - return {propName}; + return ( +
+ {propName} + {propData.deprecated && deprecated} +
+ ); case 'type': return ( diff --git a/docs-ui/src/utils/propDefs.ts b/docs-ui/src/utils/propDefs.ts index f714dd0d08..f92f1a37eb 100644 --- a/docs-ui/src/utils/propDefs.ts +++ b/docs-ui/src/utils/propDefs.ts @@ -44,6 +44,7 @@ export type PropDef = { required?: boolean; responsive?: boolean; description?: ReactNode; + deprecated?: boolean; }; export { breakpoints }; From 2b97fc41bbe454b4cf07592e7f890eb41465a520 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 10:04:35 +0200 Subject: [PATCH 03/22] docs(ui): simplify Header main example by removing metadata Signed-off-by: Charles de Dreuille Made-with: Cursor --- docs-ui/src/app/components/header/components.tsx | 1 - docs-ui/src/app/components/header/snippets.ts | 5 ----- 2 files changed, 6 deletions(-) diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 4eb911e143..1a34570c0b 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -47,7 +47,6 @@ 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={metadata} tabs={tabs.slice(0, 2)} breadcrumbs={breadcrumbs.slice(0, 2)} customActions={ diff --git a/docs-ui/src/app/components/header/snippets.ts b/docs-ui/src/app/components/header/snippets.ts index f0803ae7c7..d5ded94872 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -10,11 +10,6 @@ export const defaultSnippet = `
Date: Sun, 19 Apr 2026 10:05:31 +0200 Subject: [PATCH 04/22] fix(ui): increase Header gap from space-1 to space-4 Signed-off-by: Charles de Dreuille Made-with: Cursor --- packages/ui/src/components/Header/Header.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index c386d5a00e..f46680cc21 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -20,7 +20,7 @@ .bui-Header { display: flex; flex-direction: column; - gap: var(--bui-space-1); + gap: var(--bui-space-4); margin-top: var(--bui-space-6); } From fcc8c4d328ccdce1d9ce90b25a40b0d527c9309f Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 10:05:43 +0200 Subject: [PATCH 05/22] fix(ui): set Header gap to space-3 Signed-off-by: Charles de Dreuille Made-with: Cursor --- packages/ui/src/components/Header/Header.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index f46680cc21..2ab1eb0e8f 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -20,7 +20,7 @@ .bui-Header { display: flex; flex-direction: column; - gap: var(--bui-space-4); + gap: var(--bui-space-3); margin-top: var(--bui-space-6); } From 0e8edce069ffaf1bac8bd2e8681ca4f12ec2ceca Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 10:48:09 +0200 Subject: [PATCH 06/22] 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. * From 631079055677d71ec95a79ce94f18a043394da89 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 11:41:55 +0200 Subject: [PATCH 07/22] 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[]; From 9a5a3274cc660de2a2808dc44fd15996b88ebbbe Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 11:44:11 +0200 Subject: [PATCH 08/22] chore: update lockfile after adding react-markdown dependency Signed-off-by: Charles de Dreuille Made-with: Cursor --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 6802845bac..7726a28c2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7987,6 +7987,7 @@ __metadata: react-aria: "npm:~3.48.0" react-aria-components: "npm:~1.17.0" react-dom: "npm:^18.0.2" + react-markdown: "npm:^8.0.0" react-router-dom: "npm:^6.30.2" react-stately: "npm:~3.46.0" storybook: "npm:^10.3.3" From 039751e3f72f1430320741be8daa2b7320c76289 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 19:48:46 +0100 Subject: [PATCH 09/22] chore: fix prettier formatting in header page.mdx Signed-off-by: Charles de Dreuille Made-with: Cursor --- docs-ui/src/app/components/header/page.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx index 0a31a10f29..107bf6d7f9 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -12,7 +12,10 @@ import { WithCustomActions, WithMenu, } from './components'; -import { headerPagePropDefs, headerMetadataUsersPropDefs } from './props-definition'; +import { + headerPagePropDefs, + headerMetadataUsersPropDefs, +} from './props-definition'; import { usage, defaultSnippet, From 4e932ef781674e0127a6b0b0c701cd6f86d78932 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 19:51:53 +0100 Subject: [PATCH 10/22] chore(ui): regenerate API report Signed-off-by: Charles de Dreuille Made-with: Cursor --- packages/ui/report.api.md | 69 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 4856387abf..cb19d612b5 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1508,6 +1508,11 @@ export const HeaderDefinition: { readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; + readonly tags: 'bui-HeaderTags'; + readonly tagDivider: 'bui-HeaderTagDivider'; + readonly description: 'bui-HeaderDescription'; + readonly metaRow: 'bui-HeaderMetaRow'; + readonly metaItem: 'bui-HeaderMetaItem'; }; readonly propDefs: { readonly title: {}; @@ -1515,10 +1520,51 @@ export const HeaderDefinition: { readonly tabs: {}; readonly activeTabId: {}; readonly breadcrumbs: {}; + readonly description: {}; + readonly tags: {}; + readonly metadata: {}; readonly className: {}; }; }; +// @public +export interface HeaderMetadataItem { + // (undocumented) + label: string; + // (undocumented) + value: React.ReactNode; +} + +// @public +export const HeaderMetadataStatus: ( + input: HeaderMetadataStatusItem, +) => JSX_2.Element; + +// @public +export interface HeaderMetadataStatusItem { + // (undocumented) + color: 'danger' | 'warning' | 'success' | 'info'; + // (undocumented) + href?: string; + // (undocumented) + label: string; +} + +// @public +export interface HeaderMetadataUser { + // (undocumented) + href?: string; + // (undocumented) + name: string; + // (undocumented) + src?: string; +} + +// @public +export const HeaderMetadataUsers: (input: { + users: HeaderMetadataUser[]; +}) => JSX_2.Element | null; + // @public (undocumented) export const HeaderNavDefinition: { readonly styles: { @@ -1599,15 +1645,20 @@ export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup; export interface HeaderOwnProps { // (undocumented) activeTabId?: string | null; - // (undocumented) + // @deprecated (undocumented) breadcrumbs?: HeaderBreadcrumb[]; // (undocumented) className?: string; // (undocumented) customActions?: React.ReactNode; + description?: string; + // (undocumented) + metadata?: HeaderMetadataItem[]; // (undocumented) tabs?: HeaderNavTabItem[]; // (undocumented) + tags?: HeaderTag[]; + // (undocumented) title?: string; } @@ -1628,6 +1679,11 @@ export const HeaderPageDefinition: { readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; + readonly tags: 'bui-HeaderTags'; + readonly tagDivider: 'bui-HeaderTagDivider'; + readonly description: 'bui-HeaderDescription'; + readonly metaRow: 'bui-HeaderMetaRow'; + readonly metaItem: 'bui-HeaderMetaItem'; }; readonly propDefs: { readonly title: {}; @@ -1635,6 +1691,9 @@ export const HeaderPageDefinition: { readonly tabs: {}; readonly activeTabId: {}; readonly breadcrumbs: {}; + readonly description: {}; + readonly tags: {}; + readonly metadata: {}; readonly className: {}; }; }; @@ -1659,6 +1718,14 @@ export interface HeaderTab { matchStrategy?: TabMatchStrategy; } +// @public +export interface HeaderTag { + // (undocumented) + href?: string; + // (undocumented) + label: string; +} + // @public (undocumented) export type JustifyContent = | 'stretch' From c0817115e8dcfe641f2c4e150d807ba93e1f4af0 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 20 Apr 2026 10:38:44 +0100 Subject: [PATCH 11/22] chore: update API reports to match master Signed-off-by: Charles de Dreuille --- plugins/catalog-react/report-alpha.api.md | 4 ++-- plugins/catalog-react/report.api.md | 4 ++-- .../report.api.md | 16 ++++++++-------- plugins/scaffolder/report-alpha.api.md | 4 ++-- plugins/scaffolder/report.api.md | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index feea770f23..5634b56df4 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -82,8 +82,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; - readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; @@ -122,8 +122,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.tags': 'Tags'; + readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.targets': 'Targets'; diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index bf241251b0..1fcac904ff 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -204,8 +204,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; - readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; @@ -244,8 +244,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.tags': 'Tags'; + readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.targets': 'Targets'; diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 9e930a7866..382665eea1 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -66,15 +66,15 @@ export function createGithubBranchProtectionAction(options: { dismissStaleReviews?: boolean | undefined; bypassPullRequestAllowances?: | { + users?: string[] | undefined; apps?: string[] | undefined; teams?: string[] | undefined; - users?: string[] | undefined; } | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -241,9 +241,9 @@ export function createGithubRepoCreateAction(options: { branch?: string | undefined; bypassPullRequestAllowances?: | { + users?: string[] | undefined; apps?: string[] | undefined; teams?: string[] | undefined; - users?: string[] | undefined; } | undefined; collaborators?: @@ -290,8 +290,8 @@ export function createGithubRepoCreateAction(options: { requireLastPushApproval?: boolean | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -328,16 +328,16 @@ export function createGithubRepoPushAction(options: { requiredStatusCheckContexts?: string[] | undefined; bypassPullRequestAllowances?: | { + users?: string[] | undefined; apps?: string[] | undefined; teams?: string[] | undefined; - users?: string[] | undefined; } | undefined; requiredApprovingReviewCount?: number | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -398,16 +398,16 @@ export function createPublishGithubAction(options: { access?: string | undefined; bypassPullRequestAllowances?: | { + users?: string[] | undefined; apps?: string[] | undefined; teams?: string[] | undefined; - users?: string[] | undefined; } | undefined; requiredApprovingReviewCount?: number | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 10066f3fd3..45e52f5bcc 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -775,13 +775,13 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; - readonly 'listTaskPage.content.emptyState.title': 'No information to display'; - readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; + readonly 'listTaskPage.content.emptyState.title': 'No information to display'; + readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index a0c35e1b5e..0d2756131d 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -673,13 +673,13 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; - readonly 'listTaskPage.content.emptyState.title': 'No information to display'; - readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; + readonly 'listTaskPage.content.emptyState.title': 'No information to display'; + readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; From 2deaa491205e4bcdc9ca0757eda13f37e27d4d39 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 20 Apr 2026 17:59:45 +0100 Subject: [PATCH 12/22] fix(ui): replace react-markdown with inline parser to fix ESM Jest failures react-markdown v8+ is ESM-only and breaks Jest in Node-role packages that transitively import @backstage/ui via core-app-api. Since the Header description only needs inline link support, a small regex-based parser is sufficient and avoids the ESM dependency entirely. Signed-off-by: Charles de Dreuille --- packages/ui/package.json | 1 - packages/ui/src/components/Header/Header.tsx | 57 +++++++++++++------- yarn.lock | 1 - 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 242d15036c..1b17bc8744 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -52,7 +52,6 @@ "clsx": "^2.1.1", "react-aria": "~3.48.0", "react-aria-components": "~1.17.0", - "react-markdown": "^8.0.0", "react-stately": "~3.46.0", "use-sync-external-store": "^1.4.0" }, diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 2518a6716d..9bd8bcdeab 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -22,7 +22,40 @@ import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; -import ReactMarkdown from 'react-markdown'; + +const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; + +/** + * Renders a plain-text string that may contain inline Markdown links of the + * form `[label](href)` as an array of React nodes (strings and Link elements). + * + * We intentionally avoid `react-markdown` here: that package is ESM-only + * (v8+), which breaks Jest in Node-role packages that transitively import + * `@backstage/ui` (e.g. via `core-app-api`). Since the Header description only + * needs inline link support, a small regex-based parser is sufficient and keeps + * this package free of ESM dependencies. + */ +function renderInlineMarkdown(text: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let last = 0; + let match: RegExpExecArray | null; + INLINE_LINK_RE.lastIndex = 0; + while ((match = INLINE_LINK_RE.exec(text)) !== null) { + if (match.index > last) { + parts.push(text.slice(last, match.index)); + } + parts.push( + + {match[1]} + , + ); + last = match.index + match[0].length; + } + if (last < text.length) { + parts.push(text.slice(last)); + } + return parts; +} /** * A secondary header with title, breadcrumbs, tabs, and actions. @@ -94,25 +127,13 @@ export const Header = (props: HeaderProps) => {
{customActions}
{description && ( - ( - - {children} - - ), - a: ({ href, children }) => ( - - {children} - - ), - }} > - {description} - + {renderInlineMarkdown(description)} + )} {metadata && metadata.length > 0 && (
diff --git a/yarn.lock b/yarn.lock index 7726a28c2a..6802845bac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7987,7 +7987,6 @@ __metadata: react-aria: "npm:~3.48.0" react-aria-components: "npm:~1.17.0" react-dom: "npm:^18.0.2" - react-markdown: "npm:^8.0.0" react-router-dom: "npm:^6.30.2" react-stately: "npm:~3.46.0" storybook: "npm:^10.3.3" From 5de4a10b30c33d897875f236ac541adf2462b012 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 20 Apr 2026 19:49:05 +0100 Subject: [PATCH 13/22] chore: update scaffolder API reports Signed-off-by: Charles de Dreuille --- plugins/scaffolder/report-alpha.api.md | 4 ++-- plugins/scaffolder/report.api.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 45e52f5bcc..10066f3fd3 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -775,13 +775,13 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; + readonly 'listTaskPage.content.emptyState.title': 'No information to display'; + readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; - readonly 'listTaskPage.content.emptyState.title': 'No information to display'; - readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 0d2756131d..a0c35e1b5e 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -673,13 +673,13 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; + readonly 'listTaskPage.content.emptyState.title': 'No information to display'; + readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; - readonly 'listTaskPage.content.emptyState.title': 'No information to display'; - readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; From a2e0636c1f53010352b8476303455c1f497159f9 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 20 Apr 2026 21:22:20 +0100 Subject: [PATCH 14/22] fix(ui): address PR review comments on Header components - Guard against unsafe URL schemes (javascript:/vbscript:/data:) in description links - Use index-based keys for tags and metadata to avoid duplicate-key warnings - Render ReactNode metadata values directly instead of wrapping in Text to avoid invalid span>div nesting; only wrap plain strings in Text - Replace empty-string Avatar src fallback with data:, to prevent spurious page requests - Fix JSDoc in HeaderMetadataUsers to accurately describe the row layout Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Header/Header.tsx | 32 +++++++++++++------ .../components/Header/HeaderMetadataUsers.tsx | 8 ++--- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 9bd8bcdeab..67be3eb6bd 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -24,10 +24,14 @@ import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; +// Reject javascript:/vbscript:/data: URIs to prevent XSS via description links. +const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i; /** * Renders a plain-text string that may contain inline Markdown links of the * form `[label](href)` as an array of React nodes (strings and Link elements). + * Links with unsafe URL schemes (javascript:, vbscript:, data:) are rendered + * as plain text instead. * * We intentionally avoid `react-markdown` here: that package is ESM-only * (v8+), which breaks Jest in Node-role packages that transitively import @@ -44,11 +48,17 @@ function renderInlineMarkdown(text: string): React.ReactNode[] { if (match.index > last) { parts.push(text.slice(last, match.index)); } - parts.push( - - {match[1]} - , - ); + const href = match[2]; + const label = match[1]; + if (UNSAFE_HREF_RE.test(href)) { + parts.push(label); + } else { + parts.push( + + {label} + , + ); + } last = match.index + match[0].length; } if (last < text.length) { @@ -81,7 +91,7 @@ export const Header = (props: HeaderProps) => { {tags && tags.length > 0 && (
{tags.map((tag, i) => ( - + {i > 0 && } {tag.href ? ( { )} {metadata && metadata.length > 0 && (
- {metadata.map(item => ( -
+ {metadata.map((item, i) => ( +
{item.label} - {item.value} + {typeof item.value === 'string' ? ( + {item.value} + ) : ( + item.value + )}
))}
diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx index 3f68398ca5..e644811899 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -25,7 +25,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. + * Multiple users show avatars in a row with the name revealed on hover via tooltip. * When a user has an `href`, the avatar and name become links. * * @public @@ -41,7 +41,7 @@ export const HeaderMetadataUsers = ({ const user = users[0]; const avatar = ( Date: Tue, 21 Apr 2026 08:55:53 +0100 Subject: [PATCH 15/22] revert(ui): restore Container wrapper on Header Reverting the full-width change as it is too disruptive at this time and will be handled in a separate PR. Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Header/Header.stories.tsx | 3 +++ packages/ui/src/components/Header/Header.tsx | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index 77994ab4d9..59e5cf254d 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -28,6 +28,9 @@ import { RiMore2Line } from '@remixicon/react'; const meta = preview.meta({ title: 'Backstage UI/Header', component: Header, + parameters: { + layout: 'fullscreen', + }, }); const tabs: HeaderNavTabItem[] = [ diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 67be3eb6bd..f70423fdd6 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -20,6 +20,7 @@ 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'; @@ -87,7 +88,7 @@ export const Header = (props: HeaderProps) => { } = ownProps; return ( -
+ {tags && tags.length > 0 && (
{tags.map((tag, i) => ( @@ -166,7 +167,7 @@ export const Header = (props: HeaderProps) => {
)} -
+ ); }; From ea11646d8df7f612c79cfa4dddc588d9cdf7f48f Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 21 Apr 2026 09:34:34 +0100 Subject: [PATCH 16/22] fix(ui): address second round of PR review comments - Replace hardcoded gap: 20px with var(--bui-space-5) in metadata row - Trim leading whitespace from href before unsafe-scheme check to prevent bypass Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Header/Header.module.css | 2 +- packages/ui/src/components/Header/Header.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index ccceaf6f58..e6ea55e363 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -68,7 +68,7 @@ display: flex; flex-direction: row; align-items: center; - gap: 20px; + gap: var(--bui-space-5); flex-wrap: wrap; } diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index f70423fdd6..90e107c51c 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -49,7 +49,9 @@ function renderInlineMarkdown(text: string): React.ReactNode[] { if (match.index > last) { parts.push(text.slice(last, match.index)); } - const href = match[2]; + // Trim leading whitespace/control chars before scheme check to prevent + // bypass via inputs like " javascript:alert(1)". + const href = match[2].trimStart(); const label = match[1]; if (UNSAFE_HREF_RE.test(href)) { parts.push(label); From ec109ce7fb1299180913469260b681bf9ca03144 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 22 Apr 2026 12:41:26 +0100 Subject: [PATCH 17/22] fix(ui): replace custom regex with marked Lexer for inline description parsing Uses marked's Lexer.lexInline() instead of a hand-rolled regex to parse inline links in the Header description. marked ships CommonJS, has zero dependencies, and is already used in the monorepo. This gives us a proper token model that handles edge cases the regex could not. Signed-off-by: Charles de Dreuille --- packages/ui/package.json | 1 + packages/ui/src/components/Header/Header.tsx | 56 ++++++++------------ yarn.lock | 1 + 3 files changed, 23 insertions(+), 35 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 1b17bc8744..0c43476e6a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -50,6 +50,7 @@ "@remixicon/react": "^4.6.0", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", + "marked": "^15.0.12", "react-aria": "~3.48.0", "react-aria-components": "~1.17.0", "react-stately": "~3.46.0", diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 90e107c51c..0098fa00d4 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -21,53 +21,39 @@ import { HeaderNav } from './HeaderNav'; import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; import { Container } from '../Container'; +import { Lexer } from 'marked'; import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; -const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; // Reject javascript:/vbscript:/data: URIs to prevent XSS via description links. const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i; /** - * Renders a plain-text string that may contain inline Markdown links of the - * form `[label](href)` as an array of React nodes (strings and Link elements). - * Links with unsafe URL schemes (javascript:, vbscript:, data:) are rendered - * as plain text instead. + * Renders a plain-text string that may contain inline Markdown links as an + * array of React nodes (strings and Link elements). Links with unsafe URL + * schemes (javascript:, vbscript:, data:) are rendered as plain text instead. * - * We intentionally avoid `react-markdown` here: that package is ESM-only - * (v8+), which breaks Jest in Node-role packages that transitively import - * `@backstage/ui` (e.g. via `core-app-api`). Since the Header description only - * needs inline link support, a small regex-based parser is sufficient and keeps - * this package free of ESM dependencies. + * We use `marked`'s `Lexer.lexInline()` rather than `react-markdown` because + * `react-markdown` v8+ is ESM-only, which breaks Jest in Node-role packages + * that transitively import `@backstage/ui` (e.g. via `core-app-api`). `marked` + * ships CommonJS, has zero dependencies, and its inline lexer gives us a clean + * token model without needing to maintain a custom regex. */ function renderInlineMarkdown(text: string): React.ReactNode[] { - const parts: React.ReactNode[] = []; - let last = 0; - let match: RegExpExecArray | null; - INLINE_LINK_RE.lastIndex = 0; - while ((match = INLINE_LINK_RE.exec(text)) !== null) { - if (match.index > last) { - parts.push(text.slice(last, match.index)); - } - // Trim leading whitespace/control chars before scheme check to prevent - // bypass via inputs like " javascript:alert(1)". - const href = match[2].trimStart(); - const label = match[1]; - if (UNSAFE_HREF_RE.test(href)) { - parts.push(label); - } else { - parts.push( - - {label} - , + return Lexer.lexInline(text).map((token, i) => { + if (token.type === 'link') { + // Trim leading whitespace/control chars before scheme check to prevent + // bypass via inputs like " javascript:alert(1)". + const href = token.href.trimStart(); + if (UNSAFE_HREF_RE.test(href)) return token.text; + return ( + + {token.text} + ); } - last = match.index + match[0].length; - } - if (last < text.length) { - parts.push(text.slice(last)); - } - return parts; + return token.raw; + }); } /** diff --git a/yarn.lock b/yarn.lock index 6802845bac..bbd007be7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7983,6 +7983,7 @@ __metadata: eslint-plugin-storybook: "npm:^10.3.3" glob: "npm:^13.0.0" globals: "npm:^17.0.0" + marked: "npm:^15.0.12" react: "npm:^18.0.2" react-aria: "npm:~3.48.0" react-aria-components: "npm:~1.17.0" From a69c2e29a29f019b6876bee7837305e219c81680 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 22 Apr 2026 15:02:28 +0100 Subject: [PATCH 18/22] fix(ui): improve semantic markup and a11y across Header components - Tags: replace div+Fragment with ul/li; move circle divider to CSS ::before pseudo-element, removing it from the DOM - Metadata: replace div wrapper with dl/dt/dd for proper key-value semantics; reset dl and dd browser margins - HeaderMetadataUsers: render multi-user stack as ul/li; simplify single-user branch into one ternary with a fragment - HeaderMetadataStatus: add role="img" and aria-label to the status dot so screen readers announce its meaning - Restore Fragment import from react Signed-off-by: Charles de Dreuille --- .../src/components/Header/Header.module.css | 17 +++- packages/ui/src/components/Header/Header.tsx | 38 ++++--- .../Header/HeaderMetadataStatus.tsx | 6 +- .../Header/HeaderMetadataUsers.module.css | 3 + .../components/Header/HeaderMetadataUsers.tsx | 99 ++++++++++--------- .../ui/src/components/Header/definition.ts | 2 +- 6 files changed, 99 insertions(+), 66 deletions(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index e6ea55e363..2e3ca28d3b 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -54,9 +54,19 @@ align-items: center; gap: var(--bui-space-2); flex-wrap: wrap; + list-style: none; + margin: 0; + padding: 0; } - .bui-HeaderTagDivider { + .bui-HeaderTag { + display: flex; + align-items: center; + gap: var(--bui-space-2); + } + + .bui-HeaderTag + .bui-HeaderTag::before { + content: ''; width: 3px; height: 3px; border-radius: 50%; @@ -70,6 +80,7 @@ align-items: center; gap: var(--bui-space-5); flex-wrap: wrap; + margin: 0; } .bui-HeaderMetaItem { @@ -77,5 +88,9 @@ flex-direction: row; align-items: center; gap: var(--bui-space-2); + + dd { + margin: 0; + } } } diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 0098fa00d4..5d41d6ca0f 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -23,7 +23,7 @@ import { HeaderDefinition } from './definition'; import { Container } from '../Container'; import { Lexer } from 'marked'; import { Link } from '../Link'; -import { Fragment } from 'react/jsx-runtime'; +import { Fragment } from 'react'; // Reject javascript:/vbscript:/data: URIs to prevent XSS via description links. const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i; @@ -78,10 +78,12 @@ export const Header = (props: HeaderProps) => { return ( {tags && tags.length > 0 && ( -
+
    {tags.map((tag, i) => ( - - {i > 0 && } +
  • {tag.href ? ( { {tag.label} )} - +
  • ))} -
+ )}
@@ -135,20 +137,24 @@ export const Header = (props: HeaderProps) => { )} {metadata && metadata.length > 0 && ( -
+
{metadata.map((item, i) => (
- - {item.label} - - {typeof item.value === 'string' ? ( - {item.value} - ) : ( - item.value - )} +
+ + {item.label} + +
+
+ {typeof item.value === 'string' ? ( + {item.value} + ) : ( + item.value + )} +
))} -
+ )} {tabs && (
diff --git a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx index 75c9ea778f..2bf2effa8f 100644 --- a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx @@ -32,7 +32,11 @@ export const HeaderMetadataStatus = ({ }: HeaderMetadataStatusItem) => { return (
- + {href ? ( diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css index 745e768d56..0b84734b81 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css @@ -29,6 +29,9 @@ flex-direction: row; align-items: center; gap: var(--bui-space-1); + list-style: none; + margin: 0; + padding: 0; } .avatarLink { diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx index e644811899..ce1688643f 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -39,43 +39,10 @@ export const HeaderMetadataUsers = ({ if (users.length === 1) { const user = users[0]; - const avatar = ( - - ); return (
{user.href ? ( - - {avatar} - - ) : ( - avatar - )} - {user.href ? ( - - {user.name} - - ) : ( - {user.name} - )} -
- ); - } - - return ( -
- {users.map(user => ( - - {user.href ? ( + <> - ) : ( - - - - )} - {user.name} - + + {user.name} + + + ) : ( + <> + + {user.name} + + )} +
+ ); + } + + return ( +
    + {users.map(user => ( +
  • + + {user.href ? ( + + + + ) : ( + + + + )} + {user.name} + +
  • ))} -
+ ); }; diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 7c9878d7e9..c0420dade5 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -31,7 +31,7 @@ export const HeaderDefinition = defineComponent()({ tabsWrapper: 'bui-HeaderTabsWrapper', controls: 'bui-HeaderControls', tags: 'bui-HeaderTags', - tagDivider: 'bui-HeaderTagDivider', + tag: 'bui-HeaderTag', description: 'bui-HeaderDescription', metaRow: 'bui-HeaderMetaRow', metaItem: 'bui-HeaderMetaItem', From 6407493de8e1a067a41178501505e684588f56eb Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 22 Apr 2026 15:25:41 +0100 Subject: [PATCH 19/22] Update report.api.md Signed-off-by: Charles de Dreuille --- packages/ui/report.api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 08ec6887e0..92fb3d1511 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1545,7 +1545,7 @@ export const HeaderDefinition: { readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; readonly tags: 'bui-HeaderTags'; - readonly tagDivider: 'bui-HeaderTagDivider'; + readonly tag: 'bui-HeaderTag'; readonly description: 'bui-HeaderDescription'; readonly metaRow: 'bui-HeaderMetaRow'; readonly metaItem: 'bui-HeaderMetaItem'; @@ -1716,7 +1716,7 @@ export const HeaderPageDefinition: { readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; readonly tags: 'bui-HeaderTags'; - readonly tagDivider: 'bui-HeaderTagDivider'; + readonly tag: 'bui-HeaderTag'; readonly description: 'bui-HeaderDescription'; readonly metaRow: 'bui-HeaderMetaRow'; readonly metaItem: 'bui-HeaderMetaItem'; From 251acf38d6d2f77d26ac8ad1b3491c7ddf1cc7f5 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 23 Apr 2026 08:52:08 +0100 Subject: [PATCH 20/22] fix(ui): address further PR review comments - Replace custom UNSAFE_HREF_RE with @braintree/sanitize-url for robust XSS prevention - Shorten renderInlineMarkdown JSDoc - Single-user with href: collapse two adjacent links into one wrapping avatar + name - Multi-user list: use href ?? index:name as key to avoid collisions on duplicate names - Status dot: replace role="img"/aria-label with aria-hidden (text label is sufficient) Signed-off-by: Charles de Dreuille --- packages/ui/package.json | 1 + packages/ui/src/components/Header/Header.tsx | 22 ++----- .../Header/HeaderMetadataStatus.tsx | 3 +- .../components/Header/HeaderMetadataUsers.tsx | 59 +++++++++---------- yarn.lock | 8 +++ 5 files changed, 44 insertions(+), 49 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 0c43476e6a..84b3ada891 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@backstage/version-bridge": "workspace:^", + "@braintree/sanitize-url": "^7.1.2", "@remixicon/react": "^4.6.0", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 5d41d6ca0f..07a9ec5ab7 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -20,32 +20,22 @@ import { RiArrowRightSLine } from '@remixicon/react'; import { HeaderNav } from './HeaderNav'; import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; +import { sanitizeUrl } from '@braintree/sanitize-url'; import { Container } from '../Container'; import { Lexer } from 'marked'; import { Link } from '../Link'; import { Fragment } from 'react'; -// Reject javascript:/vbscript:/data: URIs to prevent XSS via description links. -const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i; - /** - * Renders a plain-text string that may contain inline Markdown links as an - * array of React nodes (strings and Link elements). Links with unsafe URL - * schemes (javascript:, vbscript:, data:) are rendered as plain text instead. - * - * We use `marked`'s `Lexer.lexInline()` rather than `react-markdown` because - * `react-markdown` v8+ is ESM-only, which breaks Jest in Node-role packages - * that transitively import `@backstage/ui` (e.g. via `core-app-api`). `marked` - * ships CommonJS, has zero dependencies, and its inline lexer gives us a clean - * token model without needing to maintain a custom regex. + * Parses inline Markdown links in a string and returns an array of React nodes. + * URLs are sanitized via `@braintree/sanitize-url`; unsafe URLs are rendered as + * plain text. Uses `marked` instead of `react-markdown` to avoid ESM issues. */ function renderInlineMarkdown(text: string): React.ReactNode[] { return Lexer.lexInline(text).map((token, i) => { if (token.type === 'link') { - // Trim leading whitespace/control chars before scheme check to prevent - // bypass via inputs like " javascript:alert(1)". - const href = token.href.trimStart(); - if (UNSAFE_HREF_RE.test(href)) return token.text; + const href = sanitizeUrl(token.href); + if (href === 'about:blank') return token.text; return ( {token.text} diff --git a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx index 2bf2effa8f..e39b58d74e 100644 --- a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx @@ -33,8 +33,7 @@ export const HeaderMetadataStatus = ({ return (