@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.
Files changed (99) hide show
  1. package/package.json +21 -13
  2. package/src/@types/emotion.d.ts +7 -0
  3. package/src/Alert/index.tsx +112 -0
  4. package/src/Avatar/index.tsx +173 -0
  5. package/src/Avatar/utils/nameToInitials.ts +12 -0
  6. package/src/Avatar/utils/strToHue.ts +13 -0
  7. package/src/AvatarSkeleton/index.tsx +29 -0
  8. package/src/Breadcrumb/index.tsx +93 -0
  9. package/src/BreadcrumbItem/index.tsx +83 -0
  10. package/src/Button/ButtonContent.tsx +91 -0
  11. package/src/Button/index.tsx +225 -0
  12. package/src/Button/utils/useButtonColors.ts +84 -0
  13. package/src/Checkbox/index.tsx +225 -0
  14. package/src/CheckboxSkeleton/index.tsx +50 -0
  15. package/src/DatePicker/DatePickerCalendar.tsx +220 -0
  16. package/src/DatePicker/index.tsx +568 -0
  17. package/src/Drawer/index.tsx +212 -0
  18. package/src/Form/FormConfigContext.ts +16 -0
  19. package/src/Form/index.tsx +49 -0
  20. package/src/FormDivider/index.tsx +74 -0
  21. package/src/FormItem/index.tsx +118 -0
  22. package/src/Gallery/Status.tsx +62 -0
  23. package/src/Gallery/index.tsx +290 -0
  24. package/src/GlobalStyles/index.tsx +17 -0
  25. package/src/GlobalStyles/resetStyles.ts +17 -0
  26. package/src/GlobalStyles/typographyStyles.ts +78 -0
  27. package/src/HeaderSkeleton/index.tsx +64 -0
  28. package/src/Image/index.tsx +104 -0
  29. package/src/ImageSkeleton/index.tsx +22 -0
  30. package/src/Input/index.tsx +330 -0
  31. package/src/Input/utils/getFocusableElements.ts +8 -0
  32. package/src/InputNumber/index.tsx +208 -0
  33. package/src/InputNumber/utils/defaultLocale.ts +9 -0
  34. package/src/InputPassword/index.tsx +201 -0
  35. package/src/InputPassword/utils/defaultLocale.ts +11 -0
  36. package/src/InputSearch/index.tsx +111 -0
  37. package/src/InputSearch/utils/defaultLocale.ts +9 -0
  38. package/src/InputSkeleton/index.tsx +28 -0
  39. package/src/Layout/LayoutContext.ts +21 -0
  40. package/src/Layout/index.tsx +44 -0
  41. package/src/Link/index.tsx +129 -0
  42. package/src/LinkButton/index.tsx +100 -0
  43. package/src/List/WindowScroller.tsx +53 -0
  44. package/src/List/index.tsx +255 -0
  45. package/src/List/utils/bodyPointerEvents.ts +24 -0
  46. package/src/List/utils/frameTimeout.ts +36 -0
  47. package/src/List/utils/useRWLoadNext.ts +38 -0
  48. package/src/ListItem/index.tsx +92 -0
  49. package/src/ListItemActions/index.tsx +207 -0
  50. package/src/ListItemLink/index.tsx +63 -0
  51. package/src/ListSkeleton/index.tsx +115 -0
  52. package/src/LogoLink/index.tsx +93 -0
  53. package/src/LogoLink/logo.example.svg +18 -0
  54. package/src/Menu/index.tsx +128 -0
  55. package/src/Menu/utils/useFocusWithArrows.ts +50 -0
  56. package/src/MenuDivider/index.tsx +22 -0
  57. package/src/MenuGroup/index.tsx +190 -0
  58. package/src/MenuItem/index.tsx +108 -0
  59. package/src/Modal/index.tsx +411 -0
  60. package/src/Modal/utils/defaultLocale.ts +9 -0
  61. package/src/Navigation/index.tsx +214 -0
  62. package/src/Navigation/utils/useScrollFlags.ts +39 -0
  63. package/src/NavigationItem/index.tsx +136 -0
  64. package/src/PageContent/index.tsx +99 -0
  65. package/src/PageHeader/index.tsx +246 -0
  66. package/src/PageHeader/utils/defaultLocale.ts +9 -0
  67. package/src/PageHeaderInputSearch/index.tsx +145 -0
  68. package/src/PageHeaderInputSearch/utils/defaultLocale.ts +16 -0
  69. package/src/PageHeaderSkeleton/index.tsx +33 -0
  70. package/src/ParagraphSkeleton/index.tsx +65 -0
  71. package/src/Popover/index.tsx +243 -0
  72. package/src/Popover/utils/usePopoverPosition.ts +216 -0
  73. package/src/Progress/index.tsx +100 -0
  74. package/src/RadioGroup/index.tsx +165 -0
  75. package/src/RadioGroupSkeleton/index.tsx +36 -0
  76. package/src/Result/index.tsx +109 -0
  77. package/src/ScrollButton/index.tsx +159 -0
  78. package/src/ScrollButton/utils/useContainerPosition.ts +41 -0
  79. package/src/ScrollButton/utils/useVisibility.ts +56 -0
  80. package/src/Select/index.tsx +970 -0
  81. package/src/Select/utils/defaultLocale.ts +11 -0
  82. package/src/Skeleton/index.tsx +52 -0
  83. package/src/Switch/index.tsx +217 -0
  84. package/src/SwitchSkeleton/index.tsx +30 -0
  85. package/src/Tag/index.tsx +75 -0
  86. package/src/TagLink/index.tsx +53 -0
  87. package/src/TagList/index.tsx +95 -0
  88. package/src/TagListSkeleton/index.tsx +38 -0
  89. package/src/TagSkeleton/index.tsx +40 -0
  90. package/src/TextArea/index.tsx +231 -0
  91. package/src/TextAreaSkeleton/index.tsx +20 -0
  92. package/src/ThemeSwitcher/index.tsx +39 -0
  93. package/src/TimePicker/index.tsx +142 -0
  94. package/src/Video/index.tsx +41 -0
  95. package/src/index.ts +125 -0
  96. package/src/message/AlertIcon.tsx +50 -0
  97. package/src/message/Message.tsx +108 -0
  98. package/src/message/index.tsx +64 -0
  99. package/src/message/styles.ts +25 -0
