uikit-react-public 0.25.0 → 0.25.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.
Files changed (38) hide show
  1. package/dist/components/DropdownMenu/DropdownMenu.d.ts +3 -2
  2. package/dist/components/DropdownMenu/DropdownMenuTrigger.d.ts +15 -0
  3. package/dist/components/MenuNew/ActionMenuButton.d.ts +6 -0
  4. package/dist/components/MenuNew/Menu.d.ts +10 -3
  5. package/dist/components/MenuNew/MenuAccordion/MenuAccordion.d.ts +15 -0
  6. package/dist/components/MenuNew/MenuAccordion/MenuAccordionChevron.d.ts +6 -0
  7. package/dist/components/MenuNew/MenuAccordion/MenuAccordionItem.d.ts +8 -0
  8. package/dist/components/MenuNew/MenuAccordion/index.d.ts +2 -0
  9. package/dist/components/MenuNew/MenuBaseItem.d.ts +4 -1
  10. package/dist/components/MenuNew/MenuHeading.d.ts +7 -0
  11. package/dist/components/MenuNew/MenuSection.d.ts +7 -0
  12. package/dist/components/MenuNew/PrimaryMenuItem.d.ts +2 -2
  13. package/dist/components/MenuNew/index.d.ts +1 -1
  14. package/dist/index.js +5101 -4838
  15. package/lib/components/Button/Button.tsx +4 -1
  16. package/lib/components/Button/__tests__/__snapshots__/Button.test.tsx.snap +9 -9
  17. package/lib/components/DropdownMenu/DropdownMenu.tsx +12 -24
  18. package/lib/components/DropdownMenu/DropdownMenuTrigger.tsx +74 -0
  19. package/lib/components/DropdownMenu/__tests__/DropdownMenu.test.tsx +58 -1
  20. package/lib/components/MenuNew/ActionMenuButton.tsx +39 -0
  21. package/lib/components/MenuNew/Menu.tsx +34 -7
  22. package/lib/components/MenuNew/MenuAccordion/MenuAccordion.tsx +104 -0
  23. package/lib/components/MenuNew/MenuAccordion/MenuAccordionChevron.tsx +30 -0
  24. package/lib/components/MenuNew/MenuAccordion/MenuAccordionItem.tsx +47 -0
  25. package/lib/components/MenuNew/MenuAccordion/index.ts +5 -0
  26. package/lib/components/MenuNew/MenuBaseItem.tsx +62 -5
  27. package/lib/components/MenuNew/MenuDivider.tsx +3 -1
  28. package/lib/components/MenuNew/MenuHeading.tsx +56 -0
  29. package/lib/components/MenuNew/MenuItemText.tsx +6 -0
  30. package/lib/components/MenuNew/MenuSection.tsx +36 -0
  31. package/lib/components/MenuNew/PrimaryMenuItem.tsx +9 -4
  32. package/lib/components/MenuNew/SecondaryMenuItem.tsx +9 -1
  33. package/lib/components/MenuNew/__tests__/Menu.test.tsx +180 -6
  34. package/lib/components/MenuNew/index.ts +4 -1
  35. package/lib/components/Table/subcomponents/Cell/__tests__/__snapshots__/Cell.test.tsx.snap +1 -1
  36. package/package.json +1 -1
  37. package/dist/components/MenuNew/ActionMenuItem.d.ts +0 -7
  38. package/lib/components/MenuNew/ActionMenuItem.tsx +0 -31
@@ -1,43 +1,100 @@
1
- import { HTMLAttributes, ReactNode } from 'react';
1
+ import { HTMLAttributes, KeyboardEvent, ReactNode } from 'react';
2
2
  import { css, cx } from '@emotion/css';
3
3
  import { useTheme } from '../../theme';
4
4
 
5
5
  export interface MenuBaseItemProps extends HTMLAttributes<HTMLDivElement> {
6
6
  icon?: ReactNode;
7
7
  iconPosition?: 'left' | 'right';
8
+ endAdornment?: ReactNode;
9
+ underlineOnHover?: boolean;
10
+ asChild?: boolean;
8
11
  }
