uikit-react-public 0.28.0 → 0.29.1

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,67 @@
1
+ import { css, cx } from '@emotion/css';
2
+ import Link, { LinkProps } from '../Link';
3
+ import { useTheme } from '../../theme';
4
+ import BaseBreadcrumbsItem, {
5
+ BaseBreadcrumbsItemProps,
6
+ } from './BaseBreadcrumbsItem';
7
+
8
+ export const NAME = 'ucl-uikit-breadcrumbs__link';
9
+
10
+ type BreadcrumbsLinkOwnProps = Pick<
11
+ BaseBreadcrumbsItemProps,
12
+ 'separator' | 'back' | 'size'
13
+ >;
14
+
15
+ export type BreadcrumbsLinkProps = BreadcrumbsLinkOwnProps &
16
+ Omit<LinkProps, 'size'>;
17
+
18
+ const BreadcrumbsLink = ({
19
+ separator,
20
+ back,
21
+ size = 'medium',
22
+ className,
23
+ children,
24
+ ...props
25
+ }: BreadcrumbsLinkProps) => {
26
+ const [theme] = useTheme();
27
+
28
+ const linkStyle = cx(
29
+ NAME,
30
+ css`
31
+ color: ${theme.colour.link.primary};
32
+ text-decoration: none;
33
+
34
+ &:visited {
35
+ color: ${theme.colour.link.primary};
36
+ }
37
+
38
+ &:hover {
39
+ text-decoration: underline;
40
+ }
41
+
42
+ &:active {
43
+ color: ${theme.colour.link.primaryHover};
44
+ }
45
+ `,
46
+ className
47
+ );
48
+
49
+ return (
50
+ <BaseBreadcrumbsItem
51
+ separator={separator}
52
+ back={back}
53
+ size={size}
54
+ >
55
+ <Link
56
+ className={linkStyle}
57
+ noVisited
58
+ size={size === 'small' ? 'small' : 'default'}
59
+ {...props}
60
+ >
61
+ {children}
62
+ </Link>
63
+ </BaseBreadcrumbsItem>
64
+ );
65
+ };
66
+
67
+ export default BreadcrumbsLink;
@@ -0,0 +1,53 @@
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
+ expect(screen.getByText('Current page')).toHaveAttribute(
34
+ 'aria-current',
35
+ 'page'
36
+ );
37
+ });
38
+
39
+ test('supports small breadcrumbs', () => {
40
+ render(
41
+ <ThemeContextProvider>
42
+ <Breadcrumbs size='small'>
43
+ <Breadcrumbs.Link asChild>
44
+ <a href='/parent'>Parent</a>
45
+ </Breadcrumbs.Link>
46
+ <Breadcrumbs.Current>Current page</Breadcrumbs.Current>
47
+ </Breadcrumbs>
48
+ </ThemeContextProvider>
49
+ );
50
+
51
+ expect(screen.getByText('Current page')).toHaveClass('ucl-uikit-text');
52
+ });
53
+ });
@@ -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
  });
@@ -79,6 +79,15 @@ export type { AppHeaderProps } from './AppHeader';
79
79
  export { default as Breadcrumbs } from './Breadcrumbs';
80
80
  export type { BreadcrumbsProps } from './Breadcrumbs';
81
81
 
82
+ export { default as BreadcrumbsNew } from './BreadcrumbsNew';
83
+ export type {
84
+ BaseBreadcrumbsItemProps,
85
+ BreadcrumbsCurrentProps,
86
+ BreadcrumbsLinkProps,
87
+ BreadcrumbsProps as BreadcrumbsNewProps,
88
+ BreadcrumbsSize,
89
+ } from './BreadcrumbsNew';
90
+
82
91
  export { default as Checkbox, LabelledCheckbox } from './Checkbox';
83
92
  export type { CheckboxProps, LabelledCheckboxProps } from './Checkbox';
84
93
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "uikit-react-public",
3
3
  "private": false,
4
4
  "license": "UNLICENSED",
5
- "version": "0.28.0",
5
+ "version": "0.29.1",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",