Repository navigation
Add Link primitive to @wordpress/ui
#76013
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5eed993
c84c50e
279a7ab
d9e399c
8237457
4c91ec6
fab61d0
99fc3a0
e63fd55
0135c08
92345da
39f379d
ed9aef6
7856b75
69200ba
687595a
70bb097
5b1c330
c8ce5f5
12f6a96
74f3011
ab3c7ad
e0c61f2
ba161ee
6920413
7a3ed68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { Link } from './link'; |
| 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)' ) | ||
| } | ||
| /> | ||
| </> | ||
| ) : ( | ||
| children | ||
| ), | ||
| } ), | ||
| } ); | ||
|
|
||
| return element; | ||
| } ); | ||
|
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: '#', | ||
| }, | ||
|
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 = { | ||
|
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> | ||
| ), | ||
| }; | ||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't have a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I meant |
||
| } | ||
|
|
||
| /* 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; | ||
| } | ||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about using the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||
| } | ||
| 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 | ||
| ); | ||
| } ); | ||
| } ); | ||
| } ); |
| 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; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.