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.
@@ -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
+ });
@@ -0,0 +1,8 @@
1
+ export { default } from './Breadcrumbs';
2
+ export type {
3
+ BaseBreadcrumbsItemProps,
4
+ BreadcrumbsCurrentProps,
5
+ BreadcrumbsLinkProps,
6
+ BreadcrumbsProps,
7
+ BreadcrumbsSize,
8
+ } from './Breadcrumbs';
@@ -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 ? {} : to ? { to } : href ? { href } : {})}
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
- {icon &&
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
  });