@@ -0,0 +1,970 @@
1
+ import { css } from '@emotion/react';
2
+ import styled from '@emotion/styled';
3
+ import { Close, CloseCircle, Down, Loading, Up } from '@os-design/icons';
4
+ import { m, useIsMinWidth } from '@os-design/media';
5
+ import {
6
+ WithSize,
7
+ ellipsisStyles,
8
+ resetButtonStyles,
9
+ transitionStyles,
10
+ } from '@os-design/styles';
11
+ import { ThemeOverrider, clr, useTheme } from '@os-design/theming';
12
+
13
+ import {
14
+ omitEmotionProps,
15
+ useBrowserLayoutEffect,
16
+ useEvent,
17
+ useFontSize,
18
+ useForwardedRef,
19
+ useForwardedState,
20
+ useResizeObserver,
21
+ useSize,
22
+ } from '@os-design/utils';
23
+ import React, {
24
+ forwardRef,
25
+ useCallback,
26
+ useEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ } from 'react';
31
+ import { FixedSizeList } from 'react-window';
32
+ import Button from '../Button';
33
+ import { InputContainer } from '../Input';
34
+ import InputSearch, { InputSearchProps } from '../InputSearch';
35
+ import useRWLoadNext from '../List/utils/useRWLoadNext';
36
+ import Menu from '../Menu';
37
+ import MenuItem, { MenuItemProps } from '../MenuItem';
38
+ import { PopoverProps } from '../Popover';
39
+ import Tag from '../Tag';
40
+ import defaultLocale, { SelectLocale } from './utils/defaultLocale';
41
+
42
+ export interface Option extends MenuItemProps {
43
+ title?: string;
44
+ }
45
+
46
+ type JsxDivProps = Omit<
47
+ JSX.IntrinsicElements['div'],
48
+ 'value' | 'defaultValue' | 'onChange' | 'ref'
49
+ >;
50
+ interface BaseSelectProps<T>
51
+ extends JsxDivProps,
52
+ WithSize,
53
+ Pick<PopoverProps, 'placement'> {
54
+ /**
55
+ * Options of the select.
56
+ * @default undefined
57
+ */
58
+ options?: Option[];
59
+ /**
60
+ * The component located on the left side.
61
+ * @default undefined
62
+ */
63
+ left?: React.ReactNode;
64
+ /**
65
+ * Adds padding to the left component.
66
+ * It can be useful when passing an icon or text in the left component.
67
+ * @default false
68
+ */
69
+ leftHasPadding?: boolean;
70
+ /**
71
+ * The component located on the right side.
72
+ * @default undefined
73
+ */
74
+ right?: React.ReactNode;
75
+ /**
76
+ * Adds padding to the right component.
77
+ * It can be useful when passing an icon or text in the right component.
78
+ * @default false
79
+ */
80
+ rightHasPadding?: boolean;
81
+ /**
82
+ * The placeholder of the select.
83
+ * @default undefined
84
+ */
85
+ placeholder?: string;
86
+ /**
87
+ * Whether the search input visible.
88
+ * @default false
89
+ */
90
+ searchVisible?: boolean;
91
+ /**
92
+ * Props of the search input.
93
+ * @default undefined
94
+ */
95
+ searchProps?: InputSearchProps;
96
+ /**
97
+ * Text displayed when there are no list items.
98
+ * @default Not found
99
+ */
100
+ notFoundText?: string;
101
+ /**
102
+ * Whether the border is hidden.
103
+ * @default false
104
+ */
105
+ unbordered?: boolean;
106
+ /**
107
+ * Shows the loading status.
108
+ * @default false
109
+ */
110
+ loading?: boolean;
111
+ /**
112
+ * Whether the select is disabled.
113
+ * @default false
114
+ */
115
+ disabled?: boolean;
116
+ /**
117
+ * Whether the component have a focus.
118
+ * @default false
119
+ */
120
+ autoFocus?: boolean;
121
+ /**
122
+ * Whether the component opens the popup list.
123
+ * @default false
124
+ */
125
+ autoOpen?: boolean;
126
+ /**
127
+ * Whether the select has the clear button.
128
+ * @default false
129
+ */
130
+ clearVisible?: boolean;
131
+ /**
132
+ * A threshold N means that the onLoadNext function calls when a user scrolls all items except N.
133
+ * @default 10
134
+ */
135
+ threshold?: number;
136
+ /**
137
+ * Defines how many items inside of the visible "window" to render.
138
+ * @default 6
139
+ */
140
+ visibleCount?: number;
141
+ /**
142
+ * Defines how many items outside of the visible "window" to render at all times.
143
+ * @default 10
144
+ */
145
+ overscanCount?: number;
146
+ /**
147
+ * The max number of options that the user can select. Zero means unlimited.
148
+ * Works only when multiple is true.
149
+ * @default 0
150
+ */
151
+ maxSelectedItems?: number;
152
+ /**
153
+ * The locale.
154
+ * @default undefined
155
+ */
156
+ locale?: SelectLocale;
157
+ /**
158
+ * Selected options.
159
+ * @default undefined
160
+ */
161
+ value?: T;
162
+ /**
163
+ * The default value.
164
+ * @default undefined
165
+ */
166
+ defaultValue?: T;
167
+ /**
168
+ * The change event handler.
169
+ * @default undefined
170
+ */
171
+ onChange?: (value: T) => void;
172
+ /**
173
+ * The callback to load more items.
174
+ * @default undefined
175
+ */
176
+ onLoadNext?: () => void;
177
+ /**
178
+ * The event handler that is called whenever a popup closes.
179
+ * @default undefined
180
+ */
181
+ onClose?: () => void;
182
+ }
183
+ export interface SelectNotMultipleProps extends BaseSelectProps<string | null> {
184
+ /**
185
+ * Is it possible to select multiple values.
186
+ * @default false
187
+ */
188
+ multiple?: false;
189
+ }
190
+ export interface SelectMultipleProps extends BaseSelectProps<string[]> {
191
+ /**
192
+ * Is it possible to select multiple values.
193
+ * @default false
194
+ */
195
+ multiple: true;
196
+ }
197
+ export type SelectProps = SelectNotMultipleProps | SelectMultipleProps;
198
+
199
+ const selectContainerPaddingStyles = (p) => {
200
+ const paddingVertical =
201
+ (p.theme.baseHeight - p.theme.selectToggleListItemHeight) / 2;
202
+
203
+ return css`
204
+ padding: calc(${paddingVertical}em - 1px) 0;
205
+ `;
206
+ };
207
+
208
+ const selectContainerOpenedStyles = (p) =>
209
+ p.opened &&
210
+ !p.unbordered &&
211
+ css`
212
+ border-color: ${clr(p.theme.inputFocusColorBorder)};
213
+ box-shadow: 0 0 0 0.15em ${clr(p.theme.inputFocusColorShadow)};
214
+ `;
215
+
216
+ const selectContainerUnborderedStyles = (p) =>
217
+ p.unbordered &&
218
+ css`
219
+ border: 0;
220
+ box-shadow: none !important;
221
+ ${transitionStyles('background-color')(p)};
222
+ `;
223
+
224
+ const selectContainerUnborderedHoverStyles = (p) =>
225
+ p.unbordered &&
226
+ !p.disabled &&
227
+ css`
228
+ @media (hover: hover) {
229
+ &:hover,
230
+ &:focus {
231
+ background-color: ${clr(p.theme.buttonGhostColorBgHover)};
232
+ }
233
+ }
234
+ `;
235
+
236
+ interface SelectContainerProps {
237
+ opened: boolean;
238
+ unbordered?: boolean;
239
+ disabled?: boolean;
240
+ }
241
+ export const SelectContainer = styled(
242
+ InputContainer,
243
+ omitEmotionProps('opened', 'unbordered', 'disabled')
244
+ )<SelectContainerProps>`
245
+ cursor: ${(p) => (!p.disabled ? 'pointer' : 'not-allowed')};
246
+ user-select: none;
247
+ position: relative;
248
+ display: flex;
249
+ align-items: center;
250
+
251
+ height: unset;
252
+ min-height: ${(p) => p.theme.baseHeight}em;
253
+
254
+ ${selectContainerPaddingStyles};
255
+ ${selectContainerOpenedStyles};
256
+ ${selectContainerUnborderedStyles};
257
+ ${selectContainerUnborderedHoverStyles};
258
+ `;
259
+
260
+ interface SelectMenuProps {
261
+ width: number;
262
+ }
263
+ const SelectMenu = styled(Menu, omitEmotionProps('width'))<SelectMenuProps>`
264
+ padding-top: 0;
265
+ padding-bottom: 0;
266
+ max-height: unset;
267
+
268
+ ${m.min.xs} {
269
+ width: ${(p) => p.width}px;
270
+ }
271
+ `;
272
+
273
+ const NotFound = styled.div`
274
+ height: ${(p) => p.theme.menuItemHeight}em;
275
+ display: flex;
276
+ align-items: center;
277
+
278
+ padding: 0 ${(p) => p.theme.inputPaddingHorizontal}em;
279
+ color: ${(p) => clr(p.theme.selectNotFoundColorText)};
280
+ `;
281
+
282
+ const InputSearchContainer = styled.div`
283
+ padding: ${(p) => p.theme.menuPaddingVertical}em
284
+ ${(p) => p.theme.inputPaddingHorizontal}em 0;
285
+ `;
286
+
287
+ export const ToggleContainer = styled.div`
288
+ flex: 1;
289
+ display: flex;
290
+ align-items: center;
291
+ overflow: hidden;
292
+ `;
293
+
294
+ const toggleContentNotHasLeftStyles = (p) =>
295
+ !p.hasLeft &&
296
+ css`
297
+ padding-left: ${p.theme.inputPaddingHorizontal}em;
298
+ `;
299
+
300
+ const toggleContentNotHasRightStyles = (p) =>
301
+ !p.hasRight &&
302
+ !p.unbordered &&
303
+ css`
304
+ padding-right: ${p.theme.inputPaddingHorizontal}em;
305
+ `;
306
+
307
+ interface ToggleContentProps {
308
+ hasLeft?: boolean;
309
+ hasRight?: boolean;
310
+ unbordered?: boolean;
311
+ }
312
+ export const ToggleContent = styled(
313
+ 'div',
314
+ omitEmotionProps('hasLeft', 'hasRight', 'unbordered')
315
+ )<ToggleContentProps>`
316
+ flex: 1;
317
+ ${toggleContentNotHasLeftStyles};
318
+ ${toggleContentNotHasRightStyles};
319
+ ${ellipsisStyles};
320
+ `;
321
+
322
+ export const Placeholder = styled.span`
323
+ color: ${(p) => clr(p.theme.inputColorPlaceholder)};
324
+ ${ellipsisStyles};
325
+ `;
326
+
327
+ const titleUnborderedTitleStyles = (p) =>
328
+ p.unbordered &&
329
+ css`
330
+ font-weight: 500;
331
+ ${!p.disabled && `color: ${clr(p.theme.colorPrimary)};`}
332
+ `;
333
+
334
+ const titleDisabledStyles = (p) =>
335
+ p.disabled &&
336
+ css`
337
+ color: ${clr(p.theme.inputDisabledColorText)};
338
+ `;
339
+
340
+ type TitleProps = Pick<SelectProps, 'disabled' | 'unbordered'>;
341
+ export const Title = styled(
342
+ 'span',
343
+ omitEmotionProps('disabled', 'unbordered')
344
+ )<TitleProps>`
345
+ color: ${(p) => clr(p.theme.colorText)};
346
+ ${titleUnborderedTitleStyles};
347
+ ${titleDisabledStyles};
348
+ ${ellipsisStyles};
349
+ `;
350
+
351
+ type ToggleListItemProps = Pick<SelectProps, 'disabled'>;
352
+ const ToggleListItem = styled(
353
+ Tag,
354
+ omitEmotionProps('disabled')
355
+ )<ToggleListItemProps>`
356
+ // Reset tag styles
357
+ padding-top: 0;
358
+ padding-bottom: 0;
359
+
360
+ height: ${(p) => p.theme.selectToggleListItemHeight}em;
361
+ ${titleDisabledStyles};
362
+ `;
363
+
364
+ const ToggleList = styled.div`
365
+ display: flex;
366
+ flex-wrap: wrap;
367
+ overflow: hidden; // For ellipsis styles
368
+
369
+ margin: 0 ${(p) => p.theme.selectToggleListItemGap}em
370
+ ${(p) => -p.theme.selectToggleListItemGap}em 0;
371
+
372
+ & > div {
373
+ margin: 0 ${(p) => p.theme.selectToggleListItemGap}em
374
+ ${(p) => p.theme.selectToggleListItemGap}em 0;
375
+ }
376
+ `;
377
+
378
+ const DeleteButton = styled.button`
379
+ ${resetButtonStyles};
380
+ cursor: pointer;
381
+ display: inherit;
382
+ font-size: 1em;
383
+ margin-left: 0.2em;
384
+
385
+ background-color: transparent;
386
+ color: ${(p) => clr(p.theme.selectToggleDeleteButtonColorIcon)};
387
+
388
+ @media (hover: hover) {
389
+ &:hover,
390
+ &:focus {
391
+ color: ${(p) => clr(p.theme.selectToggleDeleteButtonColorIconHover)};
392
+ }
393
+ }
394
+
395
+ ${transitionStyles('color')};
396
+ `;
397
+
398
+ const toggleIconUnborderedStyles = (p) =>
399
+ p.unbordered &&
400
+ css`
401
+ padding-top: 0.2em;
402
+ font-size: 0.8em;
403
+ ${!p.disabled && `color: ${clr(p.theme.colorPrimary)};`}
404
+ `;
405
+
406
+ interface ToggleIconContainerProps {
407
+ unbordered?: boolean;
408
+ disabled?: boolean;
409
+ }
410
+ export const ToggleIconContainer = styled(
411
+ 'span',
412
+ omitEmotionProps('unbordered', 'disabled')
413
+ )<ToggleIconContainerProps>`
414
+ color: ${(p) => clr(p.theme.selectColorIcon)};
415
+ line-height: 1;
416
+ ${toggleIconUnborderedStyles};
417
+ `;
418
+
419
+ export const ClearIcon = styled(CloseCircle)`
420
+ transform: scale(1.2) !important;
421
+ `;
422
+
423
+ interface AddonProps {
424
+ hasPadding: boolean;
425
+ }
426
+ const Addon = styled('span', omitEmotionProps('hasPadding'))<AddonProps>`
427
+ display: flex;
428
+ align-items: center;
429
+ user-select: none;
430
+ color: ${(p) => clr(p.theme.inputColorPlaceholder)};
431
+
432
+ svg {
433
+ transform: scale(1.2);
434
+ }
435
+ `;
436
+
437
+ export const ToggleLeftAddon = styled(Addon)`
438
+ padding-right: ${(p) => p.theme.inputAddonPaddingHorizontal}em;
439
+ ${(p) =>
440
+ p.hasPadding &&
441
+ css`
442
+ padding-left: ${p.theme.inputPaddingHorizontal}em;
443
+ `}
444
+ `;
445
+
446
+ export const ToggleRightAddon = styled(Addon)`
447
+ padding-left: ${(p) => p.theme.inputAddonPaddingHorizontal}em;
448
+ ${(p) =>
449
+ p.hasPadding &&
450
+ css`
451
+ padding-right: ${p.theme.inputPaddingHorizontal}em;
452
+ `}
453
+ `;
454
+
455
+ /**
456
+ * The component that allows to pick a value from predefined options.
457
+ */
458
+ const Select = forwardRef<HTMLDivElement, SelectProps>(
459
+ (
460
+ {
461
+ options = [],
462
+ left,
463
+ leftHasPadding = false,
464
+ right,
465
+ rightHasPadding = false,
466
+ placeholder,
467
+ searchVisible = false,
468
+ searchProps = {},
469
+ notFoundText = 'Not found',
470
+ unbordered = false,
471
+ loading = false,
472
+ disabled = false,
473
+ autoFocus = false,
474
+ autoOpen = false,
475
+ clearVisible = false,
476
+ threshold = 10,
477
+ visibleCount = 6,
478
+ overscanCount = 10,
479
+ multiple = false,
480
+ maxSelectedItems = 0,
481
+ locale = defaultLocale,
482
+ value,
483
+ defaultValue,
484
+ onChange,
485
+ onLoadNext = () => {},
486
+ onClose = () => {},
487
+ onBlur = () => {},
488
+ size,
489
+ placement,
490
+ ...rest
491
+ },
492
+ ref
493
+ ) => {
494
+ const [containerRef, mergedContainerRef] = useForwardedRef(ref);
495
+ const [width, setWidth] = useState(0);
496
+ const inputSearchContainerRef = useRef<HTMLDivElement>(null);
497
+ const [opened, setOpened] = useState(autoOpen);
498
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
499
+ const [forwardedValue, setForwardedValue] = useForwardedState<any>({
500
+ value,
501
+ defaultValue,
502
+ onChange,
503
+ });
504
+
505
+ useEffect(() => {
506
+ if (autoFocus) containerRef.current?.focus();
507
+ }, [autoFocus, containerRef]);
508
+
509
+ const onCloseRef = useRef(onClose);
510
+ useEffect(() => {
511
+ onCloseRef.current = onClose;
512
+ }, [onClose]);
513
+
514
+ useEffect(() => {
515
+ if (!opened) onCloseRef.current();
516
+ }, [opened]);
517
+
518
+ /**
519
+ * Detect the width of the container when the select was opened and update
520
+ * it when either the container size or the window size has been changed.
521
+ */
522
+ const resizeHandler = useCallback(() => {
523
+ window.requestAnimationFrame(() => {
524
+ if (!opened || !containerRef.current) return;
525
+ const nextWidth = containerRef.current.getBoundingClientRect().width;
526
+ if (width === nextWidth) return;
527
+ setWidth(nextWidth);
528
+ });
529
+ }, [opened, containerRef, width]);
530
+ useBrowserLayoutEffect(() => resizeHandler(), [resizeHandler]);
531
+ useResizeObserver(containerRef, resizeHandler);
532
+ useEvent(
533
+ (typeof window !== 'undefined' ? window : undefined) as EventTarget,
534
+ 'resize',
535
+ resizeHandler
536
+ );
537
+
538
+ // Replace the aria-haspopup attribute from menu to listbox
539
+ useBrowserLayoutEffect(() => {
540
+ if (!containerRef.current) return;
541
+ containerRef.current.setAttribute('aria-haspopup', 'listbox');
542
+ }, []);
543
+
544
+ const listBoxId = useMemo(
545
+ () => `listbox-${Math.random().toString(36).slice(2, 11)}`,
546
+ []
547
+ );
548
+
549
+ const blurHandler = useCallback(
550
+ (e) => {
551
+ if (!opened) onBlur(e);
552
+ },
553
+ [onBlur, opened]
554
+ );
555
+
556
+ const onDelete = useCallback(
557
+ (v: string) => {
558
+ if (!multiple) return;
559
+ setForwardedValue((forwardedValue || []).filter((item) => item !== v));
560
+ },
561
+ [forwardedValue, multiple, setForwardedValue]
562
+ );
563
+
564
+ const toggleContent = useMemo(() => {
565
+ if (multiple) {
566
+ if (!forwardedValue || forwardedValue.length === 0) {
567
+ return <Placeholder>{placeholder}</Placeholder>;
568
+ }
569
+ const items = forwardedValue.map((v) => {
570
+ const option = (options || []).find((item) => item.value === v);
571
+ return { title: option ? option.title || '' : '', value: v };
572
+ });
573
+ return (
574
+ <ToggleList>
575
+ {items.map(({ title, value: v }) => (
576
+ <ToggleListItem
577
+ key={v}
578
+ disabled={disabled}
579
+ right={
580
+ !disabled ? (
581
+ <DeleteButton
582
+ onClick={(e) => {
583
+ onDelete(v);
584
+ e.stopPropagation();
585
+ }}
586
+ onKeyDown={(e) => e.stopPropagation()}
587
+ aria-label={`${locale.deleteLabel} ${title}`}
588
+ aria-hidden
589
+ >
590
+ <Close />
591
+ </DeleteButton>
592
+ ) : undefined
593
+ }
594
+ aria-hidden
595
+ >
596
+ {title}
597
+ </ToggleListItem>
598
+ ))}
599
+ </ToggleList>
600
+ );
601
+ }
602
+ if (!forwardedValue) {
603
+ return <Placeholder>{placeholder}</Placeholder>;
604
+ }
605
+ const option = (options || []).find(
606
+ (item) => item.value === forwardedValue
607
+ );
608
+ return (
609
+ <Title disabled={disabled} unbordered={unbordered}>
610
+ {option ? option.title : ''}
611
+ </Title>
612
+ );
613
+ }, [
614
+ disabled,
615
+ forwardedValue,
616
+ locale.deleteLabel,
617
+ multiple,
618
+ onDelete,
619
+ options,
620
+ placeholder,
621
+ unbordered,
622
+ ]);
623
+
624
+ const toggleShowClearButton = useMemo(() => {
625
+ if (!clearVisible) return false;
626
+ if (multiple) return forwardedValue && forwardedValue.length > 0;
627
+ return !!forwardedValue;
628
+ }, [clearVisible, forwardedValue, multiple]);
629
+
630
+ const toggleOnClear = useCallback(() => {
631
+ setForwardedValue(multiple ? [] : null);
632
+ if (!containerRef.current) return;
633
+ containerRef.current.focus();
634
+ }, [containerRef, multiple, setForwardedValue]);
635
+
636
+ const toggleRightValue = useMemo(() => {
637
+ if (loading) return <Loading />;
638
+ if (toggleShowClearButton) {
639
+ return (
640
+ <Button
641
+ type='ghost'
642
+ wide='never'
643
+ size='small'
644
+ disabled={disabled}
645
+ onClick={(e) => {
646
+ toggleOnClear();
647
+ e.stopPropagation();
648
+ }}
649
+ onKeyDown={(e) => {
650
+ if (e.key === 'Enter') toggleOnClear();
651
+ e.stopPropagation();
652
+ }}
653
+ aria-label={locale.clearLabel}
654
+ >
655
+ <ClearIcon />
656
+ </Button>
657
+ );
658
+ }
659
+ return (
660
+ right || (
661
+ <ToggleIconContainer unbordered={unbordered} disabled={disabled}>
662
+ {opened ? <Up /> : <Down />}
663
+ </ToggleIconContainer>
664
+ )
665
+ );
666
+ }, [
667
+ disabled,
668
+ loading,
669
+ locale.clearLabel,
670
+ opened,
671
+ right,
672
+ toggleOnClear,
673
+ toggleShowClearButton,
674
+ unbordered,
675
+ ]);
676
+
677
+ const toggleRightHasPaddingValue = useMemo(() => {
678
+ if (loading) return true;
679
+ if (toggleShowClearButton) return false;
680
+ return right ? rightHasPadding : true;
681
+ }, [loading, right, rightHasPadding, toggleShowClearButton]);
682
+
683
+ const onSelect = useCallback(
684
+ (v: string) => {
685
+ if (multiple) {
686
+ // Delete the value because it was already selected
687
+ if ((forwardedValue || []).includes(v)) {
688
+ setForwardedValue(
689
+ (forwardedValue || []).filter((item) => item !== v)
690
+ );
691
+ return;
692
+ }
693
+
694
+ // Add a new value if the number of selected items is less than max
695
+ if (
696
+ maxSelectedItems === 0 ||
697
+ (forwardedValue || []).length < maxSelectedItems
698
+ ) {
699
+ setForwardedValue([...(forwardedValue || []), v]);
700
+ return;
701
+ }
702
+ return;
703
+ }
704
+ setForwardedValue(v);
705
+ },
706
+ [forwardedValue, maxSelectedItems, multiple, setForwardedValue]
707
+ );
708
+
709
+ const windowSize = useSize();
710
+ const isMinXs = useIsMinWidth('xs');
711
+ const fontSize = useFontSize(document.body);
712
+ const { theme } = useTheme();
713
+
714
+ const scaleFactor = useMemo(() => {
715
+ const s = size || 'medium';
716
+ return ['small', 'medium', 'large'].includes(s)
717
+ ? theme.sizes[s]
718
+ : Number(s.replace(/^([0-9]+(\.[0-9]+)?).*/, '$1')) || 1; // Extract the number
719
+ }, [size, theme.sizes]);
720
+
721
+ const paddingBottom = useMemo(() => {
722
+ const paddingEm = isMinXs
723
+ ? theme.menuPaddingVertical
724
+ : theme.modalBodyPaddingVertical[0];
725
+ return paddingEm * fontSize * scaleFactor;
726
+ }, [
727
+ isMinXs,
728
+ theme.menuPaddingVertical,
729
+ theme.modalBodyPaddingVertical,
730
+ fontSize,
731
+ scaleFactor,
732
+ ]);
733
+
734
+ const paddingTop = useMemo(
735
+ () => (searchVisible ? 5 * scaleFactor : paddingBottom),
736
+ [searchVisible, scaleFactor, paddingBottom]
737
+ );
738
+
739
+ const searchInputHeight = useMemo(
740
+ () =>
741
+ searchVisible
742
+ ? (theme.baseHeight + theme.menuPaddingVertical) *
743
+ fontSize *
744
+ scaleFactor
745
+ : 0,
746
+ [
747
+ searchVisible,
748
+ theme.baseHeight,
749
+ theme.menuPaddingVertical,
750
+ fontSize,
751
+ scaleFactor,
752
+ ]
753
+ );
754
+
755
+ const itemSize = useMemo(
756
+ () => theme.menuItemHeight * fontSize * scaleFactor,
757
+ [theme.menuItemHeight, fontSize, scaleFactor]
758
+ );
759
+
760
+ const height = useMemo(() => {
761
+ // Modal
762
+ if (!isMinXs) {
763
+ const maxHeight =
764
+ windowSize.height -
765
+ theme.modalHeaderHeight * fontSize * scaleFactor -
766
+ searchInputHeight;
767
+ const curHeight =
768
+ options.length * itemSize + paddingTop + paddingBottom;
769
+ return curHeight < maxHeight ? curHeight : maxHeight;
770
+ }
771
+ // Popover
772
+ const count =
773
+ options.length < visibleCount ? options.length : visibleCount;
774
+ return count * itemSize + paddingTop + paddingBottom;
775
+ }, [
776
+ isMinXs,
777
+ options.length,
778
+ visibleCount,
779
+ itemSize,
780
+ windowSize.height,
781
+ theme.modalHeaderHeight,
782
+ fontSize,
783
+ scaleFactor,
784
+ searchInputHeight,
785
+ paddingTop,
786
+ paddingBottom,
787
+ ]);
788
+
789
+ const scrollHandler = useRWLoadNext({
790
+ itemCount: options.length,
791
+ threshold,
792
+ itemSize,
793
+ paddingTop,
794
+ height,
795
+ onLoadNext,
796
+ });
797
+
798
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
799
+ const InnerElement = useCallback(
800
+ ({ style, ...innerElementRest }) => (
801
+ <div
802
+ style={{
803
+ ...style,
804
+ height: `${
805
+ parseFloat(style.height) + paddingTop + paddingBottom
806
+ }px`,
807
+ }}
808
+ role='listbox'
809
+ id={listBoxId}
810
+ {...innerElementRest}
811
+ />
812
+ ),
813
+ [listBoxId, paddingBottom, paddingTop]
814
+ );
815
+
816
+ const listItemFn = useCallback(
817
+ ({ index, style }) => {
818
+ const {
819
+ title: optionTitle,
820
+ value: optionValue,
821
+ onClick: optionOnClick,
822
+ ...restOption
823
+ } = options[index];
824
+ return (
825
+ <MenuItem
826
+ style={{
827
+ ...style,
828
+ top: `${
829
+ parseFloat(style.top ? style.top.toString() : '0') + paddingTop
830
+ }px`,
831
+ }}
832
+ selected={
833
+ (multiple &&
834
+ (forwardedValue || []).includes(optionValue || '')) ||
835
+ (!multiple && forwardedValue === optionValue)
836
+ }
837
+ onClick={(e) => {
838
+ if (!optionValue) return;
839
+ onSelect(optionValue);
840
+
841
+ // Focus the input.
842
+ // Otherwise, if multiple is false and the user presses enter to select an item,
843
+ // the input will lose focus.
844
+ if (containerRef.current && !multiple) {
845
+ containerRef.current.focus();
846
+ }
847
+
848
+ if (optionOnClick) optionOnClick(e);
849
+ }}
850
+ role='option'
851
+ aria-selected={
852
+ (multiple &&
853
+ (forwardedValue || []).includes(optionValue || '')) ||
854
+ (!multiple && forwardedValue === optionValue)
855
+ }
856
+ {...restOption}
857
+ >
858
+ {optionTitle}
859
+ </MenuItem>
860
+ );
861
+ },
862
+ [containerRef, forwardedValue, multiple, onSelect, options, paddingTop]
863
+ );
864
+
865
+ return (
866
+ <>
867
+ <SelectContainer
868
+ opened={opened}
869
+ unbordered={unbordered}
870
+ disabled={disabled}
871
+ size={size}
872
+ tabIndex={!disabled ? 0 : -1}
873
+ onClick={() => {
874
+ if (disabled) return;
875
+ setOpened(!opened);
876
+ }}
877
+ onKeyDown={(e) => {
878
+ if (disabled) return;
879
+ if (['Enter', ' '].includes(e.key)) {
880
+ setOpened(!opened);
881
+ e.preventDefault();
882
+ }
883
+ }}
884
+ onMouseDown={(e) => e.preventDefault()}
885
+ onBlur={blurHandler}
886
+ role='combobox'
887
+ aria-disabled={disabled}
888
+ aria-busy={loading}
889
+ aria-haspopup='listbox'
890
+ aria-owns={listBoxId}
891
+ {...rest}
892
+ ref={mergedContainerRef}
893
+ >
894
+ <ToggleContainer>
895
+ {left && (
896
+ <ThemeOverrider
897
+ overrides={(t) => ({
898
+ buttonPaddingHorizontal: 0.8,
899
+ baseHeight: t.selectToggleListItemHeight / t.sizes.small,
900
+ })}
901
+ >
902
+ <ToggleLeftAddon hasPadding={leftHasPadding}>
903
+ {left}
904
+ </ToggleLeftAddon>
905
+ </ThemeOverrider>
906
+ )}
907
+
908
+ <ToggleContent
909
+ hasLeft={!!left}
910
+ hasRight={!!right}
911
+ unbordered={unbordered}
912
+ >
913
+ {toggleContent}
914
+ </ToggleContent>
915
+
916
+ {toggleRightValue && (
917
+ <ThemeOverrider
918
+ overrides={(t) => ({
919
+ buttonPaddingHorizontal: 0.8,
920
+ baseHeight: t.selectToggleListItemHeight / t.sizes.small,
921
+ })}
922
+ >
923
+ <ToggleRightAddon hasPadding={toggleRightHasPaddingValue}>
924
+ {toggleRightValue}
925
+ </ToggleRightAddon>
926
+ </ThemeOverrider>
927
+ )}
928
+ </ToggleContainer>
929
+ </SelectContainer>
930
+
931
+ <SelectMenu
932
+ trigger={containerRef}
933
+ visible={opened}
934
+ onClose={() => setOpened(false)}
935
+ size={size}
936
+ width={width}
937
+ closeOnSelect={!multiple}
938
+ modalTitle={placeholder}
939
+ placement={placement}
940
+ >
941
+ {searchVisible && (
942
+ <InputSearchContainer ref={inputSearchContainerRef}>
943
+ <InputSearch {...searchProps} />
944
+ </InputSearchContainer>
945
+ )}
946
+
947
+ {options.length > 0 ? (
948
+ <FixedSizeList
949
+ width='100%'
950
+ height={height}
951
+ itemSize={itemSize}
952
+ itemCount={options.length}
953
+ overscanCount={overscanCount}
954
+ onScroll={({ scrollOffset }) => scrollHandler(scrollOffset)}
955
+ innerElementType={InnerElement}
956
+ >
957
+ {listItemFn}
958
+ </FixedSizeList>
959
+ ) : (
960
+ <NotFound>{notFoundText}</NotFound>
961
+ )}
962
+ </SelectMenu>
963
+ </>
964
+ );
965
+ }
966
+ );
967
+
968
+ Select.displayName = 'Select';
969
+
970
+ export default Select;