Merge pull request #30524 from backstage/bui-link

UI - Move Link to React Aria
This commit is contained in:
Charles de Dreuille
2025-07-14 16:09:45 +01:00
committed by GitHub
13 changed files with 375 additions and 104 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': minor
---
**Breaking changes** We are updating our Link component to use React Aria under the hood. To match their API we are updating the `to` prop to `href` to match both internal and external routing. We are also updating our variant naming to include all our new font sizes.
File diff suppressed because one or more lines are too long
+33 -7
View File
@@ -9790,14 +9790,12 @@
.bui-Link {
font-family: var(--bui-font-regular);
color: var(--bui-fg-link);
cursor: pointer;
margin: 0;
padding: 0;
text-decoration-line: none;
&:hover {
color: var(--bui-fg-link-hover);
text-underline-offset: calc(.025em + 2px);
text-decoration-line: underline;
text-decoration-style: solid;
@@ -9806,22 +9804,42 @@
}
}
.bui-Link[data-variant="body"] {
font-size: var(--bui-font-size-3);
.bui-Link[data-variant="title-large"] {
font-size: var(--bui-font-size-8);
line-height: 140%;
}
.bui-Link[data-variant="subtitle"] {
.bui-Link[data-variant="title-medium"] {
font-size: var(--bui-font-size-7);
line-height: 140%;
}
.bui-Link[data-variant="title-small"] {
font-size: var(--bui-font-size-6);
line-height: 140%;
}
.bui-Link[data-variant="title-x-small"] {
font-size: var(--bui-font-size-5);
line-height: 140%;
}
.bui-Link[data-variant="body-large"] {
font-size: var(--bui-font-size-4);
line-height: 140%;
}
.bui-Link[data-variant="caption"] {
.bui-Link[data-variant="body-medium"] {
font-size: var(--bui-font-size-3);
line-height: 140%;
}
.bui-Link[data-variant="body-small"] {
font-size: var(--bui-font-size-2);
line-height: 140%;
}
.bui-Link[data-variant="label"] {
.bui-Link[data-variant="body-x-small"] {
font-size: var(--bui-font-size-1);
line-height: 140%;
}
@@ -9834,6 +9852,14 @@
font-weight: var(--bui-font-weight-bold);
}
.bui-Link[data-color="primary"] {
color: var(--bui-fg-primary);
}
.bui-Link[data-color="secondary"] {
color: var(--bui-fg-secondary);
}
.bui-MenuPositioner {
z-index: 100;
outline: 0;
+27 -15
View File
@@ -10,7 +10,6 @@ import { Collapsible as Collapsible_2 } from '@base-ui-components/react/collapsi
import { ComponentProps } from 'react';
import type { ComponentPropsWithRef } from 'react';
import { Context } from 'react';
import type { CSSProperties } from 'react';
import type { ElementType } from 'react';
import { FocusEvent as FocusEvent_2 } from 'react';
import { ForwardRefExoticComponent } from 'react';
@@ -1219,26 +1218,39 @@ export type JustifyContent =
// @public (undocumented)
export const Link: ForwardRefExoticComponent<
Omit<LinkProps, 'ref'> & RefAttributes<HTMLElement>
LinkProps & RefAttributes<HTMLAnchorElement>
>;
// @public (undocumented)
export interface LinkProps extends useRender.ComponentProps<'a'> {
export interface LinkProps extends LinkProps_2 {
// (undocumented)
children: ReactNode;
// (undocumented)
className?: string;
// (undocumented)
style?: CSSProperties;
// (undocumented)
to?: string;
color?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint, 'primary' | 'secondary'>>;
// (undocumented)
variant?:
| 'subtitle'
| 'body'
| 'caption'
| 'label'
| Partial<Record<Breakpoint, 'subtitle' | 'body' | 'caption' | 'label'>>;
| 'title-large'
| 'title-medium'
| 'title-small'
| 'title-x-small'
| 'body-large'
| 'body-medium'
| 'body-small'
| 'body-x-small'
| Partial<
Record<
Breakpoint,
| 'title-large'
| 'title-medium'
| 'title-small'
| 'title-x-small'
| 'body-large'
| 'body-medium'
| 'body-small'
| 'body-x-small'
>
>;
// (undocumented)
weight?: 'regular' | 'bold' | Partial<Record<Breakpoint, 'regular' | 'bold'>>;
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { DataTable } from '.';
import { data, DataProps } from './mocked-components';
import { columns } from './mocked-columns';
@@ -26,9 +26,17 @@ import {
PaginationState,
} from '@tanstack/react-table';
import { useState } from 'react';
import { MemoryRouter } from 'react-router-dom';
const meta = {
title: 'Components/DataTable',
decorators: [
(Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta;
export default meta;
@@ -14,11 +14,11 @@
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { Link } from './Link';
import { Flex } from '../Flex';
import { Text } from '../Text';
import { Link as RouterLink, MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router-dom';
const meta = {
title: 'Components/Link',
@@ -26,6 +26,13 @@ const meta = {
args: {
children: 'Link',
},
decorators: [
(Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof Link>;
export default meta;
@@ -33,21 +40,55 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
to: 'https://ui.backstage.io',
href: '/home',
children: 'Sign up for Backstage',
},
};
export const ExternalLink: Story = {
args: {
href: 'https://backstage.io',
children: 'Sign up for Backstage',
target: '_blank',
},
};
export const AllVariants: Story = {
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Link href="https://ui.backstage.io" variant="subtitle" {...args} />
<Link href="https://ui.backstage.io" variant="body" {...args} />
<Link href="https://ui.backstage.io" variant="caption" {...args} />
<Link href="https://ui.backstage.io" variant="label" {...args} />
<Link href="https://ui.backstage.io" variant="title-large" {...args} />
<Link href="https://ui.backstage.io" variant="title-medium" {...args} />
<Link href="https://ui.backstage.io" variant="title-small" {...args} />
<Link href="https://ui.backstage.io" variant="title-x-small" {...args} />
<Link href="https://ui.backstage.io" variant="body-large" {...args} />
<Link href="https://ui.backstage.io" variant="body-medium" {...args} />
<Link href="https://ui.backstage.io" variant="body-small" {...args} />
<Link href="https://ui.backstage.io" variant="body-x-small" {...args} />
</Flex>
),
};
export const AllColors: Story = {
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Link
href="https://ui.backstage.io"
variant="title-small"
color="primary"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="title-small"
color="secondary"
{...args}
/>
</Flex>
),
};
@@ -58,8 +99,102 @@ export const AllWeights: Story = {
},
render: args => (
<Flex gap="4" direction="column">
<Link weight="regular" style={{ maxWidth: '600px' }} {...args} />
<Link weight="bold" style={{ maxWidth: '600px' }} {...args} />
<Link
href="https://ui.backstage.io"
variant="title-large"
weight="regular"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="title-large"
weight="bold"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="title-medium"
weight="regular"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="title-medium"
weight="bold"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="title-small"
weight="regular"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="title-small"
weight="bold"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="title-x-small"
weight="regular"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="title-x-small"
weight="bold"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="body-large"
weight="regular"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="body-large"
weight="bold"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="body-medium"
weight="regular"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="body-medium"
weight="bold"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="body-small"
weight="regular"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="body-small"
weight="bold"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="body-x-small"
weight="regular"
{...args}
/>
<Link
href="https://ui.backstage.io"
variant="body-x-small"
weight="bold"
{...args}
/>
</Flex>
),
};
@@ -68,43 +203,32 @@ export const Responsive: Story = {
args: {
...Default.args,
variant: {
xs: 'label',
md: 'body',
xs: 'title-x-small',
md: 'body-x-small',
},
},
};
export const CustomRender: Story = {
render: () => {
return (
<Flex gap="4" direction="column" align="start">
<Link render={<RouterLink to="/catalog" />}>Go to Catalog</Link>
</Flex>
);
},
decorators: [
Story => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
};
export const Playground: Story = {
args: {
...Default.args,
},
render: args => (
<Flex gap="4" direction="column">
<Text>Subtitle</Text>
<Link variant="subtitle" style={{ maxWidth: '600px' }} {...args} />
<Text>Body</Text>
<Link variant="body" style={{ maxWidth: '600px' }} {...args} />
<Text>Caption</Text>
<Link variant="caption" style={{ maxWidth: '600px' }} {...args} />
<Text>Label</Text>
<Link variant="label" style={{ maxWidth: '600px' }} {...args} />
<Text>Title X Small</Text>
<Link variant="title-x-small" style={{ maxWidth: '600px' }} {...args} />
<Text>Body X Small</Text>
<Link variant="body-x-small" style={{ maxWidth: '600px' }} {...args} />
<Text>Body Small</Text>
<Link variant="body-small" style={{ maxWidth: '600px' }} {...args} />
<Text>Body Medium</Text>
<Link variant="body-medium" style={{ maxWidth: '600px' }} {...args} />
<Text>Body Large</Text>
<Link variant="body-large" style={{ maxWidth: '600px' }} {...args} />
<Text>Title Small</Text>
<Link variant="title-small" style={{ maxWidth: '600px' }} {...args} />
<Text>Title Medium</Text>
<Link variant="title-medium" style={{ maxWidth: '600px' }} {...args} />
</Flex>
),
};
+60 -17
View File
@@ -14,37 +14,80 @@
* limitations under the License.
*/
import { useRef, forwardRef } from 'react';
import { useRender } from '@base-ui-components/react/use-render';
import { forwardRef } from 'react';
import { Link as AriaLink, RouterProvider } from 'react-aria-components';
import clsx from 'clsx';
import { useStyles } from '../../hooks/useStyles';
import type { LinkProps } from './types';
import { useNavigate, useHref } from 'react-router-dom';
// Helper function to determine if a link is external
function isExternalLink(href?: string): boolean {
if (!href) return false;
// Check if it's an absolute URL with protocol
if (href.startsWith('http://') || href.startsWith('https://')) {
return true;
}
// Check if it's a protocol-relative URL
if (href.startsWith('//')) {
return true;
}
// Check if it's a mailto: or tel: link
if (href.startsWith('mailto:') || href.startsWith('tel:')) {
return true;
}
return false;
}
/** @public */
export const Link = forwardRef<HTMLElement, LinkProps>((props, ref) => {
export const Link = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
const navigate = useNavigate();
const {
className,
variant = 'body',
weight = 'regular',
render = <a />,
color = 'primary',
href,
...restProps
} = props;
const { classNames, dataAttributes } = useStyles('Link', { variant, weight });
const internalRef = useRef<HTMLElement | null>(null);
const { renderElement } = useRender({
render,
props: {
className: clsx(classNames.root, className),
...dataAttributes,
...restProps,
},
refs: [ref, internalRef],
const { classNames, dataAttributes } = useStyles('Link', {
variant,
weight,
color,
});
return renderElement();
const isExternal = isExternalLink(href);
// If it's an external link, render AriaLink without RouterProvider
if (isExternal) {
return (
<AriaLink
ref={ref}
className={clsx(classNames.root, className)}
href={href}
{...dataAttributes}
{...restProps}
/>
);
}
// For internal links, use RouterProvider
return (
<RouterProvider navigate={navigate} useHref={useHref}>
<AriaLink
ref={ref}
className={clsx(classNames.root, className)}
href={href}
{...dataAttributes}
{...restProps}
/>
</RouterProvider>
);
});
Link.displayName = 'Link';
+33 -7
View File
@@ -16,14 +16,12 @@
.bui-Link {
font-family: var(--bui-font-regular);
color: var(--bui-fg-link);
padding: 0;
margin: 0;
cursor: pointer;
text-decoration-line: none;
&:hover {
color: var(--bui-fg-link-hover);
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: min(2px, max(1px, 0.05em));
@@ -36,22 +34,42 @@
}
}
.bui-Link[data-variant='body'] {
font-size: var(--bui-font-size-3);
.bui-Link[data-variant='title-large'] {
font-size: var(--bui-font-size-8);
line-height: 140%;
}
.bui-Link[data-variant='subtitle'] {
.bui-Link[data-variant='title-medium'] {
font-size: var(--bui-font-size-7);
line-height: 140%;
}
.bui-Link[data-variant='title-small'] {
font-size: var(--bui-font-size-6);
line-height: 140%;
}
.bui-Link[data-variant='title-x-small'] {
font-size: var(--bui-font-size-5);
line-height: 140%;
}
.bui-Link[data-variant='body-large'] {
font-size: var(--bui-font-size-4);
line-height: 140%;
}
.bui-Link[data-variant='caption'] {
.bui-Link[data-variant='body-medium'] {
font-size: var(--bui-font-size-3);
line-height: 140%;
}
.bui-Link[data-variant='body-small'] {
font-size: var(--bui-font-size-2);
line-height: 140%;
}
.bui-Link[data-variant='label'] {
.bui-Link[data-variant='body-x-small'] {
font-size: var(--bui-font-size-1);
line-height: 140%;
}
@@ -63,3 +81,11 @@
.bui-Link[data-weight='bold'] {
font-weight: var(--bui-font-weight-bold);
}
.bui-Link[data-color='primary'] {
color: var(--bui-fg-primary);
}
.bui-Link[data-color='secondary'] {
color: var(--bui-fg-secondary);
}
+27 -12
View File
@@ -14,21 +14,36 @@
* limitations under the License.
*/
import type { CSSProperties, ReactNode } from 'react';
import type { Breakpoint } from '../../types';
import type { useRender } from '@base-ui-components/react/use-render';
import type { LinkProps as AriaLinkProps } from 'react-aria-components';
/** @public */
export interface LinkProps extends useRender.ComponentProps<'a'> {
children: ReactNode;
to?: string;
export interface LinkProps extends AriaLinkProps {
variant?:
| 'subtitle'
| 'body'
| 'caption'
| 'label'
| Partial<Record<Breakpoint, 'subtitle' | 'body' | 'caption' | 'label'>>;
| 'title-large'
| 'title-medium'
| 'title-small'
| 'title-x-small'
| 'body-large'
| 'body-medium'
| 'body-small'
| 'body-x-small'
| Partial<
Record<
Breakpoint,
| 'title-large'
| 'title-medium'
| 'title-small'
| 'title-x-small'
| 'body-large'
| 'body-medium'
| 'body-small'
| 'body-x-small'
>
>;
weight?: 'regular' | 'bold' | Partial<Record<Breakpoint, 'regular' | 'bold'>>;
className?: string;
style?: CSSProperties;
color?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint, 'primary' | 'secondary'>>;
}
@@ -14,12 +14,20 @@
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { TableCellLink } from './TableCellLink';
import { MemoryRouter } from 'react-router-dom';
const meta = {
title: 'Components/Table/TableCellLink',
component: TableCellLink,
decorators: [
(Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof TableCellLink>;
export default meta;
@@ -32,11 +32,7 @@ const TableCellLink = forwardRef<HTMLDivElement, TableCellLinkProps>(
className={clsx(classNames.cellLink, className)}
{...props}
>
{title && (
<Link href={href} render={render}>
{title}
</Link>
)}
{title && <Link href={href}>{title}</Link>}
{description && (
<Text variant="body" color="secondary">
{description}
@@ -14,12 +14,20 @@
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { TableCellProfile } from './TableCellProfile';
import { MemoryRouter } from 'react-router-dom';
const meta = {
title: 'Components/Table/TableCellProfile',
component: TableCellProfile,
decorators: [
(Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof TableCellProfile>;
export default meta;
@@ -52,7 +52,7 @@ const TableCellProfile = forwardRef<HTMLDivElement, TableCellProfileProps>(
</Avatar.Root>
)}
{name && to ? (
<Link to={to}>{name}</Link>
<Link href={to}>{name}</Link>
) : (
<Text variant="body">{name}</Text>
)}