uikit-react-public 0.22.2 → 0.24.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.
- package/dist/components/Avatar/Avatar.d.ts +1 -1
- package/dist/components/Dropdown/Dropdown.context.d.ts +15 -0
- package/dist/components/Dropdown/Dropdown.d.ts +15 -0
- package/dist/components/Dropdown/Dropdown.stories.d.ts +36 -0
- package/dist/components/Dropdown/DropdownContent.d.ts +8 -0
- package/dist/components/Dropdown/DropdownTrigger.d.ts +7 -0
- package/dist/components/Dropdown/__tests__/Dropdown.test.d.ts +1 -0
- package/dist/components/Dropdown/index.d.ts +4 -0
- package/dist/components/DropdownMenu/DropdownMenu.d.ts +13 -0
- package/dist/components/DropdownMenu/__tests__/DropdownMenu.test.d.ts +1 -0
- package/dist/components/DropdownMenu/index.d.ts +2 -0
- package/dist/components/DropdownUserMenu/DropdownUserMenu.d.ts +19 -0
- package/dist/components/DropdownUserMenu/__tests__/DropdownUserMenu.test.d.ts +1 -0
- package/dist/components/DropdownUserMenu/index.d.ts +2 -0
- package/dist/components/HeaderNew/Header.d.ts +2 -0
- package/dist/components/HeaderNew/HeaderItem.d.ts +7 -0
- package/dist/components/HeaderNew/index.d.ts +1 -0
- package/dist/components/SimpleMenu/SimpleMenu.d.ts +7 -0
- package/dist/components/SimpleMenu/__tests__/SimpleMenu.test.d.ts +1 -0
- package/dist/components/SimpleMenu/index.d.ts +2 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/index.js +3948 -3609
- package/lib/components/Avatar/Avatar.tsx +1 -1
- package/lib/components/Dropdown/Dropdown.context.tsx +62 -0
- package/lib/components/Dropdown/Dropdown.stories.tsx +184 -0
- package/lib/components/Dropdown/Dropdown.tsx +91 -0
- package/lib/components/Dropdown/DropdownContent.tsx +70 -0
- package/lib/components/Dropdown/DropdownTrigger.tsx +62 -0
- package/lib/components/Dropdown/__tests__/Dropdown.test.tsx +63 -0
- package/lib/components/Dropdown/index.ts +4 -0
- package/lib/components/DropdownMenu/DropdownMenu.tsx +82 -0
- package/lib/components/DropdownMenu/__tests__/DropdownMenu.test.tsx +96 -0
- package/lib/components/DropdownMenu/index.ts +2 -0
- package/lib/components/DropdownUserMenu/DropdownUserMenu.tsx +124 -0
- package/lib/components/DropdownUserMenu/__tests__/DropdownUserMenu.test.tsx +97 -0
- package/lib/components/DropdownUserMenu/index.ts +2 -0
- package/lib/components/HeaderNew/Header.tsx +5 -2
- package/lib/components/HeaderNew/HeaderBorder.tsx +1 -0
- package/lib/components/HeaderNew/HeaderItem.tsx +43 -0
- package/lib/components/HeaderNew/HeaderLogo.tsx +3 -0
- package/lib/components/HeaderNew/HeaderMenuContainer.tsx +3 -1
- package/lib/components/HeaderNew/HeaderTitle.tsx +8 -1
- package/lib/components/HeaderNew/__tests__/__snapshots__/Header.test.tsx.snap +8 -8
- package/lib/components/HeaderNew/index.ts +1 -0
- package/lib/components/Icon/svgImports.ts +2 -0
- package/lib/components/SimpleMenu/SimpleMenu.tsx +40 -0
- package/lib/components/SimpleMenu/__tests__/SimpleMenu.test.tsx +38 -0
- package/lib/components/SimpleMenu/index.ts +2 -0
- package/lib/components/index.ts +16 -0
- package/package.json +2 -2
|
@@ -16,7 +16,7 @@ export interface AvatarProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
16
16
|
variant?: 'image' | 'initials' | 'icon';
|
|
17
17
|
imageUrl?: string;
|
|
18
18
|
name?: string;
|
|
19
|
-
size?: 48 | 56 | 72 | 80;
|
|
19
|
+
size?: 32 | 48 | 56 | 72 | 80;
|
|
20
20
|
disabled?: boolean;
|
|
21
21
|
testId?: string;
|
|
22
22
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
|
|
9
|
+
interface DropdownContextType {
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
12
|
+
toggle: () => void;
|
|
13
|
+
triggerRef: React.RefObject<HTMLElement | null>;
|
|
14
|
+
contentRef: React.RefObject<HTMLDivElement | null>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DropdownContext = createContext<DropdownContextType | undefined>(
|
|
18
|
+
undefined
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export const useDropdownContext = () => {
|
|
22
|
+
const context = useContext(DropdownContext);
|
|
23
|
+
|
|
24
|
+
if (!context) {
|
|
25
|
+
throw new Error('Dropdown components must be used within a Dropdown');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return context;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface DropdownProviderProps {
|
|
32
|
+
defaultOpen?: boolean;
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DropdownProvider = ({
|
|
37
|
+
defaultOpen = false,
|
|
38
|
+
children,
|
|
39
|
+
}: DropdownProviderProps) => {
|
|
40
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
41
|
+
const triggerRef = useRef<HTMLElement>(null);
|
|
42
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
|
|
44
|
+
const value = useMemo(
|
|
45
|
+
() => ({
|
|
46
|
+
isOpen,
|
|
47
|
+
setIsOpen,
|
|
48
|
+
toggle: () => setIsOpen((prev) => !prev),
|
|
49
|
+
triggerRef,
|
|
50
|
+
contentRef,
|
|
51
|
+
}),
|
|
52
|
+
[isOpen]
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<DropdownContext.Provider value={value}>
|
|
57
|
+
{children}
|
|
58
|
+
</DropdownContext.Provider>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default DropdownProvider;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { css } from '@emotion/css';
|
|
3
|
+
import Dropdown from './Dropdown';
|
|
4
|
+
import Button from '../Button';
|
|
5
|
+
import HeaderNew from '../HeaderNew';
|
|
6
|
+
import Icon from '../Icon';
|
|
7
|
+
import IconButton from '../IconButton';
|
|
8
|
+
import LabelledCheckbox from '../Checkbox/LabelledCheckbox';
|
|
9
|
+
import useTheme from '../../theme/useTheme';
|
|
10
|
+
import useMediaQuery from '../../hooks/useMediaQuery';
|
|
11
|
+
import { ThemeContextProvider, lightTheme } from '../../theme';
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
title: 'Components/Dropdown',
|
|
15
|
+
component: Dropdown,
|
|
16
|
+
parameters: {
|
|
17
|
+
layout: 'padded',
|
|
18
|
+
},
|
|
19
|
+
argTypes: {
|
|
20
|
+
defaultOpen: {
|
|
21
|
+
control: { type: 'boolean' },
|
|
22
|
+
type: 'boolean',
|
|
23
|
+
},
|
|
24
|
+
testId: {
|
|
25
|
+
control: { type: 'text' },
|
|
26
|
+
type: 'string',
|
|
27
|
+
},
|
|
28
|
+
className: {
|
|
29
|
+
control: { type: 'text' },
|
|
30
|
+
type: 'string',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
args: {
|
|
34
|
+
defaultOpen: false,
|
|
35
|
+
},
|
|
36
|
+
tags: ['autodocs'],
|
|
37
|
+
} satisfies Meta<typeof Dropdown>;
|
|
38
|
+
|
|
39
|
+
export default meta;
|
|
40
|
+
type Story = StoryObj<typeof meta>;
|
|
41
|
+
|
|
42
|
+
export const HeaderResponsiveTrigger: Story = {
|
|
43
|
+
render: (args) => {
|
|
44
|
+
const [theme] = useTheme();
|
|
45
|
+
const isDesktop = useMediaQuery(
|
|
46
|
+
`(min-width: ${theme.breakpoints.desktop}px)`
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const containerStyle = css`
|
|
50
|
+
min-height: 280px;
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const iconButtonStyle = css`
|
|
54
|
+
color: ${theme.colour.icon.default};
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const menuStyle = css`
|
|
58
|
+
padding: ${theme.padding.p8};
|
|
59
|
+
min-width: 220px;
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
const menuItemStyle = css`
|
|
63
|
+
display: block;
|
|
64
|
+
width: 100%;
|
|
65
|
+
text-align: left;
|
|
66
|
+
border: 0;
|
|
67
|
+
background: transparent;
|
|
68
|
+
padding: ${theme.padding.p8} ${theme.padding.p12};
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
|
|
71
|
+
&:hover {
|
|
72
|
+
background-color: ${theme.colour.fill.hover};
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<ThemeContextProvider initialTheme={lightTheme}>
|
|
78
|
+
<div className={containerStyle}>
|
|
79
|
+
<HeaderNew title='Name of the application'>
|
|
80
|
+
<HeaderNew.MenuContainer>
|
|
81
|
+
<Dropdown {...args}>
|
|
82
|
+
<Dropdown.Trigger>
|
|
83
|
+
{isDesktop ? (
|
|
84
|
+
<Button
|
|
85
|
+
variant='primary-subtle'
|
|
86
|
+
size='small'
|
|
87
|
+
icon={<Icon.Menu size={20} />}
|
|
88
|
+
aria-label='Menu'
|
|
89
|
+
>
|
|
90
|
+
Menu
|
|
91
|
+
</Button>
|
|
92
|
+
) : (
|
|
93
|
+
<IconButton
|
|
94
|
+
className={iconButtonStyle}
|
|
95
|
+
aria-label='Menu'
|
|
96
|
+
>
|
|
97
|
+
<Icon.Menu size={20} />
|
|
98
|
+
</IconButton>
|
|
99
|
+
)}
|
|
100
|
+
</Dropdown.Trigger>
|
|
101
|
+
<Dropdown.Content className={menuStyle}>
|
|
102
|
+
<button
|
|
103
|
+
type='button'
|
|
104
|
+
className={menuItemStyle}
|
|
105
|
+
>
|
|
106
|
+
View profile
|
|
107
|
+
</button>
|
|
108
|
+
<button
|
|
109
|
+
type='button'
|
|
110
|
+
className={menuItemStyle}
|
|
111
|
+
>
|
|
112
|
+
Team settings
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type='button'
|
|
116
|
+
className={menuItemStyle}
|
|
117
|
+
>
|
|
118
|
+
Sign out
|
|
119
|
+
</button>
|
|
120
|
+
</Dropdown.Content>
|
|
121
|
+
</Dropdown>
|
|
122
|
+
</HeaderNew.MenuContainer>
|
|
123
|
+
</HeaderNew>
|
|
124
|
+
</div>
|
|
125
|
+
</ThemeContextProvider>
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const StandaloneInfoPanel: Story = {
|
|
131
|
+
name: 'Standalone Info Panel',
|
|
132
|
+
render: (args) => {
|
|
133
|
+
const [theme] = useTheme();
|
|
134
|
+
|
|
135
|
+
const containerStyle = css`
|
|
136
|
+
min-height: 260px;
|
|
137
|
+
padding: ${theme.padding.p24};
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
const infoContentStyle = css`
|
|
141
|
+
padding: ${theme.padding.p16};
|
|
142
|
+
max-width: 320px;
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
const infoTitleStyle = css`
|
|
146
|
+
margin: 0 0 ${theme.margin.m8} 0;
|
|
147
|
+
font-weight: 700;
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const infoBodyStyle = css`
|
|
151
|
+
margin: 0 0 ${theme.margin.m12} 0;
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<ThemeContextProvider initialTheme={lightTheme}>
|
|
156
|
+
<div className={containerStyle}>
|
|
157
|
+
<Dropdown {...args}>
|
|
158
|
+
<Dropdown.Trigger>
|
|
159
|
+
<Button
|
|
160
|
+
variant='secondary'
|
|
161
|
+
size='small'
|
|
162
|
+
icon={<Icon.Info size={20} />}
|
|
163
|
+
>
|
|
164
|
+
Notification preferences
|
|
165
|
+
</Button>
|
|
166
|
+
</Dropdown.Trigger>
|
|
167
|
+
<Dropdown.Content
|
|
168
|
+
className={infoContentStyle}
|
|
169
|
+
align='left'
|
|
170
|
+
>
|
|
171
|
+
<p className={infoTitleStyle}>Weekly update</p>
|
|
172
|
+
<p className={infoBodyStyle}>
|
|
173
|
+
Get a summary of new activity in your workspace every Friday.
|
|
174
|
+
</p>
|
|
175
|
+
<LabelledCheckbox id='storybook-weekly-update-checkbox'>
|
|
176
|
+
Email me the weekly summary
|
|
177
|
+
</LabelledCheckbox>
|
|
178
|
+
</Dropdown.Content>
|
|
179
|
+
</Dropdown>
|
|
180
|
+
</div>
|
|
181
|
+
</ThemeContextProvider>
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { HTMLAttributes, useEffect } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import { useTheme } from '../../theme';
|
|
4
|
+
import DropdownProvider, { useDropdownContext } from './Dropdown.context';
|
|
5
|
+
import DropdownTrigger from './DropdownTrigger';
|
|
6
|
+
import DropdownContent from './DropdownContent';
|
|
7
|
+
|
|
8
|
+
export const NAME = 'ucl-uikit-dropdown';
|
|
9
|
+
|
|
10
|
+
export interface DropdownProps extends HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
defaultOpen?: boolean;
|
|
12
|
+
testId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DropdownBody = ({
|
|
16
|
+
testId = NAME,
|
|
17
|
+
className,
|
|
18
|
+
children,
|
|
19
|
+
...props
|
|
20
|
+
}: Omit<DropdownProps, 'defaultOpen'>) => {
|
|
21
|
+
const [theme] = useTheme();
|
|
22
|
+
const { setIsOpen, triggerRef, contentRef } = useDropdownContext();
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const handleDocumentClick = (event: MouseEvent) => {
|
|
26
|
+
const target = event.target as Node;
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
triggerRef.current?.contains(target) ||
|
|
30
|
+
contentRef.current?.contains(target)
|
|
31
|
+
) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setIsOpen(false);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleDocumentKeydown = (event: KeyboardEvent) => {
|
|
39
|
+
if (event.key === 'Escape') {
|
|
40
|
+
setIsOpen(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
document.addEventListener('mousedown', handleDocumentClick);
|
|
45
|
+
document.addEventListener('keydown', handleDocumentKeydown);
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
document.removeEventListener('mousedown', handleDocumentClick);
|
|
49
|
+
document.removeEventListener('keydown', handleDocumentKeydown);
|
|
50
|
+
};
|
|
51
|
+
}, [setIsOpen, triggerRef, contentRef]);
|
|
52
|
+
|
|
53
|
+
const baseStyle = css`
|
|
54
|
+
position: relative;
|
|
55
|
+
display: inline-flex;
|
|
56
|
+
font-family: ${theme.font.family.primary};
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
const style = cx(NAME, baseStyle, className);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className={style}
|
|
64
|
+
data-testid={testId}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const Dropdown = ({ defaultOpen = false, ...props }: DropdownProps) => {
|
|
73
|
+
return (
|
|
74
|
+
<DropdownProvider defaultOpen={defaultOpen}>
|
|
75
|
+
<DropdownBody {...props} />
|
|
76
|
+
</DropdownProvider>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export interface IDropdownSubComponents {
|
|
81
|
+
Trigger: typeof DropdownTrigger;
|
|
82
|
+
Content: typeof DropdownContent;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DropdownWithSubComponents = Dropdown as typeof Dropdown &
|
|
86
|
+
IDropdownSubComponents;
|
|
87
|
+
|
|
88
|
+
DropdownWithSubComponents.Trigger = DropdownTrigger;
|
|
89
|
+
DropdownWithSubComponents.Content = DropdownContent;
|
|
90
|
+
|
|
91
|
+
export default DropdownWithSubComponents;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { HTMLAttributes, memo } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import { useTheme } from '../../theme';
|
|
4
|
+
import { useDropdownContext } from './Dropdown.context';
|
|
5
|
+
|
|
6
|
+
export const NAME = 'ucl-uikit-dropdown__content';
|
|
7
|
+
|
|
8
|
+
export interface DropdownContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
testId?: string;
|
|
10
|
+
align?: 'left' | 'right';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DropdownContent = ({
|
|
14
|
+
testId = NAME,
|
|
15
|
+
align = 'right',
|
|
16
|
+
className,
|
|
17
|
+
children,
|
|
18
|
+
...props
|
|
19
|
+
}: DropdownContentProps) => {
|
|
20
|
+
const [theme] = useTheme();
|
|
21
|
+
const { isOpen, contentRef } = useDropdownContext();
|
|
22
|
+
|
|
23
|
+
const baseStyle = css`
|
|
24
|
+
display: none;
|
|
25
|
+
position: absolute;
|
|
26
|
+
top: calc(100% + ${theme.margin.m8});
|
|
27
|
+
z-index: 100;
|
|
28
|
+
min-width: max-content;
|
|
29
|
+
background-color: ${theme.colour.surface.default};
|
|
30
|
+
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};
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const alignLeftStyle = css`
|
|
37
|
+
left: 0;
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const alignRightStyle = css`
|
|
41
|
+
right: 0;
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const openStyle = css`
|
|
45
|
+
display: block;
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const style = cx(
|
|
49
|
+
NAME,
|
|
50
|
+
baseStyle,
|
|
51
|
+
align === 'left' && alignLeftStyle,
|
|
52
|
+
align === 'right' && alignRightStyle,
|
|
53
|
+
isOpen && openStyle,
|
|
54
|
+
className
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
ref={contentRef}
|
|
60
|
+
className={style}
|
|
61
|
+
role='menu'
|
|
62
|
+
data-testid={testId}
|
|
63
|
+
{...props}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default memo(DropdownContent);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KeyboardEvent,
|
|
3
|
+
MouseEvent,
|
|
4
|
+
ReactElement,
|
|
5
|
+
cloneElement,
|
|
6
|
+
isValidElement,
|
|
7
|
+
memo,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { useDropdownContext } from './Dropdown.context';
|
|
10
|
+
|
|
11
|
+
export const NAME = 'ucl-uikit-dropdown__trigger';
|
|
12
|
+
|
|
13
|
+
export interface DropdownTriggerProps {
|
|
14
|
+
children: ReactElement<Record<string, unknown>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DropdownTrigger = ({ children }: DropdownTriggerProps) => {
|
|
18
|
+
const { isOpen, toggle, triggerRef } = useDropdownContext();
|
|
19
|
+
|
|
20
|
+
if (!isValidElement(children)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const childProps = children.props as Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
const existingOnClick = childProps.onClick as
|
|
27
|
+
| ((event: MouseEvent<HTMLElement>) => void)
|
|
28
|
+
| undefined;
|
|
29
|
+
const existingOnKeyDown = childProps.onKeyDown as
|
|
30
|
+
| ((event: KeyboardEvent<HTMLElement>) => void)
|
|
31
|
+
| undefined;
|
|
32
|
+
|
|
33
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
34
|
+
existingOnKeyDown?.(event);
|
|
35
|
+
|
|
36
|
+
if (event.defaultPrevented) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
toggle();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return cloneElement(children, {
|
|
47
|
+
ref: triggerRef,
|
|
48
|
+
className: [NAME, childProps.className].filter(Boolean).join(' '),
|
|
49
|
+
onClick: (event: MouseEvent<HTMLElement>) => {
|
|
50
|
+
existingOnClick?.(event);
|
|
51
|
+
|
|
52
|
+
if (!event.defaultPrevented) {
|
|
53
|
+
toggle();
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
onKeyDown: handleKeyDown,
|
|
57
|
+
'aria-haspopup': childProps['aria-haspopup'] ?? 'menu',
|
|
58
|
+
'aria-expanded': isOpen,
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default memo(DropdownTrigger);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import Dropdown from '../Dropdown';
|
|
4
|
+
import Button from '../../Button';
|
|
5
|
+
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
6
|
+
|
|
7
|
+
describe('Dropdown', () => {
|
|
8
|
+
test('opens and closes when trigger is clicked', () => {
|
|
9
|
+
render(
|
|
10
|
+
<ThemeContextProvider>
|
|
11
|
+
<Dropdown>
|
|
12
|
+
<Dropdown.Trigger>
|
|
13
|
+
<Button aria-label='Open dropdown'>Open</Button>
|
|
14
|
+
</Dropdown.Trigger>
|
|
15
|
+
<Dropdown.Content>
|
|
16
|
+
<div>Content</div>
|
|
17
|
+
</Dropdown.Content>
|
|
18
|
+
</Dropdown>
|
|
19
|
+
</ThemeContextProvider>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const trigger = screen.getByRole('button', { name: 'Open dropdown' });
|
|
23
|
+
const content = screen.getByTestId('ucl-uikit-dropdown__content');
|
|
24
|
+
|
|
25
|
+
expect(content).not.toBeVisible();
|
|
26
|
+
|
|
27
|
+
fireEvent.click(trigger);
|
|
28
|
+
expect(content).toBeVisible();
|
|
29
|
+
|
|
30
|
+
fireEvent.click(trigger);
|
|
31
|
+
expect(content).not.toBeVisible();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('closes on outside click and escape', () => {
|
|
35
|
+
render(
|
|
36
|
+
<ThemeContextProvider>
|
|
37
|
+
<div>
|
|
38
|
+
<Dropdown defaultOpen>
|
|
39
|
+
<Dropdown.Trigger>
|
|
40
|
+
<Button aria-label='Open dropdown'>Open</Button>
|
|
41
|
+
</Dropdown.Trigger>
|
|
42
|
+
<Dropdown.Content>
|
|
43
|
+
<div>Content</div>
|
|
44
|
+
</Dropdown.Content>
|
|
45
|
+
</Dropdown>
|
|
46
|
+
<button type='button'>Outside</button>
|
|
47
|
+
</div>
|
|
48
|
+
</ThemeContextProvider>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const content = screen.getByTestId('ucl-uikit-dropdown__content');
|
|
52
|
+
expect(content).toBeVisible();
|
|
53
|
+
|
|
54
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
55
|
+
expect(content).not.toBeVisible();
|
|
56
|
+
|
|
57
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open dropdown' }));
|
|
58
|
+
expect(content).toBeVisible();
|
|
59
|
+
|
|
60
|
+
fireEvent.mouseDown(screen.getByRole('button', { name: 'Outside' }));
|
|
61
|
+
expect(content).not.toBeVisible();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import { css, cx } from '@emotion/css';
|
|
3
|
+
import Dropdown, { DropdownProps } from '../Dropdown';
|
|
4
|
+
import Button from '../Button';
|
|
5
|
+
import IconButton from '../IconButton';
|
|
6
|
+
import Icon from '../Icon';
|
|
7
|
+
import useTheme from '../../theme/useTheme';
|
|
8
|
+
import useMediaQuery from '../../hooks/useMediaQuery';
|
|
9
|
+
|
|
10
|
+
export const NAME = 'ucl-uikit-dropdown-menu';
|
|
11
|
+
|
|
12
|
+
export interface DropdownMenuProps
|
|
13
|
+
extends
|
|
14
|
+
Omit<DropdownProps, 'children'>,
|
|
15
|
+
Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
label?: string;
|
|
18
|
+
triggerAriaLabel?: string;
|
|
19
|
+
dropdownClassName?: string;
|
|
20
|
+
triggerClassName?: string;
|
|
21
|
+
contentClassName?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DropdownMenu = ({
|
|
25
|
+
children,
|
|
26
|
+
label = 'Menu',
|
|
27
|
+
triggerAriaLabel = 'Open menu',
|
|
28
|
+
dropdownClassName,
|
|
29
|
+
triggerClassName,
|
|
30
|
+
contentClassName,
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: DropdownMenuProps) => {
|
|
34
|
+
const [theme] = useTheme();
|
|
35
|
+
const isDesktop = useMediaQuery(
|
|
36
|
+
`(min-width: ${theme.breakpoints.desktop}px)`
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const triggerStyle = css`
|
|
40
|
+
color: ${theme.colour.text.brandPrimary};
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const dropdownStyle = cx(NAME, dropdownClassName);
|
|
44
|
+
const resolvedTriggerClassName = cx(
|
|
45
|
+
triggerStyle,
|
|
46
|
+
className,
|
|
47
|
+
triggerClassName
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Dropdown
|
|
52
|
+
className={dropdownStyle}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
<Dropdown.Trigger>
|
|
56
|
+
{isDesktop ? (
|
|
57
|
+
<Button
|
|
58
|
+
variant='tertiary-no-padding'
|
|
59
|
+
size='small'
|
|
60
|
+
className={resolvedTriggerClassName}
|
|
61
|
+
icon={<Icon.Menu2 size={20} />}
|
|
62
|
+
aria-label={triggerAriaLabel}
|
|
63
|
+
>
|
|
64
|
+
{label}
|
|
65
|
+
</Button>
|
|
66
|
+
) : (
|
|
67
|
+
<IconButton
|
|
68
|
+
className={resolvedTriggerClassName}
|
|
69
|
+
aria-label={triggerAriaLabel}
|
|
70
|
+
>
|
|
71
|
+
<Icon.Menu2 size={20} />
|
|
72
|
+
</IconButton>
|
|
73
|
+
)}
|
|
74
|
+
</Dropdown.Trigger>
|
|
75
|
+
<Dropdown.Content className={contentClassName}>
|
|
76
|
+
{children}
|
|
77
|
+
</Dropdown.Content>
|
|
78
|
+
</Dropdown>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default DropdownMenu;
|