uikit-react-public 0.25.3 → 0.25.5

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 (29) hide show
  1. package/dist/components/Badge/Badge.d.ts +3 -1
  2. package/dist/components/Dropdown/Dropdown.context.d.ts +2 -0
  3. package/dist/components/Dropdown/DropdownContent.d.ts +1 -1
  4. package/dist/components/DropdownMenu/DropdownMenu.d.ts +7 -2
  5. package/dist/components/MenuNew/Menu.d.ts +3 -1
  6. package/dist/components/MenuNew/MenuHead.d.ts +9 -0
  7. package/dist/components/MenuNew/index.d.ts +1 -1
  8. package/dist/index.js +3975 -3771
  9. package/lib/components/Badge/Badge.stories.tsx +2 -0
  10. package/lib/components/Badge/Badge.tsx +12 -0
  11. package/lib/components/Badge/__tests__/Badge.test.tsx +19 -0
  12. package/lib/components/Badge/__tests__/__snapshots__/Badge.test.tsx.snap +42 -0
  13. package/lib/components/Dropdown/Dropdown.context.tsx +9 -1
  14. package/lib/components/Dropdown/Dropdown.tsx +10 -2
  15. package/lib/components/Dropdown/DropdownContent.tsx +178 -18
  16. package/lib/components/Dropdown/DropdownTrigger.tsx +3 -2
  17. package/lib/components/Dropdown/__tests__/Dropdown.test.tsx +1 -1
  18. package/lib/components/DropdownMenu/DropdownMenu.tsx +41 -3
  19. package/lib/components/DropdownMenu/__tests__/DropdownMenu.test.tsx +49 -0
  20. package/lib/components/MenuNew/Menu.tsx +8 -2
  21. package/lib/components/MenuNew/MenuAccordion/MenuAccordion.tsx +1 -0
  22. package/lib/components/MenuNew/MenuHead.tsx +76 -0
  23. package/lib/components/MenuNew/MenuHeading.tsx +1 -0
  24. package/lib/components/MenuNew/PrimaryMenuItem.tsx +1 -0
  25. package/lib/components/MenuNew/SecondaryMenuItem.tsx +5 -1
  26. package/lib/components/MenuNew/__tests__/Menu.test.tsx +25 -0
  27. package/lib/components/MenuNew/index.ts +1 -0
  28. package/lib/hooks/useMediaQuery.ts +2 -0
  29. package/package.json +1 -1
@@ -4,6 +4,8 @@ import Badge from './Badge';
4
4
  import { type BadgeVariant } from './Badge';
5
5
 
