Skip to content

Commit 05019e2

Browse files
authored
feat: add Link component (#4601)
* feat: add Link component to next * fix: add focus-visible color, remove dead code, add icon variant warning * refactor: remove startIcon/endIcon props in favor of composition * fix: add text color token to storybook dark mode wrapper * fix: add data-font-size to standalone Link for gap token * chore: convert stories to CSF3 and add data-font-size tests * fix: use .eds-icon selector instead of > svg in Link * fix: use stroke token instead of hardcoded 1px in Link CSS * fix: let Link icons inherit link color instead of overriding to text-strong * fix: address review feedback on Link component - Replace hardcoded px values with design tokens for underline-offset, outline-offset and focus ring inset - Remove unnecessary cursor: pointer and duplicate display: inline-flex - Make href required in LinkProps - Make data-font-family conditional so inline inherits from context - Add data-line-height squished for standalone to match Figma height - Add ExternalLink story with target="_blank" pattern - Add tests for conditional data-font-family and data-line-height * Revert "fix(eds-core-react): add eds-text-field class to TextField root element" This reverts commit 3b64503. * fix(eds-core-react): use spacing tokens instead of stroke tokens in Link CSS Replace --eds-sizing-stroke-thick with proper spacing tokens (--eds-spacing-vertical-4xs, --eds-spacing-vertical-3xs) for text-underline-offset, outline-offset, and focus ring inset.
1 parent 376881a commit 05019e2

13 files changed

Lines changed: 373 additions & 10 deletions

File tree

packages/eds-core-react/.storybook/preview.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ legend {
99
/* Storybook color scheme wrapper */
1010
.eds-storybook-wrapper {
1111
background-color: var(--eds-color-bg-neutral-surface);
12+
color: var(--eds-color-text-strong);
1213
padding: 1rem;
1314
min-height: 100%;
1415
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import figma from '@figma/code-connect'
2+
import { Link } from '.'
3+
4+
figma.connect(
5+
Link,
6+
'https://www.figma.com/design/dz0XQdc5j7AAtjXr1gTfVR?node-id=2010:2899',
7+
{
8+
props: {
9+
variant: figma.enum('.Type', {
10+
Inline: 'inline',
11+
Standalone: 'standalone',
12+
}),
13+
},
14+
example: ({ variant }) => (
15+
<Link href="#" variant={variant}>
16+
Link
17+
</Link>
18+
),
19+
},
20+
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite'
2+
import { Link } from '.'
3+
import { Icon } from '../Icon'
4+
import { external_link, arrow_forward } from '@equinor/eds-icons'
5+
6+
const meta: Meta<typeof Link> = {
7+
title: 'EDS 2.0 (beta)/Link',
8+
component: Link,
9+
tags: ['beta'],
10+
parameters: {
11+
docs: {
12+
description: {
13+
component: `
14+
⚠️ **Beta Component** - This component is under active development.
15+
16+
\`\`\`tsx
17+
import { Link } from '@equinor/eds-core-react/next'
18+
\`\`\`
19+
`,
20+
},
21+
},
22+
},
23+
}
24+
25+
export default meta
26+
27+
type Story = StoryObj<typeof Link>
28+
29+
export const Introduction: Story = {
30+
args: {
31+
href: '#',
32+
children: 'Link',
33+
},
34+
}
35+
36+
export const Inline: Story = {
37+
render: () => (
38+
<p>
39+
This is a paragraph with an <Link href="#">inline link</Link> embedded in
40+
text.
41+
</p>
42+
),
43+
}
44+
45+
export const Standalone: Story = {
46+
render: () => (
47+
<Link href="#" variant="standalone">
48+
Standalone link
49+
</Link>
50+
),
51+
}
52+
53+
export const WithIcons: Story = {
54+
render: () => (
55+
<div
56+
style={{
57+
display: 'flex',
58+
flexDirection: 'column',
59+
alignItems: 'flex-start',
60+
gap: '16px',
61+
}}
62+
>
63+
<Link href="#" variant="standalone">
64+
<Icon data={arrow_forward} />
65+
Link with start icon
66+
</Link>
67+
<Link href="#" variant="standalone">
68+
Link with end icon
69+
<Icon data={external_link} />
70+
</Link>
71+
<Link href="#" variant="standalone">
72+
<Icon data={arrow_forward} />
73+
Link with both icons
74+
<Icon data={external_link} />
75+
</Link>
76+
</div>
77+
),
78+
}
79+
80+
export const ExternalLink: Story = {
81+
render: () => (
82+
<Link
83+
href="https://example.com"
84+
variant="standalone"
85+
target="_blank"
86+
rel="noreferrer"
87+
>
88+
External link
89+
<Icon data={external_link} />
90+
</Link>
91+
),
92+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { render, screen } from '@testing-library/react'
2+
import '@testing-library/jest-dom'
3+
import { axe } from 'jest-axe'
4+
import { Link } from '.'
5+
6+
describe('Link (next)', () => {
7+
describe('Rendering', () => {
8+
it('renders with default props', () => {
9+
render(<Link href="#">Click me</Link>)
10+
expect(screen.getByRole('link')).toBeInTheDocument()
11+
expect(screen.getByRole('link')).toHaveTextContent('Click me')
12+
})
13+
14+
it('renders inline variant by default', () => {
15+
render(
16+
<Link href="#" data-testid="eds-link">
17+
Link
18+
</Link>,
19+
)
20+
expect(screen.getByTestId('eds-link')).toHaveAttribute(
21+
'data-variant',
22+
'inline',
23+
)
24+
})
25+
26+
it('renders standalone variant', () => {
27+
render(
28+
<Link href="#" variant="standalone" data-testid="eds-link">
29+
Link
30+
</Link>,
31+
)
32+
expect(screen.getByTestId('eds-link')).toHaveAttribute(
33+
'data-variant',
34+
'standalone',
35+
)
36+
})
37+
38+
it('renders children as composition', () => {
39+
render(
40+
<Link href="#" variant="standalone">
41+
<span data-testid="icon">icon</span>
42+
Link text
43+
</Link>,
44+
)
45+
expect(screen.getByTestId('icon')).toBeInTheDocument()
46+
expect(screen.getByRole('link')).toHaveTextContent('Link text')
47+
})
48+
49+
it('applies custom className', () => {
50+
render(
51+
<Link href="#" data-testid="eds-link" className="custom">
52+
Link
53+
</Link>,
54+
)
55+
expect(screen.getByTestId('eds-link')).toHaveClass('eds-link', 'custom')
56+
})
57+
58+
it('forwards ref', () => {
59+
const ref = { current: null as HTMLAnchorElement | null }
60+
render(
61+
<Link ref={ref} href="#">
62+
Link
63+
</Link>,
64+
)
65+
expect(ref.current).toBeInstanceOf(HTMLAnchorElement)
66+
})
67+
68+
it('spreads additional props', () => {
69+
render(
70+
<Link href="#" data-testid="test" data-custom="value">
71+
Link
72+
</Link>,
73+
)
74+
expect(screen.getByTestId('test')).toHaveAttribute('data-custom', 'value')
75+
})
76+
77+
it('sets href attribute', () => {
78+
render(<Link href="https://example.com">Link</Link>)
79+
expect(screen.getByRole('link')).toHaveAttribute(
80+
'href',
81+
'https://example.com',
82+
)
83+
})
84+
85+
it('sets data-font-size for standalone variant', () => {
86+
render(
87+
<Link href="#" variant="standalone">
88+
Link
89+
</Link>,
90+
)
91+
expect(screen.getByRole('link')).toHaveAttribute('data-font-size', 'md')
92+
})
93+
94+
it('does not set data-font-size for inline variant', () => {
95+
render(<Link href="#">Link</Link>)
96+
expect(screen.getByRole('link')).not.toHaveAttribute('data-font-size')
97+
})
98+
99+
it('sets data-font-family for standalone variant', () => {
100+
render(
101+
<Link href="#" variant="standalone">
102+
Link
103+
</Link>,
104+
)
105+
expect(screen.getByRole('link')).toHaveAttribute('data-font-family', 'ui')
106+
})
107+
108+
it('does not set data-font-family for inline variant', () => {
109+
render(<Link href="#">Link</Link>)
110+
expect(screen.getByRole('link')).not.toHaveAttribute('data-font-family')
111+
})
112+
113+
it('sets data-line-height for standalone variant', () => {
114+
render(
115+
<Link href="#" variant="standalone">
116+
Link
117+
</Link>,
118+
)
119+
expect(screen.getByRole('link')).toHaveAttribute(
120+
'data-line-height',
121+
'squished',
122+
)
123+
})
124+
125+
it('does not set data-line-height for inline variant', () => {
126+
render(<Link href="#">Link</Link>)
127+
expect(screen.getByRole('link')).not.toHaveAttribute('data-line-height')
128+
})
129+
})
130+
131+
describe('Accessibility', () => {
132+
it('has no accessibility violations (inline)', async () => {
133+
const { container } = render(<Link href="#">Link</Link>)
134+
expect(await axe(container)).toHaveNoViolations()
135+
})
136+
137+
it('has no accessibility violations (standalone)', async () => {
138+
const { container } = render(
139+
<Link href="#" variant="standalone">
140+
Link
141+
</Link>,
142+
)
143+
expect(await axe(container)).toHaveNoViolations()
144+
})
145+
146+
it('supports aria attributes', () => {
147+
render(
148+
<Link href="#" aria-label="Custom label">
149+
Link
150+
</Link>,
151+
)
152+
expect(screen.getByRole('link')).toHaveAttribute(
153+
'aria-label',
154+
'Custom label',
155+
)
156+
})
157+
})
158+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { forwardRef } from 'react'
2+
import type { LinkProps } from './Link.types'
3+
4+
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
5+
{ variant = 'inline', className, children, ...rest },
6+
ref,
7+
) {
8+
const classes = ['eds-link', className].filter(Boolean).join(' ')
9+
10+
return (
11+
<a
12+
ref={ref}
13+
className={classes}
14+
data-variant={variant}
15+
data-font-family={variant === 'standalone' ? 'ui' : undefined}
16+
data-font-size={variant === 'standalone' ? 'md' : undefined}
17+
data-line-height={variant === 'standalone' ? 'squished' : undefined}
18+
{...rest}
19+
>
20+
{children}
21+
</a>
22+
)
23+
})
24+
25+
Link.displayName = 'Link'
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { AnchorHTMLAttributes } from 'react'
2+
3+
export type LinkVariant = 'inline' | 'standalone'
4+
5+
export type LinkProps = {
6+
/** Link destination URL */
7+
href: string
8+
/** Visual variant
9+
* - `inline` (default): used within text, inherits surrounding font size
10+
* - `standalone`: used on its own with flex layout, compose icons as children
11+
*/
12+
variant?: LinkVariant
13+
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Link } from './Link'
2+
export type { LinkProps, LinkVariant } from './Link.types'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
@layer eds-components {
2+
.eds-link {
3+
color: var(--eds-color-text-link);
4+
text-decoration: underline;
5+
text-decoration-thickness: var(--eds-sizing-stroke-thin);
6+
text-underline-offset: var(--eds-spacing-vertical-4xs);
7+
8+
transition: color 150ms ease-in-out;
9+
}
10+
11+
.eds-link:hover {
12+
color: var(--eds-color-text-strong);
13+
}
14+
15+
.eds-link:focus-visible {
16+
border-radius: var(--eds-spacing-border-radius-rounded);
17+
color: var(--eds-color-text-strong);
18+
outline: var(--eds-sizing-stroke-thin) solid var(--eds-color-border-focus);
19+
outline-offset: var(--eds-spacing-vertical-3xs);
20+
}
21+
22+
/* Standalone variant: flex layout for icon + text */
23+
.eds-link[data-variant='standalone'] {
24+
position: relative;
25+
26+
gap: var(--eds-typography-gap-horizontal);
27+
align-items: center;
28+
29+
border-bottom: var(--eds-sizing-stroke-thin) solid currentcolor;
30+
31+
text-decoration: none;
32+
}
33+
34+
/* Use pseudo-element for focus ring so it can have border-radius
35+
while border-bottom stays straight */
36+
.eds-link[data-variant='standalone']:focus-visible {
37+
border-radius: 0;
38+
outline: none;
39+
}
40+
41+
.eds-link[data-variant='standalone']:focus-visible::after {
42+
pointer-events: none;
43+
content: '';
44+
45+
position: absolute;
46+
inset: calc(var(--eds-spacing-vertical-3xs) * -1);
47+
48+
border: var(--eds-sizing-stroke-thin) solid var(--eds-color-border-focus);
49+
border-radius: var(--eds-spacing-border-radius-rounded);
50+
}
51+
}
52+
53+
/* Outside @layer to override typography.css [data-font-family] { display: block } */
54+
.eds-link[data-variant='standalone'] {
55+
display: inline-flex;
56+
}

packages/eds-core-react/src/components/next/TextField/TextField.test.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,6 @@ describe('TextField (Next EDS 2.0)', () => {
5555
})
5656
})
5757

58-
describe('CSS classes', () => {
59-
it('Has eds-text-field class on root element', () => {
60-
const { container } = render(<TextField label="Label" />)
61-
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
62-
expect(container.querySelector('.eds-text-field')).toBeInTheDocument()
63-
})
64-
})
65-
6658
describe('Basic functionality', () => {
6759
it('Renders label correctly', () => {
6860
render(<TextField label="Test Label" />)

0 commit comments

Comments
 (0)