9
12
 
10
13
  const MenuBaseItem = ({
11
14
  icon,
12
15
  iconPosition = 'left',
16
+ endAdornment,
13
17
  className,
14
18
  children,
19
+ onClick,
20
+ onKeyDown,
21
+ role,
22
+ tabIndex,
23
+ underlineOnHover = true,
24
+ asChild = false,
15
25
  ...props
16
26
  }: MenuBaseItemProps) => {
17
27
  const [theme] = useTheme();
28
+ const isInteractive = typeof onClick === 'function';
29
+ const shouldUseButtonSemantics = !asChild;
30
+
31
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
32
+ onKeyDown?.(event);
33
+
34
+ if (event.defaultPrevented || !isInteractive) {
35
+ return;
36
+ }
37
+
38
+ if (event.key === 'Enter') {
39
+ event.currentTarget.click();
40
+ }
41
+
42
+ if (event.key === ' ') {
43
+ event.preventDefault();
44
+ event.currentTarget.click();
45
+ }
46
+ };
47
+
18
48
  const baseStyle = css`
19
- width: 360px;
49
+ width: 100%;
20
50
  box-sizing: border-box;
21
51
  min-height: 48px;
52
+ border-radius: ${theme.radius.r2};
22
53
  display: flex;
23
54
  align-items: center;
24
- gap: ${theme.margin.m32};
55
+ gap: ${theme.margin.m16};
25
56
  cursor: pointer;
57
+ outline: none;
58
+
59
+ &:focus-visible {
60
+ box-shadow: ${theme.boxShadow.focus};
61
+ }
26
62
 
27
- &:hover .ucl-uikit-menu__item-text {
28
- text-decoration: underline;
63
+ &:focus-within {
64
+ box-shadow: ${theme.boxShadow.focus};
29
65
  }
66
+
67
+ ${underlineOnHover &&
68
+ `
69
+ &:hover .ucl-uikit-menu__item-text {
70
+ text-decoration: underline;
71
+ }
72
+ `}
30
73
  `;
31
74
  const style = cx(baseStyle, className);
32
75
 
76
+ const endAdornmentStyle = css`
77
+ margin-left: auto;
78
+ display: inline-flex;
79
+ align-items: center;
80
+ flex-shrink: 0;
81
+ `;
82
+
33
83
  return (
34
84
  <div
35
85
  className={style}
86
+ onClick={onClick}
87
+ onKeyDown={handleKeyDown}
88
+ role={role ?? (shouldUseButtonSemantics ? 'button' : undefined)}
89
+ tabIndex={tabIndex ?? (shouldUseButtonSemantics ? 0 : undefined)}
36
90
  {...props}
37
91
  >
38
92
  {iconPosition === 'left' && icon}
39
93
  {children}
40
94
  {iconPosition === 'right' && icon}
95
+ {endAdornment && (
96
+ <span className={endAdornmentStyle}>{endAdornment}</span>
97
+ )}
41
98
  </div>
42
99
  );
43
100
  };
