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/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 26dc715a5d..2a583e6c80 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -1,6 +1,8 @@ 'use client'; 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 { @@ -11,6 +13,29 @@ 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', + 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', + }, +}; + const tabs = [ { id: 'overview', label: 'Overview', href: '/overview' }, { id: 'checks', label: 'Checks', href: '/checks' }, @@ -29,12 +54,37 @@ const breadcrumbs = [ }, ]; +const tags = [ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, +]; + +const metadataUsers = [ + { label: 'Type', value: 'website' }, + { + label: 'Status', + value: , + }, + { + label: 'Owner', + value: , + }, + { + label: 'Contributors', + value: ( + + ), + }, +]; + export const WithEverything = () => (
@@ -45,6 +95,84 @@ export const WithEverything = () => ( ); +export const WithMetadataUsers = () => ( + +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + +); + +export const WithTags = () => ( + +
+ +); + +export const WithDescription = () => ( + +
+ +); + +export const WithMetadata = () => ( + +
+ +); + +export const WithMetadataStatus = () => ( + +
, + }, + { + label: 'Build', + value: ( + + ), + }, + { + label: 'Coverage', + value: , + }, + ]} + /> + +); + 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..107bf6d7f9 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -3,17 +3,28 @@ import { CodeBlock } from '@/components/CodeBlock'; import { Snippet } from '@/components/Snippet'; import { WithEverything, - WithLongBreadcrumbs, WithTabs, + WithTags, + WithDescription, + WithMetadata, + WithMetadataUsers, + WithMetadataStatus, WithCustomActions, WithMenu, } from './components'; -import { headerPagePropDefs } from './props-definition'; +import { + headerPagePropDefs, + headerMetadataUsersPropDefs, +} from './props-definition'; import { usage, defaultSnippet, withTabs, - withBreadcrumbs, + withTags, + withDescription, + withMetadata, + withMetadataUsers, + withMetadataStatus, withCustomActions, withMenu, } from './snippets'; @@ -24,7 +35,7 @@ import { ChangelogComponent } from '@/components/ChangelogComponent'; } code={defaultSnippet} /> @@ -39,11 +50,37 @@ 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 with support for inline links. Bold, italic, and block-level markdown are not rendered. + +} code={withDescription} /> + +### Metadata + +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. 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 diff --git a/docs-ui/src/app/components/header/props-definition.tsx b/docs-ui/src/app/components/header/props-definition.tsx index 0acc7d30ae..f4e7c2b670 100644 --- a/docs-ui/src/app/components/header/props-definition.tsx +++ b/docs-ui/src/app/components/header/props-definition.tsx @@ -5,6 +5,51 @@ 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 links are supported. Bold, italic, and block-level markdown are 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 secondary color.', + }, + value: { + type: 'string | ReactNode', + required: true, + description: + 'The value to display alongside the label. Pass a string for plain text or a ReactNode for custom content such as HeaderMetadataUsers.', + }, + }, + }, + }, customActions: { type: 'enum', values: ['ReactNode'], @@ -49,6 +94,7 @@ export const headerPagePropDefs: Record = { }, breadcrumbs: { type: 'complex', + deprecated: true, description: 'Breadcrumb trail displayed above the title.', complexType: { name: 'HeaderBreadcrumb[]', @@ -68,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 8dfef0e69e..ac38d6d7eb 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -2,15 +2,41 @@ export const usage = `import { Header } from '@backstage/ui';
`; -export const defaultSnippet = `
, + }, + { + label: 'Owner', + value: , + }, + { + label: 'Contributors', + value: ( + + ), + }, ]} tabs={[ { id: 'overview', label: 'Overview', href: '/overview' }, - { id: 'settings', label: 'Settings', href: '/settings' }, + { id: 'checks', label: 'Checks', href: '/checks' }, ]} customActions={ <> @@ -54,3 +80,70 @@ export const withMenu = `
} />`; + +export const withTags = `
`; + +export const withDescription = `
`; + +export const withMetadata = `
`; + +export const withMetadataStatus = `import { Header, HeaderMetadataStatus } from '@backstage/ui'; + +
, + }, + { + label: 'Build', + value: , + }, + { + label: 'Coverage', + value: , + }, + ]} +/>`; + +export const withMetadataUsers = `import { Header, HeaderMetadataUsers } from '@backstage/ui'; + +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} +/>`; 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 }; diff --git a/packages/ui/package.json b/packages/ui/package.json index f3774f5bef..4a9689b487 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -47,10 +47,12 @@ }, "dependencies": { "@backstage/version-bridge": "workspace:^", + "@braintree/sanitize-url": "^7.1.2", "@internationalized/date": "^3.12.0", "@remixicon/react": ">=4.6.0 <4.9.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/report.api.md b/packages/ui/report.api.md index f158f5dade..1e549de36d 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1585,6 +1585,11 @@ export const HeaderDefinition: { readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; + readonly tags: 'bui-HeaderTags'; + readonly tag: 'bui-HeaderTag'; + readonly description: 'bui-HeaderDescription'; + readonly metaRow: 'bui-HeaderMetaRow'; + readonly metaItem: 'bui-HeaderMetaItem'; }; readonly propDefs: { readonly title: {}; @@ -1592,10 +1597,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: HeaderMetadataStatusProps, +) => JSX_2.Element; + +// @public +export interface HeaderMetadataStatusProps { + // (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: { @@ -1676,15 +1722,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; } @@ -1705,6 +1756,11 @@ export const HeaderPageDefinition: { readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; + readonly tags: 'bui-HeaderTags'; + readonly tag: 'bui-HeaderTag'; + readonly description: 'bui-HeaderDescription'; + readonly metaRow: 'bui-HeaderMetaRow'; + readonly metaItem: 'bui-HeaderMetaItem'; }; readonly propDefs: { readonly title: {}; @@ -1712,6 +1768,9 @@ export const HeaderPageDefinition: { readonly tabs: {}; readonly activeTabId: {}; readonly breadcrumbs: {}; + readonly description: {}; + readonly tags: {}; + readonly metadata: {}; readonly className: {}; }; }; @@ -1736,6 +1795,14 @@ export interface HeaderTab { matchStrategy?: TabMatchStrategy; } +// @public +export interface HeaderTag { + // (undocumented) + href?: string; + // (undocumented) + label: string; +} + // @public (undocumented) export type JustifyContent = | 'stretch' diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index d74d3eeb50..2e3ca28d3b 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-3); margin-top: var(--bui-space-6); } @@ -47,4 +47,50 @@ 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; + list-style: none; + margin: 0; + padding: 0; + } + + .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%; + background-color: var(--bui-fg-secondary); + flex-shrink: 0; + } + + .bui-HeaderMetaRow { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-5); + flex-wrap: wrap; + margin: 0; + } + + .bui-HeaderMetaItem { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-2); + + dd { + margin: 0; + } + } } diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index c30534a993..59e5cf254d 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -17,6 +17,8 @@ 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'; @@ -152,16 +154,208 @@ export const WithLongBreadcrumbs = meta.story({ }, }); -export const WithEverything = meta.story({ +export const WithDescription = 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).', }, }); +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' }, + ], + }, +}); + +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', + }, +}; + +export const WithMetadataUsers = meta.story({ + decorators: [withRouter], + render: () => ( +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + ), +}); + +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: () => ( +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, + ]} + /> + ), +}); + +export const WithEverything = 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)." + tags={[ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, + { label: 'Gold' }, + ]} + metadata={[ + { label: 'Type', value: 'website' }, + { + label: 'Owner', + value: , + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + ), +}); + const groupedTabs: HeaderNavTabItem[] = [ { id: 'overview', label: 'Overview', href: '/overview' }, { diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index bc8289cdf1..3f17e35732 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -20,9 +20,31 @@ 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/jsx-runtime'; +import { Fragment, useMemo } from 'react'; + +/** + * 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') { + const href = sanitizeUrl(token.href); + if (href === 'about:blank') return token.text; + return ( + + {token.text} + + ); + } + return token.raw; + }); +} /** * A secondary header with title, breadcrumbs, tabs, and actions. @@ -31,11 +53,50 @@ 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; + + const descriptionNodes = useMemo( + () => (description ? renderInlineMarkdown(description) : null), + [description], + ); return ( + {tags && tags.length > 0 && ( +
    + {tags.map((tag, i) => ( +
  • + {tag.href ? ( + + {tag.label} + + ) : ( + + {tag.label} + + )} +
  • + ))} +
+ )}
{breadcrumbs && @@ -61,6 +122,35 @@ export const Header = (props: HeaderProps) => {
{customActions}
+ {description && ( + + {descriptionNodes} + + )} + {metadata && metadata.length > 0 && ( +
+ {metadata.map((item, i) => ( +
+
+ + {item.label} + +
+
+ {typeof item.value === 'string' ? ( + {item.value} + ) : ( + item.value + )} +
+
+ ))} +
+ )} {tabs && (
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..fb7229143c --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx @@ -0,0 +1,50 @@ +/* + * 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 { HeaderMetadataStatusProps } 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, +}: HeaderMetadataStatusProps) => { + return ( +
+
+ ); +}; 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..0b84734b81 --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css @@ -0,0 +1,41 @@ +/* + * 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); + list-style: none; + margin: 0; + padding: 0; + } + + .avatarLink { + display: flex; + text-decoration: none; + } +} diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx new file mode 100644 index 0000000000..e4d5c2d07b --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -0,0 +1,108 @@ +/* + * 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 { Link } from '../Link'; +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 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 + */ +export const HeaderMetadataUsers = ({ + users, +}: { + users: HeaderMetadataUser[]; +}) => { + if (users.length === 0) return null; + + if (users.length === 1) { + const user = users[0]; + if (user.href) { + return ( + + + {user.name} + + ); + } + + return ( +
+ + {user.name} +
+ ); + } + + return ( +
    + {users.map((user, i) => ( +
  • + + {user.href ? ( + + + + ) : ( + + + + )} + {user.name} + +
  • + ))} +
+ ); +}; diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 2876770339..c0420dade5 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -30,6 +30,11 @@ export const HeaderDefinition = defineComponent()({ breadcrumbs: 'bui-HeaderBreadcrumbs', tabsWrapper: 'bui-HeaderTabsWrapper', controls: 'bui-HeaderControls', + tags: 'bui-HeaderTags', + tag: 'bui-HeaderTag', + description: 'bui-HeaderDescription', + metaRow: 'bui-HeaderMetaRow', + metaItem: 'bui-HeaderMetaItem', }, propDefs: { title: {}, @@ -37,6 +42,9 @@ export const HeaderDefinition = defineComponent()({ tabs: {}, activeTabId: {}, breadcrumbs: {}, + description: {}, + tags: {}, + metadata: {}, className: {}, }, }); diff --git a/packages/ui/src/components/Header/index.tsx b/packages/ui/src/components/Header/index.tsx index 586ee32bfb..d11db12734 100644 --- a/packages/ui/src/components/Header/index.tsx +++ b/packages/ui/src/components/Header/index.tsx @@ -20,6 +20,8 @@ export { HeaderNavItemDefinition, HeaderNavGroupDefinition, } from './HeaderNavDefinition'; +export { HeaderMetadataUsers } from './HeaderMetadataUsers'; +export { HeaderMetadataStatus } from './HeaderMetadataStatus'; export type { HeaderNavTab, HeaderNavTabGroup, @@ -27,6 +29,10 @@ export type { HeaderOwnProps, HeaderProps, HeaderBreadcrumb, + HeaderTag, + HeaderMetadataItem, + HeaderMetadataUser, + HeaderMetadataStatusProps, HeaderPageOwnProps, HeaderPageProps, HeaderPageBreadcrumb, diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index 1d4acc45ea..bc9ce8985b 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -52,6 +52,48 @@ 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; +} + +/** + * Represents a user in the HeaderMetadataUsers component. + * + * @public + */ +export interface HeaderMetadataUser { + name: string; + src?: string; + href?: string; +} + +/** + * Represents a status item in the HeaderMetadataStatus component. + * + * @public + */ +export interface HeaderMetadataStatusProps { + label: string; + color: 'danger' | 'warning' | 'success' | 'info'; + href?: string; +} + /** * Own props for the Header component. * @@ -62,7 +104,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 links are supported. + * Bold, italic, and block-level markdown are not rendered. + */ + description?: string; + tags?: HeaderTag[]; + metadata?: HeaderMetadataItem[]; className?: string; } 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/yarn.lock b/yarn.lock index b933661580..65fd5cbe84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7945,6 +7945,7 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/version-bridge": "workspace:^" + "@braintree/sanitize-url": "npm:^7.1.2" "@internationalized/date": "npm:^3.12.0" "@remixicon/react": "npm:>=4.6.0 <4.9.0" "@storybook/react-vite": "npm:^10.3.3" @@ -7958,6 +7959,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" @@ -8020,6 +8022,13 @@ __metadata: languageName: node linkType: hard +"@braintree/sanitize-url@npm:^7.1.2": + version: 7.1.2 + resolution: "@braintree/sanitize-url@npm:7.1.2" + checksum: 10/d9626ff8f8eb5e192cd055e6e743449c21102c76bb59e405b7028fe56230fa080bfcc80dfb1e21850a6876e75adda9f7b3c888cf0685942bb74da4d2866d6ec3 + languageName: node + linkType: hard + "@bundled-es-modules/cookie@npm:^2.0.1": version: 2.0.1 resolution: "@bundled-es-modules/cookie@npm:2.0.1" @@ -11859,8 +11868,8 @@ __metadata: linkType: hard "@mswjs/interceptors@npm:^0.39.1": - version: 0.39.6 - resolution: "@mswjs/interceptors@npm:0.39.6" + version: 0.39.8 + resolution: "@mswjs/interceptors@npm:0.39.8" dependencies: "@open-draft/deferred-promise": "npm:^2.2.0" "@open-draft/logger": "npm:^0.3.0" @@ -11868,7 +11877,7 @@ __metadata: is-node-process: "npm:^1.2.0" outvariant: "npm:^1.4.3" strict-event-emitter: "npm:^0.5.1" - checksum: 10/c87d3edf08353bde825c87b151b24d538070540ab419206cef1774c932e888af0f920183182fb7c94c3eee42068da5a0a5855853fded8514f33c870921ef37ec + checksum: 10/d92546cf9bf670ddb927c53f5fa19f0554b7475a264ead4e1ae2339874f4312fe4ada5d42588f27eea3577bee29fa8f46889d398f0e7ecb3f7a4c1d3e0b71bdc languageName: node linkType: hard