uikit-react-public 0.24.5 → 0.25.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.
Files changed (43) hide show
  1. package/dist/components/MenuNew/ActionMenuItem.d.ts +7 -0
  2. package/dist/components/MenuNew/Menu.d.ts +16 -15
  3. package/dist/components/MenuNew/MenuBaseItem.d.ts +7 -0
  4. package/dist/components/MenuNew/MenuDivider.d.ts +6 -0
  5. package/dist/components/MenuNew/MenuItemText.d.ts +9 -0
  6. package/dist/components/MenuNew/PrimaryMenuItem.d.ts +9 -0
  7. package/dist/components/MenuNew/SecondaryMenuItem.d.ts +8 -0
  8. package/dist/components/MenuNew/index.d.ts +1 -5
  9. package/dist/components/Text/Text.d.ts +10 -0
  10. package/dist/components/Text/index.d.ts +2 -0
  11. package/dist/components/index.d.ts +2 -4
  12. package/dist/index.js +5575 -5597
  13. package/lib/components/MenuNew/ActionMenuItem.tsx +31 -0
  14. package/lib/components/MenuNew/Menu.tsx +41 -44
  15. package/lib/components/MenuNew/MenuBaseItem.tsx +72 -0
  16. package/lib/components/MenuNew/MenuDivider.tsx +25 -0
  17. package/lib/components/MenuNew/MenuItemText.tsx +58 -0
  18. package/lib/components/MenuNew/PrimaryMenuItem.tsx +60 -0
  19. package/lib/components/MenuNew/SecondaryMenuItem.tsx +67 -0
  20. package/lib/components/MenuNew/__tests__/Menu.test.tsx +138 -0
  21. package/lib/components/MenuNew/index.ts +8 -7
  22. package/lib/components/Text/Text.tsx +171 -0
  23. package/lib/components/Text/index.ts +2 -0
  24. package/lib/components/index.ts +2 -7
  25. package/package.json +1 -1
  26. package/dist/components/MenuNew/Menu.context.d.ts +0 -14
  27. package/dist/components/MenuNew/MenuContent.d.ts +0 -9
  28. package/dist/components/MenuNew/MenuItem.d.ts +0 -10
  29. package/dist/components/MenuNew/MenuSection.d.ts +0 -7
  30. package/dist/components/MenuNew/trigger/ButtonMenuTrigger.d.ts +0 -8
  31. package/dist/components/MenuNew/trigger/IconMenuTrigger.d.ts +0 -8
  32. package/dist/components/SimpleMenu/SimpleMenu.d.ts +0 -7
  33. package/dist/components/SimpleMenu/index.d.ts +0 -2
  34. package/lib/components/MenuNew/Menu.context.tsx +0 -149
  35. package/lib/components/MenuNew/MenuContent.tsx +0 -140
  36. package/lib/components/MenuNew/MenuItem.tsx +0 -101
  37. package/lib/components/MenuNew/MenuSection.tsx +0 -47
  38. package/lib/components/MenuNew/trigger/ButtonMenuTrigger.tsx +0 -42
  39. package/lib/components/MenuNew/trigger/IconMenuTrigger.tsx +0 -40
  40. package/lib/components/SimpleMenu/SimpleMenu.tsx +0 -40
  41. package/lib/components/SimpleMenu/__tests__/SimpleMenu.test.tsx +0 -38
  42. package/lib/components/SimpleMenu/index.ts +0 -2
  43. /package/dist/components/{SimpleMenu/__tests__/SimpleMenu.test.d.ts → MenuNew/__tests__/Menu.test.d.ts} +0 -0
