@true-engineering/true-react-common-ui-kit 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Input/Input.styles.d.ts +1 -0
- package/dist/components/Select/Select.d.ts +12 -3
- package/dist/components/Select/Select.styles.d.ts +10 -0
- package/dist/components/Select/SelectList/SelectList.d.ts +6 -4
- package/dist/components/Select/SelectList/SelectList.styles.d.ts +5 -0
- package/dist/components/Select/SelectListItem/SelectListItem.d.ts +14 -0
- package/dist/components/Select/SelectListItem/SelectListItem.styles.d.ts +2 -0
- package/dist/components/Select/constants.d.ts +2 -0
- package/dist/components/Select/helpers.d.ts +4 -1
- package/dist/components/Select/index.d.ts +1 -0
- package/dist/components/Select/types.d.ts +1 -0
- package/dist/helpers/utils.d.ts +2 -0
- package/dist/true-react-common-ui-kit.js +340 -163
- package/dist/true-react-common-ui-kit.js.map +1 -1
- package/dist/true-react-common-ui-kit.umd.cjs +340 -163
- package/dist/true-react-common-ui-kit.umd.cjs.map +1 -1
- package/package.json +1 -1
- package/src/components/Input/Input.styles.ts +2 -0
- package/src/components/Input/Input.tsx +4 -1
- package/src/components/Select/MultiSelect.stories.tsx +262 -0
- package/src/components/Select/Select.styles.ts +13 -0
- package/src/components/Select/Select.tsx +215 -114
- package/src/components/Select/SelectList/SelectList.styles.ts +6 -2
- package/src/components/Select/SelectList/SelectList.tsx +64 -39
- package/src/components/Select/SelectListItem/SelectListItem.styles.ts +14 -0
- package/src/components/Select/SelectListItem/SelectListItem.tsx +73 -0
- package/src/components/Select/constants.ts +2 -0
- package/src/components/Select/helpers.ts +16 -8
- package/src/components/Select/index.ts +1 -0
- package/src/components/Select/types.ts +1 -0
- package/src/helpers/utils.ts +29 -0
|
@@ -26,20 +26,30 @@ import {
|
|
|
26
26
|
useTweakStyles,
|
|
27
27
|
} from '../../hooks';
|
|
28
28
|
import { IDropdownWithPopperOptions } from '../../types';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
createFilter,
|
|
31
|
+
getTestId,
|
|
32
|
+
hasExactParent,
|
|
33
|
+
isNotEmpty,
|
|
34
|
+
isStringNotEmpty,
|
|
35
|
+
} from '../../helpers';
|
|
30
36
|
import {
|
|
31
37
|
defaultConvertFunction,
|
|
32
38
|
defaultCompareFunction,
|
|
33
|
-
getActiveValueIndex,
|
|
34
39
|
defaultIsOptionDisabled,
|
|
40
|
+
getDefaultConvertToIdFunction,
|
|
41
|
+
isMultiSelectValue,
|
|
35
42
|
} from './helpers';
|
|
36
43
|
import { SelectStyles, styles } from './Select.styles';
|
|
37
44
|
import { ISearchInputProps, SearchInput } from '../SearchInput';
|
|
45
|
+
import { IMultipleSelectValue } from './types';
|
|
46
|
+
import { ALL_OPTION_INDEX, DEFAULT_OPTION_INDEX } from './constants';
|
|
38
47
|
|
|
39
48
|
export interface ISelectProps<Value>
|
|
40
49
|
extends Omit<IInputProps, 'value' | 'onChange' | 'onBlur' | 'type'> {
|
|
41
50
|
tweakStyles?: SelectStyles;
|
|
42
51
|
defaultOptionLabel?: string;
|
|
52
|
+
allOptionsLabel?: string;
|
|
43
53
|
noMatchesLabel?: string;
|
|
44
54
|
loadingLabel?: ReactNode;
|
|
45
55
|
optionsMode?: 'search' | 'dynamic' | 'normal';
|
|
@@ -50,17 +60,18 @@ export interface ISelectProps<Value>
|
|
|
50
60
|
options: Value[];
|
|
51
61
|
value: Value | undefined;
|
|
52
62
|
shouldScrollToList?: boolean;
|
|
63
|
+
isMultiSelect?: boolean;
|
|
53
64
|
searchInput?: { shouldRenderInList: true } & Pick<
|
|
54
65
|
ISearchInputProps,
|
|
55
66
|
'placeholder'
|
|
56
67
|
>;
|
|
57
68
|
isOptionDisabled?(option: Value): boolean;
|
|
58
|
-
onChange(value
|
|
69
|
+
onChange(value?: Value): void; // подумать как возвращать индекс
|
|
59
70
|
onBlur?(event: Event | SyntheticEvent): void;
|
|
60
71
|
onType?(value: string): Promise<void>;
|
|
61
72
|
optionsFilter?(options: Value[], query: string): Value[];
|
|
62
73
|
onOpen?(): void;
|
|
63
|
-
compareValuesOnChange?(v1
|
|
74
|
+
compareValuesOnChange?(v1?: Value, v2?: Value): boolean;
|
|
64
75
|
// Для избежания проблем юзайте useCallback на эти функции
|
|
65
76
|
// или выносите их из компонента (чтобы не было сайдэфектов от перерендеринга их)
|
|
66
77
|
convertValueToString?(value: Value): string | undefined;
|
|
@@ -68,45 +79,64 @@ export interface ISelectProps<Value>
|
|
|
68
79
|
convertValueToId?(value: Value): string | undefined;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
export
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
export interface IMultipleSelectProps<Value>
|
|
83
|
+
extends Omit<
|
|
84
|
+
ISelectProps<Value>,
|
|
85
|
+
'value' | 'onChange' | 'compareValuesOnChange'
|
|
86
|
+
> {
|
|
87
|
+
isMultiSelect: true;
|
|
88
|
+
value: IMultipleSelectValue<Value> | undefined;
|
|
89
|
+
onChange(value?: IMultipleSelectValue<Value>): void;
|
|
90
|
+
compareValuesOnChange?(
|
|
91
|
+
v1?: IMultipleSelectValue<Value>,
|
|
92
|
+
v2?: IMultipleSelectValue<Value>,
|
|
93
|
+
): boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function Select<Value>(
|
|
97
|
+
props: ISelectProps<Value> | IMultipleSelectProps<Value>,
|
|
98
|
+
): JSX.Element {
|
|
99
|
+
const {
|
|
100
|
+
options,
|
|
101
|
+
value,
|
|
102
|
+
defaultOptionLabel,
|
|
103
|
+
allOptionsLabel,
|
|
104
|
+
debounceTime = 400,
|
|
105
|
+
optionsMode = 'normal',
|
|
106
|
+
noMatchesLabel,
|
|
107
|
+
loadingLabel,
|
|
108
|
+
tweakStyles,
|
|
109
|
+
testId,
|
|
110
|
+
isReadonly,
|
|
111
|
+
isDisabled,
|
|
112
|
+
dropdownOptions,
|
|
113
|
+
minSymbolsCountToOpenList = 0,
|
|
114
|
+
dropdownIcon = 'chevron-down',
|
|
115
|
+
shouldScrollToList = true,
|
|
116
|
+
searchInput,
|
|
117
|
+
units,
|
|
118
|
+
onChange,
|
|
119
|
+
onFocus,
|
|
120
|
+
onBlur,
|
|
121
|
+
onType,
|
|
122
|
+
onOpen,
|
|
123
|
+
isOptionDisabled = defaultIsOptionDisabled,
|
|
124
|
+
compareValuesOnChange = defaultCompareFunction,
|
|
125
|
+
convertValueToString = defaultConvertFunction,
|
|
126
|
+
convertValueToId,
|
|
127
|
+
convertValueToReactNode,
|
|
128
|
+
optionsFilter,
|
|
129
|
+
...inputProps
|
|
130
|
+
} = props;
|
|
101
131
|
const { classes, componentStyles } = useTheme('Select', styles, tweakStyles);
|
|
102
132
|
const isMounted = useIsMounted();
|
|
103
133
|
const [isListOpen, setIsListOpen] = useState(false);
|
|
104
134
|
const [areOptionsLoading, setAreOptionsLoading] = useState(false);
|
|
105
|
-
const hasDefaultOption = defaultOptionLabel
|
|
135
|
+
const hasDefaultOption = isStringNotEmpty(defaultOptionLabel);
|
|
106
136
|
|
|
107
|
-
const [focusedListCellIndex, setFocusedListCellIndex] =
|
|
137
|
+
const [focusedListCellIndex, setFocusedListCellIndex] =
|
|
138
|
+
useState(DEFAULT_OPTION_INDEX);
|
|
108
139
|
const [searchValue, setSearchValue] = useState('');
|
|
109
|
-
|
|
110
140
|
// если мы ввели что то в строку поиска - то этот булеан будет отключаться
|
|
111
141
|
// вынесен отдельно, из-за проблем с дебаунсом при динамич. опциях
|
|
112
142
|
const [shouldShowDefaultOption, setShouldShowDefaultOption] = useState(true);
|
|
@@ -117,36 +147,62 @@ export function Select<Value>({
|
|
|
117
147
|
|
|
118
148
|
const shouldRenderSearchInputInList =
|
|
119
149
|
searchInput?.shouldRenderInList === true;
|
|
120
|
-
|
|
121
150
|
const hasSearchInputInList =
|
|
122
151
|
optionsMode !== 'normal' && shouldRenderSearchInputInList;
|
|
123
152
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
153
|
+
const isMultiSelect = isMultiSelectValue(props, value);
|
|
154
|
+
const strValue = isMultiSelect ? value?.[0] : value;
|
|
155
|
+
const shouldShowAllOption =
|
|
156
|
+
isMultiSelect && isNotEmpty(allOptionsLabel) && searchValue === '';
|
|
127
157
|
|
|
128
158
|
const filteredOptions = useMemo(() => {
|
|
129
159
|
if (optionsMode !== 'search') {
|
|
130
160
|
return options;
|
|
131
161
|
}
|
|
132
|
-
if (isNotEmpty(optionsFilter)) {
|
|
133
|
-
return optionsFilter(options, searchValue);
|
|
134
|
-
}
|
|
135
|
-
const lowerCaseValue = searchValue?.toLowerCase();
|
|
136
|
-
return options.filter((option) => {
|
|
137
|
-
const convertedOption = convertValueToString(option);
|
|
138
|
-
return (
|
|
139
|
-
convertedOption !== undefined &&
|
|
140
|
-
convertedOption.toLowerCase().includes(lowerCaseValue)
|
|
141
|
-
);
|
|
142
|
-
});
|
|
143
|
-
}, [optionsMode, optionsFilter, options, convertValueToString, searchValue]);
|
|
144
162
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
const filter =
|
|
164
|
+
optionsFilter ??
|
|
165
|
+
createFilter<Value>((option) => [convertValueToString(option) ?? '']);
|
|
166
|
+
|
|
167
|
+
return filter(options, searchValue);
|
|
168
|
+
}, [optionsFilter, options, convertValueToString, searchValue, optionsMode]);
|
|
169
|
+
|
|
170
|
+
const optionsIndexesForNavigation = useMemo(() => {
|
|
171
|
+
const result: number[] = [];
|
|
172
|
+
if (shouldShowDefaultOption && hasDefaultOption) {
|
|
173
|
+
result.push(DEFAULT_OPTION_INDEX);
|
|
174
|
+
}
|
|
175
|
+
if (shouldShowAllOption) {
|
|
176
|
+
result.push(ALL_OPTION_INDEX);
|
|
177
|
+
}
|
|
178
|
+
return result.concat(
|
|
179
|
+
filteredOptions.reduce((acc, cur, i) => {
|
|
180
|
+
if (!isOptionDisabled(cur)) {
|
|
181
|
+
acc.push(i);
|
|
182
|
+
}
|
|
183
|
+
return acc;
|
|
184
|
+
}, [] as number[]),
|
|
148
185
|
);
|
|
149
|
-
}, [filteredOptions
|
|
186
|
+
}, [filteredOptions]);
|
|
187
|
+
|
|
188
|
+
const stringValue = isNotEmpty(strValue)
|
|
189
|
+
? convertValueToString(strValue)
|
|
190
|
+
: undefined;
|
|
191
|
+
// Для мультиселекта пытаемся показать "Все опции" если выбраны все опции
|
|
192
|
+
const showedStringValue =
|
|
193
|
+
isMultiSelect &&
|
|
194
|
+
value?.length === filteredOptions.length &&
|
|
195
|
+
isNotEmpty(allOptionsLabel)
|
|
196
|
+
? allOptionsLabel
|
|
197
|
+
: stringValue;
|
|
198
|
+
|
|
199
|
+
const convertToId = useCallback(
|
|
200
|
+
(v: Value) =>
|
|
201
|
+
(convertValueToId ?? getDefaultConvertToIdFunction(convertValueToString))(
|
|
202
|
+
v,
|
|
203
|
+
),
|
|
204
|
+
[convertValueToId, convertValueToString],
|
|
205
|
+
);
|
|
150
206
|
|
|
151
207
|
const handleListClose = useCallback(
|
|
152
208
|
(event: Event | SyntheticEvent) => {
|
|
@@ -200,25 +256,57 @@ export function Select<Value>({
|
|
|
200
256
|
};
|
|
201
257
|
|
|
202
258
|
const handleOnChange = useCallback(
|
|
203
|
-
(newValue: Value | undefined) => {
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
259
|
+
(newValue: Value | IMultipleSelectValue<Value> | undefined) => {
|
|
260
|
+
// Тут беда с типами, сорри
|
|
261
|
+
if (!compareValuesOnChange(value as never, newValue as never)) {
|
|
262
|
+
onChange(newValue as (Value & IMultipleSelectValue<Value>) | undefined);
|
|
207
263
|
}
|
|
208
|
-
onChange(newValue);
|
|
209
264
|
},
|
|
210
265
|
[value, compareValuesOnChange, onChange],
|
|
211
266
|
);
|
|
212
267
|
|
|
213
268
|
const handleOptionSelect = useCallback(
|
|
214
269
|
(index: number, event: MouseEvent<HTMLElement> | KeyboardEvent) => {
|
|
215
|
-
handleOnChange(
|
|
270
|
+
handleOnChange(
|
|
271
|
+
index === DEFAULT_OPTION_INDEX ? undefined : filteredOptions[index],
|
|
272
|
+
);
|
|
216
273
|
handleListClose(event);
|
|
217
274
|
input.current?.blur();
|
|
218
275
|
},
|
|
219
276
|
[handleOnChange, handleListClose, filteredOptions],
|
|
220
277
|
);
|
|
221
278
|
|
|
279
|
+
// MultiSelect
|
|
280
|
+
const handleToggleOptionCheckbox = useCallback(
|
|
281
|
+
(index: number, isSelected: boolean) => {
|
|
282
|
+
if (!isMultiSelect) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Если выбрана не дефолтная опция, которая сетит андеф
|
|
287
|
+
if (
|
|
288
|
+
index === DEFAULT_OPTION_INDEX ||
|
|
289
|
+
(index === ALL_OPTION_INDEX && !isSelected)
|
|
290
|
+
) {
|
|
291
|
+
handleOnChange(undefined);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (index === ALL_OPTION_INDEX && isSelected) {
|
|
295
|
+
handleOnChange(options as IMultipleSelectValue<Value>);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const option = filteredOptions[index];
|
|
299
|
+
handleOnChange(
|
|
300
|
+
isSelected
|
|
301
|
+
? // Добавляем
|
|
302
|
+
([...(value ?? []), option] as IMultipleSelectValue<Value>)
|
|
303
|
+
: // Убираем
|
|
304
|
+
value?.filter((o) => convertToId(o) !== convertToId(option)),
|
|
305
|
+
);
|
|
306
|
+
},
|
|
307
|
+
[handleOnChange, filteredOptions, isMultiSelect, value],
|
|
308
|
+
);
|
|
309
|
+
|
|
222
310
|
const handleOnType = useCallback(
|
|
223
311
|
async (v: string) => {
|
|
224
312
|
if (onType === undefined) {
|
|
@@ -265,71 +353,65 @@ export function Select<Value>({
|
|
|
265
353
|
}
|
|
266
354
|
|
|
267
355
|
event.stopPropagation();
|
|
356
|
+
const curIndexInNavigation = optionsIndexesForNavigation.findIndex(
|
|
357
|
+
(index) => index === focusedListCellIndex,
|
|
358
|
+
);
|
|
268
359
|
|
|
269
360
|
switch (event.code) {
|
|
270
361
|
case 'Enter':
|
|
271
362
|
case 'NumpadEnter': {
|
|
272
|
-
let
|
|
363
|
+
let indexToSelect = focusedListCellIndex;
|
|
273
364
|
|
|
274
|
-
// если
|
|
365
|
+
// если осталась одна опция в списке,
|
|
275
366
|
// то выбираем ее нажатием на enter
|
|
276
|
-
if (
|
|
277
|
-
|
|
367
|
+
if (
|
|
368
|
+
indexToSelect === DEFAULT_OPTION_INDEX &&
|
|
369
|
+
filteredOptions.length === 1
|
|
370
|
+
) {
|
|
371
|
+
indexToSelect = 0;
|
|
278
372
|
}
|
|
279
373
|
|
|
280
|
-
|
|
374
|
+
if (isMultiSelect) {
|
|
375
|
+
let isThisValueAlreadySelected: boolean;
|
|
376
|
+
if (indexToSelect === ALL_OPTION_INDEX) {
|
|
377
|
+
isThisValueAlreadySelected = value?.length === options.length;
|
|
378
|
+
} else {
|
|
379
|
+
// подумать над концептом реального фокуса на опциях, а не вот эти вот focusedCell
|
|
380
|
+
const valueIdToSelect = convertToId(filteredOptions[indexToSelect]);
|
|
381
|
+
isThisValueAlreadySelected =
|
|
382
|
+
value?.some((opt) => convertToId(opt) === valueIdToSelect) ??
|
|
383
|
+
false;
|
|
384
|
+
}
|
|
385
|
+
handleToggleOptionCheckbox(
|
|
386
|
+
indexToSelect,
|
|
387
|
+
!isThisValueAlreadySelected,
|
|
388
|
+
);
|
|
389
|
+
} else {
|
|
390
|
+
handleOptionSelect(indexToSelect, event);
|
|
391
|
+
}
|
|
281
392
|
break;
|
|
282
393
|
}
|
|
283
394
|
|
|
284
395
|
case 'ArrowDown': {
|
|
285
396
|
// чтобы убрать перемещение курсора в инпуте
|
|
286
397
|
event.preventDefault();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
shouldShowDefaultOption &&
|
|
293
|
-
hasDefaultOption &&
|
|
294
|
-
newIndex > -1 &&
|
|
295
|
-
targetIndex === 0
|
|
296
|
-
) {
|
|
297
|
-
newIndex = -1;
|
|
298
|
-
break;
|
|
299
|
-
}
|
|
300
|
-
if (!isOptionDisabled(filteredOptions[targetIndex])) {
|
|
301
|
-
newIndex = targetIndex;
|
|
302
|
-
break;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
setFocusedListCellIndex(newIndex);
|
|
398
|
+
const targetIndexInNavigation =
|
|
399
|
+
(curIndexInNavigation + 1) % optionsIndexesForNavigation.length;
|
|
400
|
+
setFocusedListCellIndex(
|
|
401
|
+
optionsIndexesForNavigation[targetIndexInNavigation],
|
|
402
|
+
);
|
|
306
403
|
break;
|
|
307
404
|
}
|
|
308
405
|
|
|
309
406
|
case 'ArrowUp': {
|
|
310
407
|
// чтобы убрать перемещение курсора в инпуте
|
|
311
408
|
event.preventDefault();
|
|
312
|
-
const
|
|
313
|
-
(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
shouldShowDefaultOption &&
|
|
319
|
-
hasDefaultOption &&
|
|
320
|
-
focusedListCellIndex !== -1 &&
|
|
321
|
-
targetIndex === filteredOptions.length - 1
|
|
322
|
-
) {
|
|
323
|
-
// не выносить сет наружу (приведет к багу)
|
|
324
|
-
setFocusedListCellIndex(-1);
|
|
325
|
-
break;
|
|
326
|
-
}
|
|
327
|
-
if (!isOptionDisabled(filteredOptions[targetIndex])) {
|
|
328
|
-
// не выносить сет наружу (приведет к багу)
|
|
329
|
-
setFocusedListCellIndex(targetIndex);
|
|
330
|
-
break;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
409
|
+
const targetIndexInNavigation =
|
|
410
|
+
(curIndexInNavigation - 1 + optionsIndexesForNavigation.length) %
|
|
411
|
+
optionsIndexesForNavigation.length;
|
|
412
|
+
setFocusedListCellIndex(
|
|
413
|
+
optionsIndexesForNavigation[targetIndexInNavigation],
|
|
414
|
+
);
|
|
333
415
|
break;
|
|
334
416
|
}
|
|
335
417
|
}
|
|
@@ -405,10 +487,17 @@ export function Select<Value>({
|
|
|
405
487
|
});
|
|
406
488
|
|
|
407
489
|
useEffect(() => {
|
|
408
|
-
|
|
409
|
-
|
|
490
|
+
const val = isMultiSelect ? value?.[0] : value;
|
|
491
|
+
setFocusedListCellIndex(
|
|
492
|
+
optionsIndexesForNavigation.find(
|
|
493
|
+
(index) => filteredOptions[index] === val,
|
|
494
|
+
) ?? optionsIndexesForNavigation[0],
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
if (isOpen) {
|
|
498
|
+
onOpen?.();
|
|
410
499
|
}
|
|
411
|
-
}, [isOpen
|
|
500
|
+
}, [isOpen]);
|
|
412
501
|
|
|
413
502
|
const listEl = (
|
|
414
503
|
<div
|
|
@@ -429,6 +518,7 @@ export function Select<Value>({
|
|
|
429
518
|
? defaultOptionLabel
|
|
430
519
|
: undefined
|
|
431
520
|
}
|
|
521
|
+
allOptionsLabel={shouldShowAllOption ? allOptionsLabel : undefined}
|
|
432
522
|
customListHeader={
|
|
433
523
|
hasSearchInputInList ? (
|
|
434
524
|
<SearchInput
|
|
@@ -454,8 +544,11 @@ export function Select<Value>({
|
|
|
454
544
|
isOptionDisabled={isOptionDisabled}
|
|
455
545
|
convertValueToString={convertValueToString}
|
|
456
546
|
convertValueToReactNode={convertValueToReactNode}
|
|
457
|
-
convertValueToId={
|
|
458
|
-
|
|
547
|
+
convertValueToId={convertToId}
|
|
548
|
+
onOptionSelect={handleOptionSelect}
|
|
549
|
+
onToggleCheckbox={
|
|
550
|
+
isMultiSelect ? handleToggleOptionCheckbox : undefined
|
|
551
|
+
}
|
|
459
552
|
/>
|
|
460
553
|
)}
|
|
461
554
|
</div>
|
|
@@ -472,7 +565,7 @@ export function Select<Value>({
|
|
|
472
565
|
value={
|
|
473
566
|
searchValue !== '' && !shouldRenderSearchInputInList
|
|
474
567
|
? searchValue
|
|
475
|
-
:
|
|
568
|
+
: showedStringValue
|
|
476
569
|
}
|
|
477
570
|
onChange={handleInputChange}
|
|
478
571
|
isActive={isListOpen}
|
|
@@ -484,6 +577,14 @@ export function Select<Value>({
|
|
|
484
577
|
isLoading={areOptionsLoading}
|
|
485
578
|
tweakStyles={tweakInputStyles}
|
|
486
579
|
testId={testId}
|
|
580
|
+
units={
|
|
581
|
+
isMultiSelect &&
|
|
582
|
+
isNotEmpty(value) &&
|
|
583
|
+
value.length > 1 &&
|
|
584
|
+
value.length !== options.length
|
|
585
|
+
? `(+${value.length - 1})`
|
|
586
|
+
: units
|
|
587
|
+
}
|
|
487
588
|
{...inputProps}
|
|
488
589
|
/>
|
|
489
590
|
<div
|
|
@@ -2,8 +2,8 @@ import { colors, dimensions, helpers } from '../../../theme';
|
|
|
2
2
|
import { ComponentStyles } from '../../../types';
|
|
3
3
|
|
|
4
4
|
export const ROW_HEIGHT = 40;
|
|
5
|
-
const CONTAINER_PADDING = 10;
|
|
6
|
-
const CELL_PADDING = [10, 20];
|
|
5
|
+
export const CONTAINER_PADDING = 10;
|
|
6
|
+
export const CELL_PADDING = [10, 20];
|
|
7
7
|
|
|
8
8
|
export const styles = {
|
|
9
9
|
root: {
|
|
@@ -45,6 +45,10 @@ export const styles = {
|
|
|
45
45
|
fontSize: 14,
|
|
46
46
|
},
|
|
47
47
|
|
|
48
|
+
cellWithCheckbox: {
|
|
49
|
+
padding: 0,
|
|
50
|
+
},
|
|
51
|
+
|
|
48
52
|
noMatchesLabel: {
|
|
49
53
|
pointerEvents: 'none',
|
|
50
54
|
},
|
|
@@ -3,30 +3,33 @@ import clsx from 'clsx';
|
|
|
3
3
|
import { ScrollIntoViewIfNeeded } from '../../ScrollIntoViewIfNeeded';
|
|
4
4
|
import { useTheme } from '../../../hooks';
|
|
5
5
|
import { ICommonProps } from '../../../types';
|
|
6
|
-
import {
|
|
6
|
+
import { isNotEmpty } from '../../../helpers';
|
|
7
7
|
import { SelectListStyles, styles } from './SelectList.styles';
|
|
8
|
+
import { IMultipleSelectValue } from '../types';
|
|
9
|
+
import { SelectListItem } from '../SelectListItem/SelectListItem';
|
|
10
|
+
import { ALL_OPTION_INDEX, DEFAULT_OPTION_INDEX } from '../constants';
|
|
8
11
|
|
|
9
12
|
export interface ISelectListProps<Value> extends ICommonProps {
|
|
10
13
|
tweakStyles?: SelectListStyles;
|
|
11
14
|
options: Value[];
|
|
12
15
|
focusedIndex?: number;
|
|
13
|
-
activeValue?: Value;
|
|
16
|
+
activeValue?: Value | Value[];
|
|
14
17
|
noMatchesLabel?: string;
|
|
15
18
|
isLoading?: boolean;
|
|
16
19
|
loadingLabel?: ReactNode;
|
|
17
20
|
defaultOptionLabel?: string;
|
|
18
21
|
testId?: string;
|
|
22
|
+
allOptionsLabel?: string;
|
|
19
23
|
shouldScrollToList?: boolean;
|
|
20
24
|
customListHeader?: ReactNode;
|
|
21
|
-
|
|
25
|
+
onOptionSelect(index: number, event: MouseEvent<HTMLElement>): void;
|
|
26
|
+
onToggleCheckbox?(index: number, isSelected: boolean): void;
|
|
22
27
|
isOptionDisabled(value: Value): boolean;
|
|
23
28
|
convertValueToString(value: Value): string | undefined;
|
|
24
29
|
convertValueToReactNode?(value: Value, isDisabled: boolean): ReactNode;
|
|
25
|
-
convertValueToId
|
|
30
|
+
convertValueToId(value: Value): string | undefined;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
const DEFAULT_OPTION_INDEX = -1;
|
|
29
|
-
|
|
30
33
|
export function SelectList<Value>({
|
|
31
34
|
options,
|
|
32
35
|
focusedIndex,
|
|
@@ -40,20 +43,25 @@ export function SelectList<Value>({
|
|
|
40
43
|
shouldScrollToList = true,
|
|
41
44
|
customListHeader,
|
|
42
45
|
isOptionDisabled,
|
|
43
|
-
|
|
46
|
+
allOptionsLabel,
|
|
47
|
+
onOptionSelect,
|
|
48
|
+
onToggleCheckbox,
|
|
44
49
|
convertValueToString,
|
|
45
|
-
convertValueToReactNode,
|
|
46
|
-
convertValueToId
|
|
50
|
+
convertValueToReactNode = convertValueToString,
|
|
51
|
+
convertValueToId,
|
|
47
52
|
}: ISelectListProps<Value>): JSX.Element {
|
|
48
53
|
const { classes } = useTheme('SelectList', styles, tweakStyles);
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
convertValueToId(item) === activeValueId;
|
|
54
|
+
const isMultiSelect = isNotEmpty(onToggleCheckbox);
|
|
55
|
+
const multiSelectValue = activeValue as
|
|
56
|
+
| IMultipleSelectValue<Value>
|
|
57
|
+
| undefined;
|
|
58
|
+
const selectedOptionsCount = multiSelectValue?.length ?? 0;
|
|
55
59
|
|
|
56
|
-
|
|
60
|
+
// MultiSelect
|
|
61
|
+
const activeOptionsIdMap = useMemo(
|
|
62
|
+
() => (isMultiSelect ? multiSelectValue?.map(convertValueToId) ?? [] : []),
|
|
63
|
+
[isMultiSelect, multiSelectValue, convertValueToId],
|
|
64
|
+
);
|
|
57
65
|
|
|
58
66
|
const optionsDisableMap = useMemo(
|
|
59
67
|
() => options.map((o) => isOptionDisabled(o)),
|
|
@@ -61,13 +69,22 @@ export function SelectList<Value>({
|
|
|
61
69
|
);
|
|
62
70
|
|
|
63
71
|
const listOptions = useMemo(
|
|
64
|
-
() =>
|
|
65
|
-
|
|
72
|
+
() =>
|
|
73
|
+
options.map((opt, i) =>
|
|
74
|
+
convertValueToReactNode(opt, optionsDisableMap[i]),
|
|
75
|
+
),
|
|
76
|
+
[options, convertValueToReactNode, optionsDisableMap],
|
|
66
77
|
);
|
|
67
78
|
|
|
79
|
+
const isActiveOption = (item: Value): boolean =>
|
|
80
|
+
isMultiSelect
|
|
81
|
+
? activeOptionsIdMap.includes(convertValueToId(item))
|
|
82
|
+
: isNotEmpty(activeValue) &&
|
|
83
|
+
convertValueToId(activeValue as Value) === convertValueToId(item);
|
|
84
|
+
|
|
68
85
|
return (
|
|
69
86
|
<ScrollIntoViewIfNeeded
|
|
70
|
-
active={shouldScrollToList}
|
|
87
|
+
active={shouldScrollToList && !isMultiSelect}
|
|
71
88
|
className={clsx(classes.root, {
|
|
72
89
|
[classes.withListHeader]: isNotEmpty(customListHeader),
|
|
73
90
|
})}
|
|
@@ -82,7 +99,7 @@ export function SelectList<Value>({
|
|
|
82
99
|
</div>
|
|
83
100
|
) : (
|
|
84
101
|
<>
|
|
85
|
-
{defaultOptionLabel
|
|
102
|
+
{isNotEmpty(defaultOptionLabel) && (
|
|
86
103
|
<ScrollIntoViewIfNeeded
|
|
87
104
|
active={focusedIndex === DEFAULT_OPTION_INDEX}
|
|
88
105
|
options={{ block: 'nearest' }}
|
|
@@ -91,39 +108,47 @@ export function SelectList<Value>({
|
|
|
91
108
|
classes.defaultCell,
|
|
92
109
|
focusedIndex === DEFAULT_OPTION_INDEX && classes.focused,
|
|
93
110
|
)}
|
|
94
|
-
onClick={(event) =>
|
|
111
|
+
onClick={(event) => onOptionSelect(DEFAULT_OPTION_INDEX, event)}
|
|
95
112
|
>
|
|
96
113
|
{defaultOptionLabel}
|
|
97
114
|
</ScrollIntoViewIfNeeded>
|
|
98
115
|
)}
|
|
116
|
+
{isNotEmpty(allOptionsLabel) && (
|
|
117
|
+
<SelectListItem
|
|
118
|
+
classes={classes}
|
|
119
|
+
index={ALL_OPTION_INDEX}
|
|
120
|
+
isSemiChecked={
|
|
121
|
+
selectedOptionsCount > 0 &&
|
|
122
|
+
selectedOptionsCount < options.length
|
|
123
|
+
}
|
|
124
|
+
isActive={selectedOptionsCount === options.length}
|
|
125
|
+
isFocused={focusedIndex === ALL_OPTION_INDEX}
|
|
126
|
+
onOptionSelect={onOptionSelect}
|
|
127
|
+
onToggleCheckbox={onToggleCheckbox}
|
|
128
|
+
>
|
|
129
|
+
{allOptionsLabel}
|
|
130
|
+
</SelectListItem>
|
|
131
|
+
)}
|
|
99
132
|
{listOptions.map((opt, i) => {
|
|
100
133
|
const optionValue = options[i];
|
|
101
|
-
const isFocused =
|
|
134
|
+
const isFocused = focusedIndex === i;
|
|
102
135
|
const isActive = isActiveOption(optionValue);
|
|
103
136
|
// проверяем, что опция задизейблена
|
|
104
137
|
const isDisabled = optionsDisableMap[i];
|
|
105
138
|
|
|
106
139
|
return (
|
|
107
|
-
<
|
|
108
|
-
active={isFocused}
|
|
109
|
-
options={{ block: 'nearest' }}
|
|
140
|
+
<SelectListItem
|
|
110
141
|
key={i}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
{
|
|
117
|
-
|
|
118
|
-
active: isActive,
|
|
119
|
-
focused: isFocused,
|
|
120
|
-
})}
|
|
121
|
-
onClick={
|
|
122
|
-
!isDisabled ? (event) => onOptionClick(i, event) : undefined
|
|
123
|
-
}
|
|
142
|
+
classes={classes}
|
|
143
|
+
index={i}
|
|
144
|
+
isDisabled={isDisabled}
|
|
145
|
+
isActive={isActive}
|
|
146
|
+
isFocused={isFocused}
|
|
147
|
+
onOptionSelect={onOptionSelect}
|
|
148
|
+
onToggleCheckbox={onToggleCheckbox}
|
|
124
149
|
>
|
|
125
150
|
{opt}
|
|
126
|
-
</
|
|
151
|
+
</SelectListItem>
|
|
127
152
|
);
|
|
128
153
|
})}
|
|
129
154
|
{listOptions.length === 0 && (
|