Skip to content

Commit 069a699

Browse files
committed
feat: add asChild prop to Link and Slot utility component
Add Slot component that merges parent props onto a child element, enabling the asChild pattern for polymorphic rendering. Link now supports asChild for router integration (React Router, Next.js, etc.) without needing a polymorphic `as` prop.
1 parent 3ad6d80 commit 069a699

8 files changed

Lines changed: 161 additions & 16 deletions

File tree

packages/eds-core-react/src/components/next/Link/Link.stories.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ export const Standalone: Story = {
5050
),
5151
}
5252

53+
export const AsChild: Story = {
54+
render: () => {
55+
const CustomLink = (
56+
props: React.AnchorHTMLAttributes<HTMLAnchorElement>,
57+
) => (
58+
// eslint-disable-next-line jsx-a11y/anchor-has-content
59+
<a {...props} data-custom-router />
60+
)
61+
return (
62+
<Link asChild>
63+
<CustomLink href="/my-page">Router link</CustomLink>
64+
</Link>
65+
)
66+
},
67+
}
68+
5369
export const ExternalLink: Story = {
5470
render: () => (
5571
<Link

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,55 @@ describe('Link (next)', () => {
128128
})
129129
})
130130

131+
describe('asChild', () => {
132+
it('renders child element instead of <a>', () => {
133+
render(
134+
<Link asChild>
135+
<button type="button">Link as button</button>
136+
</Link>,
137+
)
138+
expect(screen.getByRole('button')).toBeInTheDocument()
139+
expect(screen.getByRole('button')).toHaveTextContent('Link as button')
140+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
141+
})
142+
143+
it('merges className onto child', () => {
144+
render(
145+
<Link asChild className="custom">
146+
<button type="button" className="child-class">
147+
Link
148+
</button>
149+
</Link>,
150+
)
151+
expect(screen.getByRole('button')).toHaveClass(
152+
'eds-link',
153+
'custom',
154+
'child-class',
155+
)
156+
})
157+
158+
it('merges data attributes onto child', () => {
159+
render(
160+
<Link asChild variant="standalone">
161+
<a href="/page">Link</a>
162+
</Link>,
163+
)
164+
expect(screen.getByRole('link')).toHaveAttribute(
165+
'data-variant',
166+
'standalone',
167+
)
168+
})
169+
170+
it('preserves child href', () => {
171+
render(
172+
<Link asChild>
173+
<a href="/my-route">Router link</a>
174+
</Link>,
175+
)
176+
expect(screen.getByRole('link')).toHaveAttribute('href', '/my-route')
177+
})
178+
})
179+
131180
describe('Accessibility', () => {
132181
it('has no accessibility violations (inline)', async () => {
133182
const { container } = render(<Link href="#">Link</Link>)
Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
11
import { forwardRef } from 'react'
22
import type { LinkProps } from './Link.types'
3+
import { Slot } from '../Slot'
34

45
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
5-
{ variant = 'inline', className, children, ...rest },
6+
{ variant = 'inline', asChild, className, children, ...rest },
67
ref,
78
) {
89
const classes = ['eds-link', className].filter(Boolean).join(' ')
910

10-
return (
11-
<a
12-
ref={ref}
13-
className={classes}
14-
data-variant={variant}
15-
data-color-appearance="info"
16-
data-font-family={variant === 'standalone' ? 'ui' : undefined}
17-
data-font-size={variant === 'standalone' ? 'md' : undefined}
18-
data-line-height={variant === 'standalone' ? 'squished' : undefined}
19-
{...rest}
20-
>
21-
{children}
22-
</a>
23-
)
11+
const sharedProps = {
12+
ref,
13+
className: classes,
14+
'data-variant': variant,
15+
'data-color-appearance': 'info',
16+
'data-font-family': variant === 'standalone' ? 'ui' : undefined,
17+
'data-font-size': variant === 'standalone' ? 'md' : undefined,
18+
'data-line-height': variant === 'standalone' ? 'squished' : undefined,
19+
...rest,
20+
}
21+
22+
if (asChild) {
23+
return <Slot {...sharedProps}>{children}</Slot>
24+
}
25+
26+
return <a {...sharedProps}>{children}</a>
2427
})
2528

2629
Link.displayName = 'Link'

packages/eds-core-react/src/components/next/Link/Link.types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ export type LinkVariant = 'inline' | 'standalone'
44

55
export type LinkProps = {
66
/** Link destination URL */
7-
href: string
7+
href?: string
88
/** Visual variant
99
* - `inline` (default): used within text, inherits surrounding font size
1010
* - `standalone`: used on its own with flex layout, compose icons as children
1111
*/
1212
variant?: LinkVariant
13+
/** Render as child element instead of `<a>`, merging Link styles onto the child.
14+
* Useful for integrating with router links (React Router, Next.js, etc.)
15+
*/
16+
asChild?: boolean
1317
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
forwardRef,
3+
isValidElement,
4+
cloneElement,
5+
type ReactElement,
6+
} from 'react'
7+
import type { SlotProps } from './Slot.types'
8+
9+
function mergeClassNames(...classNames: (string | undefined)[]) {
10+
return classNames.filter(Boolean).join(' ')
11+
}
12+
13+
function mergeProps(
14+
slotProps: Record<string, unknown>,
15+
childProps: Record<string, unknown>,
16+
) {
17+
const merged: Record<string, unknown> = { ...childProps }
18+
19+
for (const key of Object.keys(slotProps)) {
20+
const slotValue = slotProps[key]
21+
const childValue = childProps[key]
22+
23+
if (key === 'className') {
24+
merged[key] = mergeClassNames(
25+
slotValue as string | undefined,
26+
childValue as string | undefined,
27+
)
28+
} else if (key === 'style') {
29+
merged[key] = { ...(slotValue as object), ...(childValue as object) }
30+
} else if (
31+
typeof slotValue === 'function' &&
32+
typeof childValue === 'function'
33+
) {
34+
merged[key] = (...args: unknown[]) => {
35+
;(childValue as (...a: unknown[]) => void)(...args)
36+
;(slotValue as (...a: unknown[]) => void)(...args)
37+
}
38+
} else if (slotValue !== undefined) {
39+
merged[key] = slotValue
40+
}
41+
}
42+
43+
return merged
44+
}
45+
46+
export const Slot = forwardRef<HTMLElement, SlotProps>(function Slot(
47+
{ children, ...slotProps },
48+
ref,
49+
) {
50+
if (!isValidElement(children)) {
51+
if (process.env.NODE_ENV !== 'production') {
52+
console.warn('Slot: asChild requires a single valid React element child')
53+
}
54+
return null
55+
}
56+
57+
const child = children as ReactElement<Record<string, unknown>>
58+
const merged = mergeProps(slotProps, child.props)
59+
60+
return cloneElement(child, { ...merged, ref })
61+
})
62+
63+
Slot.displayName = 'Slot'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { HTMLAttributes, ReactNode } from 'react'
2+
3+
export type SlotProps = {
4+
children: ReactNode
5+
} & HTMLAttributes<HTMLElement>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Slot } from './Slot'
2+
export type { SlotProps } from './Slot.types'

packages/eds-core-react/src/components/next/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ export type { LinkProps, LinkVariant } from './Link'
3535

3636
export { Search } from './Search'
3737
export type { SearchProps } from './Search'
38+
39+
export { Slot } from './Slot'
40+
export type { SlotProps } from './Slot'

0 commit comments

Comments
 (0)