@os-design/core 1.0.199 → 1.0.200
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/package.json +21 -13
- package/src/@types/emotion.d.ts +7 -0
- package/src/Alert/index.tsx +112 -0
- package/src/Avatar/index.tsx +173 -0
- package/src/Avatar/utils/nameToInitials.ts +12 -0
- package/src/Avatar/utils/strToHue.ts +13 -0
- package/src/AvatarSkeleton/index.tsx +29 -0
- package/src/Breadcrumb/index.tsx +93 -0
- package/src/BreadcrumbItem/index.tsx +83 -0
- package/src/Button/ButtonContent.tsx +91 -0
- package/src/Button/index.tsx +225 -0
- package/src/Button/utils/useButtonColors.ts +84 -0
- package/src/Checkbox/index.tsx +225 -0
- package/src/CheckboxSkeleton/index.tsx +50 -0
- package/src/DatePicker/DatePickerCalendar.tsx +220 -0
- package/src/DatePicker/index.tsx +568 -0
- package/src/Drawer/index.tsx +212 -0
- package/src/Form/FormConfigContext.ts +16 -0
- package/src/Form/index.tsx +49 -0
- package/src/FormDivider/index.tsx +74 -0
- package/src/FormItem/index.tsx +118 -0
- package/src/Gallery/Status.tsx +62 -0
- package/src/Gallery/index.tsx +290 -0
- package/src/GlobalStyles/index.tsx +17 -0
- package/src/GlobalStyles/resetStyles.ts +17 -0
- package/src/GlobalStyles/typographyStyles.ts +78 -0
- package/src/HeaderSkeleton/index.tsx +64 -0
- package/src/Image/index.tsx +104 -0
- package/src/ImageSkeleton/index.tsx +22 -0
- package/src/Input/index.tsx +330 -0
- package/src/Input/utils/getFocusableElements.ts +8 -0
- package/src/InputNumber/index.tsx +208 -0
- package/src/InputNumber/utils/defaultLocale.ts +9 -0
- package/src/InputPassword/index.tsx +201 -0
- package/src/InputPassword/utils/defaultLocale.ts +11 -0
- package/src/InputSearch/index.tsx +111 -0
- package/src/InputSearch/utils/defaultLocale.ts +9 -0
- package/src/InputSkeleton/index.tsx +28 -0
- package/src/Layout/LayoutContext.ts +21 -0
- package/src/Layout/index.tsx +44 -0
- package/src/Link/index.tsx +129 -0
- package/src/LinkButton/index.tsx +100 -0
- package/src/List/WindowScroller.tsx +53 -0
- package/src/List/index.tsx +255 -0
- package/src/List/utils/bodyPointerEvents.ts +24 -0
- package/src/List/utils/frameTimeout.ts +36 -0
- package/src/List/utils/useRWLoadNext.ts +38 -0
- package/src/ListItem/index.tsx +92 -0
- package/src/ListItemActions/index.tsx +207 -0
- package/src/ListItemLink/index.tsx +63 -0
- package/src/ListSkeleton/index.tsx +115 -0
- package/src/LogoLink/index.tsx +93 -0
- package/src/LogoLink/logo.example.svg +18 -0
- package/src/Menu/index.tsx +128 -0
- package/src/Menu/utils/useFocusWithArrows.ts +50 -0
- package/src/MenuDivider/index.tsx +22 -0
- package/src/MenuGroup/index.tsx +190 -0
- package/src/MenuItem/index.tsx +108 -0
- package/src/Modal/index.tsx +411 -0
- package/src/Modal/utils/defaultLocale.ts +9 -0
- package/src/Navigation/index.tsx +214 -0
- package/src/Navigation/utils/useScrollFlags.ts +39 -0
- package/src/NavigationItem/index.tsx +136 -0
- package/src/PageContent/index.tsx +99 -0
- package/src/PageHeader/index.tsx +246 -0
- package/src/PageHeader/utils/defaultLocale.ts +9 -0
- package/src/PageHeaderInputSearch/index.tsx +145 -0
- package/src/PageHeaderInputSearch/utils/defaultLocale.ts +16 -0
- package/src/PageHeaderSkeleton/index.tsx +33 -0
- package/src/ParagraphSkeleton/index.tsx +65 -0
- package/src/Popover/index.tsx +243 -0
- package/src/Popover/utils/usePopoverPosition.ts +216 -0
- package/src/Progress/index.tsx +100 -0
- package/src/RadioGroup/index.tsx +165 -0
- package/src/RadioGroupSkeleton/index.tsx +36 -0
- package/src/Result/index.tsx +109 -0
- package/src/ScrollButton/index.tsx +159 -0
- package/src/ScrollButton/utils/useContainerPosition.ts +41 -0
- package/src/ScrollButton/utils/useVisibility.ts +56 -0
- package/src/Select/index.tsx +970 -0
- package/src/Select/utils/defaultLocale.ts +11 -0
- package/src/Skeleton/index.tsx +52 -0
- package/src/Switch/index.tsx +217 -0
- package/src/SwitchSkeleton/index.tsx +30 -0
- package/src/Tag/index.tsx +75 -0
- package/src/TagLink/index.tsx +53 -0
- package/src/TagList/index.tsx +95 -0
- package/src/TagListSkeleton/index.tsx +38 -0
- package/src/TagSkeleton/index.tsx +40 -0
- package/src/TextArea/index.tsx +231 -0
- package/src/TextAreaSkeleton/index.tsx +20 -0
- package/src/ThemeSwitcher/index.tsx +39 -0
- package/src/TimePicker/index.tsx +142 -0
- package/src/Video/index.tsx +41 -0
- package/src/index.ts +125 -0
- package/src/message/AlertIcon.tsx +50 -0
- package/src/message/Message.tsx +108 -0
- package/src/message/index.tsx +64 -0
- package/src/message/styles.ts +25 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import styled from '@emotion/styled';
|
|
2
|
+
import { horizontalPaddingStyles } from '@os-design/styles';
|
|
3
|
+
import { clr } from '@os-design/theming';
|
|
4
|
+
import React, { forwardRef, useCallback } from 'react';
|
|
5
|
+
import { FixedSizeList } from 'react-window';
|
|
6
|
+
import List, { ListProps } from '../List';
|
|
7
|
+
import { ListItemProps } from '../ListItem';
|
|
8
|
+
import Skeleton from '../Skeleton';
|
|
9
|
+
|
|
10
|
+
export interface ListSkeletonProps
|
|
11
|
+
extends Omit<ListProps, 'itemCount' | 'itemRenderer'>,
|
|
12
|
+
Pick<ListItemProps, 'left' | 'right'> {
|
|
13
|
+
/**
|
|
14
|
+
* The description placeholder.
|
|
15
|
+
* @default false
|
|
16
|
+
*/
|
|
17
|
+
hasDescription?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The width of the title.
|
|
20
|
+
* @default 30%
|
|
21
|
+
*/
|
|
22
|
+
titleWidth?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The width of the description.
|
|
25
|
+
* @default 40%
|
|
26
|
+
*/
|
|
27
|
+
descriptionWidth?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Total count of items.
|
|
30
|
+
* @default 10
|
|
31
|
+
*/
|
|
32
|
+
itemCount?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ListItem = styled.div`
|
|
36
|
+
width: 100%;
|
|
37
|
+
height: 100%;
|
|
38
|
+
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
|
|
43
|
+
&:not(:last-of-type) {
|
|
44
|
+
border-bottom: 1px solid ${(p) => clr(p.theme.listItemColorBorder)};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
${horizontalPaddingStyles()};
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const Content = styled.div`
|
|
51
|
+
width: 100%;
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const DescriptionSkeleton = styled(Skeleton)`
|
|
55
|
+
height: ${(p) => p.theme.sizes.small}em;
|
|
56
|
+
margin-top: 0.3em;
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
const LeftAddon = styled.div`
|
|
60
|
+
color: ${(p) => clr(p.theme.colorText)};
|
|
61
|
+
padding-right: ${(p) => p.theme.listItemAddonPaddingHorizontal}em;
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
const RightAddon = styled.div`
|
|
65
|
+
color: ${(p) => clr(p.theme.colorText)};
|
|
66
|
+
margin-left: auto;
|
|
67
|
+
padding-left: ${(p) => p.theme.listItemAddonPaddingHorizontal}em;
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Provides a list placeholder while a user waits for the content to load.
|
|
72
|
+
*/
|
|
73
|
+
const ListSkeleton = forwardRef<FixedSizeList, ListSkeletonProps>(
|
|
74
|
+
(
|
|
75
|
+
{
|
|
76
|
+
hasDescription = false,
|
|
77
|
+
titleWidth = '30%',
|
|
78
|
+
descriptionWidth = '40%',
|
|
79
|
+
itemCount = 10,
|
|
80
|
+
left,
|
|
81
|
+
right,
|
|
82
|
+
...rest
|
|
83
|
+
},
|
|
84
|
+
ref
|
|
85
|
+
) => {
|
|
86
|
+
const itemRenderer = useCallback(
|
|
87
|
+
({ style }) => (
|
|
88
|
+
<ListItem style={style}>
|
|
89
|
+
{left && <LeftAddon>{left}</LeftAddon>}
|
|
90
|
+
|
|
91
|
+
<Content>
|
|
92
|
+
<Skeleton width={titleWidth} />
|
|
93
|
+
{hasDescription && <DescriptionSkeleton width={descriptionWidth} />}
|
|
94
|
+
</Content>
|
|
95
|
+
|
|
96
|
+
{right && <RightAddon>{right}</RightAddon>}
|
|
97
|
+
</ListItem>
|
|
98
|
+
),
|
|
99
|
+
[descriptionWidth, hasDescription, left, right, titleWidth]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<List
|
|
104
|
+
itemCount={itemCount}
|
|
105
|
+
itemRenderer={itemRenderer}
|
|
106
|
+
{...rest}
|
|
107
|
+
ref={ref}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
ListSkeleton.displayName = 'ListSkeleton';
|
|
114
|
+
|
|
115
|
+
export default ListSkeleton;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { css } from '@emotion/react';
|
|
2
|
+
import styled from '@emotion/styled';
|
|
3
|
+
import {
|
|
4
|
+
resetFocusStyles,
|
|
5
|
+
sizeStyles,
|
|
6
|
+
transitionStyles,
|
|
7
|
+
WithSize,
|
|
8
|
+
} from '@os-design/styles';
|
|
9
|
+
import { useTheme } from '@os-design/theming';
|
|
10
|
+
import { omitEmotionProps } from '@os-design/utils';
|
|
11
|
+
import React, { forwardRef } from 'react';
|
|
12
|
+
import { LinkProps, ReactRouterLinkProps } from '../Link';
|
|
13
|
+
|
|
14
|
+
type JsxAProps = Omit<JSX.IntrinsicElements['a'], 'ref'>;
|
|
15
|
+
export interface LogoLinkProps
|
|
16
|
+
extends JsxAProps,
|
|
17
|
+
ReactRouterLinkProps,
|
|
18
|
+
Pick<LinkProps, 'as'>,
|
|
19
|
+
WithSize {
|
|
20
|
+
/**
|
|
21
|
+
* The source of the logo image.
|
|
22
|
+
*/
|
|
23
|
+
src?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const darkStyles = (p) =>
|
|
27
|
+
p.dark &&
|
|
28
|
+
css`
|
|
29
|
+
img {
|
|
30
|
+
filter: brightness(0) invert(1); // Make the logo white
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
interface StyledLogoLinkProps extends WithSize {
|
|
35
|
+
dark: boolean;
|
|
36
|
+
}
|
|
37
|
+
const StyledLogoLink = styled(
|
|
38
|
+
'a',
|
|
39
|
+
omitEmotionProps('dark', 'size', 'as')
|
|
40
|
+
)<StyledLogoLinkProps>`
|
|
41
|
+
${resetFocusStyles};
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
text-decoration: none;
|
|
44
|
+
display: block;
|
|
45
|
+
height: 1.5em;
|
|
46
|
+
|
|
47
|
+
img {
|
|
48
|
+
display: block;
|
|
49
|
+
height: 100%;
|
|
50
|
+
transform: rotate(0); // Fixes moves on hover
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (hover: hover) {
|
|
54
|
+
&:hover,
|
|
55
|
+
&:focus {
|
|
56
|
+
opacity: 0.7;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
${darkStyles};
|
|
61
|
+
${sizeStyles};
|
|
62
|
+
${transitionStyles('opacity')};
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Logo with a link.
|
|
67
|
+
*/
|
|
68
|
+
const LogoLink = forwardRef<HTMLAnchorElement, LogoLinkProps>(
|
|
69
|
+
({ src, as, onMouseDown = () => {}, ...rest }, ref) => {
|
|
70
|
+
const { activeTheme } = useTheme();
|
|
71
|
+
const ariaLabel = rest['aria-label'] || 'Logo';
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<StyledLogoLink
|
|
75
|
+
dark={activeTheme === 'dark'}
|
|
76
|
+
aria-label={ariaLabel}
|
|
77
|
+
as={as}
|
|
78
|
+
onMouseDown={(e) => {
|
|
79
|
+
onMouseDown(e);
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
}}
|
|
82
|
+
{...rest}
|
|
83
|
+
ref={ref}
|
|
84
|
+
>
|
|
85
|
+
<img src={src} alt={ariaLabel} />
|
|
86
|
+
</StyledLogoLink>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
LogoLink.displayName = 'LogoLink';
|
|
92
|
+
|
|
93
|
+
export default LogoLink;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="223" viewBox="0 0 1024 223">
|
|
2
|
+
<g transform="translate(0,223) scale(0.1,-0.1)" fill="#0a66c2">
|
|
3
|
+
<path
|
|
4
|
+
d='M522 1740 c-173 -46 -328 -169 -410 -326 -133 -258 -91 -556 108 -754 109 -109 193 -154 340 -186 196 -42 429 20 558 147 85 85 91 163 17 228 -63 55 -115 50 -217 -22 -90 -63 -133 -77 -236 -77 -75 0 -95 4 -147 29 -282 134 -282 528 1 667 50 25 71 29 139 29 104 0 143 -13 244 -80 71 -47 90 -55 130 -55 37 0 54 6 83 30 78 64 72 150 -15 235 -139 134 -385 190 -595 135z'/>
|
|
5
|
+
<path
|
|
6
|
+
d='M1800 1744 c-111 -24 -223 -86 -310 -173 -134 -132 -193 -274 -193 -461 0 -188 59 -329 193 -462 314 -311 844 -222 1033 173 54 111 67 169 67 289 0 120 -13 178 -67 289 -128 267 -425 409 -723 345z m282 -294 c73 -27 124 -69 170 -137 46 -70 62 -134 56 -233 -9 -156 -104 -273 -260 -322 -73 -22 -124 -23 -197 -2 -115 32 -209 119 -251 231 -30 80 -25 205 10 277 91 180 284 257 472 186z'/>
|
|
7
|
+
<path
|
|
8
|
+
d='M2864 1750 c-11 -4 -34 -24 -52 -44 l-32 -37 0 -545 c0 -602 -2 -583 63 -623 64 -39 153 -22 193 36 18 26 19 56 24 408 6 430 3 417 88 482 112 86 266 64 325 -46 l27 -49 0 -366 c0 -288 3 -374 14 -399 41 -96 157 -120 230 -48 l31 31 5 383 c6 421 4 409 72 474 70 67 176 91 255 58 52 -22 72 -42 99 -100 24 -50 24 -53 24 -411 0 -230 4 -372 11 -392 36 -103 212 -109 255 -7 22 52 20 725 -2 833 -37 179 -158 315 -319 356 -153 41 -320 5 -468 -101 l-48 -34 -55 50 c-79 72 -143 95 -274 95 -95 1 -111 -2 -165 -27 -33 -16 -71 -38 -84 -50 -20 -18 -25 -19 -29 -7 -3 8 -22 32 -42 53 -32 32 -44 37 -81 36 -24 0 -53 -4 -65 -9z'/>
|
|
9
|
+
<path
|
|
10
|
+
d='M4835 1750 c-27 -11 -61 -49 -74 -83 -8 -19 -11 -271 -11 -778 0 -810 -1 -787 53 -838 35 -32 111 -40 156 -17 17 10 41 31 51 49 18 29 20 52 20 260 0 126 3 227 8 225 4 -2 32 -16 62 -32 89 -47 159 -67 260 -73 318 -19 597 184 675 492 19 76 19 234 0 310 -46 182 -173 342 -335 423 -188 94 -398 95 -580 4 -41 -20 -77 -38 -80 -39 -3 -2 -15 15 -27 36 -34 59 -116 87 -178 61z m710 -301 c79 -30 154 -100 191 -182 26 -54 29 -73 29 -157 0 -82 -4 -103 -26 -151 -49 -104 -127 -170 -238 -203 -69 -20 -123 -20 -192 0 -140 41 -236 144 -265 285 -25 119 6 222 95 321 98 110 256 143 406 87z'/>
|
|
11
|
+
<path
|
|
12
|
+
d='M6675 1746 c-142 -33 -236 -87 -337 -194 -119 -127 -172 -262 -172 -442 0 -195 53 -320 193 -460 89 -89 158 -130 271 -165 84 -25 231 -30 315 -11 71 17 182 71 223 109 l22 20 0 -22 c0 -78 118 -128 201 -86 16 9 39 30 49 48 19 30 20 52 20 334 0 343 -7 401 -67 522 -44 89 -169 226 -248 271 -135 77 -328 109 -470 76z m296 -307 c82 -38 139 -95 177 -177 23 -49 27 -70 27 -152 0 -87 -3 -101 -33 -162 -39 -79 -92 -129 -181 -172 -58 -27 -77 -31 -152 -31 -77 0 -91 3 -153 34 -156 80 -235 230 -206 393 45 244 293 372 521 267z'/>
|
|
13
|
+
<path
|
|
14
|
+
d='M7789 1737 c-71 -47 -69 -32 -69 -615 0 -572 -1 -564 59 -615 42 -35 124 -38 165 -6 50 39 51 44 57 394 5 319 6 332 29 390 28 68 93 140 155 170 24 11 69 21 111 23 88 5 157 -21 210 -80 82 -91 94 -161 94 -545 l0 -292 30 -35 c51 -58 126 -69 188 -28 50 33 52 46 52 407 0 383 -5 425 -72 565 -137 286 -478 377 -733 196 l-65 -47 0 27 c0 18 -14 40 -44 70 -38 39 -49 44 -88 44 -30 0 -56 -8 -79 -23z'/>
|
|
15
|
+
<path
|
|
16
|
+
d='M9105 1751 c-44 -19 -78 -68 -82 -118 -4 -45 10 -78 216 -531 122 -266 221 -489 221 -494 0 -6 -43 -108 -97 -227 -100 -223 -111 -268 -84 -319 42 -79 175 -82 229 -5 10 16 171 361 357 768 317 691 339 744 339 797 1 53 -2 60 -35 94 -61 61 -154 57 -206 -9 -10 -13 -92 -190 -183 -395 -91 -204 -168 -368 -171 -364 -3 4 -79 174 -168 377 -105 237 -173 380 -191 399 -22 22 -39 30 -77 32 -26 2 -57 0 -68 -5z'/>
|
|
17
|
+
</g>
|
|
18
|
+
</svg>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import styled from '@emotion/styled';
|
|
2
|
+
import { useIsMinWidth } from '@os-design/media';
|
|
3
|
+
import { MenuContext } from '@os-design/menu-utils';
|
|
4
|
+
import { enableScrollingStyles } from '@os-design/styles';
|
|
5
|
+
import {
|
|
6
|
+
useBrowserLayoutEffect,
|
|
7
|
+
useForwardedRef,
|
|
8
|
+
useKeyPress,
|
|
9
|
+
} from '@os-design/utils';
|
|
10
|
+
import React, { forwardRef, RefObject, useMemo } from 'react';
|
|
11
|
+
import Modal from '../Modal';
|
|
12
|
+
import Popover, { PopoverProps } from '../Popover';
|
|
13
|
+
import useFocusWithArrows from './utils/useFocusWithArrows';
|
|
14
|
+
|
|
15
|
+
export interface MenuProps extends PopoverProps {
|
|
16
|
+
/**
|
|
17
|
+
* Whether the menu closes when the user selects a menu item.
|
|
18
|
+
* @default true
|
|
19
|
+
*/
|
|
20
|
+
closeOnSelect?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The title of the modal.
|
|
23
|
+
* @default undefined
|
|
24
|
+
*/
|
|
25
|
+
modalTitle?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const StyledPopover = styled(Popover)`
|
|
29
|
+
padding: ${(p) => p.theme.menuPaddingVertical}em 0;
|
|
30
|
+
min-width: ${(p) => p.theme.menuMinWidth}em;
|
|
31
|
+
max-height: ${(p) => p.theme.menuMaxHeight}em;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
${enableScrollingStyles('y')};
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const StyledModal = styled(Modal)`
|
|
37
|
+
padding-left: 0;
|
|
38
|
+
padding-right: 0;
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The dropdown menu.
|
|
43
|
+
*/
|
|
44
|
+
const Menu = forwardRef<HTMLDivElement, MenuProps>(
|
|
45
|
+
(
|
|
46
|
+
{
|
|
47
|
+
closeOnSelect = true,
|
|
48
|
+
modalTitle,
|
|
49
|
+
trigger,
|
|
50
|
+
placement = 'bottom-start',
|
|
51
|
+
visible,
|
|
52
|
+
onClose = () => {},
|
|
53
|
+
size,
|
|
54
|
+
className,
|
|
55
|
+
id,
|
|
56
|
+
children,
|
|
57
|
+
...rest
|
|
58
|
+
},
|
|
59
|
+
ref
|
|
60
|
+
) => {
|
|
61
|
+
const [containerRef, mergedContainerRef] = useForwardedRef(ref);
|
|
62
|
+
useFocusWithArrows(containerRef);
|
|
63
|
+
useKeyPress(
|
|
64
|
+
(typeof window !== 'undefined' ? window : undefined) as EventTarget,
|
|
65
|
+
'Escape',
|
|
66
|
+
onClose
|
|
67
|
+
);
|
|
68
|
+
const isMinXs = useIsMinWidth('xs');
|
|
69
|
+
|
|
70
|
+
const menuId = useMemo(
|
|
71
|
+
() => id || `menu-${Math.random().toString(36).slice(2, 11)}`,
|
|
72
|
+
[id]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Replace the aria-haspopup attribute from true to menu
|
|
76
|
+
useBrowserLayoutEffect(() => {
|
|
77
|
+
if (!trigger) return;
|
|
78
|
+
const { current } = trigger as RefObject<Element>;
|
|
79
|
+
if (!current) return;
|
|
80
|
+
current.setAttribute('aria-haspopup', 'menu');
|
|
81
|
+
current.setAttribute('aria-controls', menuId);
|
|
82
|
+
}, [menuId]);
|
|
83
|
+
|
|
84
|
+
const contextValue = useMemo(
|
|
85
|
+
() => ({ closeOnSelect, onClose }),
|
|
86
|
+
[closeOnSelect, onClose]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<MenuContext.Provider value={contextValue}>
|
|
91
|
+
{isMinXs ? (
|
|
92
|
+
<StyledPopover
|
|
93
|
+
trigger={trigger}
|
|
94
|
+
placement={placement}
|
|
95
|
+
visible={visible}
|
|
96
|
+
onClose={onClose}
|
|
97
|
+
size={size}
|
|
98
|
+
className={className}
|
|
99
|
+
id={menuId}
|
|
100
|
+
role='menu'
|
|
101
|
+
{...rest}
|
|
102
|
+
ref={mergedContainerRef}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</StyledPopover>
|
|
106
|
+
) : (
|
|
107
|
+
<StyledModal
|
|
108
|
+
title={modalTitle}
|
|
109
|
+
footer={null}
|
|
110
|
+
visible={visible}
|
|
111
|
+
onClose={onClose}
|
|
112
|
+
size={size}
|
|
113
|
+
className={className}
|
|
114
|
+
id={menuId}
|
|
115
|
+
role='menu'
|
|
116
|
+
ref={mergedContainerRef}
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
</StyledModal>
|
|
120
|
+
)}
|
|
121
|
+
</MenuContext.Provider>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
Menu.displayName = 'Menu';
|
|
127
|
+
|
|
128
|
+
export default Menu;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { KeyPressListener, useKeyPress } from '@os-design/utils';
|
|
2
|
+
import { RefObject, useCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
const useFocusWithArrows = (ref: RefObject<Element>): void => {
|
|
5
|
+
const arrowKeyPressListener = useCallback<KeyPressListener>(
|
|
6
|
+
(e) => {
|
|
7
|
+
if (!ref.current) return;
|
|
8
|
+
const focusableListItems = ref.current.querySelectorAll<HTMLElement>(
|
|
9
|
+
'button:not([disabled])'
|
|
10
|
+
);
|
|
11
|
+
const { activeElement } = document;
|
|
12
|
+
const curFocusedIndex = activeElement
|
|
13
|
+
? ([...focusableListItems] as Element[]).indexOf(activeElement)
|
|
14
|
+
: -1;
|
|
15
|
+
|
|
16
|
+
let nextFocusedIndex;
|
|
17
|
+
if (curFocusedIndex === -1 && e.key === 'ArrowUp') {
|
|
18
|
+
nextFocusedIndex = focusableListItems.length - 1;
|
|
19
|
+
} else if (curFocusedIndex === -1 && e.key === 'ArrowDown') {
|
|
20
|
+
nextFocusedIndex = 0;
|
|
21
|
+
} else if (curFocusedIndex > -1 && e.key === 'ArrowUp') {
|
|
22
|
+
nextFocusedIndex =
|
|
23
|
+
curFocusedIndex > 0
|
|
24
|
+
? curFocusedIndex - 1
|
|
25
|
+
: focusableListItems.length - 1;
|
|
26
|
+
} else if (curFocusedIndex > -1 && e.key === 'ArrowDown') {
|
|
27
|
+
nextFocusedIndex =
|
|
28
|
+
curFocusedIndex < focusableListItems.length - 1
|
|
29
|
+
? curFocusedIndex + 1
|
|
30
|
+
: 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (nextFocusedIndex === undefined) return;
|
|
34
|
+
const nextFocusedListItem = focusableListItems.item(nextFocusedIndex);
|
|
35
|
+
if (!nextFocusedListItem) return;
|
|
36
|
+
|
|
37
|
+
nextFocusedListItem.focus();
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
},
|
|
40
|
+
[ref]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
useKeyPress(
|
|
44
|
+
(typeof window !== 'undefined' ? window : undefined) as EventTarget,
|
|
45
|
+
['ArrowUp', 'ArrowDown'],
|
|
46
|
+
arrowKeyPressListener
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default useFocusWithArrows;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import styled from '@emotion/styled';
|
|
2
|
+
import { clr } from '@os-design/theming';
|
|
3
|
+
import React, { forwardRef } from 'react';
|
|
4
|
+
|
|
5
|
+
export type MenuDividerProps = Omit<JSX.IntrinsicElements['div'], 'ref'>;
|
|
6
|
+
|
|
7
|
+
const Container = styled.div`
|
|
8
|
+
padding-top: 0.4em;
|
|
9
|
+
border-bottom: 1px solid ${(p) => clr(p.theme.menuDividerColor)};
|
|
10
|
+
margin-bottom: 0.4em;
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The divider of menu items.
|
|
15
|
+
*/
|
|
16
|
+
const MenuDivider = forwardRef<HTMLDivElement, MenuDividerProps>(
|
|
17
|
+
(props, ref) => <Container role='separator' {...props} ref={ref} />
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
MenuDivider.displayName = 'MenuDivider';
|
|
21
|
+
|
|
22
|
+
export default MenuDivider;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import styled from '@emotion/styled';
|
|
2
|
+
import { m } from '@os-design/media';
|
|
3
|
+
import { MenuContext } from '@os-design/menu-utils';
|
|
4
|
+
import { ellipsisStyles } from '@os-design/styles';
|
|
5
|
+
import { clr } from '@os-design/theming';
|
|
6
|
+
import { useForwardedState } from '@os-design/utils';
|
|
7
|
+
import React, { forwardRef, useCallback, useContext, useMemo } from 'react';
|
|
8
|
+
import MenuItem from '../MenuItem';
|
|
9
|
+
|
|
10
|
+
type JsxDivProps = Omit<
|
|
11
|
+
JSX.IntrinsicElements['div'],
|
|
12
|
+
'defaultValue' | 'value' | 'onChange' | 'ref'
|
|
13
|
+
>;
|
|
14
|
+
interface BaseMenuGroupProps<T> extends JsxDivProps {
|
|
15
|
+
/**
|
|
16
|
+
* The title of the menu group.
|
|
17
|
+
* @default undefined
|
|
18
|
+
*/
|
|
19
|
+
title?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The max number of options that the user can select. Zero means unlimited.
|
|
22
|
+
* Works only when multiple is true.
|
|
23
|
+
* @default 0
|
|
24
|
+
*/
|
|
25
|
+
maxSelectedItems?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Selected menu items.
|
|
28
|
+
* @default undefined
|
|
29
|
+
*/
|
|
30
|
+
value?: T;
|
|
31
|
+
/**
|
|
32
|
+
* The default value.
|
|
33
|
+
* @default undefined
|
|
34
|
+
*/
|
|
35
|
+
defaultValue?: T;
|
|
36
|
+
/**
|
|
37
|
+
* The change event handler.
|
|
38
|
+
* @default undefined
|
|
39
|
+
*/
|
|
40
|
+
onChange?: (value: T) => void;
|
|
41
|
+
}
|
|
42
|
+
interface MenuGroupNotMultipleProps extends BaseMenuGroupProps<string | null> {
|
|
43
|
+
/**
|
|
44
|
+
* Is it possible to select multiple values.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
multiple?: false;
|
|
48
|
+
}
|
|
49
|
+
interface MenuGroupMultipleProps extends BaseMenuGroupProps<string[]> {
|
|
50
|
+
/**
|
|
51
|
+
* Is it possible to select multiple values.
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
multiple: true;
|
|
55
|
+
}
|
|
56
|
+
export type MenuGroupProps = MenuGroupNotMultipleProps | MenuGroupMultipleProps;
|
|
57
|
+
|
|
58
|
+
const Title = styled.div`
|
|
59
|
+
font-weight: 500;
|
|
60
|
+
font-size: ${(p) => p.theme.sizes.small}em;
|
|
61
|
+
color: ${(p) => clr(p.theme.menuGroupColorTitle)};
|
|
62
|
+
margin-bottom: 0.4em;
|
|
63
|
+
|
|
64
|
+
padding: 0 ${(p) => p.theme.modalBodyPaddingHorizontal[0]}em;
|
|
65
|
+
${m.min.xs} {
|
|
66
|
+
padding: 0 0.8em;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
${ellipsisStyles};
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
const Container = styled.div`
|
|
73
|
+
&:not(:last-of-type) {
|
|
74
|
+
padding-bottom: 0.4em;
|
|
75
|
+
border-bottom: 1px solid ${(p) => clr(p.theme.menuGroupColorDivider)};
|
|
76
|
+
}
|
|
77
|
+
&:not(:first-of-type) {
|
|
78
|
+
margin-top: ${(p) => p.theme.modalBodyPaddingVertical[0]}em;
|
|
79
|
+
${m.min.xs} {
|
|
80
|
+
margin-top: 0.4em;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The group of menu items.
|
|
87
|
+
*/
|
|
88
|
+
const MenuGroup = forwardRef<HTMLDivElement, MenuGroupProps>(
|
|
89
|
+
(
|
|
90
|
+
{
|
|
91
|
+
title,
|
|
92
|
+
multiple = false,
|
|
93
|
+
maxSelectedItems = 0,
|
|
94
|
+
value,
|
|
95
|
+
defaultValue,
|
|
96
|
+
onChange = () => {},
|
|
97
|
+
children,
|
|
98
|
+
...rest
|
|
99
|
+
},
|
|
100
|
+
ref
|
|
101
|
+
) => {
|
|
102
|
+
const { closeOnSelect } = useContext(MenuContext);
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
const [forwardedValue, setForwardedValue] = useForwardedState<any>({
|
|
105
|
+
value,
|
|
106
|
+
defaultValue,
|
|
107
|
+
onChange,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const onSelect = useCallback(
|
|
111
|
+
(v: string) => {
|
|
112
|
+
if (multiple) {
|
|
113
|
+
// Delete the value because it was already selected
|
|
114
|
+
if ((forwardedValue || []).includes(v)) {
|
|
115
|
+
setForwardedValue(
|
|
116
|
+
(forwardedValue || []).filter((item) => item !== v)
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Add a new value if the number of selected items is less than max
|
|
122
|
+
if (
|
|
123
|
+
maxSelectedItems === 0 ||
|
|
124
|
+
(forwardedValue || []).length < maxSelectedItems
|
|
125
|
+
) {
|
|
126
|
+
setForwardedValue([...(forwardedValue || []), v]);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setForwardedValue(v);
|
|
132
|
+
},
|
|
133
|
+
[forwardedValue, maxSelectedItems, multiple, setForwardedValue]
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const menuItems = useMemo(
|
|
137
|
+
() =>
|
|
138
|
+
React.Children.map(children, (child) => {
|
|
139
|
+
if (!React.isValidElement(child) || child.type !== MenuItem)
|
|
140
|
+
return child;
|
|
141
|
+
const { value: childValue, onClick: childOnClick } = child.props;
|
|
142
|
+
const selected =
|
|
143
|
+
(multiple && (forwardedValue || []).includes(childValue)) ||
|
|
144
|
+
(!multiple && forwardedValue === childValue);
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
|
+
return React.cloneElement<any>(child, {
|
|
147
|
+
key: childValue,
|
|
148
|
+
selected,
|
|
149
|
+
onClick: (e) => {
|
|
150
|
+
if (!childValue) return;
|
|
151
|
+
onSelect(childValue);
|
|
152
|
+
if (childOnClick) childOnClick(e);
|
|
153
|
+
},
|
|
154
|
+
...(!closeOnSelect
|
|
155
|
+
? {
|
|
156
|
+
role:
|
|
157
|
+
maxSelectedItems === 1
|
|
158
|
+
? 'menuitemradio'
|
|
159
|
+
: 'menuitemcheckbox',
|
|
160
|
+
'aria-checked': selected,
|
|
161
|
+
}
|
|
162
|
+
: {}),
|
|
163
|
+
});
|
|
164
|
+
}),
|
|
165
|
+
[
|
|
166
|
+
children,
|
|
167
|
+
closeOnSelect,
|
|
168
|
+
forwardedValue,
|
|
169
|
+
maxSelectedItems,
|
|
170
|
+
multiple,
|
|
171
|
+
onSelect,
|
|
172
|
+
]
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Container
|
|
177
|
+
role={maxSelectedItems === 1 ? 'radiogroup' : 'group'}
|
|
178
|
+
{...rest}
|
|
179
|
+
ref={ref}
|
|
180
|
+
>
|
|
181
|
+
{title && <Title>{title}</Title>}
|
|
182
|
+
{menuItems}
|
|
183
|
+
</Container>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
MenuGroup.displayName = 'MenuGroup';
|
|
189
|
+
|
|
190
|
+
export default MenuGroup;
|