6
6
  const VARIANTS: BadgeVariant[] = [
7
+ 'info',
8
+ 'info-subtle',
7
9
  'neutral',
8
10
  'neutral-subtle',
9
11
  'critical',
@@ -6,6 +6,8 @@ import type { SpecificIconProps } from '../Icon/Icon';
6
6
  import type { ThemeType } from '../../theme';
7
7
 
8
8
  export type BadgeVariant =
9
+ | 'info'
10
+ | 'info-subtle'
9
11
  | 'neutral'
10
12
  | 'neutral-subtle'
11
13
  | 'critical'
@@ -19,6 +21,8 @@ export type BadgeVariant =
19
21
  | 'disabled';
20
22
 
21
23
  export const VARIANT_ICON_MAP = {
24
+ info: Icon.Info,
25
+ 'info-subtle': Icon.Info,
22
26
  neutral: Icon.Info,
23
27
  'neutral-subtle': Icon.Info,
24
28
  critical: Icon.XCircle,
@@ -41,6 +45,14 @@ export interface BadgeVariantStyle {
41
45
  export const getBadgeVariantColours = (
42
46
  theme: ThemeType
43
47
  ): Record<BadgeVariant, BadgeVariantStyle> => ({
48
+ info: {
49
+ color: theme.colour.text.inverse,
50
+ backgroundColor: theme.colour.fill.brandPrimary,
51
+ },
52
+ 'info-subtle': {
53
+ color: theme.colour.text.default,
54
+ backgroundColor: theme.primitiveColour.brandPurple['03'].$value.hex,
55
+ },
44
56
  neutral: {
45
57
  color: theme.colour.text.default,
46
58
  backgroundColor: 'transparent',
@@ -29,6 +29,25 @@ describe('Badge', () => {
29
29
  expect(container.firstChild).toMatchSnapshot();
30
30
  });
31
31
 
32
+ test('snapshot: variant=info with icon', () => {
33
+ const { container } = wrap(
34
+ <Badge
35
+ variant='info'
36
+ icon
37
+ >
38
+ Info
39
+ </Badge>
40
+ );
41
+ expect(container.firstChild).toMatchSnapshot();
42
+ });
43
+
44
+ test('snapshot: variant=info-subtle', () => {
45
+ const { container } = wrap(
46
+ <Badge variant='info-subtle'>Info subtle</Badge>
47
+ );
48
+ expect(container.firstChild).toMatchSnapshot();
49
+ });
50
+
32
51
  test('snapshot: variant=critical with icon', () => {
33
52
  const { container } = wrap(
34
53
  <Badge
@@ -73,6 +73,48 @@ exports[`Badge > snapshot: variant=disabled 1`] = `
73
73
  </span>
74
74
  `;
75
75
 
76
+ exports[`Badge > snapshot: variant=info with icon 1`] = `
77
+ <span
78
+ class="ucl-uikit-badge css-11fgrnl"
79
+ data-testid="ucl-uikit-badge"
80
+ >
81
+ <svg
82
+ aria-hidden="true"
83
+ class="ucl-uikit-icon css-y1s4m2"
84
+ data-testid="ucl-uikit-icon"
85
+ fill="none"
86
+ focusable="false"
87
+ height="14"
88
+ stroke="currentColor"
89
+ stroke-linecap="round"
90
+ stroke-linejoin="round"
91
+ stroke-width="2"
92
+ viewBox="0 0 24 24"
93
+ width="14"
94
+ xmlns="http://www.w3.org/2000/svg"
95
+ >
96
+ <circle
97
+ cx="12"
98
+ cy="12"
99
+ r="10"
100
+ />
101
+ <path
102
+ d="M12 16v-4m0-4h.01"
103
+ />
104
+ </svg>
105
+ Info
106
+ </span>
107
+ `;
108
+
109
+ exports[`Badge > snapshot: variant=info-subtle 1`] = `
110
+ <span
111
+ class="ucl-uikit-badge css-1t6kxcm"
112
+ data-testid="ucl-uikit-badge"
113
+ >
114
+ Info subtle
115
+ </span>
116
+ `;
117
+
76
118
  exports[`Badge > snapshot: variant=success with icon 1`] = `
77
119
  <span
78
120
  class="ucl-uikit-badge css-16a4ap6"
@@ -1,6 +1,7 @@
1
1
  import React, {
2
2
  createContext,
3
3
  useContext,
4
+ useId,
4
5
  useMemo,
5
6
  useRef,
6
7
  useState,
@@ -12,6 +13,7 @@ interface DropdownContextType {
12
13
  toggle: () => void;
13
14
  triggerRef: React.RefObject<HTMLElement | null>;
14
15
  contentRef: React.RefObject<HTMLDivElement | null>;
16
+ contentId: string;
15
17
  }
16
18
 
17
19
  const DropdownContext = createContext<DropdownContextType | undefined>(
@@ -28,6 +30,10 @@ export const useDropdownContext = () => {
28
30
  return context;
29
31
  };
30
32
 
33
+ export const useDropdownContextOptional = () => {
34
+ return useContext(DropdownContext);
35
+ };
36
+
31
37
  interface DropdownProviderProps {
32
38
  defaultOpen?: boolean;
33
39
  children: React.ReactNode;
@@ -40,6 +46,7 @@ const DropdownProvider = ({
40
46
  const [isOpen, setIsOpen] = useState(defaultOpen);
41
47
  const triggerRef = useRef<HTMLElement>(null);
42
48
  const contentRef = useRef<HTMLDivElement>(null);
49
+ const contentId = useId();
43
50
 
44
51
  const value = useMemo(
45
52
  () => ({
@@ -48,8 +55,9 @@ const DropdownProvider = ({
48
55
  toggle: () => setIsOpen((prev) => !prev),
49
56
  triggerRef,
50
57
  contentRef,
58
+ contentId,
51
59
  }),
52
- [isOpen]
60
+ [contentId, isOpen]
53
61
  );
54
62
 
55
63
  return (
@@ -1,4 +1,4 @@
1
- import { HTMLAttributes, useEffect } from 'react';
1
+ import { HTMLAttributes, useEffect, useRef } from 'react';
2
2
  import { css, cx } from '@emotion/css';
3
3
  import { useTheme } from '../../theme';
4
4
  import DropdownProvider, { useDropdownContext } from './Dropdown.context';
@@ -19,7 +19,8 @@ const DropdownBody = ({
19
19
  ...props
20
20
  }: Omit<DropdownProps, 'defaultOpen'>) => {
21
21
  const [theme] = useTheme();
22
- const { setIsOpen, triggerRef, contentRef } = useDropdownContext();
22
+ const { isOpen, setIsOpen, triggerRef, contentRef } = useDropdownContext();
23
+ const wasOpenRef = useRef(isOpen);
23
24
 
24
25
  useEffect(() => {
25
26
  const handleDocumentClick = (event: MouseEvent) => {
@@ -50,6 +51,13 @@ const DropdownBody = ({
50
51
  };
51
52
  }, [setIsOpen, triggerRef, contentRef]);
52
53
 
54
+ useEffect(() => {
55
+ if (wasOpenRef.current && !isOpen) {
56
+ triggerRef.current?.focus();
57
+ }
58
+ wasOpenRef.current = isOpen;
59
+ }, [isOpen, triggerRef]);
60
+
53
61
  const baseStyle = css`
54
62
  position: relative;
55
63
  display: inline-flex;
@@ -1,6 +1,8 @@
1
- import { HTMLAttributes, memo } from 'react';
1
+ import { HTMLAttributes, memo, useEffect, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
  import { css, cx } from '@emotion/css';
3
4
  import { useTheme } from '../../theme';
5
+ import useMediaQuery from '../../hooks/useMediaQuery';
4
6
  import { useDropdownContext } from './Dropdown.context';
5
7
 
6
8
  export const NAME = 'ucl-uikit-dropdown__content';
@@ -10,35 +12,167 @@ export interface DropdownContentProps extends HTMLAttributes<HTMLDivElement> {
10
12
  align?: 'left' | 'right';
11
13
  }
12
14
 
15
+ const TABLET_BOTTOM_GAP_PX = 32;
16
+
13
17
  const DropdownContent = ({
14
18
  testId = NAME,
15
19
  align = 'right',
16
20
  className,
17
21
  children,
22
+ id,
18
23
  ...props
19
24
  }: DropdownContentProps) => {
20
25
  const [theme] = useTheme();
21
- const { isOpen, contentRef } = useDropdownContext();
26
+ const { isOpen, contentRef, contentId } = useDropdownContext();
27
+ const [tabletMaxHeight, setTabletMaxHeight] = useState<number | undefined>(
28
+ undefined
29
+ );
30
+ const isTabletAndUp = useMediaQuery(
31
+ `(min-width: ${theme.breakpoints.tablet}px)`
32
+ );
33
+
34
+ useEffect(() => {
35
+ if (typeof document === 'undefined') return;
36
+ if (isTabletAndUp || !isOpen) return;
37
+
38
+ const container = contentRef.current;
39
+ if (!container) return;
40
+
41
+ const { body } = document;
42
+ const previousOverflow = body.style.overflow;
43
+ const backgroundElements = Array.from(body.children).filter(
44
+ (element) => element !== container
45
+ ) as HTMLElement[];
46
+
47
+ const previousBackgroundState = backgroundElements.map((element) => ({
48
+ element,
49
+ ariaHidden: element.getAttribute('aria-hidden'),
50
+ inert: (element as HTMLElement & { inert?: boolean }).inert,
51
+ }));
52
+
53
+ body.style.overflow = 'hidden';
54
+ previousBackgroundState.forEach(({ element }) => {
55
+ element.setAttribute('aria-hidden', 'true');
56
+ (element as HTMLElement & { inert?: boolean }).inert = true;
57
+ });
58
+
59
+ const getFocusableElements = () =>
60
+ Array.from(
61
+ container.querySelectorAll<HTMLElement>(
62
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
63
+ )
64
+ ).filter((element) => !element.hasAttribute('disabled'));
65
+
66
+ const focusableElements = getFocusableElements();
67
+ if (focusableElements.length > 0) {
68
+ focusableElements[0].focus();
69
+ } else {
70
+ container.focus();
71
+ }
72
+
73
+ const handleKeyDown = (event: KeyboardEvent) => {
74
+ if (event.key !== 'Tab') return;
75
+
76
+ const elements = getFocusableElements();
77
+ if (elements.length === 0) {
78
+ event.preventDefault();
79
+ container.focus();
80
+ return;
81
+ }
82
+
83
+ const first = elements[0];
84
+ const last = elements[elements.length - 1];
85
+ const active = document.activeElement as HTMLElement | null;
86
+
87
+ if (event.shiftKey && active === first) {
88
+ event.preventDefault();
89
+ last.focus();
90
+ } else if (!event.shiftKey && active === last) {
91
+ event.preventDefault();
92
+ first.focus();
93
+ }
94
+ };
95
+
96
+ document.addEventListener('keydown', handleKeyDown);
97
+
98
+ return () => {
99
+ body.style.overflow = previousOverflow;
100
+ previousBackgroundState.forEach(({ element, ariaHidden, inert }) => {
101
+ if (ariaHidden === null) {
102
+ element.removeAttribute('aria-hidden');
103
+ } else {
104
+ element.setAttribute('aria-hidden', ariaHidden);
105
+ }
106
+ (element as HTMLElement & { inert?: boolean }).inert = inert;
107
+ });
108
+ document.removeEventListener('keydown', handleKeyDown);
109
+ };
110
+ }, [contentRef, isOpen, isTabletAndUp]);
111
+
112
+ useEffect(() => {
113
+ if (typeof window === 'undefined') return;
114
+ if (!isTabletAndUp || !isOpen) {
115
+ setTabletMaxHeight(undefined);
116
+ return;
117
+ }
118
+
119
+ const updateMaxHeight = () => {
120
+ const top = contentRef.current?.getBoundingClientRect().top ?? 0;
121
+ setTabletMaxHeight(
122
+ Math.max(window.innerHeight - top - TABLET_BOTTOM_GAP_PX, 0)
123
+ );
124
+ };
125
+
126
+ updateMaxHeight();
127
+ window.addEventListener('resize', updateMaxHeight);
128
+
129
+ return () => {
130
+ window.removeEventListener('resize', updateMaxHeight);
131
+ };
132
+ }, [contentRef, isOpen, isTabletAndUp]);
22
133
 
23
134
  const baseStyle = css`
24
135
  display: none;
25
- position: absolute;
26
- top: calc(100% + ${theme.margin.m8});
27
- z-index: 100;
28
- min-width: max-content;
136
+ z-index: 9999;
137
+ position: fixed;
138
+ inset: 0;
139
+ width: 100vw;
140
+ max-width: 100vw;
141
+ height: 100dvh;
142
+ box-sizing: border-box;
143
+ overflow-y: auto;
144
+ overflow-x: hidden;
29
145
  background-color: ${theme.colour.surface.default};
30
146
  color: ${theme.colour.text.default};
31
- border: ${theme.border.b1} solid ${theme.colour.border.default};
32
- box-shadow: ${theme.boxShadow.y1};
33
- border-radius: ${theme.radius.r4};
147
+
148
+ @media (min-width: ${theme.breakpoints.tablet}px) {
149
+ z-index: 100;
150
+ position: absolute;
151
+ inset: auto;
152
+ top: calc(100% + ${theme.margin.m8});
153
+ width: auto;
154
+ max-width: none;
155
+ height: auto;
156
+ overflow-y: auto;
157
+ overflow-x: visible;
158
+ background-color: ${theme.colour.surface.default};
159
+ color: ${theme.colour.text.default};
160
+ border: ${theme.border.b1} solid ${theme.colour.border.default};
161
+ box-shadow: ${theme.boxShadow.y1};
162
+ border-radius: ${theme.radius.r4};
163
+ }
34
164
  `;
35
165
 
36
166
  const alignLeftStyle = css`
37
- left: 0;
167
+ @media (min-width: ${theme.breakpoints.tablet}px) {
168
+ left: 0;
169
+ }
38
170
  `;
39
171
 
40
172
  const alignRightStyle = css`
41
- right: 0;
173
+ @media (min-width: ${theme.breakpoints.tablet}px) {
174
+ right: 0;
175
+ }
42
176
  `;
43
177
 
44
178
  const openStyle = css`
@@ -50,21 +184,47 @@ const DropdownContent = ({
50
184
  baseStyle,
51
185
  align === 'left' && alignLeftStyle,
52
186
  align === 'right' && alignRightStyle,
53
- isOpen && openStyle,
54
- className
187
+ isOpen && openStyle
55
188
  );
56
189
 
57
- return (
190
+ const panelStyle = css`
191
+ box-sizing: border-box;
192
+ width: 100%;
193
+ max-width: 100%;
194
+ min-height: 100%;
195
+
196
+ @media (min-width: ${theme.breakpoints.tablet}px) {
197
+ width: auto;
198
+ max-width: none;
199
+ min-width: max-content;
200
+ min-height: auto;
201
+ overflow-y: auto;
202
+ background: transparent;
203
+ color: inherit;
204
+ }
205
+ `;
206
+
207
+ const content = (
58
208
  <div
59
209
  ref={contentRef}
60
210
  className={style}
61
- role='menu'
62
- data-testid={testId}
63
- {...props}
211
+ style={isTabletAndUp ? { maxHeight: tabletMaxHeight } : undefined}
212
+ tabIndex={isTabletAndUp ? undefined : -1}
213
+ role={isTabletAndUp ? undefined : 'dialog'}
214
+ aria-modal={isTabletAndUp ? undefined : true}
64
215
  >
65
- {children}
216
+ <div
217
+ className={cx(panelStyle, className)}
218
+ id={id ?? contentId}
219
+ data-testid={testId}
220
+ {...props}
221
+ >
222
+ {children}
223
+ </div>
66
224
  </div>
67
225
  );
226
+
227
+ return !isTabletAndUp ? createPortal(content, document.body) : content;
68
228
  };
69
229
 
70
230
  export default memo(DropdownContent);
@@ -15,7 +15,7 @@ export interface DropdownTriggerProps {
15
15
  }
16
16
 
17
17
  const DropdownTrigger = ({ children }: DropdownTriggerProps) => {
18
- const { isOpen, toggle, triggerRef } = useDropdownContext();
18
+ const { isOpen, toggle, triggerRef, contentId } = useDropdownContext();
19
19
 
20
20
  if (!isValidElement(children)) {
21
21
  return null;
@@ -54,8 +54,9 @@ const DropdownTrigger = ({ children }: DropdownTriggerProps) => {
54
54
  }
55
55
  },
56
56
  onKeyDown: handleKeyDown,
57
- 'aria-haspopup': childProps['aria-haspopup'] ?? 'menu',
57
+ 'aria-haspopup': childProps['aria-haspopup'],
58
58
  'aria-expanded': isOpen,
59
+ 'aria-controls': childProps['aria-controls'] ?? contentId,
59
60
  });
60
61
  };
61
62
 
@@ -57,7 +57,7 @@ describe('Dropdown', () => {
57
57
  fireEvent.click(screen.getByRole('button', { name: 'Open dropdown' }));
58
58
  expect(content).toBeVisible();
59
59
 
60
- fireEvent.mouseDown(screen.getByRole('button', { name: 'Outside' }));
60
+ fireEvent.mouseDown(screen.getByText('Outside'));
61
61
  expect(content).not.toBeVisible();
62
62
  });
63
63
  });
@@ -4,14 +4,24 @@ import Dropdown, { DropdownProps } from '../Dropdown';
4
4
  import useTheme from '../../theme/useTheme';
5
5
  import useMediaQuery from '../../hooks/useMediaQuery';
6
6
  import DropdownMenuTrigger from './DropdownMenuTrigger';
7
+ import { useDropdownContext } from '../Dropdown/Dropdown.context';
7
8
 
8
9
  export const NAME = 'ucl-uikit-dropdown-menu';
9
10
 
11
+ export interface DropdownMenuChildrenApi {
12
+ close: () => void;
13
+ }
14
+
15
+ type DropdownMenuChildren =
16
+ | ReactNode
17
+ | ((api: DropdownMenuChildrenApi) => ReactNode);
18
+
10
19
  export interface DropdownMenuProps
11
20
  extends
12
21
  Omit<DropdownProps, 'children'>,
13
22
  Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
14
- children: ReactNode;
23
+ align?: 'left' | 'right';
24
+ children: DropdownMenuChildren;
15
25
  label?: string;
16
26
  triggerAriaLabelCollapsed?: string;
17
27
  triggerAriaLabelExpanded?: string;
@@ -20,7 +30,22 @@ export interface DropdownMenuProps
20
30
  contentClassName?: string;
21
31
  }
22
32
 
33
+ const DropdownMenuContent = ({
34
+ children,
35
+ }: {
36
+ children: DropdownMenuChildren;
37
+ }) => {
38
+ const { setIsOpen } = useDropdownContext();
39
+
40
+ if (typeof children === 'function') {
41
+ return children({ close: () => setIsOpen(false) });
42
+ }
43
+
44
+ return children;
45
+ };
46
+
23
47
  const DropdownMenu = ({
48
+ align = 'right',
24
49
  children,
25
50
  label = 'MENU',
26
51
  triggerAriaLabelCollapsed = 'Open menu',
@@ -50,6 +75,16 @@ const DropdownMenu = ({
50
75
  triggerClassName
51
76
  );
52
77
 
78
+ const contentBaseStyle = css`
79
+ background-color: ${theme.colour.surface.primary};
80
+
81
+ @media (min-width: ${theme.breakpoints.tablet}px) {
82
+ width: 460px;
83
+ }
84
+ `;
85
+
86
+ const contentStyle = cx(contentBaseStyle, contentClassName);
87
+
53
88
  return (
54
89
  <Dropdown
55
90
  className={dropdownStyle}
@@ -64,8 +99,11 @@ const DropdownMenu = ({
64
99
  className={resolvedTriggerClassName}
65
100
  />
66
101
  </Dropdown.Trigger>
67
- <Dropdown.Content className={contentClassName}>
68
- {children}
102
+ <Dropdown.Content
103
+ className={contentStyle}
104
+ align={align}
105
+ >
106
+ <DropdownMenuContent>{children}</DropdownMenuContent>
69
107
  </Dropdown.Content>
70
108
  </Dropdown>
71
109
  );
@@ -1,6 +1,8 @@
1
1
  import { beforeEach, describe, expect, test, vi } from 'vitest';
2
2
  import { fireEvent, render, screen } from '@testing-library/react';
3
3
  import DropdownMenu from '../DropdownMenu';
4
+ import Button from '../../Button';
5
+ import Menu from '../../MenuNew';
4
6
  import { ThemeContextProvider } from '../../../theme/useTheme';
5
7
 
6
8
  const mockMatchMedia = (matchesDesktop: boolean) => {
@@ -150,4 +152,51 @@ describe('DropdownMenu', () => {
150
152
  screen.getByRole('button', { name: 'Hide menu options' })
151
153
  ).toBeInTheDocument();
152
154
  });
155
+
156
+ test('supports render-prop children with close helper', () => {
157
+ render(
158
+ <ThemeContextProvider>
159
+ <DropdownMenu>
160
+ {({ close }) => (
161
+ <Button
162
+ aria-label='Close from content'
163
+ onClick={close}
164
+ >
165
+ Close
166
+ </Button>
167
+ )}
168
+ </DropdownMenu>
169
+ </ThemeContextProvider>
170
+ );
171
+
172
+ const trigger = screen.getByRole('button', { name: 'Open menu' });
173
+ const content = screen.getByTestId('ucl-uikit-dropdown__content');
174
+
175
+ fireEvent.click(trigger);
176
+ expect(content).toBeVisible();
177
+
178
+ fireEvent.click(screen.getByRole('button', { name: 'Close from content' }));
179
+ expect(content).not.toBeVisible();
180
+ });
181
+
182
+ test('menu head close button closes dropdown without render-prop usage', () => {
183
+ render(
184
+ <ThemeContextProvider>
185
+ <DropdownMenu>
186
+ <Menu>
187
+ <Menu.Head />
188
+ </Menu>
189
+ </DropdownMenu>
190
+ </ThemeContextProvider>
191
+ );
192
+
193
+ const trigger = screen.getByRole('button', { name: 'Open menu' });
194
+ const content = screen.getByTestId('ucl-uikit-dropdown__content');
195
+
196
+ fireEvent.click(trigger);
197
+ expect(content).toBeVisible();
198
+
199
+ fireEvent.click(screen.getByTestId('ucl-uikit-menu__head__close-button'));
200
+ expect(content).not.toBeVisible();
201
+ });
153
202
  });
@@ -2,6 +2,7 @@ import { HTMLAttributes, NamedExoticComponent, memo } from 'react';
2
2
  import { css, cx } from '@emotion/css';
3
3
  import MenuBaseItem, { MenuBaseItemProps } from './MenuBaseItem';
4
4
  import MenuDivider, { MenuDividerProps } from './MenuDivider';
5
+ import MenuHead, { MenuHeadProps } from './MenuHead';
5
6
  import MenuHeading, { MenuHeadingProps } from './MenuHeading';
6
7
  import MenuSection, { MenuSectionProps } from './MenuSection';
7
8
  import MenuAccordion, {
@@ -22,6 +23,7 @@ export interface MenuProps extends HTMLAttributes<HTMLDivElement> {
22
23
 
23
24
  export type {
24
25
  MenuDividerProps,
26
+ MenuHeadProps,
25
27
  MenuHeadingProps,
26
28
  MenuSectionProps,
27
29
  MenuAccordionProps,
@@ -41,11 +43,12 @@ const Menu = ({
41
43
  }: MenuProps) => {
42
44
  const [theme] = useTheme();
43
45
  const baseStyle = css`
44
- padding: ${theme.padding.p16} ${theme.padding.p24};
46
+ width: 100%;
47
+ box-sizing: border-box;
48
+ padding: ${theme.padding.p16} ${theme.padding.p16};
45
49
  background-color: ${theme.colour.surface.primary};
46
50
 
47
51
  @media screen and (min-width: ${theme.breakpoints.tablet}px) {
48
- width: 360px;
49
52
  padding: ${theme.padding.p48} ${theme.padding.p64};
50
53
  }
51
54
  `;
@@ -58,6 +61,7 @@ const Menu = ({
58
61
  <nav
59
62
  className={style}
60
63
  data-testid={testId}
64
+ role='menu'
61
65
  {...props}
62
66
  >
63
67
  {children}
@@ -67,6 +71,7 @@ const Menu = ({
67
71
 
68
72
  interface MenuComponent extends NamedExoticComponent<MenuProps> {
69
73
  Section: typeof MenuSection;
74
+ Head: typeof MenuHead;
70
75
  Heading: typeof MenuHeading;
71
76
  Accordion: typeof MenuAccordion;
72
77
  Divider: typeof MenuDivider;
@@ -78,6 +83,7 @@ interface MenuComponent extends NamedExoticComponent<MenuProps> {
78
83
 
79
84
  const MemoMenu: MenuComponent = Object.assign(memo(Menu), {
80
85
  Section: MenuSection,
86
+ Head: MenuHead,
81
87
  Heading: MenuHeading,
82
88
  Accordion: MenuAccordion,
83
89
  Divider: MenuDivider,
@@ -38,6 +38,7 @@ const MenuAccordion = ({
38
38
  const baseStyle = css`
39
39
  width: 100%;
40
40
  color: ${theme.colour.text.default};
41
+ padding: 0 ${theme.padding.p16};
41
42
 
42
43
  @media screen and (min-width: ${theme.breakpoints.tablet}px) {
43
44
  padding: 0 ${theme.padding.p16};