@@ -0,0 +1,31 @@
1
+ import { cx } from '@emotion/css';
2
+ import { MenuBaseItemProps } from './MenuBaseItem';
3
+ import MenuBaseItem from './MenuBaseItem';
4
+ import MenuItemText from './MenuItemText';
5
+
6
+ export const NAME = 'ucl-uikit-menu__action-item';
7
+
8
+ export interface ActionMenuItemProps extends MenuBaseItemProps {
9
+ asChild?: boolean;
10
+ }
11
+
12
+ const ActionMenuItem = ({
13
+ className,
14
+ children,
15
+ asChild,
16
+ ...props
17
+ }: ActionMenuItemProps) => {
18
+ const style = cx(NAME, className);
19
+
20
+ return (
21
+ <MenuBaseItem
22
+ className={style}
23
+ {...props}
24
+ iconPosition='right'
25
+ >
26
+ <MenuItemText asChild={asChild}>{children}</MenuItemText>
27
+ </MenuBaseItem>
28
+ );
29
+ };
30
+
31
+ export default ActionMenuItem;
@@ -1,44 +1,46 @@
1
- import { HTMLAttributes, memo } from 'react';
1
+ import { HTMLAttributes, NamedExoticComponent, memo } from 'react';
2
2
  import { css, cx } from '@emotion/css';
3
+ import MenuBaseItem, { MenuBaseItemProps } from './MenuBaseItem';
4
+ import MenuDivider, { MenuDividerProps } from './MenuDivider';
5
+ import ActionMenuItem, { ActionMenuItemProps } from './ActionMenuItem';
6
+ import PrimaryMenuItem, { PrimaryMenuItemProps } from './PrimaryMenuItem';
7
+ import SecondaryMenuItem, { SecondaryMenuItemProps } from './SecondaryMenuItem';
3
8
  import { useTheme } from '../../theme';
4
- import MenuProvider from './Menu.context';
5
- import MenuContent from './MenuContent';
6
- import MenuItem from './MenuItem';
7
- import MenuSection from './MenuSection';
8
- import ButtonMenuTrigger, { ButtonMenuTriggerProps } from './trigger/ButtonMenuTrigger';
9
9
 
10
10
  export const NAME = 'ucl-uikit-menu';
11
11
 
12
12
  export interface MenuProps extends HTMLAttributes<HTMLDivElement> {
13
- defaultOpen?: boolean;
14
- title?: string;
15
- menuId?: string;
16
- position?: 'left' | 'right';
17
- trigger?: React.ComponentType<ButtonMenuTriggerProps>;
18
13
  testId?: string;
14
+ border?: boolean;
19
15
  }
20
16
 
