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.
- package/dist/components/Badge/Badge.d.ts +3 -1
- package/dist/components/Dropdown/Dropdown.context.d.ts +2 -0
- package/dist/components/Dropdown/DropdownContent.d.ts +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.d.ts +7 -2
- package/dist/components/MenuNew/Menu.d.ts +3 -1
- package/dist/components/MenuNew/MenuHead.d.ts +9 -0
- package/dist/components/MenuNew/index.d.ts +1 -1
- package/dist/index.js +3975 -3771
- package/lib/components/Badge/Badge.stories.tsx +2 -0
- package/lib/components/Badge/Badge.tsx +12 -0
- package/lib/components/Badge/__tests__/Badge.test.tsx +19 -0
- package/lib/components/Badge/__tests__/__snapshots__/Badge.test.tsx.snap +42 -0
- package/lib/components/Dropdown/Dropdown.context.tsx +9 -1
- package/lib/components/Dropdown/Dropdown.tsx +10 -2
- package/lib/components/Dropdown/DropdownContent.tsx +178 -18
- package/lib/components/Dropdown/DropdownTrigger.tsx +3 -2
- package/lib/components/Dropdown/__tests__/Dropdown.test.tsx +1 -1
- package/lib/components/DropdownMenu/DropdownMenu.tsx +41 -3
- package/lib/components/DropdownMenu/__tests__/DropdownMenu.test.tsx +49 -0
- package/lib/components/MenuNew/Menu.tsx +8 -2
- package/lib/components/MenuNew/MenuAccordion/MenuAccordion.tsx +1 -0
- package/lib/components/MenuNew/MenuHead.tsx +76 -0
- package/lib/components/MenuNew/MenuHeading.tsx +1 -0
- package/lib/components/MenuNew/PrimaryMenuItem.tsx +1 -0
- package/lib/components/MenuNew/SecondaryMenuItem.tsx +5 -1
- package/lib/components/MenuNew/__tests__/Menu.test.tsx +25 -0
- package/lib/components/MenuNew/index.ts +1 -0
- package/lib/hooks/useMediaQuery.ts +2 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
167
|
+
@media (min-width: ${theme.breakpoints.tablet}px) {
|
|
168
|
+
left: 0;
|
|
169
|
+
}
|
|
38
170
|
`;
|
|
39
171
|
|
|
40
172
|
const alignRightStyle = css`
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
{
|
|
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
|
-
|
|
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']
|
|
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.
|
|
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
|
-
|
|
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
|
|
68
|
-
{
|
|
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
|
-
|
|
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,
|