Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5eed993
Add `Link` primitive to `@wordpress/ui`
jameskoster Feb 27, 2026
c84c50e
Remove WithRenderProp story from Link component
jameskoster Feb 27, 2026
279a7ab
Refactor Notice ActionLink to use shared Link component
jameskoster Feb 27, 2026
d9e399c
Add `openInNewTab` prop to Link component
jameskoster Mar 4, 2026
8237457
Enhance `openInNewTab` with visual indicator, rel merging, and anchor…
jameskoster Mar 5, 2026
4c91ec6
Remove automatic rel injection from Link component
jameskoster Mar 6, 2026
fab61d0
Derive ActionLinkProps from LinkProps, matching ActionButtonProps pat…
jameskoster Mar 6, 2026
99fc3a0
Update packages/ui/src/notice/action-link.tsx
jameskoster Mar 6, 2026
e63fd55
Fix duplicate JSX attributes in ActionLink from bad merge
jameskoster Mar 6, 2026
0135c08
Omit `target` from LinkProps to enforce `openInNewTab` usage
jameskoster Mar 9, 2026
92345da
Changelog
jameskoster Mar 10, 2026
39f379d
Add Inline and Standalone stories demonstrating Text + Link composition
jameskoster Mar 10, 2026
ed9aef6
Apply suggestions from code review
jameskoster Mar 10, 2026
7856b75
Simplify LinkProps, fix link-contents thickness, and document render …
jameskoster Mar 10, 2026
69200ba
Remove redundant text-underline-offset from .link-contents
jameskoster Mar 10, 2026
687595a
Refactor Link stories to inherit Default args and disable hardcoded c…
jameskoster Mar 10, 2026
70bb097
Add unit tests for Link ref forwarding and openInNewTab behavior
jameskoster Mar 10, 2026
5b1c330
Update packages/ui/src/notice/action-link.tsx
jameskoster Mar 10, 2026
c8ce5f5
Update packages/ui/CHANGELOG.md
jameskoster Mar 10, 2026
12f6a96
Fix formatting, restore Standalone JSDoc, and fix duplicate ActionLin…
jameskoster Mar 10, 2026
74f3011
Use common reset and focus styles
ciampo Mar 10, 2026
ab3c7ad
Correct CSS comment about CSS prop inheritance
ciampo Mar 10, 2026
e0c61f2
Enforce fixed tone and variant on `Notice.ActionLink`
ciampo Mar 10, 2026
ba161ee
Story comment re. tone + unstyled variant
ciampo Mar 10, 2026
6920413
Add JSDoc to exported Link component
ciampo Mar 10, 2026
7a3ed68
Import order in Notice.ActionLink
ciampo Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Add `Text` primitive with predefined typographic variants (`heading-2xl` through `heading-sm`, `body-xl` through `body-sm`) built on design tokens ([#75870](https://github.com/WordPress/gutenberg/pull/75870)).
- Add `Card` and `CollapsibleCard` primitives ([#76252](https://github.com/WordPress/gutenberg/pull/76252)).
- Add `Link` primitive ([#76013](https://github.com/WordPress/gutenberg/pull/76013)).

### Bug Fixes

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * as Dialog from './dialog';
export * from './form/primitives';
export * from './icon';
export * from './icon-button';
export * from './link';
export * as Notice from './notice';
export * from './stack';
export * as Tabs from './tabs';
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/link/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Link } from './link';
73 changes: 73 additions & 0 deletions packages/ui/src/link/link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useRender, mergeProps } from '@base-ui/react';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { type LinkProps } from './types';
import resetStyles from '../utils/css/resets.module.css';
import focusStyles from '../utils/css/focus.module.css';
import styles from './style.module.css';

/**
* A styled anchor element with support for semantic color tones and an
* unstyled escape hatch.
*/
export const Link = forwardRef< HTMLAnchorElement, LinkProps >( function Link(
{
children,
variant = 'default',
tone = 'brand',
openInNewTab = false,
render,
className,
onClick,
...props
},
ref
) {
const isInternalAnchor = !! props.href?.startsWith( '#' );

const handleClick = ( event: React.MouseEvent< HTMLAnchorElement > ) => {
if ( openInNewTab && isInternalAnchor ) {
event.preventDefault();
}
onClick?.( event );
};

const element = useRender( {
render,
defaultTagName: 'a',
ref,
props: mergeProps< 'a' >( props, {
className: clsx(
resetStyles[ 'box-sizing' ],
focusStyles[ 'outset-ring--focus' ],
variant !== 'unstyled' && styles.link,
variant !== 'unstyled' && styles[ `is-${ tone }` ],
variant === 'unstyled' && styles[ 'is-unstyled' ],
openInNewTab && styles[ 'has-link-icon' ],
className
),
onClick: handleClick,
target: openInNewTab ? '_blank' : undefined,
children: openInNewTab ? (
<>
<span className={ styles[ 'link-contents' ] }>
{ children }
</span>
<span
className={ styles[ 'link-icon' ] }
role="img"
aria-label={
/* translators: accessibility text appended to link text */
__( '(opens in a new tab)' )
}
/>
Comment thread
jameskoster marked this conversation as resolved.
</>
) : (
children
),
} ),
} );

return element;
} );
92 changes: 92 additions & 0 deletions packages/ui/src/link/stories/index.story.tsx
Comment thread
jameskoster marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Link } from '../index';
import { Stack } from '../../stack';
import { Text } from '../../text';

const meta: Meta< typeof Link > = {
title: 'Design System/Components/Link',
component: Link,
};
export default meta;

type Story = StoryObj< typeof Link >;

export const Default: Story = {
args: {
children: 'Learn more',
href: '#',
},
Comment thread
jameskoster marked this conversation as resolved.
};

/**
* Note: `tone` has no effect on `unstyled` variant
*/
export const AllTonesAndVariants: Story = {
...Default,
argTypes: {
tone: {
control: false,
},
variant: {
control: false,
},
},
render: ( args ) => (
<Stack direction="column" gap="lg">
{ ( [ 'brand', 'neutral' ] as const ).map( ( tone ) =>
( [ 'default', 'unstyled' ] as const ).map( ( variant ) => (
<Stack
direction="column"
gap="xs"
key={ `${ tone }-${ variant }` }
>
<Text variant="heading-sm">
{ tone } tone, { variant } variant
</Text>
<Link { ...args } tone={ tone } variant={ variant } />
</Stack>
) )
) }
</Stack>
),
};

export const Inline: Story = {
...Default,
args: {
...Default.args,
children: 'inline link',
},
render: ( args ) => (
<Text variant="body-md" render={ <p /> }>
This is a paragraph with an <Link { ...args } /> that inherits its
typography from the parent Text component.
</Text>
),
};

/**
* When composing `Text` and `Link` via the `render` prop, the order matters:
* - `<Text render={ <Link /> } />` renders an `<a>` element (Link's default tag wins).
* - `<Link render={ <Text /> } />` renders a `<span>` element (Text's default tag wins).
*/
export const Standalone: Story = {
Comment thread
jameskoster marked this conversation as resolved.
args: {
href: '#',
},
argTypes: {
children: {
control: false,
},
},
render: ( args ) => (
<Stack direction="column" gap="md">
<Text variant="body-md" render={ <Link { ...args } /> }>
A standalone link with body-md typography
</Text>
<Text variant="body-sm" render={ <Link { ...args } /> }>
A standalone link with body-sm typography
</Text>
</Stack>
),
};
68 changes: 68 additions & 0 deletions packages/ui/src/link/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;

@layer wp-ui-components {
.link {
text-underline-offset: 0.2em;
text-decoration-thickness: 0.5px;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be --wpds-border-width-2xl? Should it depend on screen density, since it's sub-pixel?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a --wpds-border-width-2xl? I think we should follow up to explore adding a token for this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I meant 2xs! But yeah, I'm ok with tackling this in a follow-up

}

/* Brand tone */
.is-brand,
.is-brand:visited {
color: var(--wpds-color-fg-interactive-brand);
}

.is-brand:hover,
.is-brand:active {
color: var(--wpds-color-fg-interactive-brand-active);
}

/* Neutral tone */
.is-neutral,
.is-neutral:visited {
color: var(--wpds-color-fg-interactive-neutral);
text-decoration-color: var(--wpds-color-stroke-interactive-neutral);
}

.is-neutral:hover,
.is-neutral:active {
color: var(--wpds-color-fg-interactive-neutral-active);
}

/* Unstyled variant */
.is-unstyled {
color: inherit;
text-decoration: none;
}

/* Link icon pattern — arrow rendered via ::after pseudo-element to avoid
* Twemoji replacement and text-selection issues. Root <a> gets
* text-decoration:none; the link-contents span re-applies it.
* text-underline-offset is inherited and propagates from .link
* automatically. text-decoration-thickness and text-decoration-color
* are NOT inherited, so they are explicitly redeclared on
* .link-contents (the latter via `inherit` to carry the neutral
* tone's custom color token through). */
.has-link-icon {
text-decoration: none;
}

.link-contents {
text-decoration: underline;
text-decoration-color: inherit;
text-decoration-thickness: 0.5px;
}
Comment thread
jameskoster marked this conversation as resolved.

.link-icon {
margin-inline-start: var(--wpds-dimension-padding-xs);
font-weight: var(--wpds-font-weight-regular);
}

.link-icon::after {
content: "\2197";
}

.link-icon:dir(rtl)::after {
content: "\2196";
}
Comment on lines +61 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker for this PR, but I wonder if we should chose an icon that reflects more explicitly the "opens in a new tab" meaning. Also, seeing the feedback from #63175, maybe we should use an SVG icon instead? I feel like external from @wordpress/icons would be a good choice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had that before but it was removed because it didn't render well at certain sizes. I wouldn't object to using the right svg, but it probably needs a separate exploration.

@ciampo ciampo Mar 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using the arrowUpRight SVG for now, and discuss changing icon separately?

@mirka mirka Mar 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep the Unicode symbol for the purposes of this PR. If we switch, the change needs to happen globally (@wordpress/components counterpart, and probably a few other components in the codebase).

}
93 changes: 93 additions & 0 deletions packages/ui/src/link/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from '@wordpress/element';
import { Link } from '../index';

describe( 'Link', () => {
it( 'forwards ref', () => {
const ref = createRef< HTMLAnchorElement >();

render(
<Link ref={ ref } href="/">
Home
</Link>
);

expect( ref.current ).toBeInstanceOf( HTMLAnchorElement );
} );

describe( 'openInNewTab', () => {
it( 'sets target="_blank" when true', () => {
render(
<Link href="https://example.com" openInNewTab>
External
</Link>
);

expect( screen.getByRole( 'link' ) ).toHaveAttribute(
'target',
'_blank'
);
} );

it( 'does not set target="_blank" when false', () => {
render( <Link href="https://example.com">External</Link> );

expect( screen.getByRole( 'link' ) ).not.toHaveAttribute(
'target'
);
} );

it( 'renders an accessible arrow indicator', () => {
render(
<Link href="https://example.com" openInNewTab>
External
</Link>
);

expect(
screen.getByLabelText( '(opens in a new tab)' )
).toBeInTheDocument();
} );

it( 'prevents default for internal anchor links', async () => {
const user = userEvent.setup();
const onClick = jest.fn();

render(
<Link href="#section" openInNewTab onClick={ onClick }>
Jump
</Link>
);

await user.click( screen.getByRole( 'link' ) );

expect( onClick ).toHaveBeenCalledTimes( 1 );
expect( onClick.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe(
true
);
} );

it( 'does not prevent default for external links', async () => {
const user = userEvent.setup();
const onClick = jest.fn();

render(
<Link
href="https://example.com"
openInNewTab
onClick={ onClick }
>
External
</Link>
);

await user.click( screen.getByRole( 'link' ) );

expect( onClick ).toHaveBeenCalledTimes( 1 );
expect( onClick.mock.calls[ 0 ][ 0 ].defaultPrevented ).toBe(
false
);
} );
} );
} );
36 changes: 36 additions & 0 deletions packages/ui/src/link/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type ReactNode } from 'react';
import { type ComponentProps } from '../utils/types';

export interface LinkProps extends Omit< ComponentProps< 'a' >, 'target' > {
/**
* The visual treatment of the link.
*
* - `default`: Applies tone-based color and underline styles.
* - `unstyled`: Strips all visual styles so consumers can bring their own.
*
* @default "default"
*/
variant?: 'default' | 'unstyled';

/**
* The tone of the link. Tone describes a semantic color intent.
* Only applies when `variant` is `default`.
*
* @default "brand"
*/
tone?: 'brand' | 'neutral';

/**
* Whether to open the link in a new browser tab.
* When true, sets `target="_blank"`, appends a visual arrow indicator,
* and prevents navigation for internal anchors (`#`-prefixed hrefs).
*
* @default false
*/
openInNewTab?: boolean;

/**
* The content to be rendered inside the component.
*/
children?: ReactNode;
}
Loading
Loading