uikit-react-public 0.28.0 → 0.29.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/BreadcrumbsNew/BaseBreadcrumbsItem.d.ts +12 -0
- package/dist/components/BreadcrumbsNew/Breadcrumbs.d.ts +18 -0
- package/dist/components/BreadcrumbsNew/BreadcrumbsCurrent.d.ts +8 -0
- package/dist/components/BreadcrumbsNew/BreadcrumbsLink.d.ts +7 -0
- package/dist/components/BreadcrumbsNew/__tests__/Breadcrumbs.test.d.ts +1 -0
- package/dist/components/BreadcrumbsNew/index.d.ts +2 -0
- package/dist/components/Link/BaseLink.d.ts +3 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/index.js +5745 -5538
- package/lib/components/BreadcrumbsNew/BaseBreadcrumbsItem.tsx +69 -0
- package/lib/components/BreadcrumbsNew/Breadcrumbs.tsx +118 -0
- package/lib/components/BreadcrumbsNew/BreadcrumbsCurrent.tsx +54 -0
- package/lib/components/BreadcrumbsNew/BreadcrumbsLink.tsx +82 -0
- package/lib/components/BreadcrumbsNew/__tests__/Breadcrumbs.test.tsx +59 -0
- package/lib/components/BreadcrumbsNew/index.ts +8 -0
- package/lib/components/Link/BaseLink.tsx +40 -2
- package/lib/components/Link/__tests__/link.test.tsx +58 -1
- package/lib/components/StandaloneLink/StandaloneLink.tsx +40 -7
- package/lib/components/StandaloneLink/__tests__/StandaloneLink.test.tsx +44 -0
- package/lib/components/StandaloneLink/__tests__/__snapshots__/StandaloneLink.test.tsx.snap +2 -2
- package/lib/components/index.ts +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { LiHTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import Icon from '../Icon';
|
|
4
|
+
import { useTheme } from '../../theme';
|
|
5
|
+
import type { BreadcrumbsSize } from './Breadcrumbs';
|
|
6
|
+
|
|
7
|
+
export const NAME = 'ucl-uikit-breadcrumbs__item';
|
|
8
|
+
|
|
9
|
+
export interface BaseBreadcrumbsItemProps extends LiHTMLAttributes<HTMLLIElement> {
|
|
10
|
+
separator?: boolean;
|
|
11
|
+
back?: boolean;
|
|
12
|
+
size?: BreadcrumbsSize;
|
|
13
|
+
testId?: string;
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BaseBreadcrumbsItem = ({
|
|
18
|
+
separator = false,
|
|
19
|
+
back = false,
|
|
20
|
+
size = 'medium',
|
|
21
|
+
testId = NAME,
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
...props
|
|
25
|
+
}: BaseBreadcrumbsItemProps) => {
|
|
26
|
+
const [theme] = useTheme();
|
|
27
|
+
|
|
28
|
+
const style = cx(
|
|
29
|
+
NAME,
|
|
30
|
+
css`
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: ${theme.margin.m8};
|
|
34
|
+
min-width: 0;
|
|
35
|
+
`,
|
|
36
|
+
className
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const iconStyle = css`
|
|
40
|
+
flex: 0 0 auto;
|
|
41
|
+
color: ${theme.colour.icon.brandPrimary};
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<li
|
|
46
|
+
className={style}
|
|
47
|
+
data-testid={testId}
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
{back && (
|
|
51
|
+
<Icon.ArrowLeft
|
|
52
|
+
aria-hidden
|
|
53
|
+
className={iconStyle}
|
|
54
|
+
size={size === 'small' ? 16 : 20}
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
{children}
|
|
58
|
+
{separator && (
|
|
59
|
+
<Icon.ChevronRight
|
|
60
|
+
aria-hidden
|
|
61
|
+
className={iconStyle}
|
|
62
|
+
size={size === 'small' ? 12 : 16}
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default BaseBreadcrumbsItem;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
HTMLAttributes,
|
|
4
|
+
NamedExoticComponent,
|
|
5
|
+
ReactElement,
|
|
6
|
+
cloneElement,
|
|
7
|
+
isValidElement,
|
|
8
|
+
memo,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { css, cx } from '@emotion/css';
|
|
11
|
+
import { useTheme } from '../../theme';
|
|
12
|
+
import BaseBreadcrumbsItem, {
|
|
13
|
+
BaseBreadcrumbsItemProps,
|
|
14
|
+
} from './BaseBreadcrumbsItem';
|
|
15
|
+
import BreadcrumbsCurrent, {
|
|
16
|
+
BreadcrumbsCurrentProps,
|
|
17
|
+
} from './BreadcrumbsCurrent';
|
|
18
|
+
import BreadcrumbsLink, { BreadcrumbsLinkProps } from './BreadcrumbsLink';
|
|
19
|
+
|
|
20
|
+
export const NAME = 'ucl-uikit-breadcrumbs';
|
|
21
|
+
|
|
22
|
+
export type BreadcrumbsSize = 'small' | 'medium';
|
|
23
|
+
|
|
24
|
+
export interface BreadcrumbsProps extends HTMLAttributes<HTMLElement> {
|
|
25
|
+
size?: BreadcrumbsSize;
|
|
26
|
+
testId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type BreadcrumbChild = ReactElement<{
|
|
30
|
+
separator?: boolean;
|
|
31
|
+
back?: boolean;
|
|
32
|
+
size?: BreadcrumbsSize;
|
|
33
|
+
}>;
|
|
34
|
+
|
|
35
|
+
const Breadcrumbs = ({
|
|
36
|
+
size = 'medium',
|
|
37
|
+
testId = NAME,
|
|
38
|
+
className,
|
|
39
|
+
children,
|
|
40
|
+
'aria-label': ariaLabel = 'Breadcrumb',
|
|
41
|
+
...props
|
|
42
|
+
}: BreadcrumbsProps) => {
|
|
43
|
+
const [theme] = useTheme();
|
|
44
|
+
const items = Children.toArray(children).filter(isValidElement);
|
|
45
|
+
const parentItem = items[items.length - 2] as BreadcrumbChild | undefined;
|
|
46
|
+
|
|
47
|
+
const listStyle = css`
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: ${theme.margin.m8};
|
|
51
|
+
margin: 0;
|
|
52
|
+
padding: 0;
|
|
53
|
+
list-style: none;
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const desktopStyle = css`
|
|
57
|
+
display: none;
|
|
58
|
+
|
|
59
|
+
@media screen and (min-width: ${theme.breakpoints.tablet}px) {
|
|
60
|
+
display: flex;
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
const mobileStyle = css`
|
|
65
|
+
@media screen and (min-width: ${theme.breakpoints.tablet}px) {
|
|
66
|
+
display: none;
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const style = cx(NAME, className);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<nav
|
|
74
|
+
aria-label={ariaLabel}
|
|
75
|
+
className={style}
|
|
76
|
+
data-testid={testId}
|
|
77
|
+
{...props}
|
|
78
|
+
>
|
|
79
|
+
<ol className={cx(listStyle, desktopStyle)}>
|
|
80
|
+
{items.map((item, index) =>
|
|
81
|
+
cloneElement(item as BreadcrumbChild, {
|
|
82
|
+
separator: index < items.length - 1,
|
|
83
|
+
size,
|
|
84
|
+
})
|
|
85
|
+
)}
|
|
86
|
+
</ol>
|
|
87
|
+
{parentItem && (
|
|
88
|
+
<ol className={cx(listStyle, mobileStyle)}>
|
|
89
|
+
{cloneElement(parentItem, {
|
|
90
|
+
back: true,
|
|
91
|
+
separator: false,
|
|
92
|
+
size,
|
|
93
|
+
})}
|
|
94
|
+
</ol>
|
|
95
|
+
)}
|
|
96
|
+
</nav>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
interface BreadcrumbsComponent extends NamedExoticComponent<BreadcrumbsProps> {
|
|
101
|
+
BaseItem: typeof BaseBreadcrumbsItem;
|
|
102
|
+
Link: typeof BreadcrumbsLink;
|
|
103
|
+
Current: typeof BreadcrumbsCurrent;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const MemoBreadcrumbs: BreadcrumbsComponent = Object.assign(memo(Breadcrumbs), {
|
|
107
|
+
BaseItem: BaseBreadcrumbsItem,
|
|
108
|
+
Link: BreadcrumbsLink,
|
|
109
|
+
Current: BreadcrumbsCurrent,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export type {
|
|
113
|
+
BaseBreadcrumbsItemProps,
|
|
114
|
+
BreadcrumbsCurrentProps,
|
|
115
|
+
BreadcrumbsLinkProps,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export default MemoBreadcrumbs;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { HTMLAttributes } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import Text from '../Text';
|
|
4
|
+
import { useTheme } from '../../theme';
|
|
5
|
+
import BaseBreadcrumbsItem, {
|
|
6
|
+
BaseBreadcrumbsItemProps,
|
|
7
|
+
} from './BaseBreadcrumbsItem';
|
|
8
|
+
|
|
9
|
+
export const NAME = 'ucl-uikit-breadcrumbs__current';
|
|
10
|
+
|
|
11
|
+
export interface BreadcrumbsCurrentProps
|
|
12
|
+
extends
|
|
13
|
+
HTMLAttributes<HTMLSpanElement>,
|
|
14
|
+
Pick<BaseBreadcrumbsItemProps, 'separator' | 'size'> {
|
|
15
|
+
testId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BreadcrumbsCurrent = ({
|
|
19
|
+
separator,
|
|
20
|
+
size = 'medium',
|
|
21
|
+
testId = NAME,
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
...props
|
|
25
|
+
}: BreadcrumbsCurrentProps) => {
|
|
26
|
+
const [theme] = useTheme();
|
|
27
|
+
|
|
28
|
+
const style = cx(
|
|
29
|
+
NAME,
|
|
30
|
+
css`
|
|
31
|
+
color: ${theme.colour.text.secondary};
|
|
32
|
+
`,
|
|
33
|
+
className
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<BaseBreadcrumbsItem
|
|
38
|
+
separator={separator}
|
|
39
|
+
size={size}
|
|
40
|
+
>
|
|
41
|
+
<Text
|
|
42
|
+
aria-current='page'
|
|
43
|
+
className={style}
|
|
44
|
+
data-testid={testId}
|
|
45
|
+
level={size === 'small' ? 'sm-semibold' : 'md-semibold'}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</Text>
|
|
50
|
+
</BaseBreadcrumbsItem>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default BreadcrumbsCurrent;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Children, isValidElement, ReactNode } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import StandaloneLink, { StandaloneLinkProps } from '../StandaloneLink';
|
|
4
|
+
import { useTheme } from '../../theme';
|
|
5
|
+
import BaseBreadcrumbsItem, {
|
|
6
|
+
BaseBreadcrumbsItemProps,
|
|
7
|
+
} from './BaseBreadcrumbsItem';
|
|
8
|
+
|
|
9
|
+
export const NAME = 'ucl-uikit-breadcrumbs__link';
|
|
10
|
+
|
|
11
|
+
type BreadcrumbsLinkOwnProps = Pick<
|
|
12
|
+
BaseBreadcrumbsItemProps,
|
|
13
|
+
'separator' | 'back' | 'size'
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
export type BreadcrumbsLinkProps = BreadcrumbsLinkOwnProps &
|
|
17
|
+
Omit<StandaloneLinkProps, 'size' | 'variant'>;
|
|
18
|
+
|
|
19
|
+
const getTextContent = (node: ReactNode): string =>
|
|
20
|
+
Children.toArray(node)
|
|
21
|
+
.map((child) => {
|
|
22
|
+
if (typeof child === 'string' || typeof child === 'number') {
|
|
23
|
+
return String(child);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return isValidElement<{ children?: ReactNode }>(child)
|
|
27
|
+
? getTextContent(child.props.children)
|
|
28
|
+
: '';
|
|
29
|
+
})
|
|
30
|
+
.join('');
|
|
31
|
+
|
|
32
|
+
const BreadcrumbsLink = ({
|
|
33
|
+
separator,
|
|
34
|
+
back,
|
|
35
|
+
size = 'medium',
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
...props
|
|
39
|
+
}: BreadcrumbsLinkProps) => {
|
|
40
|
+
const [theme] = useTheme();
|
|
41
|
+
const label = getTextContent(children);
|
|
42
|
+
const semiboldWeight =
|
|
43
|
+
size === 'small'
|
|
44
|
+
? theme.typography.body.smSemibold.fontWeight
|
|
45
|
+
: theme.typography.body.mdSemibold.fontWeight;
|
|
46
|
+
|
|
47
|
+
const linkStyle = css`
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
align-items: flex-start;
|
|
50
|
+
gap: 0;
|
|
51
|
+
|
|
52
|
+
&::after {
|
|
53
|
+
content: attr(data-breadcrumb-label);
|
|
54
|
+
height: 0;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
visibility: hidden;
|
|
57
|
+
font-weight: ${semiboldWeight};
|
|
58
|
+
pointer-events: none;
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<BaseBreadcrumbsItem
|
|
64
|
+
separator={separator}
|
|
65
|
+
back={back}
|
|
66
|
+
size={size}
|
|
67
|
+
>
|
|
68
|
+
<StandaloneLink
|
|
69
|
+
className={cx(NAME, linkStyle, className)}
|
|
70
|
+
variant='secondary'
|
|
71
|
+
noVisited
|
|
72
|
+
size={size === 'small' ? 'small' : 'default'}
|
|
73
|
+
data-breadcrumb-label={label}
|
|
74
|
+
{...props}
|
|
75
|
+
>
|
|
76
|
+
{children}
|
|
77
|
+
</StandaloneLink>
|
|
78
|
+
</BaseBreadcrumbsItem>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default BreadcrumbsLink;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { render, screen, within } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
4
|
+
import Breadcrumbs from '../Breadcrumbs';
|
|
5
|
+
|
|
6
|
+
describe('BreadcrumbsNew', () => {
|
|
7
|
+
test('renders the full trail and a mobile parent link', () => {
|
|
8
|
+
render(
|
|
9
|
+
<ThemeContextProvider>
|
|
10
|
+
<Breadcrumbs>
|
|
11
|
+
<Breadcrumbs.Link asChild>
|
|
12
|
+
<a href='/'>Home</a>
|
|
13
|
+
</Breadcrumbs.Link>
|
|
14
|
+
<Breadcrumbs.Link asChild>
|
|
15
|
+
<a href='/parent'>Parent</a>
|
|
16
|
+
</Breadcrumbs.Link>
|
|
17
|
+
<Breadcrumbs.Current>Current page</Breadcrumbs.Current>
|
|
18
|
+
</Breadcrumbs>
|
|
19
|
+
</ThemeContextProvider>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const lists = screen.getAllByRole('list', { hidden: true });
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
within(lists[0]).getAllByRole('listitem', { hidden: true })
|
|
26
|
+
).toHaveLength(3);
|
|
27
|
+
expect(
|
|
28
|
+
within(lists[1]).getAllByRole('listitem', { hidden: true })
|
|
29
|
+
).toHaveLength(1);
|
|
30
|
+
expect(
|
|
31
|
+
within(lists[1]).getByRole('link', { hidden: true })
|
|
32
|
+
).toHaveAttribute('href', '/parent');
|
|
33
|
+
const parentLink = within(lists[0]).getByRole('link', {
|
|
34
|
+
name: 'Parent',
|
|
35
|
+
hidden: true,
|
|
36
|
+
});
|
|
37
|
+
expect(parentLink).toHaveClass('ucl-uikit-standalone-link');
|
|
38
|
+
expect(parentLink).toHaveAttribute('data-breadcrumb-label', 'Parent');
|
|
39
|
+
expect(screen.getByText('Current page')).toHaveAttribute(
|
|
40
|
+
'aria-current',
|
|
41
|
+
'page'
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('supports small breadcrumbs', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ThemeContextProvider>
|
|
48
|
+
<Breadcrumbs size='small'>
|
|
49
|
+
<Breadcrumbs.Link asChild>
|
|
50
|
+
<a href='/parent'>Parent</a>
|
|
51
|
+
</Breadcrumbs.Link>
|
|
52
|
+
<Breadcrumbs.Current>Current page</Breadcrumbs.Current>
|
|
53
|
+
</Breadcrumbs>
|
|
54
|
+
</ThemeContextProvider>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(screen.getByText('Current page')).toHaveClass('ucl-uikit-text');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import React, {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
2
4
|
ComponentPropsWithoutRef,
|
|
3
5
|
ElementType,
|
|
4
6
|
forwardRef,
|
|
7
|
+
isValidElement,
|
|
8
|
+
ReactElement,
|
|
5
9
|
useCallback,
|
|
6
10
|
} from 'react';
|
|
7
11
|
import { css, cx } from '@emotion/css';
|
|
@@ -30,13 +34,16 @@ type BaseLinkOwnProps = {
|
|
|
30
34
|
testId?: string;
|
|
31
35
|
className?: string;
|
|
32
36
|
children?: React.ReactNode;
|
|
37
|
+
/** @deprecated Prefer `asChild` with a routing link child. */
|
|
33
38
|
to?: string;
|
|
39
|
+
asChild?: boolean;
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
type PolymorphicRef<C extends ElementType> =
|
|
37
43
|
React.ComponentPropsWithRef<C>['ref'];
|
|
38
44
|
|
|
39
45
|
export type BaseLinkProps<C extends ElementType = 'a'> = BaseLinkOwnProps & {
|
|
46
|
+
/** @deprecated Prefer `asChild` with a custom link child. */
|
|
40
47
|
component?: C;
|
|
41
48
|
} & Omit<
|
|
42
49
|
ComponentPropsWithoutRef<C>,
|
|
@@ -58,6 +65,7 @@ const BaseLink = forwardRef(function BaseLinkInner(
|
|
|
58
65
|
disabled = false,
|
|
59
66
|
component,
|
|
60
67
|
to,
|
|
68
|
+
asChild = false,
|
|
61
69
|
...props
|
|
62
70
|
}: BaseLinkProps<ElementType>,
|
|
63
71
|
ref: React.Ref<Element>
|
|
@@ -110,6 +118,31 @@ const BaseLink = forwardRef(function BaseLinkInner(
|
|
|
110
118
|
className
|
|
111
119
|
);
|
|
112
120
|
|
|
121
|
+
if (asChild) {
|
|
122
|
+
const child = Children.only(children) as ReactElement<
|
|
123
|
+
Record<string, unknown> & {
|
|
124
|
+
className?: string;
|
|
125
|
+
onClick?: React.MouseEventHandler;
|
|
126
|
+
tabIndex?: number;
|
|
127
|
+
}
|
|
128
|
+
>;
|
|
129
|
+
|
|
130
|
+
if (isValidElement(child)) {
|
|
131
|
+
return cloneElement(child, {
|
|
132
|
+
...props,
|
|
133
|
+
ref,
|
|
134
|
+
className: cx(style, child.props.className),
|
|
135
|
+
'data-testid': testId,
|
|
136
|
+
'aria-disabled': disabled || undefined,
|
|
137
|
+
tabIndex: disabled ? -1 : (props.tabIndex ?? child.props.tabIndex),
|
|
138
|
+
...(to ? { to } : {}),
|
|
139
|
+
onClick: disabled
|
|
140
|
+
? disabledHandleClick
|
|
141
|
+
: (props.onClick ?? child.props.onClick),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
113
146
|
return (
|
|
114
147
|
<Component
|
|
115
148
|
ref={ref}
|
|
@@ -117,9 +150,14 @@ const BaseLink = forwardRef(function BaseLinkInner(
|
|
|
117
150
|
data-testid={testId}
|
|
118
151
|
aria-disabled={disabled || undefined}
|
|
119
152
|
tabIndex={disabled ? -1 : props.tabIndex}
|
|
120
|
-
href={isAnchor ? (disabled ? undefined : (href ?? to)) : undefined}
|
|
121
153
|
onClick={disabled ? disabledHandleClick : onClick}
|
|
122
|
-
{...(isAnchor
|
|
154
|
+
{...(isAnchor
|
|
155
|
+
? { href: disabled ? undefined : (href ?? to) }
|
|
156
|
+
: to
|
|
157
|
+
? { to }
|
|
158
|
+
: href
|
|
159
|
+
? { href }
|
|
160
|
+
: {})}
|
|
123
161
|
{...rest}
|
|
124
162
|
>
|
|
125
163
|
{children}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest';
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
import Link from '../Link';
|
|
4
4
|
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
@@ -57,4 +57,61 @@ describe('Link', () => {
|
|
|
57
57
|
expect(link.textContent).toBe('linktext');
|
|
58
58
|
expect(link.href).toBe('http://localhost:3000/testlink');
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
test('renders a custom link child with asChild', () => {
|
|
62
|
+
const handleClick = vi.fn((event: React.MouseEvent) =>
|
|
63
|
+
event.preventDefault()
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
render(
|
|
67
|
+
<ThemeContextProvider>
|
|
68
|
+
<Link asChild>
|
|
69
|
+
<a
|
|
70
|
+
href='/custom'
|
|
71
|
+
onClick={handleClick}
|
|
72
|
+
>
|
|
73
|
+
Custom link
|
|
74
|
+
</a>
|
|
75
|
+
</Link>
|
|
76
|
+
</ThemeContextProvider>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const link = screen.getByRole('link', { name: 'Custom link' });
|
|
80
|
+
link.click();
|
|
81
|
+
|
|
82
|
+
expect(link).toHaveAttribute('href', '/custom');
|
|
83
|
+
expect(link).toHaveAttribute('data-testid', 'ucl-uikit-link');
|
|
84
|
+
expect(handleClick).toHaveBeenCalledOnce();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('continues to support the deprecated component and to props', () => {
|
|
88
|
+
const CustomLink = ({
|
|
89
|
+
to,
|
|
90
|
+
children,
|
|
91
|
+
...props
|
|
92
|
+
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { to?: string }) => (
|
|
93
|
+
<a
|
|
94
|
+
href={to}
|
|
95
|
+
{...props}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</a>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
render(
|
|
102
|
+
<ThemeContextProvider>
|
|
103
|
+
<Link
|
|
104
|
+
component={CustomLink}
|
|
105
|
+
to='/legacy'
|
|
106
|
+
>
|
|
107
|
+
Legacy link
|
|
108
|
+
</Link>
|
|
109
|
+
</ThemeContextProvider>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(screen.getByRole('link', { name: 'Legacy link' })).toHaveAttribute(
|
|
113
|
+
'href',
|
|
114
|
+
'/legacy'
|
|
115
|
+
);
|
|
116
|
+
});
|
|
60
117
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import React, {
|
|
2
|
+
Children,
|
|
2
3
|
cloneElement,
|
|
3
4
|
ComponentPropsWithoutRef,
|
|
4
5
|
ElementType,
|
|
5
6
|
forwardRef,
|
|
7
|
+
isValidElement,
|
|
6
8
|
ReactElement,
|
|
7
9
|
} from 'react';
|
|
8
10
|
import { css, cx } from '@emotion/css';
|
|
@@ -41,6 +43,8 @@ const StandaloneLink = forwardRef(function StandaloneLinkInner(
|
|
|
41
43
|
testId = NAME,
|
|
42
44
|
className,
|
|
43
45
|
children,
|
|
46
|
+
asChild = false,
|
|
47
|
+
size = 'default',
|
|
44
48
|
...props
|
|
45
49
|
}: StandaloneLinkProps<ElementType>,
|
|
46
50
|
ref: React.Ref<Element>
|
|
@@ -52,11 +56,22 @@ const StandaloneLink = forwardRef(function StandaloneLinkInner(
|
|
|
52
56
|
}[variant];
|
|
53
57
|
|
|
54
58
|
const [theme] = useTheme();
|
|
59
|
+
const {
|
|
60
|
+
typography: {
|
|
61
|
+
body: { md, sm },
|
|
62
|
+
},
|
|
63
|
+
} = theme;
|
|
64
|
+
const typography = size === 'small' ? sm : md;
|
|
55
65
|
|
|
56
66
|
const baseStyle = css`
|
|
57
67
|
display: inline-flex;
|
|
58
68
|
align-items: center;
|
|
59
69
|
gap: 8px;
|
|
70
|
+
font-family: ${typography.fontFamily};
|
|
71
|
+
font-feature-settings: ${typography.fontSettings};
|
|
72
|
+
font-size: ${typography.fontSize}px;
|
|
73
|
+
font-weight: ${typography.fontWeight};
|
|
74
|
+
line-height: ${typography.lineHeight}%;
|
|
60
75
|
text-decoration: none;
|
|
61
76
|
`;
|
|
62
77
|
|
|
@@ -178,21 +193,39 @@ const StandaloneLink = forwardRef(function StandaloneLinkInner(
|
|
|
178
193
|
className
|
|
179
194
|
);
|
|
180
195
|
|
|
196
|
+
const renderContent = (content: React.ReactNode) => (
|
|
197
|
+
<>
|
|
198
|
+
{icon &&
|
|
199
|
+
iconPosition === 'left' &&
|
|
200
|
+
cloneElement(icon, { size: iconSize })}
|
|
201
|
+
{content}
|
|
202
|
+
{icon &&
|
|
203
|
+
iconPosition === 'right' &&
|
|
204
|
+
cloneElement(icon, { size: iconSize })}
|
|
205
|
+
</>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const content = asChild
|
|
209
|
+
? (() => {
|
|
210
|
+
const child = Children.only(children);
|
|
211
|
+
|
|
212
|
+
return isValidElement<{ children?: React.ReactNode }>(child)
|
|
213
|
+
? cloneElement(child, {}, renderContent(child.props.children))
|
|
214
|
+
: child;
|
|
215
|
+
})()
|
|
216
|
+
: renderContent(children);
|
|
217
|
+
|
|
181
218
|
return (
|
|
182
219
|
<BaseLink
|
|
183
220
|
ref={ref}
|
|
184
221
|
className={style}
|
|
185
222
|
testId={testId}
|
|
186
223
|
disabled={disabled}
|
|
224
|
+
asChild={asChild}
|
|
225
|
+
size={size}
|
|
187
226
|
{...props}
|
|
188
227
|
>
|
|
189
|
-
{
|
|
190
|
-
iconPosition === 'left' &&
|
|
191
|
-
cloneElement(icon, { size: iconSize })}
|
|
192
|
-
{children}
|
|
193
|
-
{icon &&
|
|
194
|
-
iconPosition === 'right' &&
|
|
195
|
-
cloneElement(icon, { size: iconSize })}
|
|
228
|
+
{content}
|
|
196
229
|
</BaseLink>
|
|
197
230
|
);
|
|
198
231
|
}) as unknown as StandaloneLinkComponent;
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
|
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
import StandaloneLink from '../StandaloneLink';
|
|
4
4
|
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
5
|
+
import Text from '../../Text';
|
|
5
6
|
|
|
6
7
|
describe('Link', () => {
|
|
7
8
|
// Snapshot tests
|
|
@@ -54,4 +55,47 @@ describe('Link', () => {
|
|
|
54
55
|
expect(link.textContent).toBe('linktext');
|
|
55
56
|
expect(link.href).toBe('http://localhost:3000/testlink');
|
|
56
57
|
});
|
|
58
|
+
|
|
59
|
+
test('renders a custom link child with asChild', () => {
|
|
60
|
+
render(
|
|
61
|
+
<ThemeContextProvider>
|
|
62
|
+
<StandaloneLink asChild>
|
|
63
|
+
<a href='/testlink'>linktext</a>
|
|
64
|
+
</StandaloneLink>
|
|
65
|
+
</ThemeContextProvider>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const link = screen.getByRole('link');
|
|
69
|
+
|
|
70
|
+
expect(link).toHaveAttribute('href', '/testlink');
|
|
71
|
+
expect(link).toHaveClass('ucl-uikit-standalone-link');
|
|
72
|
+
expect(link).toHaveTextContent('linktext');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test.each([
|
|
76
|
+
{ size: 'default' as const, textLevel: 'md' as const },
|
|
77
|
+
{ size: 'small' as const, textLevel: 'sm' as const },
|
|
78
|
+
])('uses the same typography as $textLevel Text', ({ size, textLevel }) => {
|
|
79
|
+
render(
|
|
80
|
+
<ThemeContextProvider>
|
|
81
|
+
<StandaloneLink
|
|
82
|
+
variant='secondary'
|
|
83
|
+
size={size}
|
|
84
|
+
href='/testlink'
|
|
85
|
+
>
|
|
86
|
+
linktext
|
|
87
|
+
</StandaloneLink>
|
|
88
|
+
<Text level={textLevel}>text</Text>
|
|
89
|
+
</ThemeContextProvider>
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const linkStyle = getComputedStyle(screen.getByRole('link'));
|
|
93
|
+
const textStyle = getComputedStyle(screen.getByText('text'));
|
|
94
|
+
|
|
95
|
+
expect(linkStyle.fontFamily).toBe(textStyle.fontFamily);
|
|
96
|
+
expect(linkStyle.fontFeatureSettings).toBe(textStyle.fontFeatureSettings);
|
|
97
|
+
expect(linkStyle.fontSize).toBe(textStyle.fontSize);
|
|
98
|
+
expect(linkStyle.fontWeight).toBe(textStyle.fontWeight);
|
|
99
|
+
expect(linkStyle.lineHeight).toBe(textStyle.lineHeight);
|
|
100
|
+
});
|
|
57
101
|
});
|