17
+ export type {
18
+ MenuDividerProps,
19
+ MenuBaseItemProps,
20
+ PrimaryMenuItemProps,
21
+ SecondaryMenuItemProps,
22
+ ActionMenuItemProps,
23
+ };
24
+
21
25
  const Menu = ({
22
- defaultOpen = false,
23
- title,
24
- position = 'right',
25
- trigger,
26
- menuId = NAME,
27
26
  testId = NAME,
28
- children,
29
27
  className,
28
+ children,
29
+ border = false,
30
30
  ...props
31
31
  }: MenuProps) => {
32
32
  const [theme] = useTheme();
33
-
34
33
  const baseStyle = css`
35
- position: relative;
36
- font-family: ${theme.font.family.primary};
37
- `;
34
+ padding: ${theme.padding.p16} ${theme.padding.p24};
38
35
 
39
- const style = cx(NAME, baseStyle, className);
40
-
41
- const TriggerComp = trigger ?? ButtonMenuTrigger;
36
+ @media screen and (min-width: ${theme.breakpoints.tablet}px) {
37
+ padding: ${theme.padding.p48} ${theme.padding.p64};
38
+ }
39
+ `;
40
+ const borderStyle = css`
41
+ border: 1px solid ${theme.colour.border.subtle};
42
+ `;
43
+ const style = cx(baseStyle, border && borderStyle, className);
42
44
 
43
45
  return (
44
46
  <nav
@@ -46,30 +48,25 @@ const Menu = ({
46
48
  data-testid={testId}
47
49
  {...props}
48
50
  >
49
- <MenuProvider defaultOpen={defaultOpen}>
50
- <TriggerComp aria-controls={menuId} />
51
- <MenuContent
52
- id={menuId}
53
- title={title}
54
- position={position}
55
- >
56
- {children}
57
- </MenuContent>
58
- </MenuProvider>
51
+ {children}
59
52
  </nav>
60
53
  );
61
54
  };
62
55
 
63
- export interface IMenuSubComponents {
64
- Section: typeof MenuSection;
65
- Item: typeof MenuItem;
56
+ interface MenuComponent extends NamedExoticComponent<MenuProps> {
57
+ Divider: typeof MenuDivider;
58
+ BaseItem: typeof MenuBaseItem;
59
+ PrimaryItem: typeof PrimaryMenuItem;
60
+ SecondaryItem: typeof SecondaryMenuItem;
61
+ ActionItem: typeof ActionMenuItem;
66
62
  }
67
63
 
68
- const MemoMenu = memo(Menu);
69
-
70
- const MenuWithSubComponents = MemoMenu as typeof MemoMenu & IMenuSubComponents;
71
-
72
- MenuWithSubComponents.Section = MenuSection;
73
- MenuWithSubComponents.Item = MenuItem;
64
+ const MemoMenu: MenuComponent = Object.assign(memo(Menu), {
65
+ Divider: MenuDivider,
66
+ BaseItem: MenuBaseItem,
67
+ PrimaryItem: PrimaryMenuItem,
68
+ SecondaryItem: SecondaryMenuItem,
69
+ ActionItem: ActionMenuItem,
70
+ });
74
71
 
75
- export default MenuWithSubComponents;
72
+ export default MemoMenu;
@@ -0,0 +1,72 @@
1
+ import { HTMLAttributes, KeyboardEvent, ReactNode } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import { useTheme } from '../../theme';
4
+
5
+ export interface MenuBaseItemProps extends HTMLAttributes<HTMLDivElement> {
6
+ icon?: ReactNode;
7
+ iconPosition?: 'left' | 'right';
8
+ }
9
+
10
+ const MenuBaseItem = ({
11
+ icon,
12
+ iconPosition = 'left',
13
+ className,
14
+ children,
15
+ onClick,
16
+ onKeyDown,
17
+ role,
18
+ tabIndex,
19
+ ...props
20
+ }: MenuBaseItemProps) => {
21
+ const [theme] = useTheme();
22
+ const isInteractive = typeof onClick === 'function';
23
+
24
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
25
+ onKeyDown?.(event);
26
+
27
+ if (event.defaultPrevented || !isInteractive) {
28
+ return;
29
+ }
30
+
31
+ if (event.key === 'Enter') {
32
+ event.currentTarget.click();
33
+ }
34
+
35
+ if (event.key === ' ') {
36
+ event.preventDefault();
37
+ event.currentTarget.click();
38
+ }
39
+ };
40
+
41
+ const baseStyle = css`
42
+ width: 360px;
43
+ box-sizing: border-box;
44
+ min-height: 48px;
45
+ display: flex;
46
+ align-items: center;
47
+ gap: ${theme.margin.m32};
48
+ cursor: pointer;
49
+
50
+ &:hover .ucl-uikit-menu__item-text {
51
+ text-decoration: underline;
52
+ }
53
+ `;
54
+ const style = cx(baseStyle, className);
55
+
56
+ return (
57
+ <div
58
+ className={style}
59
+ onClick={onClick}
60
+ onKeyDown={handleKeyDown}
61
+ role={role ?? (isInteractive ? 'button' : undefined)}
62
+ tabIndex={tabIndex ?? (isInteractive ? 0 : undefined)}
63
+ {...props}
64
+ >
65
+ {iconPosition === 'left' && icon}
66
+ {children}
67
+ {iconPosition === 'right' && icon}
68
+ </div>
69
+ );
70
+ };
71
+
72
+ export default MenuBaseItem;
@@ -0,0 +1,25 @@
1
+ import { HTMLAttributes } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import { useTheme } from '../../theme';
4
+
5
+ export const NAME = 'ucl-uikit-menu__divider';
6
+
7
+ export interface MenuDividerProps extends HTMLAttributes<HTMLHRElement> {}
8
+
9
+ const MenuDivider = ({ className, ...props }: MenuDividerProps) => {
10
+ const [theme] = useTheme();
11
+ const baseStyle = css`
12
+ border: 0;
13
+ border-top: 1px solid ${theme.colour.border.subtle};
14
+ `;
15
+ const style = cx(NAME, baseStyle, className);
16
+
17
+ return (
18
+ <hr
19
+ className={style}
20
+ {...props}
21
+ />
22
+ );
23
+ };
24
+
25
+ export default MenuDivider;
@@ -0,0 +1,58 @@
1
+ import { css, cx } from '@emotion/css';
2
+ import {
3
+ Children,
4
+ ReactElement,
5
+ ReactNode,
6
+ cloneElement,
7
+ isValidElement,
8
+ } from 'react';
9
+ import Text from '../Text';
10
+
11
+ export const NAME = 'ucl-uikit-menu__item-text';
12
+
13
+ export interface MenuItemTextProps {
14
+ asChild?: boolean;
15
+ className?: string;
16
+ children?: ReactNode;
17
+ }
18
+
19
+ const MenuItemText = ({
20
+ asChild = false,
21
+ className,
22
+ children,
23
+ }: MenuItemTextProps) => {
24
+ const asChildResetStyle = css`
25
+ font: inherit;
26
+ color: inherit;
27
+ text-decoration: none;
28
+
29
+ &:hover,
30
+ &:visited,
31
+ &:active,
32
+ &:focus {
33
+ color: inherit;
34
+ text-decoration: inherit;
35
+ }
36
+ `;
37
+ const style = cx(NAME, className);
38
+
39
+ if (asChild) {
40
+ const child = Children.only(children) as ReactElement<{
41
+ className?: string;
42
+ }>;
43
+
44
+ if (isValidElement(child)) {
45
+ return (
46
+ <Text className={style}>
47
+ {cloneElement(child, {
48
+ className: cx(asChildResetStyle, child.props.className),
49
+ })}
50
+ </Text>
51
+ );
52
+ }
53
+ }
54
+
55
+ return <Text className={style}>{children}</Text>;
56
+ };
57
+
58
+ export default MenuItemText;
@@ -0,0 +1,60 @@
1
+ import { css, cx } from '@emotion/css';
2
+ import { ReactNode } from 'react';
3
+ import { MenuBaseItemProps } from './MenuBaseItem';
4
+ import MenuBaseItem from './MenuBaseItem';
5
+ import MenuItemText from './MenuItemText';
6
+ import { useTheme } from '../../theme';
7
+
8
+ export const NAME = 'ucl-uikit-menu__primary-item';
9
+
10
+ export interface PrimaryMenuItemProps extends Omit<
11
+ MenuBaseItemProps,
12
+ 'iconPosition'
13
+ > {
14
+ asChild?: boolean;
15
+ trailingContent?: ReactNode;
16
+ }
17
+
18
+ const PrimaryMenuItem = ({
19
+ className,
20
+ children,
21
+ asChild,
22
+ trailingContent,
23
+ ...props
24
+ }: PrimaryMenuItemProps) => {
25
+ const [theme] = useTheme();
26
+
27
+ const baseStyle = css`
28
+ color: ${theme.colour.text.default};
29
+ `;
30
+
31
+ const style = cx(NAME, baseStyle, className);
32
+
33
+ const textStyle = css``;
34
+
35
+ const contentStyle = css`
36
+ display: inline-flex;
37
+ align-items: center;
38
+ gap: ${theme.margin.m16};
39
+ `;
40
+
41
+ return (
42
+ <MenuBaseItem
43
+ className={style}
44
+ {...props}
45
+ iconPosition='left'
46
+ >
47
+ <span className={contentStyle}>
48
+ <MenuItemText
49
+ asChild={asChild}
50
+ className={textStyle}
51
+ >
52
+ {children}
53
+ </MenuItemText>
54
+ {trailingContent}
55
+ </span>
56
+ </MenuBaseItem>
57
+ );
58
+ };
59
+
60
+ export default PrimaryMenuItem;
@@ -0,0 +1,67 @@
1
+ import { css, cx } from '@emotion/css';
2
+ import { MenuBaseItemProps } from './MenuBaseItem';
3
+ import MenuBaseItem from './MenuBaseItem';
4
+ import MenuItemText from './MenuItemText';
5
+ import { useTheme } from '../../theme';
6
+ import Icon from '../Icon';
7
+
8
+ export const NAME = 'ucl-uikit-menu__secondary-item';
9
+
10
+ export interface SecondaryMenuItemProps extends Omit<
11
+ MenuBaseItemProps,
12
+ 'iconPosition'
13
+ > {
14
+ asChild?: boolean;
15
+ externalLink?: boolean;
16
+ }
17
+
18
+ const SecondaryMenuItem = ({
19
+ className,
20
+ children,
21
+ asChild,
22
+ externalLink,
23
+ ...props
24
+ }: SecondaryMenuItemProps) => {
25
+ const [theme] = useTheme();
26
+
27
+ const baseStyle = css`
28
+ color: ${theme.colour.text.secondary};
29
+ `;
30
+
31
+ const style = cx(NAME, baseStyle, className);
32
+
33
+ const textStyle = css`
34
+ color: ${theme.colour.text.secondary};
35
+ `;
36
+
37
+ const contentStyle = css`
38
+ display: inline-flex;
39
+ align-items: center;
40
+ gap: ${theme.margin.m8};
41
+ `;
42
+
43
+ return (
44
+ <MenuBaseItem
45
+ className={style}
46
+ {...props}
47
+ >
48
+ <span className={contentStyle}>
49
+ <MenuItemText
50
+ asChild={asChild}
51
+ className={textStyle}
52
+ >
53
+ {children}
54
+ </MenuItemText>
55
+ {externalLink && (
56
+ <Icon.ExternalLink
57
+ size={16}
58
+ aria-hidden='true'
59
+ focusable='false'
60
+ />
61
+ )}
62
+ </span>
63
+ </MenuBaseItem>
64
+ );
65
+ };
66
+
67
+ export default SecondaryMenuItem;
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import Menu from '../Menu';
4
+ import { ThemeContextProvider } from '../../../theme/useTheme';
5
+
6
+ const wrap = (ui: React.ReactElement) =>
7
+ render(<ThemeContextProvider>{ui}</ThemeContextProvider>);
8
+
9
+ describe('Menu', () => {
10
+ test('renders menu root with default test id', () => {
11
+ wrap(<Menu>Content</Menu>);
12
+
13
+ expect(screen.getByTestId('ucl-uikit-menu')).toBeInTheDocument();
14
+ });
15
+
16
+ test('has no border by default', () => {
17
+ wrap(<Menu>Content</Menu>);
18
+
19
+ const menu = screen.getByTestId('ucl-uikit-menu');
20
+ const computedStyles = window.getComputedStyle(menu);
21
+ expect(computedStyles.borderTopStyle).toBe('');
22
+ });
23
+
24
+ test('allows border to be enabled with prop override', () => {
25
+ wrap(<Menu border>Content</Menu>);
26
+
27
+ const menu = screen.getByTestId('ucl-uikit-menu');
28
+ const computedStyles = window.getComputedStyle(menu);
29
+ expect(computedStyles.borderTopStyle).toBe('solid');
30
+ });
31
+
32
+ test('supports click-handler items without asChild link component', () => {
33
+ const onClick = vi.fn();
34
+
35
+ wrap(
36
+ <Menu>
37
+ <Menu.PrimaryItem onClick={onClick}>Home</Menu.PrimaryItem>
38
+ </Menu>
39
+ );
40
+
41
+ fireEvent.click(screen.getByText('Home'));
42
+ expect(onClick).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ test('click-handler items are keyboard accessible', () => {
46
+ const onClick = vi.fn();
47
+
48
+ wrap(
49
+ <Menu>
50
+ <Menu.PrimaryItem onClick={onClick}>Home</Menu.PrimaryItem>
51
+ </Menu>
52
+ );
53
+
54
+ const item = screen
55
+ .getByText('Home')
56
+ .closest('.ucl-uikit-menu__primary-item');
57
+
58
+ expect(item).toHaveAttribute('role', 'button');
59
+ expect(item).toHaveAttribute('tabindex', '0');
60
+
61
+ fireEvent.keyDown(item as Element, { key: 'Enter' });
62
+ fireEvent.keyDown(item as Element, { key: ' ' });
63
+ expect(onClick).toHaveBeenCalledTimes(2);
64
+ });
65
+
66
+ test('renders asChild link content for primary item', () => {
67
+ wrap(
68
+ <Menu>
69
+ <Menu.PrimaryItem asChild>
70
+ <a href='/home'>Home</a>
71
+ </Menu.PrimaryItem>
72
+ </Menu>
73
+ );
74
+
75
+ const link = screen.getByRole('link', { name: 'Home' });
76
+ expect(link).toBeInTheDocument();
77
+ expect(link.closest('.ucl-uikit-menu__item-text')).toBeInTheDocument();
78
+ });
79
+
80
+ test('primary item enforces icon on the left', () => {
81
+ const { getByText } = wrap(
82
+ <Menu>
83
+ <Menu.PrimaryItem icon={<span data-testid='left-icon'>I</span>}>
84
+ Home
85
+ </Menu.PrimaryItem>
86
+ </Menu>
87
+ );
88
+
89
+ const item = getByText('Home').closest('.ucl-uikit-menu__primary-item');
90
+ expect(item?.firstChild).toHaveAttribute('data-testid', 'left-icon');
91
+ });
92
+
93
+ test('action item enforces icon on the right', () => {
94
+ const { getByText } = wrap(
95
+ <Menu>
96
+ <Menu.ActionItem icon={<span data-testid='right-icon'>I</span>}>
97
+ Sign out
98
+ </Menu.ActionItem>
99
+ </Menu>
100
+ );
101
+
102
+ const item = getByText('Sign out').closest('.ucl-uikit-menu__action-item');
103
+ expect(item?.lastChild).toHaveAttribute('data-testid', 'right-icon');
104
+ });
105
+
106
+ test('secondary externalLink item renders external icon', () => {
107
+ const { container } = wrap(
108
+ <Menu>
109
+ <Menu.SecondaryItem externalLink>
110
+ Accessibility statement
111
+ </Menu.SecondaryItem>
112
+ </Menu>
113
+ );
114
+
115
+ const externalItem = screen
116
+ .getByText('Accessibility statement')
117
+ .closest('.ucl-uikit-menu__secondary-item');
118
+
119
+ expect(externalItem).toBeInTheDocument();
120
+ expect(
121
+ container.querySelector('.ucl-uikit-menu__secondary-item svg')
122
+ ).toBeTruthy();
123
+ });
124
+
125
+ test('secondary externalLink icon is hidden from assistive tech', () => {
126
+ const { container } = wrap(
127
+ <Menu>
128
+ <Menu.SecondaryItem externalLink>
129
+ Accessibility statement
130
+ </Menu.SecondaryItem>
131
+ </Menu>
132
+ );
133
+
134
+ const icon = container.querySelector('.ucl-uikit-menu__secondary-item svg');
135
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
136
+ expect(icon).toHaveAttribute('focusable', 'false');
137
+ });
138
+ });
@@ -1,8 +1,9 @@
1
1
  export { default } from './Menu';
2
- export type { MenuProps } from './Menu';
3
-
4
- export { default as MenuContent } from './MenuContent';
5
- export type { MenuContentProps } from './MenuContent';
6
-
7
- export { default as ButtonMenuTrigger } from './trigger/ButtonMenuTrigger';
8
- export type { ButtonMenuTriggerProps } from './trigger/ButtonMenuTrigger';
2
+ export type {
3
+ MenuProps,
4
+ MenuDividerProps,
5
+ MenuBaseItemProps,
6
+ PrimaryMenuItemProps,
7
+ SecondaryMenuItemProps,
8
+ ActionMenuItemProps,
9
+ } from './Menu';