@true-engineering/true-react-common-ui-kit 3.8.0 → 3.8.1
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/LICENSE +201 -201
- package/README.md +6 -0
- package/dist/components/NewMoreMenu/NewMoreMenu.d.ts +1 -1
- package/dist/components/WithPopup/WithPopup.d.ts +2 -0
- package/dist/true-react-common-ui-kit.js +62 -60
- package/dist/true-react-common-ui-kit.js.map +1 -1
- package/dist/true-react-common-ui-kit.umd.cjs +62 -60
- package/dist/true-react-common-ui-kit.umd.cjs.map +1 -1
- package/package.json +1 -1
- package/src/components/AccountInfo/AccountInfo.stories.tsx +32 -32
- package/src/components/AccountInfo/AccountInfo.tsx +80 -80
- package/src/components/AddButton/AddButton.stories.tsx +21 -21
- package/src/components/AddButton/AddButton.tsx +52 -52
- package/src/components/Button/Button.stories.tsx +56 -56
- package/src/components/Button/Button.tsx +129 -129
- package/src/components/Checkbox/Checkbox.stories.tsx +28 -28
- package/src/components/Checkbox/Checkbox.tsx +85 -85
- package/src/components/CloseButton/CloseButton.tsx +34 -34
- package/src/components/Colors/Colors.stories.tsx +7 -7
- package/src/components/DateInput/DateInput.tsx +90 -90
- package/src/components/DateInput/constants.ts +2 -2
- package/src/components/DatePicker/DatePicker.tsx +308 -308
- package/src/components/Description/Description.stories.tsx +27 -27
- package/src/components/Description/Description.tsx +61 -61
- package/src/components/FiltersPane/FiltersPane.tsx +158 -158
- package/src/components/FiltersPane/components/Filter/Filter.tsx +203 -203
- package/src/components/FiltersPane/components/FilterValueView/FilterValueView.tsx +166 -166
- package/src/components/FiltersPane/components/FilterWithDates/FilterWithDates.tsx +210 -210
- package/src/components/FiltersPane/components/FilterWithPeriod/FilterWithPeriod.tsx +177 -177
- package/src/components/FiltersPane/components/FilterWrapper/FilterWrapper.tsx +167 -167
- package/src/components/Flag/Flag.stories.tsx +29 -29
- package/src/components/Flag/Flag.tsx +26 -26
- package/src/components/Flag/augment.d.ts +1 -1
- package/src/components/FlexibleTable/FlexibleTable.stories.tsx +267 -267
- package/src/components/FlexibleTable/FlexibleTable.styles.ts +110 -110
- package/src/components/FlexibleTable/FlexibleTable.tsx +271 -271
- package/src/components/FlexibleTable/components/FlexibleTableCell/FlexibleTableCell.styles.ts +38 -38
- package/src/components/FlexibleTable/components/FlexibleTableCell/FlexibleTableCell.tsx +83 -83
- package/src/components/FlexibleTable/components/FlexibleTableRow/FlexibleTableRow.styles.ts +25 -25
- package/src/components/FlexibleTable/components/FlexibleTableRow/FlexibleTableRow.tsx +196 -196
- package/src/components/FlexibleTable/helpers.ts +13 -13
- package/src/components/FlexibleTable/types.ts +52 -52
- package/src/components/Icon/Icon.stories.tsx +86 -86
- package/src/components/Icon/complexIcons/augment.d.ts +1 -1
- package/src/components/Icon/complexIcons/avatarGreen.svg +57 -57
- package/src/components/Icon/complexIcons/index.ts +1 -1
- package/src/components/IncrementInput/IncrementInput.tsx +105 -105
- package/src/components/Input/Input.tsx +297 -297
- package/src/components/Input/types.ts +32 -32
- package/src/components/List/List.stories.tsx +70 -70
- package/src/components/List/List.tsx +33 -33
- package/src/components/List/components/ListItem/ListItem.tsx +57 -57
- package/src/components/Modal/Modal.stories.tsx +105 -105
- package/src/components/Modal/Modal.tsx +196 -196
- package/src/components/MoreMenu/MoreMenu.styles.ts +68 -68
- package/src/components/MultiSelect/MultiSelect.stories.tsx +46 -46
- package/src/components/MultiSelect/MultiSelect.tsx +106 -106
- package/src/components/MultiSelect/components/MultiSelectInput/MultiSelectInput.tsx +53 -53
- package/src/components/MultiSelectList/MultiSelectList.tsx +461 -461
- package/src/components/NewMoreMenu/NewMoreMenu.stories.tsx +1 -0
- package/src/components/NewMoreMenu/NewMoreMenu.tsx +3 -1
- package/src/components/Notification/Notification.stories.tsx +46 -46
- package/src/components/Notification/Notification.tsx +69 -69
- package/src/components/NumberInput/NumberInput.tsx +137 -137
- package/src/components/NumberInput/index.ts +1 -1
- package/src/components/PhoneInput/PhoneInput.tsx +214 -214
- package/src/components/PhoneInput/components/PhoneInputCountryList/PhoneInputCountryList.tsx +155 -155
- package/src/components/PhoneInput/types.ts +16 -16
- package/src/components/RadioButton/RadioButton.stories.tsx +46 -46
- package/src/components/RadioButton/RadioButton.tsx +57 -57
- package/src/components/ScrollIntoViewIfNeeded/index.ts +1 -1
- package/src/components/Select/CustomSelect.stories.tsx +217 -217
- package/src/components/Select/MultiSelect.stories.tsx +240 -240
- package/src/components/Select/Select.stories.tsx +235 -235
- package/src/components/Select/Select.tsx +580 -580
- package/src/components/Select/components/SelectList/SelectList.tsx +157 -157
- package/src/components/Select/components/SelectListItem/SelectListItem.tsx +68 -68
- package/src/components/Select/constants.ts +2 -2
- package/src/components/Select/types.ts +1 -1
- package/src/components/Selector/Selector.stories.tsx +62 -62
- package/src/components/Selector/Selector.styles.ts +164 -164
- package/src/components/Selector/Selector.tsx +115 -115
- package/src/components/Selector/index.ts +2 -2
- package/src/components/Selector/types.ts +12 -12
- package/src/components/Skeleton/Skeleton.stories.tsx +19 -19
- package/src/components/SmartInput/SmartInput.tsx +134 -134
- package/src/components/Status/Status.stories.tsx +73 -73
- package/src/components/Status/Status.styles.ts +143 -143
- package/src/components/Status/Status.tsx +49 -49
- package/src/components/Status/constants.ts +11 -11
- package/src/components/Status/index.ts +3 -3
- package/src/components/Status/types.ts +5 -5
- package/src/components/Switch/Switch.stories.tsx +40 -40
- package/src/components/Switch/Switch.tsx +75 -75
- package/src/components/TextArea/TextArea.tsx +180 -180
- package/src/components/TextButton/TextButton.stories.tsx +46 -46
- package/src/components/TextButton/TextButton.styles.ts +129 -129
- package/src/components/TextButton/TextButton.tsx +103 -103
- package/src/components/TextButton/index.ts +4 -4
- package/src/components/TextWithInfo/TextWithInfo.stories.tsx +53 -53
- package/src/components/TextWithInfo/TextWithInfo.tsx +62 -62
- package/src/components/TextWithTooltip/TextWithTooltip.stories.tsx +58 -58
- package/src/components/ThemedPreloader/ThemedPreloader.stories.tsx +41 -41
- package/src/components/ThemedPreloader/ThemedPreloader.tsx +54 -54
- package/src/components/ThemedPreloader/components/DefaultPreloader/index.ts +1 -1
- package/src/components/Toaster/Toaster.stories.tsx +30 -30
- package/src/components/Toaster/Toaster.tsx +108 -108
- package/src/components/Tooltip/Tooltip.stories.tsx +19 -19
- package/src/components/Tooltip/Tooltip.tsx +35 -35
- package/src/components/Tooltip/types.ts +1 -1
- package/src/components/WithPopup/WithPopup.stories.tsx +1 -0
- package/src/components/WithPopup/WithPopup.tsx +7 -1
- package/src/helpers/popper-helpers.ts +17 -17
- package/src/hooks/use-dropdown.ts +84 -84
- package/src/hooks/use-is-mounted.ts +15 -15
- package/src/theme/helpers.ts +76 -76
- package/src/vite-env.d.ts +1 -1
|
@@ -1,580 +1,580 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ReactNode,
|
|
3
|
-
FocusEvent,
|
|
4
|
-
KeyboardEvent,
|
|
5
|
-
MouseEvent,
|
|
6
|
-
useCallback,
|
|
7
|
-
useEffect,
|
|
8
|
-
useMemo,
|
|
9
|
-
useRef,
|
|
10
|
-
useState,
|
|
11
|
-
SyntheticEvent,
|
|
12
|
-
} from 'react';
|
|
13
|
-
import { Portal } from 'react-overlays';
|
|
14
|
-
import clsx from 'clsx';
|
|
15
|
-
import { Styles } from 'jss';
|
|
16
|
-
import { debounce } from 'ts-debounce';
|
|
17
|
-
import {
|
|
18
|
-
getTestId,
|
|
19
|
-
isNotEmpty,
|
|
20
|
-
isReactNodeNotEmpty,
|
|
21
|
-
isStringNotEmpty,
|
|
22
|
-
createFilter,
|
|
23
|
-
} from '@true-engineering/true-react-platform-helpers';
|
|
24
|
-
import { hasExactParent } from '../../helpers';
|
|
25
|
-
import { useIsMounted, useOnClickOutsideWithRef, useDropdown, useTweakStyles } from '../../hooks';
|
|
26
|
-
import { ICommonProps, IDropdownWithPopperOptions } from '../../types';
|
|
27
|
-
import { renderIcon, IIcon } from '../Icon';
|
|
28
|
-
import { IInputProps, Input } from '../Input';
|
|
29
|
-
import { ISearchInputProps, SearchInput } from '../SearchInput';
|
|
30
|
-
import { SelectList } from './components';
|
|
31
|
-
import { ALL_OPTION_INDEX, DEFAULT_OPTION_INDEX } from './constants';
|
|
32
|
-
import {
|
|
33
|
-
defaultConvertFunction,
|
|
34
|
-
defaultCompareFunction,
|
|
35
|
-
defaultIsOptionDisabled,
|
|
36
|
-
getDefaultConvertToIdFunction,
|
|
37
|
-
isMultiSelectValue,
|
|
38
|
-
} from './helpers';
|
|
39
|
-
import { IMultipleSelectValue } from './types';
|
|
40
|
-
import { useStyles, ISelectStyles, searchInputStyles, getInputStyles } from './Select.styles';
|
|
41
|
-
|
|
42
|
-
export interface ISelectProps<Value>
|
|
43
|
-
extends Omit<IInputProps, 'value' | 'onChange' | 'onBlur' | 'type' | 'tweakStyles'>,
|
|
44
|
-
ICommonProps<ISelectStyles> {
|
|
45
|
-
defaultOptionLabel?: ReactNode;
|
|
46
|
-
allOptionsLabel?: string;
|
|
47
|
-
noMatchesLabel?: string;
|
|
48
|
-
loadingLabel?: ReactNode;
|
|
49
|
-
/** @default 'normal' */
|
|
50
|
-
optionsMode?: 'search' | 'dynamic' | 'normal';
|
|
51
|
-
/** @default 400 */
|
|
52
|
-
debounceTime?: number;
|
|
53
|
-
/** @default 0 */
|
|
54
|
-
minSymbolsCountToOpenList?: number;
|
|
55
|
-
dropdownOptions?: IDropdownWithPopperOptions;
|
|
56
|
-
/** @default 'chevron-down' */
|
|
57
|
-
dropdownIcon?: IIcon;
|
|
58
|
-
options: Value[];
|
|
59
|
-
value: Value | undefined;
|
|
60
|
-
/** @default true */
|
|
61
|
-
shouldScrollToList?: boolean;
|
|
62
|
-
isMultiSelect?: boolean;
|
|
63
|
-
searchInput?: { shouldRenderInList: true } & Pick<ISearchInputProps, 'placeholder'>;
|
|
64
|
-
isOptionDisabled?: (option: Value) => boolean;
|
|
65
|
-
onChange: (value?: Value) => void; // подумать как возвращать индекс
|
|
66
|
-
onBlur?: (event: Event | SyntheticEvent) => void;
|
|
67
|
-
onType?: (value: string) => Promise<void>;
|
|
68
|
-
optionsFilter?: (options: Value[], query: string) => Value[];
|
|
69
|
-
onOpen?: () => void;
|
|
70
|
-
compareValuesOnChange?: (v1?: Value, v2?: Value) => boolean;
|
|
71
|
-
// Для избежания проблем юзайте useCallback на эти функции
|
|
72
|
-
// или выносите их из компонента (чтобы не было сайдэфектов от перерендеринга их)
|
|
73
|
-
convertValueToString?: (value: Value) => string | undefined;
|
|
74
|
-
convertValueToReactNode?: (value: Value, isDisabled: boolean) => ReactNode;
|
|
75
|
-
convertValueToId?: (value: Value) => string | undefined;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface IMultipleSelectProps<Value>
|
|
79
|
-
extends Omit<ISelectProps<Value>, 'value' | 'onChange' | 'compareValuesOnChange'> {
|
|
80
|
-
isMultiSelect: true;
|
|
81
|
-
value: IMultipleSelectValue<Value> | undefined;
|
|
82
|
-
onChange: (value?: IMultipleSelectValue<Value>) => void;
|
|
83
|
-
compareValuesOnChange?: (
|
|
84
|
-
v1?: IMultipleSelectValue<Value>,
|
|
85
|
-
v2?: IMultipleSelectValue<Value>,
|
|
86
|
-
) => boolean;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function Select<Value>(
|
|
90
|
-
props: ISelectProps<Value> | IMultipleSelectProps<Value>,
|
|
91
|
-
): JSX.Element {
|
|
92
|
-
const {
|
|
93
|
-
options,
|
|
94
|
-
value,
|
|
95
|
-
defaultOptionLabel,
|
|
96
|
-
allOptionsLabel,
|
|
97
|
-
debounceTime = 400,
|
|
98
|
-
optionsMode = 'normal',
|
|
99
|
-
noMatchesLabel,
|
|
100
|
-
loadingLabel,
|
|
101
|
-
tweakStyles,
|
|
102
|
-
testId,
|
|
103
|
-
isReadonly,
|
|
104
|
-
isDisabled,
|
|
105
|
-
dropdownOptions,
|
|
106
|
-
minSymbolsCountToOpenList = 0,
|
|
107
|
-
dropdownIcon = 'chevron-down',
|
|
108
|
-
shouldScrollToList = true,
|
|
109
|
-
searchInput,
|
|
110
|
-
iconType,
|
|
111
|
-
onChange,
|
|
112
|
-
onFocus,
|
|
113
|
-
onBlur,
|
|
114
|
-
onType,
|
|
115
|
-
onOpen,
|
|
116
|
-
isOptionDisabled = defaultIsOptionDisabled,
|
|
117
|
-
compareValuesOnChange = defaultCompareFunction,
|
|
118
|
-
convertValueToString = defaultConvertFunction,
|
|
119
|
-
convertValueToId,
|
|
120
|
-
convertValueToReactNode,
|
|
121
|
-
optionsFilter,
|
|
122
|
-
...inputProps
|
|
123
|
-
} = props;
|
|
124
|
-
const classes = useStyles({ theme: tweakStyles });
|
|
125
|
-
|
|
126
|
-
const shouldRenderSearchInputInList = searchInput?.shouldRenderInList === true;
|
|
127
|
-
const hasSearchInputInList = optionsMode !== 'normal' && shouldRenderSearchInputInList;
|
|
128
|
-
const isMultiSelect = isMultiSelectValue(props, value);
|
|
129
|
-
const hasReadonlyInput = isReadonly || optionsMode === 'normal' || shouldRenderSearchInputInList;
|
|
130
|
-
|
|
131
|
-
const tweakInputStyles = useTweakStyles({
|
|
132
|
-
innerStyles: getInputStyles({ hasReadonlyInput, isMultiSelect }),
|
|
133
|
-
tweakStyles,
|
|
134
|
-
className: 'tweakInput',
|
|
135
|
-
currentComponentName: 'Select',
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
const tweakSearchInputStyles = useTweakStyles({
|
|
139
|
-
innerStyles: searchInputStyles,
|
|
140
|
-
tweakStyles,
|
|
141
|
-
className: 'tweakSearchInput',
|
|
142
|
-
currentComponentName: 'Select',
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
const tweakSelectListStyles = useTweakStyles({
|
|
146
|
-
tweakStyles,
|
|
147
|
-
className: 'tweakSelectList',
|
|
148
|
-
currentComponentName: 'Select',
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
const isMounted = useIsMounted();
|
|
152
|
-
const [isListOpen, setIsListOpen] = useState(false);
|
|
153
|
-
const [areOptionsLoading, setAreOptionsLoading] = useState(false);
|
|
154
|
-
const hasDefaultOption = isReactNodeNotEmpty(defaultOptionLabel);
|
|
155
|
-
|
|
156
|
-
const [focusedListCellIndex, setFocusedListCellIndex] = useState(DEFAULT_OPTION_INDEX);
|
|
157
|
-
const [searchValue, setSearchValue] = useState('');
|
|
158
|
-
// если мы ввели что то в строку поиска - то этот булеан будет отключаться
|
|
159
|
-
// вынесен отдельно, из-за проблем с дебаунсом при динамич. опциях
|
|
160
|
-
const [shouldShowDefaultOption, setShouldShowDefaultOption] = useState(true);
|
|
161
|
-
|
|
162
|
-
const inputWrapper = useRef<HTMLDivElement>(null);
|
|
163
|
-
const list = useRef<HTMLDivElement>(null);
|
|
164
|
-
const input = useRef<HTMLInputElement>(null); // TODO ref снаружи?
|
|
165
|
-
|
|
166
|
-
const strValue = isMultiSelect ? value?.[0] : value;
|
|
167
|
-
const shouldShowAllOption =
|
|
168
|
-
isMultiSelect && isStringNotEmpty(allOptionsLabel) && searchValue === '';
|
|
169
|
-
|
|
170
|
-
const filteredOptions = useMemo(() => {
|
|
171
|
-
if (optionsMode !== 'search') {
|
|
172
|
-
return options;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const filter =
|
|
176
|
-
optionsFilter ?? createFilter<Value>((option) => [convertValueToString(option) ?? '']);
|
|
177
|
-
|
|
178
|
-
return filter(options, searchValue);
|
|
179
|
-
}, [optionsFilter, options, convertValueToString, searchValue, optionsMode]);
|
|
180
|
-
|
|
181
|
-
const availableOptions = useMemo(
|
|
182
|
-
() => options.filter((o) => !isOptionDisabled(o)),
|
|
183
|
-
[options, isOptionDisabled],
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
const areAllOptionsSelected = isMultiSelect && value?.length === availableOptions.length;
|
|
187
|
-
const shouldShowMultiSelectCounter =
|
|
188
|
-
isMultiSelect && isNotEmpty(value) && value.length > 1 && !areAllOptionsSelected;
|
|
189
|
-
|
|
190
|
-
const optionsIndexesForNavigation = useMemo(() => {
|
|
191
|
-
const result: number[] = [];
|
|
192
|
-
if (shouldShowDefaultOption && hasDefaultOption) {
|
|
193
|
-
result.push(DEFAULT_OPTION_INDEX);
|
|
194
|
-
}
|
|
195
|
-
if (shouldShowAllOption) {
|
|
196
|
-
result.push(ALL_OPTION_INDEX);
|
|
197
|
-
}
|
|
198
|
-
return result.concat(
|
|
199
|
-
filteredOptions.reduce((acc, cur, i) => {
|
|
200
|
-
if (!isOptionDisabled(cur)) {
|
|
201
|
-
acc.push(i);
|
|
202
|
-
}
|
|
203
|
-
return acc;
|
|
204
|
-
}, [] as number[]),
|
|
205
|
-
);
|
|
206
|
-
}, [filteredOptions]);
|
|
207
|
-
|
|
208
|
-
const stringValue = isNotEmpty(strValue) ? convertValueToString(strValue) : undefined;
|
|
209
|
-
// Для мультиселекта пытаемся показать "Все опции" если выбраны все опции
|
|
210
|
-
const showedStringValue =
|
|
211
|
-
areAllOptionsSelected && isNotEmpty(allOptionsLabel) ? allOptionsLabel : stringValue;
|
|
212
|
-
|
|
213
|
-
const convertToId = useCallback(
|
|
214
|
-
(v: Value) => (convertValueToId ?? getDefaultConvertToIdFunction(convertValueToString))(v),
|
|
215
|
-
[convertValueToId, convertValueToString],
|
|
216
|
-
);
|
|
217
|
-
|
|
218
|
-
const handleListClose = useCallback(
|
|
219
|
-
(event: Event | SyntheticEvent) => {
|
|
220
|
-
setIsListOpen(false);
|
|
221
|
-
setSearchValue('');
|
|
222
|
-
setShouldShowDefaultOption(true);
|
|
223
|
-
onBlur?.(event);
|
|
224
|
-
},
|
|
225
|
-
[onBlur],
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
const handleListOpen = () => {
|
|
229
|
-
if (!isListOpen) {
|
|
230
|
-
setIsListOpen(true);
|
|
231
|
-
}
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
|
|
235
|
-
onFocus?.(event);
|
|
236
|
-
handleListOpen();
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const handleOnClick = () => {
|
|
240
|
-
handleListOpen();
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
244
|
-
// Когда что-то блокирует открытие листа, но блур все равно должен сработать
|
|
245
|
-
// например minSymbolsCount
|
|
246
|
-
if (isListOpen && !isOpen) {
|
|
247
|
-
handleListClose(event);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
!isNotEmpty(event.relatedTarget) ||
|
|
253
|
-
!isNotEmpty(list.current) ||
|
|
254
|
-
!isNotEmpty(inputWrapper.current)
|
|
255
|
-
) {
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const isActionInsideSelect =
|
|
260
|
-
hasExactParent(event.relatedTarget, list.current) ||
|
|
261
|
-
hasExactParent(event.relatedTarget, inputWrapper.current);
|
|
262
|
-
|
|
263
|
-
// Ниче не делаем если клик был внутри селекта
|
|
264
|
-
if (!isActionInsideSelect) {
|
|
265
|
-
handleListClose(event);
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
const handleOnChange = useCallback(
|
|
270
|
-
(newValue: Value | IMultipleSelectValue<Value> | undefined) => {
|
|
271
|
-
// Тут беда с типами, сорри
|
|
272
|
-
if (!compareValuesOnChange(value as never, newValue as never)) {
|
|
273
|
-
onChange(newValue as (Value & IMultipleSelectValue<Value>) | undefined);
|
|
274
|
-
}
|
|
275
|
-
},
|
|
276
|
-
[value, compareValuesOnChange, onChange],
|
|
277
|
-
);
|
|
278
|
-
|
|
279
|
-
const handleOptionSelect = useCallback(
|
|
280
|
-
(index: number, event: MouseEvent<HTMLElement> | KeyboardEvent) => {
|
|
281
|
-
handleOnChange(index === DEFAULT_OPTION_INDEX ? undefined : filteredOptions[index]);
|
|
282
|
-
handleListClose(event);
|
|
283
|
-
input.current?.blur();
|
|
284
|
-
},
|
|
285
|
-
[handleOnChange, handleListClose, filteredOptions],
|
|
286
|
-
);
|
|
287
|
-
|
|
288
|
-
// MultiSelect
|
|
289
|
-
const handleToggleOptionCheckbox = useCallback(
|
|
290
|
-
(index: number, isSelected: boolean) => {
|
|
291
|
-
if (!isMultiSelect) {
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Если выбрана не дефолтная опция, которая сетит андеф
|
|
296
|
-
if (index === DEFAULT_OPTION_INDEX || (index === ALL_OPTION_INDEX && !isSelected)) {
|
|
297
|
-
handleOnChange(undefined);
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
if (index === ALL_OPTION_INDEX && isSelected) {
|
|
301
|
-
handleOnChange(availableOptions as IMultipleSelectValue<Value>);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
const option = filteredOptions[index];
|
|
305
|
-
handleOnChange(
|
|
306
|
-
isSelected
|
|
307
|
-
? // Добавляем
|
|
308
|
-
([...(value ?? []), option] as IMultipleSelectValue<Value>)
|
|
309
|
-
: // Убираем
|
|
310
|
-
value?.filter((o) => convertToId(o) !== convertToId(option)),
|
|
311
|
-
);
|
|
312
|
-
},
|
|
313
|
-
[handleOnChange, filteredOptions, isMultiSelect, value],
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
const handleOnType = useCallback(
|
|
317
|
-
async (v: string) => {
|
|
318
|
-
if (onType === undefined) {
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
if (isMounted()) {
|
|
322
|
-
setAreOptionsLoading(true);
|
|
323
|
-
}
|
|
324
|
-
await onType(v);
|
|
325
|
-
if (isMounted()) {
|
|
326
|
-
setAreOptionsLoading(false);
|
|
327
|
-
}
|
|
328
|
-
if (optionsMode === 'dynamic') {
|
|
329
|
-
setShouldShowDefaultOption(v === '');
|
|
330
|
-
}
|
|
331
|
-
},
|
|
332
|
-
[onType, optionsMode],
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
const debounceHandleOnType = useCallback(debounce(handleOnType, debounceTime), [
|
|
336
|
-
handleOnType,
|
|
337
|
-
debounceTime,
|
|
338
|
-
]);
|
|
339
|
-
|
|
340
|
-
const handleInputChange = (v: string) => {
|
|
341
|
-
if (onType !== undefined) {
|
|
342
|
-
debounceHandleOnType(v);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (optionsMode !== 'dynamic') {
|
|
346
|
-
setShouldShowDefaultOption(v === '');
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (v === '' && !hasSearchInputInList) {
|
|
350
|
-
handleOnChange(undefined);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
setSearchValue(v);
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
357
|
-
if (!isListOpen) {
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
event.stopPropagation();
|
|
362
|
-
const curIndexInNavigation = optionsIndexesForNavigation.findIndex(
|
|
363
|
-
(index) => index === focusedListCellIndex,
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
switch (event.code) {
|
|
367
|
-
case 'Enter':
|
|
368
|
-
case 'NumpadEnter': {
|
|
369
|
-
let indexToSelect = focusedListCellIndex;
|
|
370
|
-
|
|
371
|
-
// если осталась одна опция в списке,
|
|
372
|
-
// то выбираем ее нажатием на enter
|
|
373
|
-
if (indexToSelect === DEFAULT_OPTION_INDEX && filteredOptions.length === 1) {
|
|
374
|
-
indexToSelect = 0;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (isMultiSelect) {
|
|
378
|
-
let isThisValueAlreadySelected: boolean;
|
|
379
|
-
if (indexToSelect === ALL_OPTION_INDEX) {
|
|
380
|
-
isThisValueAlreadySelected = areAllOptionsSelected;
|
|
381
|
-
} else {
|
|
382
|
-
// подумать над концептом реального фокуса на опциях, а не вот эти вот focusedCell
|
|
383
|
-
const valueIdToSelect = convertToId(filteredOptions[indexToSelect]);
|
|
384
|
-
isThisValueAlreadySelected =
|
|
385
|
-
value?.some((opt) => convertToId(opt) === valueIdToSelect) ?? false;
|
|
386
|
-
}
|
|
387
|
-
handleToggleOptionCheckbox(indexToSelect, !isThisValueAlreadySelected);
|
|
388
|
-
} else {
|
|
389
|
-
handleOptionSelect(indexToSelect, event);
|
|
390
|
-
}
|
|
391
|
-
break;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
case 'ArrowDown': {
|
|
395
|
-
// чтобы убрать перемещение курсора в инпуте
|
|
396
|
-
event.preventDefault();
|
|
397
|
-
const targetIndexInNavigation =
|
|
398
|
-
(curIndexInNavigation + 1) % optionsIndexesForNavigation.length;
|
|
399
|
-
setFocusedListCellIndex(optionsIndexesForNavigation[targetIndexInNavigation]);
|
|
400
|
-
break;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
case 'ArrowUp': {
|
|
404
|
-
// чтобы убрать перемещение курсора в инпуте
|
|
405
|
-
event.preventDefault();
|
|
406
|
-
const targetIndexInNavigation =
|
|
407
|
-
(curIndexInNavigation - 1 + optionsIndexesForNavigation.length) %
|
|
408
|
-
optionsIndexesForNavigation.length;
|
|
409
|
-
setFocusedListCellIndex(optionsIndexesForNavigation[targetIndexInNavigation]);
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
const onArrowClick = () => {
|
|
416
|
-
if (isListOpen) {
|
|
417
|
-
input.current?.blur();
|
|
418
|
-
} else {
|
|
419
|
-
input.current?.focus();
|
|
420
|
-
}
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
useOnClickOutsideWithRef(list, handleListClose, inputWrapper);
|
|
424
|
-
|
|
425
|
-
const hasEnoughSymbolsToSearch = searchValue.trim().length >= minSymbolsCountToOpenList;
|
|
426
|
-
|
|
427
|
-
const isOpen =
|
|
428
|
-
// Пользователь пытается открыть лист
|
|
429
|
-
isListOpen &&
|
|
430
|
-
// Нам есть что показать:
|
|
431
|
-
// Есть опции
|
|
432
|
-
(filteredOptions.length > 0 ||
|
|
433
|
-
// Дефолтная опция
|
|
434
|
-
(defaultOptionLabel !== undefined && !hasEnoughSymbolsToSearch) ||
|
|
435
|
-
// Текст "Загрузка..."
|
|
436
|
-
inputProps.isLoading ||
|
|
437
|
-
// Текст "Совпадений не найдено"
|
|
438
|
-
noMatchesLabel !== undefined ||
|
|
439
|
-
// У нас есть инпут с поиском внутри листа
|
|
440
|
-
hasSearchInputInList) &&
|
|
441
|
-
// Последняя проверка на случай, если мы че то ищем в опциях
|
|
442
|
-
(optionsMode === 'normal' || hasEnoughSymbolsToSearch);
|
|
443
|
-
|
|
444
|
-
// Эти значения ставятся в false по дефолту также в useDropdown
|
|
445
|
-
const {
|
|
446
|
-
shouldUsePopper = false,
|
|
447
|
-
shouldRenderInBody = false,
|
|
448
|
-
shouldHideOnScroll = false,
|
|
449
|
-
} = dropdownOptions ?? {};
|
|
450
|
-
|
|
451
|
-
const popperData = useDropdown({
|
|
452
|
-
isOpen,
|
|
453
|
-
onDropdownClose: handleListClose,
|
|
454
|
-
referenceElement: inputWrapper.current,
|
|
455
|
-
dropdownElement: list.current,
|
|
456
|
-
options: dropdownOptions,
|
|
457
|
-
dependenciesForPositionUpdating: [inputProps.isLoading, filteredOptions.length],
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
useEffect(() => {
|
|
461
|
-
setFocusedListCellIndex(
|
|
462
|
-
optionsIndexesForNavigation.find(
|
|
463
|
-
(index) =>
|
|
464
|
-
isNotEmpty(strValue) &&
|
|
465
|
-
isNotEmpty(filteredOptions[index]) &&
|
|
466
|
-
convertToId(filteredOptions[index]) === convertToId(strValue),
|
|
467
|
-
) ?? optionsIndexesForNavigation[0],
|
|
468
|
-
);
|
|
469
|
-
}, [strValue, filteredOptions, optionsIndexesForNavigation, convertToId]);
|
|
470
|
-
|
|
471
|
-
useEffect(() => {
|
|
472
|
-
if (isOpen) {
|
|
473
|
-
onOpen?.();
|
|
474
|
-
}
|
|
475
|
-
}, [isOpen]);
|
|
476
|
-
|
|
477
|
-
const listEl = (
|
|
478
|
-
<div
|
|
479
|
-
className={clsx(classes.listWrapper, {
|
|
480
|
-
[classes.withoutPopper]: !shouldUsePopper,
|
|
481
|
-
[classes.listWrapperInBody]: shouldRenderInBody,
|
|
482
|
-
})}
|
|
483
|
-
ref={list}
|
|
484
|
-
style={popperData?.styles.popper as Styles}
|
|
485
|
-
onBlur={handleBlur} // обработка для Tab из списка
|
|
486
|
-
{...popperData?.attributes.popper}
|
|
487
|
-
>
|
|
488
|
-
{isOpen && (
|
|
489
|
-
<SelectList
|
|
490
|
-
options={filteredOptions}
|
|
491
|
-
defaultOptionLabel={
|
|
492
|
-
hasDefaultOption && shouldShowDefaultOption ? defaultOptionLabel : undefined
|
|
493
|
-
}
|
|
494
|
-
allOptionsLabel={shouldShowAllOption ? allOptionsLabel : undefined}
|
|
495
|
-
areAllOptionsSelected={areAllOptionsSelected}
|
|
496
|
-
customListHeader={
|
|
497
|
-
hasSearchInputInList ? (
|
|
498
|
-
<SearchInput
|
|
499
|
-
value={searchValue}
|
|
500
|
-
onChange={handleInputChange}
|
|
501
|
-
tweakStyles={tweakSearchInputStyles}
|
|
502
|
-
placeholder="Поиск"
|
|
503
|
-
{...searchInput}
|
|
504
|
-
/>
|
|
505
|
-
) : undefined
|
|
506
|
-
}
|
|
507
|
-
noMatchesLabel={noMatchesLabel}
|
|
508
|
-
focusedIndex={focusedListCellIndex}
|
|
509
|
-
activeValue={value}
|
|
510
|
-
isLoading={inputProps.isLoading}
|
|
511
|
-
loadingLabel={loadingLabel}
|
|
512
|
-
tweakStyles={tweakSelectListStyles}
|
|
513
|
-
testId={getTestId(testId, 'list')}
|
|
514
|
-
// скролл не работает с включеным поппером
|
|
515
|
-
shouldScrollToList={shouldScrollToList && !shouldUsePopper && !shouldHideOnScroll}
|
|
516
|
-
isOptionDisabled={isOptionDisabled}
|
|
517
|
-
convertValueToString={convertValueToString}
|
|
518
|
-
convertValueToReactNode={convertValueToReactNode}
|
|
519
|
-
convertValueToId={convertToId}
|
|
520
|
-
onOptionSelect={handleOptionSelect}
|
|
521
|
-
onToggleCheckbox={isMultiSelect ? handleToggleOptionCheckbox : undefined}
|
|
522
|
-
/>
|
|
523
|
-
)}
|
|
524
|
-
</div>
|
|
525
|
-
);
|
|
526
|
-
|
|
527
|
-
const multiSelectCounterWithIcon =
|
|
528
|
-
shouldShowMultiSelectCounter || isNotEmpty(iconType) ? (
|
|
529
|
-
<>
|
|
530
|
-
{shouldShowMultiSelectCounter && (
|
|
531
|
-
<div className={classes.counter}>(+{value.length - 1})</div>
|
|
532
|
-
)}
|
|
533
|
-
{isNotEmpty(iconType) && <div className={classes.icon}>{renderIcon(iconType)}</div>}
|
|
534
|
-
</>
|
|
535
|
-
) : undefined;
|
|
536
|
-
|
|
537
|
-
return (
|
|
538
|
-
<div className={classes.root} onKeyDown={handleKeyDown}>
|
|
539
|
-
<div
|
|
540
|
-
className={clsx(classes.inputWrapper, isDisabled && classes.disabled)}
|
|
541
|
-
onClick={isDisabled ? undefined : handleOnClick}
|
|
542
|
-
ref={inputWrapper}
|
|
543
|
-
>
|
|
544
|
-
<Input
|
|
545
|
-
value={
|
|
546
|
-
searchValue !== '' && !shouldRenderSearchInputInList ? searchValue : showedStringValue
|
|
547
|
-
}
|
|
548
|
-
onChange={handleInputChange}
|
|
549
|
-
isActive={isListOpen}
|
|
550
|
-
isReadonly={hasReadonlyInput}
|
|
551
|
-
onFocus={handleFocus}
|
|
552
|
-
onBlur={handleBlur}
|
|
553
|
-
isDisabled={isDisabled}
|
|
554
|
-
ref={input}
|
|
555
|
-
isLoading={areOptionsLoading}
|
|
556
|
-
tweakStyles={tweakInputStyles}
|
|
557
|
-
testId={testId}
|
|
558
|
-
iconType={isMultiSelect ? multiSelectCounterWithIcon : iconType}
|
|
559
|
-
{...inputProps}
|
|
560
|
-
/>
|
|
561
|
-
<div
|
|
562
|
-
onMouseDown={(event: MouseEvent) => {
|
|
563
|
-
event.preventDefault();
|
|
564
|
-
}}
|
|
565
|
-
onClick={onArrowClick}
|
|
566
|
-
className={clsx(classes.arrow, isOpen && classes.activeArrow)}
|
|
567
|
-
>
|
|
568
|
-
{renderIcon(dropdownIcon)}
|
|
569
|
-
</div>
|
|
570
|
-
</div>
|
|
571
|
-
{shouldUsePopper ? (
|
|
572
|
-
<Portal container={shouldRenderInBody ? document.body : inputWrapper.current}>
|
|
573
|
-
<>{listEl}</>
|
|
574
|
-
</Portal>
|
|
575
|
-
) : (
|
|
576
|
-
<>{isOpen && listEl}</>
|
|
577
|
-
)}
|
|
578
|
-
</div>
|
|
579
|
-
);
|
|
580
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
ReactNode,
|
|
3
|
+
FocusEvent,
|
|
4
|
+
KeyboardEvent,
|
|
5
|
+
MouseEvent,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
SyntheticEvent,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { Portal } from 'react-overlays';
|
|
14
|
+
import clsx from 'clsx';
|
|
15
|
+
import { Styles } from 'jss';
|
|
16
|
+
import { debounce } from 'ts-debounce';
|
|
17
|
+
import {
|
|
18
|
+
getTestId,
|
|
19
|
+
isNotEmpty,
|
|
20
|
+
isReactNodeNotEmpty,
|
|
21
|
+
isStringNotEmpty,
|
|
22
|
+
createFilter,
|
|
23
|
+
} from '@true-engineering/true-react-platform-helpers';
|
|
24
|
+
import { hasExactParent } from '../../helpers';
|
|
25
|
+
import { useIsMounted, useOnClickOutsideWithRef, useDropdown, useTweakStyles } from '../../hooks';
|
|
26
|
+
import { ICommonProps, IDropdownWithPopperOptions } from '../../types';
|
|
27
|
+
import { renderIcon, IIcon } from '../Icon';
|
|
28
|
+
import { IInputProps, Input } from '../Input';
|
|
29
|
+
import { ISearchInputProps, SearchInput } from '../SearchInput';
|
|
30
|
+
import { SelectList } from './components';
|
|
31
|
+
import { ALL_OPTION_INDEX, DEFAULT_OPTION_INDEX } from './constants';
|
|
32
|
+
import {
|
|
33
|
+
defaultConvertFunction,
|
|
34
|
+
defaultCompareFunction,
|
|
35
|
+
defaultIsOptionDisabled,
|
|
36
|
+
getDefaultConvertToIdFunction,
|
|
37
|
+
isMultiSelectValue,
|
|
38
|
+
} from './helpers';
|
|
39
|
+
import { IMultipleSelectValue } from './types';
|
|
40
|
+
import { useStyles, ISelectStyles, searchInputStyles, getInputStyles } from './Select.styles';
|
|
41
|
+
|
|
42
|
+
export interface ISelectProps<Value>
|
|
43
|
+
extends Omit<IInputProps, 'value' | 'onChange' | 'onBlur' | 'type' | 'tweakStyles'>,
|
|
44
|
+
ICommonProps<ISelectStyles> {
|
|
45
|
+
defaultOptionLabel?: ReactNode;
|
|
46
|
+
allOptionsLabel?: string;
|
|
47
|
+
noMatchesLabel?: string;
|
|
48
|
+
loadingLabel?: ReactNode;
|
|
49
|
+
/** @default 'normal' */
|
|
50
|
+
optionsMode?: 'search' | 'dynamic' | 'normal';
|
|
51
|
+
/** @default 400 */
|
|
52
|
+
debounceTime?: number;
|
|
53
|
+
/** @default 0 */
|
|
54
|
+
minSymbolsCountToOpenList?: number;
|
|
55
|
+
dropdownOptions?: IDropdownWithPopperOptions;
|
|
56
|
+
/** @default 'chevron-down' */
|
|
57
|
+
dropdownIcon?: IIcon;
|
|
58
|
+
options: Value[];
|
|
59
|
+
value: Value | undefined;
|
|
60
|
+
/** @default true */
|
|
61
|
+
shouldScrollToList?: boolean;
|
|
62
|
+
isMultiSelect?: boolean;
|
|
63
|
+
searchInput?: { shouldRenderInList: true } & Pick<ISearchInputProps, 'placeholder'>;
|
|
64
|
+
isOptionDisabled?: (option: Value) => boolean;
|
|
65
|
+
onChange: (value?: Value) => void; // подумать как возвращать индекс
|
|
66
|
+
onBlur?: (event: Event | SyntheticEvent) => void;
|
|
67
|
+
onType?: (value: string) => Promise<void>;
|
|
68
|
+
optionsFilter?: (options: Value[], query: string) => Value[];
|
|
69
|
+
onOpen?: () => void;
|
|
70
|
+
compareValuesOnChange?: (v1?: Value, v2?: Value) => boolean;
|
|
71
|
+
// Для избежания проблем юзайте useCallback на эти функции
|
|
72
|
+
// или выносите их из компонента (чтобы не было сайдэфектов от перерендеринга их)
|
|
73
|
+
convertValueToString?: (value: Value) => string | undefined;
|
|
74
|
+
convertValueToReactNode?: (value: Value, isDisabled: boolean) => ReactNode;
|
|
75
|
+
convertValueToId?: (value: Value) => string | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface IMultipleSelectProps<Value>
|
|
79
|
+
extends Omit<ISelectProps<Value>, 'value' | 'onChange' | 'compareValuesOnChange'> {
|
|
80
|
+
isMultiSelect: true;
|
|
81
|
+
value: IMultipleSelectValue<Value> | undefined;
|
|
82
|
+
onChange: (value?: IMultipleSelectValue<Value>) => void;
|
|
83
|
+
compareValuesOnChange?: (
|
|
84
|
+
v1?: IMultipleSelectValue<Value>,
|
|
85
|
+
v2?: IMultipleSelectValue<Value>,
|
|
86
|
+
) => boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function Select<Value>(
|
|
90
|
+
props: ISelectProps<Value> | IMultipleSelectProps<Value>,
|
|
91
|
+
): JSX.Element {
|
|
92
|
+
const {
|
|
93
|
+
options,
|
|
94
|
+
value,
|
|
95
|
+
defaultOptionLabel,
|
|
96
|
+
allOptionsLabel,
|
|
97
|
+
debounceTime = 400,
|
|
98
|
+
optionsMode = 'normal',
|
|
99
|
+
noMatchesLabel,
|
|
100
|
+
loadingLabel,
|
|
101
|
+
tweakStyles,
|
|
102
|
+
testId,
|
|
103
|
+
isReadonly,
|
|
104
|
+
isDisabled,
|
|
105
|
+
dropdownOptions,
|
|
106
|
+
minSymbolsCountToOpenList = 0,
|
|
107
|
+
dropdownIcon = 'chevron-down',
|
|
108
|
+
shouldScrollToList = true,
|
|
109
|
+
searchInput,
|
|
110
|
+
iconType,
|
|
111
|
+
onChange,
|
|
112
|
+
onFocus,
|
|
113
|
+
onBlur,
|
|
114
|
+
onType,
|
|
115
|
+
onOpen,
|
|
116
|
+
isOptionDisabled = defaultIsOptionDisabled,
|
|
117
|
+
compareValuesOnChange = defaultCompareFunction,
|
|
118
|
+
convertValueToString = defaultConvertFunction,
|
|
119
|
+
convertValueToId,
|
|
120
|
+
convertValueToReactNode,
|
|
121
|
+
optionsFilter,
|
|
122
|
+
...inputProps
|
|
123
|
+
} = props;
|
|
124
|
+
const classes = useStyles({ theme: tweakStyles });
|
|
125
|
+
|
|
126
|
+
const shouldRenderSearchInputInList = searchInput?.shouldRenderInList === true;
|
|
127
|
+
const hasSearchInputInList = optionsMode !== 'normal' && shouldRenderSearchInputInList;
|
|
128
|
+
const isMultiSelect = isMultiSelectValue(props, value);
|
|
129
|
+
const hasReadonlyInput = isReadonly || optionsMode === 'normal' || shouldRenderSearchInputInList;
|
|
130
|
+
|
|
131
|
+
const tweakInputStyles = useTweakStyles({
|
|
132
|
+
innerStyles: getInputStyles({ hasReadonlyInput, isMultiSelect }),
|
|
133
|
+
tweakStyles,
|
|
134
|
+
className: 'tweakInput',
|
|
135
|
+
currentComponentName: 'Select',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const tweakSearchInputStyles = useTweakStyles({
|
|
139
|
+
innerStyles: searchInputStyles,
|
|
140
|
+
tweakStyles,
|
|
141
|
+
className: 'tweakSearchInput',
|
|
142
|
+
currentComponentName: 'Select',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const tweakSelectListStyles = useTweakStyles({
|
|
146
|
+
tweakStyles,
|
|
147
|
+
className: 'tweakSelectList',
|
|
148
|
+
currentComponentName: 'Select',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const isMounted = useIsMounted();
|
|
152
|
+
const [isListOpen, setIsListOpen] = useState(false);
|
|
153
|
+
const [areOptionsLoading, setAreOptionsLoading] = useState(false);
|
|
154
|
+
const hasDefaultOption = isReactNodeNotEmpty(defaultOptionLabel);
|
|
155
|
+
|
|
156
|
+
const [focusedListCellIndex, setFocusedListCellIndex] = useState(DEFAULT_OPTION_INDEX);
|
|
157
|
+
const [searchValue, setSearchValue] = useState('');
|
|
158
|
+
// если мы ввели что то в строку поиска - то этот булеан будет отключаться
|
|
159
|
+
// вынесен отдельно, из-за проблем с дебаунсом при динамич. опциях
|
|
160
|
+
const [shouldShowDefaultOption, setShouldShowDefaultOption] = useState(true);
|
|
161
|
+
|
|
162
|
+
const inputWrapper = useRef<HTMLDivElement>(null);
|
|
163
|
+
const list = useRef<HTMLDivElement>(null);
|
|
164
|
+
const input = useRef<HTMLInputElement>(null); // TODO ref снаружи?
|
|
165
|
+
|
|
166
|
+
const strValue = isMultiSelect ? value?.[0] : value;
|
|
167
|
+
const shouldShowAllOption =
|
|
168
|
+
isMultiSelect && isStringNotEmpty(allOptionsLabel) && searchValue === '';
|
|
169
|
+
|
|
170
|
+
const filteredOptions = useMemo(() => {
|
|
171
|
+
if (optionsMode !== 'search') {
|
|
172
|
+
return options;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const filter =
|
|
176
|
+
optionsFilter ?? createFilter<Value>((option) => [convertValueToString(option) ?? '']);
|
|
177
|
+
|
|
178
|
+
return filter(options, searchValue);
|
|
179
|
+
}, [optionsFilter, options, convertValueToString, searchValue, optionsMode]);
|
|
180
|
+
|
|
181
|
+
const availableOptions = useMemo(
|
|
182
|
+
() => options.filter((o) => !isOptionDisabled(o)),
|
|
183
|
+
[options, isOptionDisabled],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const areAllOptionsSelected = isMultiSelect && value?.length === availableOptions.length;
|
|
187
|
+
const shouldShowMultiSelectCounter =
|
|
188
|
+
isMultiSelect && isNotEmpty(value) && value.length > 1 && !areAllOptionsSelected;
|
|
189
|
+
|
|
190
|
+
const optionsIndexesForNavigation = useMemo(() => {
|
|
191
|
+
const result: number[] = [];
|
|
192
|
+
if (shouldShowDefaultOption && hasDefaultOption) {
|
|
193
|
+
result.push(DEFAULT_OPTION_INDEX);
|
|
194
|
+
}
|
|
195
|
+
if (shouldShowAllOption) {
|
|
196
|
+
result.push(ALL_OPTION_INDEX);
|
|
197
|
+
}
|
|
198
|
+
return result.concat(
|
|
199
|
+
filteredOptions.reduce((acc, cur, i) => {
|
|
200
|
+
if (!isOptionDisabled(cur)) {
|
|
201
|
+
acc.push(i);
|
|
202
|
+
}
|
|
203
|
+
return acc;
|
|
204
|
+
}, [] as number[]),
|
|
205
|
+
);
|
|
206
|
+
}, [filteredOptions]);
|
|
207
|
+
|
|
208
|
+
const stringValue = isNotEmpty(strValue) ? convertValueToString(strValue) : undefined;
|
|
209
|
+
// Для мультиселекта пытаемся показать "Все опции" если выбраны все опции
|
|
210
|
+
const showedStringValue =
|
|
211
|
+
areAllOptionsSelected && isNotEmpty(allOptionsLabel) ? allOptionsLabel : stringValue;
|
|
212
|
+
|
|
213
|
+
const convertToId = useCallback(
|
|
214
|
+
(v: Value) => (convertValueToId ?? getDefaultConvertToIdFunction(convertValueToString))(v),
|
|
215
|
+
[convertValueToId, convertValueToString],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const handleListClose = useCallback(
|
|
219
|
+
(event: Event | SyntheticEvent) => {
|
|
220
|
+
setIsListOpen(false);
|
|
221
|
+
setSearchValue('');
|
|
222
|
+
setShouldShowDefaultOption(true);
|
|
223
|
+
onBlur?.(event);
|
|
224
|
+
},
|
|
225
|
+
[onBlur],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const handleListOpen = () => {
|
|
229
|
+
if (!isListOpen) {
|
|
230
|
+
setIsListOpen(true);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
|
|
235
|
+
onFocus?.(event);
|
|
236
|
+
handleListOpen();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const handleOnClick = () => {
|
|
240
|
+
handleListOpen();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
|
244
|
+
// Когда что-то блокирует открытие листа, но блур все равно должен сработать
|
|
245
|
+
// например minSymbolsCount
|
|
246
|
+
if (isListOpen && !isOpen) {
|
|
247
|
+
handleListClose(event);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (
|
|
252
|
+
!isNotEmpty(event.relatedTarget) ||
|
|
253
|
+
!isNotEmpty(list.current) ||
|
|
254
|
+
!isNotEmpty(inputWrapper.current)
|
|
255
|
+
) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const isActionInsideSelect =
|
|
260
|
+
hasExactParent(event.relatedTarget, list.current) ||
|
|
261
|
+
hasExactParent(event.relatedTarget, inputWrapper.current);
|
|
262
|
+
|
|
263
|
+
// Ниче не делаем если клик был внутри селекта
|
|
264
|
+
if (!isActionInsideSelect) {
|
|
265
|
+
handleListClose(event);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const handleOnChange = useCallback(
|
|
270
|
+
(newValue: Value | IMultipleSelectValue<Value> | undefined) => {
|
|
271
|
+
// Тут беда с типами, сорри
|
|
272
|
+
if (!compareValuesOnChange(value as never, newValue as never)) {
|
|
273
|
+
onChange(newValue as (Value & IMultipleSelectValue<Value>) | undefined);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
[value, compareValuesOnChange, onChange],
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const handleOptionSelect = useCallback(
|
|
280
|
+
(index: number, event: MouseEvent<HTMLElement> | KeyboardEvent) => {
|
|
281
|
+
handleOnChange(index === DEFAULT_OPTION_INDEX ? undefined : filteredOptions[index]);
|
|
282
|
+
handleListClose(event);
|
|
283
|
+
input.current?.blur();
|
|
284
|
+
},
|
|
285
|
+
[handleOnChange, handleListClose, filteredOptions],
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// MultiSelect
|
|
289
|
+
const handleToggleOptionCheckbox = useCallback(
|
|
290
|
+
(index: number, isSelected: boolean) => {
|
|
291
|
+
if (!isMultiSelect) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Если выбрана не дефолтная опция, которая сетит андеф
|
|
296
|
+
if (index === DEFAULT_OPTION_INDEX || (index === ALL_OPTION_INDEX && !isSelected)) {
|
|
297
|
+
handleOnChange(undefined);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (index === ALL_OPTION_INDEX && isSelected) {
|
|
301
|
+
handleOnChange(availableOptions as IMultipleSelectValue<Value>);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const option = filteredOptions[index];
|
|
305
|
+
handleOnChange(
|
|
306
|
+
isSelected
|
|
307
|
+
? // Добавляем
|
|
308
|
+
([...(value ?? []), option] as IMultipleSelectValue<Value>)
|
|
309
|
+
: // Убираем
|
|
310
|
+
value?.filter((o) => convertToId(o) !== convertToId(option)),
|
|
311
|
+
);
|
|
312
|
+
},
|
|
313
|
+
[handleOnChange, filteredOptions, isMultiSelect, value],
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const handleOnType = useCallback(
|
|
317
|
+
async (v: string) => {
|
|
318
|
+
if (onType === undefined) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (isMounted()) {
|
|
322
|
+
setAreOptionsLoading(true);
|
|
323
|
+
}
|
|
324
|
+
await onType(v);
|
|
325
|
+
if (isMounted()) {
|
|
326
|
+
setAreOptionsLoading(false);
|
|
327
|
+
}
|
|
328
|
+
if (optionsMode === 'dynamic') {
|
|
329
|
+
setShouldShowDefaultOption(v === '');
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
[onType, optionsMode],
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const debounceHandleOnType = useCallback(debounce(handleOnType, debounceTime), [
|
|
336
|
+
handleOnType,
|
|
337
|
+
debounceTime,
|
|
338
|
+
]);
|
|
339
|
+
|
|
340
|
+
const handleInputChange = (v: string) => {
|
|
341
|
+
if (onType !== undefined) {
|
|
342
|
+
debounceHandleOnType(v);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (optionsMode !== 'dynamic') {
|
|
346
|
+
setShouldShowDefaultOption(v === '');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (v === '' && !hasSearchInputInList) {
|
|
350
|
+
handleOnChange(undefined);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
setSearchValue(v);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
357
|
+
if (!isListOpen) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
event.stopPropagation();
|
|
362
|
+
const curIndexInNavigation = optionsIndexesForNavigation.findIndex(
|
|
363
|
+
(index) => index === focusedListCellIndex,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
switch (event.code) {
|
|
367
|
+
case 'Enter':
|
|
368
|
+
case 'NumpadEnter': {
|
|
369
|
+
let indexToSelect = focusedListCellIndex;
|
|
370
|
+
|
|
371
|
+
// если осталась одна опция в списке,
|
|
372
|
+
// то выбираем ее нажатием на enter
|
|
373
|
+
if (indexToSelect === DEFAULT_OPTION_INDEX && filteredOptions.length === 1) {
|
|
374
|
+
indexToSelect = 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (isMultiSelect) {
|
|
378
|
+
let isThisValueAlreadySelected: boolean;
|
|
379
|
+
if (indexToSelect === ALL_OPTION_INDEX) {
|
|
380
|
+
isThisValueAlreadySelected = areAllOptionsSelected;
|
|
381
|
+
} else {
|
|
382
|
+
// подумать над концептом реального фокуса на опциях, а не вот эти вот focusedCell
|
|
383
|
+
const valueIdToSelect = convertToId(filteredOptions[indexToSelect]);
|
|
384
|
+
isThisValueAlreadySelected =
|
|
385
|
+
value?.some((opt) => convertToId(opt) === valueIdToSelect) ?? false;
|
|
386
|
+
}
|
|
387
|
+
handleToggleOptionCheckbox(indexToSelect, !isThisValueAlreadySelected);
|
|
388
|
+
} else {
|
|
389
|
+
handleOptionSelect(indexToSelect, event);
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case 'ArrowDown': {
|
|
395
|
+
// чтобы убрать перемещение курсора в инпуте
|
|
396
|
+
event.preventDefault();
|
|
397
|
+
const targetIndexInNavigation =
|
|
398
|
+
(curIndexInNavigation + 1) % optionsIndexesForNavigation.length;
|
|
399
|
+
setFocusedListCellIndex(optionsIndexesForNavigation[targetIndexInNavigation]);
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case 'ArrowUp': {
|
|
404
|
+
// чтобы убрать перемещение курсора в инпуте
|
|
405
|
+
event.preventDefault();
|
|
406
|
+
const targetIndexInNavigation =
|
|
407
|
+
(curIndexInNavigation - 1 + optionsIndexesForNavigation.length) %
|
|
408
|
+
optionsIndexesForNavigation.length;
|
|
409
|
+
setFocusedListCellIndex(optionsIndexesForNavigation[targetIndexInNavigation]);
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const onArrowClick = () => {
|
|
416
|
+
if (isListOpen) {
|
|
417
|
+
input.current?.blur();
|
|
418
|
+
} else {
|
|
419
|
+
input.current?.focus();
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
useOnClickOutsideWithRef(list, handleListClose, inputWrapper);
|
|
424
|
+
|
|
425
|
+
const hasEnoughSymbolsToSearch = searchValue.trim().length >= minSymbolsCountToOpenList;
|
|
426
|
+
|
|
427
|
+
const isOpen =
|
|
428
|
+
// Пользователь пытается открыть лист
|
|
429
|
+
isListOpen &&
|
|
430
|
+
// Нам есть что показать:
|
|
431
|
+
// Есть опции
|
|
432
|
+
(filteredOptions.length > 0 ||
|
|
433
|
+
// Дефолтная опция
|
|
434
|
+
(defaultOptionLabel !== undefined && !hasEnoughSymbolsToSearch) ||
|
|
435
|
+
// Текст "Загрузка..."
|
|
436
|
+
inputProps.isLoading ||
|
|
437
|
+
// Текст "Совпадений не найдено"
|
|
438
|
+
noMatchesLabel !== undefined ||
|
|
439
|
+
// У нас есть инпут с поиском внутри листа
|
|
440
|
+
hasSearchInputInList) &&
|
|
441
|
+
// Последняя проверка на случай, если мы че то ищем в опциях
|
|
442
|
+
(optionsMode === 'normal' || hasEnoughSymbolsToSearch);
|
|
443
|
+
|
|
444
|
+
// Эти значения ставятся в false по дефолту также в useDropdown
|
|
445
|
+
const {
|
|
446
|
+
shouldUsePopper = false,
|
|
447
|
+
shouldRenderInBody = false,
|
|
448
|
+
shouldHideOnScroll = false,
|
|
449
|
+
} = dropdownOptions ?? {};
|
|
450
|
+
|
|
451
|
+
const popperData = useDropdown({
|
|
452
|
+
isOpen,
|
|
453
|
+
onDropdownClose: handleListClose,
|
|
454
|
+
referenceElement: inputWrapper.current,
|
|
455
|
+
dropdownElement: list.current,
|
|
456
|
+
options: dropdownOptions,
|
|
457
|
+
dependenciesForPositionUpdating: [inputProps.isLoading, filteredOptions.length],
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
setFocusedListCellIndex(
|
|
462
|
+
optionsIndexesForNavigation.find(
|
|
463
|
+
(index) =>
|
|
464
|
+
isNotEmpty(strValue) &&
|
|
465
|
+
isNotEmpty(filteredOptions[index]) &&
|
|
466
|
+
convertToId(filteredOptions[index]) === convertToId(strValue),
|
|
467
|
+
) ?? optionsIndexesForNavigation[0],
|
|
468
|
+
);
|
|
469
|
+
}, [strValue, filteredOptions, optionsIndexesForNavigation, convertToId]);
|
|
470
|
+
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
if (isOpen) {
|
|
473
|
+
onOpen?.();
|
|
474
|
+
}
|
|
475
|
+
}, [isOpen]);
|
|
476
|
+
|
|
477
|
+
const listEl = (
|
|
478
|
+
<div
|
|
479
|
+
className={clsx(classes.listWrapper, {
|
|
480
|
+
[classes.withoutPopper]: !shouldUsePopper,
|
|
481
|
+
[classes.listWrapperInBody]: shouldRenderInBody,
|
|
482
|
+
})}
|
|
483
|
+
ref={list}
|
|
484
|
+
style={popperData?.styles.popper as Styles}
|
|
485
|
+
onBlur={handleBlur} // обработка для Tab из списка
|
|
486
|
+
{...popperData?.attributes.popper}
|
|
487
|
+
>
|
|
488
|
+
{isOpen && (
|
|
489
|
+
<SelectList
|
|
490
|
+
options={filteredOptions}
|
|
491
|
+
defaultOptionLabel={
|
|
492
|
+
hasDefaultOption && shouldShowDefaultOption ? defaultOptionLabel : undefined
|
|
493
|
+
}
|
|
494
|
+
allOptionsLabel={shouldShowAllOption ? allOptionsLabel : undefined}
|
|
495
|
+
areAllOptionsSelected={areAllOptionsSelected}
|
|
496
|
+
customListHeader={
|
|
497
|
+
hasSearchInputInList ? (
|
|
498
|
+
<SearchInput
|
|
499
|
+
value={searchValue}
|
|
500
|
+
onChange={handleInputChange}
|
|
501
|
+
tweakStyles={tweakSearchInputStyles}
|
|
502
|
+
placeholder="Поиск"
|
|
503
|
+
{...searchInput}
|
|
504
|
+
/>
|
|
505
|
+
) : undefined
|
|
506
|
+
}
|
|
507
|
+
noMatchesLabel={noMatchesLabel}
|
|
508
|
+
focusedIndex={focusedListCellIndex}
|
|
509
|
+
activeValue={value}
|
|
510
|
+
isLoading={inputProps.isLoading}
|
|
511
|
+
loadingLabel={loadingLabel}
|
|
512
|
+
tweakStyles={tweakSelectListStyles}
|
|
513
|
+
testId={getTestId(testId, 'list')}
|
|
514
|
+
// скролл не работает с включеным поппером
|
|
515
|
+
shouldScrollToList={shouldScrollToList && !shouldUsePopper && !shouldHideOnScroll}
|
|
516
|
+
isOptionDisabled={isOptionDisabled}
|
|
517
|
+
convertValueToString={convertValueToString}
|
|
518
|
+
convertValueToReactNode={convertValueToReactNode}
|
|
519
|
+
convertValueToId={convertToId}
|
|
520
|
+
onOptionSelect={handleOptionSelect}
|
|
521
|
+
onToggleCheckbox={isMultiSelect ? handleToggleOptionCheckbox : undefined}
|
|
522
|
+
/>
|
|
523
|
+
)}
|
|
524
|
+
</div>
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const multiSelectCounterWithIcon =
|
|
528
|
+
shouldShowMultiSelectCounter || isNotEmpty(iconType) ? (
|
|
529
|
+
<>
|
|
530
|
+
{shouldShowMultiSelectCounter && (
|
|
531
|
+
<div className={classes.counter}>(+{value.length - 1})</div>
|
|
532
|
+
)}
|
|
533
|
+
{isNotEmpty(iconType) && <div className={classes.icon}>{renderIcon(iconType)}</div>}
|
|
534
|
+
</>
|
|
535
|
+
) : undefined;
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<div className={classes.root} onKeyDown={handleKeyDown}>
|
|
539
|
+
<div
|
|
540
|
+
className={clsx(classes.inputWrapper, isDisabled && classes.disabled)}
|
|
541
|
+
onClick={isDisabled ? undefined : handleOnClick}
|
|
542
|
+
ref={inputWrapper}
|
|
543
|
+
>
|
|
544
|
+
<Input
|
|
545
|
+
value={
|
|
546
|
+
searchValue !== '' && !shouldRenderSearchInputInList ? searchValue : showedStringValue
|
|
547
|
+
}
|
|
548
|
+
onChange={handleInputChange}
|
|
549
|
+
isActive={isListOpen}
|
|
550
|
+
isReadonly={hasReadonlyInput}
|
|
551
|
+
onFocus={handleFocus}
|
|
552
|
+
onBlur={handleBlur}
|
|
553
|
+
isDisabled={isDisabled}
|
|
554
|
+
ref={input}
|
|
555
|
+
isLoading={areOptionsLoading}
|
|
556
|
+
tweakStyles={tweakInputStyles}
|
|
557
|
+
testId={testId}
|
|
558
|
+
iconType={isMultiSelect ? multiSelectCounterWithIcon : iconType}
|
|
559
|
+
{...inputProps}
|
|
560
|
+
/>
|
|
561
|
+
<div
|
|
562
|
+
onMouseDown={(event: MouseEvent) => {
|
|
563
|
+
event.preventDefault();
|
|
564
|
+
}}
|
|
565
|
+
onClick={onArrowClick}
|
|
566
|
+
className={clsx(classes.arrow, isOpen && classes.activeArrow)}
|
|
567
|
+
>
|
|
568
|
+
{renderIcon(dropdownIcon)}
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
{shouldUsePopper ? (
|
|
572
|
+
<Portal container={shouldRenderInBody ? document.body : inputWrapper.current}>
|
|
573
|
+
<>{listEl}</>
|
|
574
|
+
</Portal>
|
|
575
|
+
) : (
|
|
576
|
+
<>{isOpen && listEl}</>
|
|
577
|
+
)}
|
|
578
|
+
</div>
|
|
579
|
+
);
|
|
580
|
+
}
|