From 8bd2335b95f010f22b442b2b9f8e4ce86baee03c Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 30 Nov 2025 09:01:03 +0000 Subject: [PATCH 1/4] Improve Link component to add title Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Link/Link.tsx | 56 ++++++++++++++++++------ packages/ui/src/components/Link/types.ts | 7 +++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index 56b0cae5bb..fbd77bdaff 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import { forwardRef } from 'react'; -import { Link as AriaLink, RouterProvider } from 'react-aria-components'; +import { forwardRef, useRef } from 'react'; +import { useLink } from 'react-aria'; +import { RouterProvider } from 'react-aria-components'; import clsx from 'clsx'; import { useStyles } from '../../hooks/useStyles'; import { LinkDefinition } from './definition'; @@ -37,29 +38,56 @@ export const Link = forwardRef((props, ref) => { }, ); - const { className, href, ...restProps } = cleanedProps; + const { + className, + href, + title, + children, + onPress, + variant, + weight, + color, + truncate, + slot, + ...restProps + } = cleanedProps; const isExternal = isExternalLink(href); + const internalRef = useRef(null); + const linkRef = (ref || internalRef) as React.RefObject; - const component = ( - + // Use useLink hook to get link props + const { linkProps } = useLink( + { + href, + onPress, + ...restProps, + }, + linkRef, ); - // If it's an external link, render AriaLink without RouterProvider + const anchorElement = ( + + {children} + + ); + + // If it's an external link, render without RouterProvider if (isExternal) { - return component; + return anchorElement; } // For internal links, use RouterProvider return ( - {component} + {anchorElement} ); }); diff --git a/packages/ui/src/components/Link/types.ts b/packages/ui/src/components/Link/types.ts index 33c33b26c0..796676e593 100644 --- a/packages/ui/src/components/Link/types.ts +++ b/packages/ui/src/components/Link/types.ts @@ -22,6 +22,7 @@ import type { TextWeights, } from '../../types'; import type { LinkProps as AriaLinkProps } from 'react-aria-components'; +import type { ReactNode } from 'react'; /** @public */ export interface LinkProps extends AriaLinkProps { @@ -32,4 +33,10 @@ export interface LinkProps extends AriaLinkProps { | TextColorStatus | Partial>; truncate?: boolean; + + // This is used to set the title attribute on the link + title?: string; + + // This is used to set the children of the link + children?: ReactNode; } From 0334faab1fefd892ca6ed76124473348370b52ae Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 30 Nov 2025 09:01:47 +0000 Subject: [PATCH 2/4] Cell -> CellText Signed-off-by: Charles de Dreuille --- docs-ui/src/content/table.props.ts | 22 +- .../ui/src/components/Table/Table.module.css | 19 +- .../ui/src/components/Table/Table.stories.tsx | 304 ++++++++++-------- .../src/components/Table/components/Cell.tsx | 41 +-- .../components/Table/components/CellText.tsx | 98 ++++++ packages/ui/src/components/Table/index.ts | 8 +- .../ui/src/components/Table/mocked-data2.ts | 41 +++ packages/ui/src/components/Table/types.ts | 5 +- 8 files changed, 350 insertions(+), 188 deletions(-) create mode 100644 packages/ui/src/components/Table/components/CellText.tsx diff --git a/docs-ui/src/content/table.props.ts b/docs-ui/src/content/table.props.ts index dfd3d12988..21bd5597c6 100644 --- a/docs-ui/src/content/table.props.ts +++ b/docs-ui/src/content/table.props.ts @@ -267,7 +267,7 @@ export const tablePaginationPropDefs: Record = { ...stylePropDefs, }; -export const tableUsageSnippet = `import { Cell, ..., TableHeader, TablePagination } from '@backstage/ui'; +export const tableUsageSnippet = `import { Cell, CellText, ..., TableHeader, TablePagination } from '@backstage/ui'; @@ -275,14 +275,14 @@ export const tableUsageSnippet = `import { Cell, ..., TableHeader, TablePaginati - +
`; -export const tableBasicSnippet = `import { Table, TableHeader, Column, TableBody, Row, Cell, CellProfile, TablePagination, useTable } from '@backstage/ui'; +export const tableBasicSnippet = `import { Table, TableHeader, Column, TableBody, Row, CellText, CellProfile, TablePagination, useTable } from '@backstage/ui'; const data = [ { @@ -318,16 +318,16 @@ const { data: paginatedData, paginationProps } = useTable({ src={item.image} href={item.website} /> - - - + + + ))} `; -export const tableSelectionActionsSnippet = `import { Table, TableHeader, TableBody, Column, Row, Cell } from '@backstage/ui'; +export const tableSelectionActionsSnippet = `import { Table, TableHeader, TableBody, Column, Row, CellText } from '@backstage/ui'; function MyTable() { const [selectedKeys, setSelectedKeys] = React.useState(new Set([])); @@ -346,12 +346,12 @@ function MyTable() { - - + + - - + + diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css index 6f720f85b2..001d5b21d1 100644 --- a/packages/ui/src/components/Table/Table.module.css +++ b/packages/ui/src/components/Table/Table.module.css @@ -21,6 +21,7 @@ width: 100%; caption-side: bottom; border-collapse: collapse; + table-layout: fixed; } .bui-TableHeader { @@ -109,6 +110,10 @@ .bui-TableCell { padding: var(--bui-space-3); font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + min-width: 0; + overflow: hidden; } .bui-TableCellSelection { @@ -116,10 +121,13 @@ } .bui-TableCellContentWrapper { - display: inline-flex; + display: flex; flex-direction: row; align-items: center; gap: var(--bui-space-2); + min-width: 0; + width: 100%; + max-width: 100%; } .bui-TableCellIcon, @@ -133,6 +141,15 @@ display: flex; flex-direction: column; gap: var(--bui-space-0_5); + min-width: 0; + flex: 1; + overflow: hidden; + max-width: 100%; + } + + .bui-TableCellContent > * { + min-width: 0; + max-width: 100%; } .bui-TableCellProfile { diff --git a/packages/ui/src/components/Table/Table.stories.tsx b/packages/ui/src/components/Table/Table.stories.tsx index 547dbef7bf..37790c5055 100644 --- a/packages/ui/src/components/Table/Table.stories.tsx +++ b/packages/ui/src/components/Table/Table.stories.tsx @@ -24,7 +24,8 @@ import { TableBody, Row, Cell, - CellProfile as CellProfileBUI, + CellText, + CellProfile, useTable, } from '.'; import { RadioGroup, Radio } from '../RadioGroup'; @@ -69,18 +70,18 @@ export const TableOnly: Story = { {data1.map(item => ( - } description={item.description} /> - - - + + ))} @@ -105,14 +106,14 @@ export const WithPaginationUncontrolled: Story = { {data?.map(item => ( - } description={item.description} /> - - - + + + ))} @@ -152,14 +153,14 @@ export const WithPaginationControlled: Story = { {data?.map(item => ( - - - - + + + ))} @@ -188,18 +189,18 @@ export const Sorting: Story = { {data1.map(item => ( - } description={item.description} /> - - - + + ))} @@ -229,14 +230,14 @@ export const TableRockBand: Story = { {data?.map(item => ( - - - - + + + ))} @@ -268,14 +269,14 @@ export const RowClick: Story = { {data?.map(item => ( alert('Row clicked')}> - - - - + + + ))} @@ -307,14 +308,14 @@ export const RowLink: Story = { {data?.map(item => ( - - - - + + + ))} @@ -325,7 +326,38 @@ export const RowLink: Story = { }, }; -export const CellText: Story = { +export const CellComponent: Story = { + name: 'Cell', + render: () => { + return ( + + + Name + + + + Hello world + + + + This is a very long text that demonstrates how the Cell component + handles lengthy content. It should wrap appropriately and maintain + proper styling even when the text extends beyond the normal cell + width. This helps ensure that the table remains readable and + visually consistent regardless of the content length. + + + + Hello world + + +
+ ); + }, +}; + +export const CellTextComponent: Story = { + name: 'CellText', render: () => { return ( @@ -335,10 +367,11 @@ export const CellText: Story = { {data2.map(item => ( - ))} @@ -348,7 +381,8 @@ export const CellText: Story = { }, }; -export const CellProfile: Story = { +export const CellProfileComponent: Story = { + name: 'CellProfile', render: () => { return (
@@ -358,7 +392,7 @@ export const CellProfile: Story = { {data3.map(item => ( - - - - + + + - - - + + + - - - + + +
@@ -428,19 +462,19 @@ export const SelectionMultiToggle: Story = { - - - + + + - - - + + + - - - + + + @@ -466,19 +500,19 @@ export const SelectionSingleReplace: Story = { - - - + + + - - - + + + - - - + + + @@ -504,19 +538,19 @@ export const SelectionMultiReplace: Story = { - - - + + + - - - + + + - - - + + + @@ -543,19 +577,19 @@ export const SelectionToggleWithActions: Story = { - - - + + + - - - + + + - - - + + + @@ -582,19 +616,19 @@ export const SelectionReplaceWithActions: Story = { - - - + + + - - - + + + - - - + + + @@ -620,19 +654,19 @@ export const SelectionToggleWithLinks: Story = { - - - + + + - - - + + + - - - + + + @@ -658,19 +692,19 @@ export const SelectionReplaceWithLinks: Story = { - - - + + + - - - + + + - - - + + + @@ -697,19 +731,19 @@ export const SelectionWithDisabledRows: Story = { - - - + + + - - - + + + - - - + + + @@ -744,9 +778,9 @@ export const SelectionWithPagination: Story = { {data?.map(item => ( - - - + + + ))} @@ -779,19 +813,19 @@ export const SelectionModePlayground: Story = { - - - + + + - - - + + + - - - + + + @@ -839,19 +873,19 @@ export const SelectionBehaviorPlayground: Story = { - - - + + + - - - + + + - - - + + + diff --git a/packages/ui/src/components/Table/components/Cell.tsx b/packages/ui/src/components/Table/components/Cell.tsx index 65be8482a7..809b224a07 100644 --- a/packages/ui/src/components/Table/components/Cell.tsx +++ b/packages/ui/src/components/Table/components/Cell.tsx @@ -15,8 +15,6 @@ */ import clsx from 'clsx'; -import { Text } from '../../Text'; -import { Link } from '../../Link'; import { Cell as ReactAriaCell } from 'react-aria-components'; import type { CellProps } from '../types'; import { useStyles } from '../../../hooks/useStyles'; @@ -29,49 +27,14 @@ const Cell = (props: CellProps) => { color: 'primary' as const, ...props, }); - const { className, title, description, color, leadingIcon, href, ...rest } = - cleanedProps; + const { className, children, ...rest } = cleanedProps; return ( -
- {leadingIcon && ( -
- {leadingIcon} -
- )} -
- {href ? ( - - {title} - - ) : ( - - {title} - - )} - {description && ( - - {description} - - )} -
-
+ {children}
); }; diff --git a/packages/ui/src/components/Table/components/CellText.tsx b/packages/ui/src/components/Table/components/CellText.tsx new file mode 100644 index 0000000000..8855c92e80 --- /dev/null +++ b/packages/ui/src/components/Table/components/CellText.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2024 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 clsx from 'clsx'; +import { Text } from '../../Text'; +import { Link } from '../../Link'; +import { Cell as ReactAriaCell } from 'react-aria-components'; +import type { CellTextProps } from '../types'; +import { useStyles } from '../../../hooks/useStyles'; +import { TableDefinition } from '../definition'; +import styles from '../Table.module.css'; + +/** @public */ +const CellText = (props: CellTextProps) => { + const { classNames, cleanedProps } = useStyles(TableDefinition, { + color: 'primary' as const, + ...props, + }); + const { className, title, description, color, leadingIcon, href, ...rest } = + cleanedProps; + + return ( + +
+ {leadingIcon && ( +
+ {leadingIcon} +
+ )} +
+ {href ? ( + + {title} + + ) : ( + + {title} + + )} + {description && ( + + {description} + + )} +
+
+
+ ); +}; + +CellText.displayName = 'CellText'; + +export { CellText }; diff --git a/packages/ui/src/components/Table/index.ts b/packages/ui/src/components/Table/index.ts index 8d6603ee00..42ba6ff9c9 100644 --- a/packages/ui/src/components/Table/index.ts +++ b/packages/ui/src/components/Table/index.ts @@ -20,10 +20,16 @@ export { TableBody } from './components/TableBody'; export { Column } from './components/Column'; export { Row } from './components/Row'; export { Cell } from './components/Cell'; +export { CellText } from './components/CellText'; export { CellProfile } from './components/CellProfile'; export { useTable } from './hooks/useTable'; -export type { CellProps, CellProfileProps, ColumnProps } from './types'; +export type { + CellProps, + CellTextProps, + CellProfileProps, + ColumnProps, +} from './types'; export type { UseTableConfig, UseTableResult, diff --git a/packages/ui/src/components/Table/mocked-data2.ts b/packages/ui/src/components/Table/mocked-data2.ts index 30ca36a689..0aff7bcd98 100644 --- a/packages/ui/src/components/Table/mocked-data2.ts +++ b/packages/ui/src/components/Table/mocked-data2.ts @@ -21,6 +21,7 @@ export interface DataProps { name: string; description?: string; icon?: React.ReactNode; + href?: string; } export const data: DataProps[] = [ @@ -43,4 +44,44 @@ export const data: DataProps[] = [ 'A comprehensive service handling user authentication and role-based access control across all applications.', icon: createElement(RiCactusLine), }, + { + icon: createElement(RiCactusLine), + name: 'Cell with extremely long title that demonstrates text wrapping behavior and how the component handles content that extends well beyond typical cell widths in table layouts', + description: + 'This is a comprehensive and detailed description that contains a substantial amount of text to demonstrate how the CellText component handles lengthy content. It includes multiple sentences and covers various aspects of the service or component being described. The text should wrap appropriately within the cell boundaries while maintaining readability and proper spacing. This example helps ensure that the table remains visually consistent and user-friendly even when dealing with extensive content that might otherwise break the layout or cause accessibility issues. The component should gracefully handle such scenarios by providing appropriate text wrapping, maintaining proper line heights, and ensuring that all content remains accessible and readable regardless of its length.', + }, + { + name: 'Cell with title and URL', + href: '/catalog/default/service/auth-service', + }, + { + name: 'Cell with title, description and URL', + description: + 'A comprehensive service handling user authentication and role-based access control across all applications.', + href: '/catalog/default/service/auth-service', + }, + { + name: 'Cell with title, icon and URL', + icon: createElement(RiCactusLine), + href: '/catalog/default/service/auth-service', + }, + { + name: 'Cell with title, description, icon and URL', + description: + 'A comprehensive service handling user authentication and role-based access control across all applications.', + icon: createElement(RiCactusLine), + href: '/catalog/default/service/auth-service', + }, + { + icon: createElement(RiCactusLine), + name: 'Cell with extremely long title that demonstrates text wrapping behavior and how the component handles content that extends well beyond typical cell widths in table layouts', + description: + 'This is a comprehensive and detailed description that contains a substantial amount of text to demonstrate how the CellText component handles lengthy content. It includes multiple sentences and covers various aspects of the service or component being described. The text should wrap appropriately within the cell boundaries while maintaining readability and proper spacing. This example helps ensure that the table remains visually consistent and user-friendly even when dealing with extensive content that might otherwise break the layout or cause accessibility issues. The component should gracefully handle such scenarios by providing appropriate text wrapping, maintaining proper line heights, and ensuring that all content remains accessible and readable regardless of its length.', + href: '/catalog/default/service/auth-service', + }, + { + name: 'External link example', + description: 'This is an external link that opens in a new tab', + href: 'https://backstage.io', + }, ]; diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts index be9e860a0c..1ac477e3ff 100644 --- a/packages/ui/src/components/Table/types.ts +++ b/packages/ui/src/components/Table/types.ts @@ -21,7 +21,10 @@ import { import type { TextColors } from '../../types'; /** @public */ -export interface CellProps extends ReactAriaCellProps { +export interface CellProps extends ReactAriaCellProps {} + +/** @public */ +export interface CellTextProps extends ReactAriaCellProps { title: string; description?: string; color?: TextColors; From 4505de77fc18a03281a041ea9c126ab1900724c7 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 30 Nov 2025 09:05:42 +0000 Subject: [PATCH 3/4] Update Link.tsx Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Link/Link.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index fbd77bdaff..351fc15279 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -70,6 +70,7 @@ export const Link = forwardRef((props, ref) => { )} ref={linkRef} href={href} title={title} From 16543fa272f44c256a9debb7b2d2c4221ed1d4ef Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 30 Nov 2025 09:11:46 +0000 Subject: [PATCH 4/4] Improve report & changeset Signed-off-by: Charles de Dreuille --- .changeset/open-points-beam.md | 37 ++++++++++++++++++++++++++++++++++ packages/ui/report.api.md | 15 +++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 .changeset/open-points-beam.md diff --git a/.changeset/open-points-beam.md b/.changeset/open-points-beam.md new file mode 100644 index 0000000000..b58bf2b3d5 --- /dev/null +++ b/.changeset/open-points-beam.md @@ -0,0 +1,37 @@ +--- +'@backstage/ui': minor +--- + +**Breaking change** The `Cell` component has been refactored to be a generic wrapper component that accepts `children` for custom cell content. The text-specific functionality (previously part of `Cell`) has been moved to a new `CellText` component. + +### Migration Guide + +If you were using `Cell` with text-specific props (`title`, `description`, `leadingIcon`, `href`), you need to update your code to use `CellText` instead: + +**Before:** + +```tsx +} + href="/path" +/> +``` + +**After:** + +```tsx +} + href="/path" +/> +``` + +For custom cell content, use the new generic `Cell` component: + +```tsx +{/* Your custom content */} +``` diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 2308142d77..0a7e289d0a 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -406,7 +406,16 @@ export interface CellProfileProps extends CellProps_2 { } // @public (undocumented) -export interface CellProps extends CellProps_2 { +export interface CellProps extends CellProps_2 {} + +// @public (undocumented) +export const CellText: { + (props: CellTextProps): JSX_2.Element; + displayName: string; +}; + +// @public (undocumented) +export interface CellTextProps extends CellProps_2 { // (undocumented) color?: TextColors; // (undocumented) @@ -863,12 +872,16 @@ export const LinkDefinition: { // @public (undocumented) export interface LinkProps extends LinkProps_2 { + // (undocumented) + children?: ReactNode; // (undocumented) color?: | TextColors | TextColorStatus | Partial>; // (undocumented) + title?: string; + // (undocumented) truncate?: boolean; // (undocumented) variant?: TextVariants | Partial>;