@@ -10,7 +10,9 @@ const MenuDivider = ({ className, ...props }: MenuDividerProps) => {
10
10
  const [theme] = useTheme();
11
11
  const baseStyle = css`
12
12
  border: 0;
13
- border-top: 1px solid ${theme.colour.border.subtle};
13
+ height: 1px;
14
+ background-color: ${theme.colour.border.subtle};
15
+ margin: ${theme.margin.m16} 0;
14
16
  `;
15
17
  const style = cx(NAME, baseStyle, className);
16
18
 
@@ -0,0 +1,56 @@
1
+ import { HTMLAttributes } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import Heading from '../Heading';
4
+ import { useTheme } from '../../theme';
5
+
6
+ export const NAME = 'ucl-uikit-menu__heading';
7
+
8
+ export interface MenuHeadingProps extends HTMLAttributes<HTMLDivElement> {
9
+ testId?: string;
10
+ }
11
+
12
+ const MenuHeading = ({
13
+ testId = NAME,
14
+ className,
15
+ children,
16
+ ...props
17
+ }: MenuHeadingProps) => {
18
+ const [theme] = useTheme();
19
+
20
+ const baseStyle = css`
21
+ box-sizing: border-box;
22
+ margin: ${theme.margin.m32} 0 ${theme.margin.m16};
23
+ text-transform: uppercase;
24
+
25
+ @media screen and (min-width: ${theme.breakpoints.tablet}px) {
26
+ padding: 0 ${theme.padding.p16};
27
+ }
28
+ `;
29
+
30
+ const style = cx(NAME, baseStyle, className);
31
+
32
+ const headingStyle = css`
33
+ color: ${theme.colour.text.secondary};
34
+ text-transform: uppercase;
35
+ font-size: 14px;
36
+ `;
37
+
38
+ return (
39
+ <div
40
+ className={style}
41
+ data-testid={testId}
42
+ {...props}
43
+ >
44
+ <Heading
45
+ as='h3'
46
+ level='xs'
47
+ margins={false}
48
+ className={headingStyle}
49
+ >
50
+ {children}
51
+ </Heading>
52
+ </div>
53
+ );
54
+ };
55
+
56
+ export default MenuHeading;
@@ -33,6 +33,12 @@ const MenuItemText = ({
33
33
  color: inherit;
34
34
  text-decoration: inherit;
35
35
  }
36
+
37
+ &:focus,
38
+ &:focus-visible {
39
+ outline: none;
40
+ box-shadow: none;
41
+ }
36
42
  `;
37
43
  const style = cx(NAME, className);
38
44
 
@@ -0,0 +1,36 @@
1
+ import { HTMLAttributes } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+
4
+ export const NAME = 'ucl-uikit-menu__section';
5
+
6
+ export interface MenuSectionProps extends HTMLAttributes<HTMLElement> {
7
+ testId?: string;
8
+ }
9
+
10
+ const MenuSection = ({
11
+ testId = NAME,
12
+ className,
13
+ children,
14
+ ...props
15
+ }: MenuSectionProps) => {
16
+ const style = cx(
17
+ NAME,
18
+ css`
19
+ margin: 0;
20
+ padding: 0;
21
+ `,
22
+ className
23
+ );
24
+
25
+ return (
26
+ <section
27
+ className={style}
28
+ data-testid={testId}
29
+ {...props}
30
+ >
31
+ {children}
32
+ </section>
33
+ );
34
+ };
35
+
36
+ export default MenuSection;
@@ -12,20 +12,24 @@ export interface PrimaryMenuItemProps extends Omit<
12
12
  'iconPosition'
13
13
  > {
14
14
  asChild?: boolean;
15
- badge?: ReactNode;
15
+ trailingContent?: ReactNode;
16
16
  }
17
17
 
18
18
  const PrimaryMenuItem = ({
19
19
  className,
20
20
  children,
21
21
  asChild,
22
- badge,
22
+ trailingContent,
23
23
  ...props
24
24
  }: PrimaryMenuItemProps) => {
25
25
  const [theme] = useTheme();
26
26
 
27
27
  const baseStyle = css`
28
28
  color: ${theme.colour.text.default};
29
+
30
+ @media screen and (min-width: ${theme.breakpoints.tablet}px) {
31
+ padding: 0 ${theme.padding.p16};
32
+ }
29
33
  `;
30
34
 
31
35
  const style = cx(NAME, baseStyle, className);
@@ -41,8 +45,9 @@ const PrimaryMenuItem = ({
41
45
  return (
42
46
  <MenuBaseItem
43
47
  className={style}
44
- {...props}
48
+ asChild={asChild}
45
49
  iconPosition='left'
50
+ {...props}
46
51
  >
47
52
  <span className={contentStyle}>
48
53
  <MenuItemText
@@ -51,7 +56,7 @@ const PrimaryMenuItem = ({
51
56
  >
52
57
  {children}
53
58
  </MenuItemText>
54
- {badge}
59
+ {trailingContent}
55
60
  </span>
56
61
  </MenuBaseItem>
57
62
  );
@@ -25,6 +25,7 @@ const SecondaryMenuItem = ({
25
25
  const [theme] = useTheme();
26
26
 
27
27
  const baseStyle = css`
28
+ padding-inline: ${theme.margin.m16};
28
29
  color: ${theme.colour.text.secondary};
29
30
  `;
30
31
 
@@ -43,6 +44,7 @@ const SecondaryMenuItem = ({
43
44
  return (
44
45
  <MenuBaseItem
45
46
  className={style}
47
+ asChild={asChild}
46
48
  {...props}
47
49
  >
48
50
  <span className={contentStyle}>
@@ -52,7 +54,13 @@ const SecondaryMenuItem = ({
52
54
  >
53
55
  {children}
54
56
  </MenuItemText>
55
- {externalLink && <Icon.ExternalLink size={16} />}
57
+ {externalLink && (
58
+ <Icon.ExternalLink
59
+ size={16}
60
+ aria-hidden='true'
61
+ focusable='false'
62
+ />
63
+ )}
56
64
  </span>
57
65
  </MenuBaseItem>
58
66
  );
@@ -13,6 +13,108 @@ describe('Menu', () => {
13
13
  expect(screen.getByTestId('ucl-uikit-menu')).toBeInTheDocument();
14
14
  });
15
15
 
16
+ test('renders heading item', () => {
17
+ wrap(
18
+ <Menu>
19
+ <Menu.Heading>Main navigation</Menu.Heading>
20
+ </Menu>
21
+ );
22
+
23
+ const heading = screen.getByTestId('ucl-uikit-menu__heading');
24
+ expect(heading).toBeInTheDocument();
25
+ expect(heading).toHaveTextContent('Main navigation');
26
+ expect(heading).not.toHaveAttribute('role', 'button');
27
+ });
28
+
29
+ test('renders semantic section wrapper', () => {
30
+ wrap(
31
+ <Menu>
32
+ <Menu.Section>
33
+ <Menu.Heading>Support</Menu.Heading>
34
+ </Menu.Section>
35
+ </Menu>
36
+ );
37
+
38
+ const section = screen.getByTestId('ucl-uikit-menu__section');
39
+ expect(section.tagName).toBe('SECTION');
40
+ });
41
+
42
+ test('accordion renders like a primary item', () => {
43
+ const { getByText } = wrap(
44
+ <Menu>
45
+ <Menu.Accordion
46
+ label='Services'
47
+ icon={<span data-testid='accordion-left-icon'>I</span>}
48
+ >
49
+ <Menu.Accordion.Item>Library</Menu.Accordion.Item>
50
+ </Menu.Accordion>
51
+ </Menu>
52
+ );
53
+
54
+ const item = getByText('Services').closest('.ucl-uikit-menu__accordion');
55
+ expect(item?.firstChild).toHaveAttribute(
56
+ 'data-testid',
57
+ 'accordion-left-icon'
58
+ );
59
+ expect(
60
+ screen.getByTestId('ucl-uikit-menu__accordion__icon--chevron-down')
61
+ ).toBeInTheDocument();
62
+ });
63
+
64
+ test('accordion toggles to expanded state with up chevron', () => {
65
+ wrap(
66
+ <Menu>
67
+ <Menu.Accordion
68
+ label='Services'
69
+ icon={<span data-testid='accordion-left-icon'>I</span>}
70
+ >
71
+ <Menu.Accordion.Item>Library</Menu.Accordion.Item>
72
+ </Menu.Accordion>
73
+ </Menu>
74
+ );
75
+
76
+ expect(screen.queryByText('Library')).not.toBeInTheDocument();
77
+ fireEvent.click(screen.getByText('Services'));
78
+ expect(
79
+ screen.getByTestId('ucl-uikit-menu__accordion__icon--chevron-up')
80
+ ).toBeInTheDocument();
81
+ expect(screen.getByText('Library')).toBeInTheDocument();
82
+ });
83
+
84
+ test('accordion item supports asChild link content', () => {
85
+ wrap(
86
+ <Menu>
87
+ <Menu.Accordion
88
+ label='Services'
89
+ icon={<span data-testid='accordion-left-icon'>I</span>}
90
+ defaultExpanded
91
+ >
92
+ <Menu.Accordion.Item asChild>
93
+ <a href='/library'>Library</a>
94
+ </Menu.Accordion.Item>
95
+ </Menu.Accordion>
96
+ </Menu>
97
+ );
98
+
99
+ expect(screen.getByRole('link', { name: 'Library' })).toBeInTheDocument();
100
+ });
101
+
102
+ test('has no border by default', () => {
103
+ wrap(<Menu>Content</Menu>);
104
+
105
+ const menu = screen.getByTestId('ucl-uikit-menu');
106
+ const computedStyles = window.getComputedStyle(menu);
107
+ expect(computedStyles.borderTopStyle).toBe('');
108
+ });
109
+
110
+ test('allows border to be enabled with prop override', () => {
111
+ wrap(<Menu border>Content</Menu>);
112
+
113
+ const menu = screen.getByTestId('ucl-uikit-menu');
114
+ const computedStyles = window.getComputedStyle(menu);
115
+ expect(computedStyles.borderTopStyle).toBe('solid');
116
+ });
117
+
16
118
  test('supports click-handler items without asChild link component', () => {
17
119
  const onClick = vi.fn();
18
120
 
@@ -26,6 +128,42 @@ describe('Menu', () => {
26
128
  expect(onClick).toHaveBeenCalledTimes(1);
27
129
  });
28
130
 
131
+ test('click-handler items are keyboard accessible', () => {
132
+ const onClick = vi.fn();
133
+
134
+ wrap(
135
+ <Menu>
136
+ <Menu.PrimaryItem onClick={onClick}>Home</Menu.PrimaryItem>
137
+ </Menu>
138
+ );
139
+
140
+ const item = screen
141
+ .getByText('Home')
142
+ .closest('.ucl-uikit-menu__primary-item');
143
+
144
+ expect(item).toHaveAttribute('role', 'button');
145
+ expect(item).toHaveAttribute('tabindex', '0');
146
+
147
+ fireEvent.keyDown(item as Element, { key: 'Enter' });
148
+ fireEvent.keyDown(item as Element, { key: ' ' });
149
+ expect(onClick).toHaveBeenCalledTimes(2);
150
+ });
151
+
152
+ test('non-asChild items expose button semantics', () => {
153
+ wrap(
154
+ <Menu>
155
+ <Menu.PrimaryItem>Home</Menu.PrimaryItem>
156
+ </Menu>
157
+ );
158
+
159
+ const item = screen
160
+ .getByText('Home')
161
+ .closest('.ucl-uikit-menu__primary-item');
162
+
163
+ expect(item).toHaveAttribute('role', 'button');
164
+ expect(item).toHaveAttribute('tabindex', '0');
165
+ });
166
+
29
167
  test('renders asChild link content for primary item', () => {
30
168
  wrap(
31
169
  <Menu>
@@ -38,6 +176,10 @@ describe('Menu', () => {
38
176
  const link = screen.getByRole('link', { name: 'Home' });
39
177
  expect(link).toBeInTheDocument();
40
178
  expect(link.closest('.ucl-uikit-menu__item-text')).toBeInTheDocument();
179
+ expect(link.closest('.ucl-uikit-menu__primary-item')).not.toHaveAttribute(
180
+ 'role',
181
+ 'button'
182
+ );
41
183
  });
42
184
 
43
185
  test('primary item enforces icon on the left', () => {
@@ -53,17 +195,35 @@ describe('Menu', () => {
53
195
  expect(item?.firstChild).toHaveAttribute('data-testid', 'left-icon');
54
196
  });
55
197
 
56
- test('action item enforces icon on the right', () => {
57
- const { getByText } = wrap(
198
+ test('can disable hover underline', () => {
199
+ wrap(
200
+ <Menu>
201
+ <Menu.PrimaryItem underlineOnHover={false}>Home</Menu.PrimaryItem>
202
+ </Menu>
203
+ );
204
+
205
+ const item = screen
206
+ .getByText('Home')
207
+ .closest('.ucl-uikit-menu__primary-item');
208
+ const text = screen.getByText('Home');
209
+
210
+ fireEvent.mouseEnter(item as Element);
211
+
212
+ expect(window.getComputedStyle(text).textDecorationLine).not.toBe(
213
+ 'underline'
214
+ );
215
+ });
216
+
217
+ test('action button renders icon', () => {
218
+ wrap(
58
219
  <Menu>
59
- <Menu.ActionItem icon={<span data-testid='right-icon'>I</span>}>
220
+ <Menu.ActionButton icon={<span data-testid='right-icon'>I</span>}>
60
221
  Sign out
61
- </Menu.ActionItem>
222
+ </Menu.ActionButton>
62
223
  </Menu>
63
224
  );
64
225
 
65
- const item = getByText('Sign out').closest('.ucl-uikit-menu__action-item');
66
- expect(item?.lastChild).toHaveAttribute('data-testid', 'right-icon');
226
+ expect(screen.getByTestId('right-icon')).toBeInTheDocument();
67
227
  });
68
228
 
69
229
  test('secondary externalLink item renders external icon', () => {
@@ -84,4 +244,18 @@ describe('Menu', () => {
84
244
  container.querySelector('.ucl-uikit-menu__secondary-item svg')
85
245
  ).toBeTruthy();
86
246
  });
247
+
248
+ test('secondary externalLink icon is hidden from assistive tech', () => {
249
+ const { container } = wrap(
250
+ <Menu>
251
+ <Menu.SecondaryItem externalLink>
252
+ Accessibility statement
253
+ </Menu.SecondaryItem>
254
+ </Menu>
255
+ );
256
+
257
+ const icon = container.querySelector('.ucl-uikit-menu__secondary-item svg');
258
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
259
+ expect(icon).toHaveAttribute('focusable', 'false');
260
+ });
87
261
  });
@@ -2,8 +2,11 @@ export { default } from './Menu';
2
2
  export type {
3
3
  MenuProps,
4
4
  MenuDividerProps,
5
+ MenuHeadingProps,
6
+ MenuSectionProps,
7
+ MenuAccordionProps,
5
8
  MenuBaseItemProps,
6
9
  PrimaryMenuItemProps,
7
10
  SecondaryMenuItemProps,
8
- ActionMenuItemProps,
11
+ ActionMenuButtonProps,
9
12
  } from './Menu';
@@ -95,7 +95,7 @@ exports[`Cell > Snapshot: variants 1`] = `
95
95
  >
96
96
  <button
97
97
  aria-disabled="false"
98
- class="ucl-uikit-button css-9aah8t"
98
+ class="ucl-uikit-button css-1r34e1w"
99
99
  data-testid="ucl-uikit-button"
100
100
  >
101
101
  Button
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.25.0",
5
+ "version": "0.25.2",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
@@ -1,7 +0,0 @@
1
- import { MenuBaseItemProps } from './MenuBaseItem';
2
- export declare const NAME = "ucl-uikit-menu__action-item";
3
- export interface ActionMenuItemProps extends MenuBaseItemProps {
4
- asChild?: boolean;
5
- }
6
- declare const ActionMenuItem: ({ className, children, asChild, ...props }: ActionMenuItemProps) => import("react/jsx-runtime").JSX.Element;
7
- export default ActionMenuItem;
@@ -1,31 +0,0 @@
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;