@true-engineering/true-react-common-ui-kit 1.9.0 → 1.11.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.
Files changed (31) hide show
  1. package/dist/components/Input/Input.styles.d.ts +1 -0
  2. package/dist/components/Select/Select.d.ts +12 -3
  3. package/dist/components/Select/Select.styles.d.ts +9 -0
  4. package/dist/components/Select/SelectList/SelectList.d.ts +7 -4
  5. package/dist/components/Select/SelectList/SelectList.styles.d.ts +5 -0
  6. package/dist/components/Select/SelectListItem/SelectListItem.d.ts +14 -0
  7. package/dist/components/Select/SelectListItem/SelectListItem.styles.d.ts +2 -0
  8. package/dist/components/Select/constants.d.ts +2 -0
  9. package/dist/components/Select/helpers.d.ts +4 -1
  10. package/dist/components/Select/index.d.ts +1 -0
  11. package/dist/components/Select/types.d.ts +1 -0
  12. package/dist/helpers/utils.d.ts +2 -0
  13. package/dist/true-react-common-ui-kit.js +365 -163
  14. package/dist/true-react-common-ui-kit.js.map +1 -1
  15. package/dist/true-react-common-ui-kit.umd.cjs +365 -163
  16. package/dist/true-react-common-ui-kit.umd.cjs.map +1 -1
  17. package/package.json +1 -1
  18. package/src/components/Input/Input.styles.ts +2 -0
  19. package/src/components/Input/Input.tsx +4 -1
  20. package/src/components/Select/MultiSelect.stories.tsx +263 -0
  21. package/src/components/Select/Select.styles.ts +11 -0
  22. package/src/components/Select/Select.tsx +234 -114
  23. package/src/components/Select/SelectList/SelectList.styles.ts +6 -2
  24. package/src/components/Select/SelectList/SelectList.tsx +65 -39
  25. package/src/components/Select/SelectListItem/SelectListItem.styles.ts +14 -0
  26. package/src/components/Select/SelectListItem/SelectListItem.tsx +73 -0
  27. package/src/components/Select/constants.ts +2 -0
  28. package/src/components/Select/helpers.ts +16 -8
  29. package/src/components/Select/index.ts +1 -0
  30. package/src/components/Select/types.ts +1 -0
  31. package/src/helpers/utils.ts +29 -0
@@ -26,20 +26,31 @@ import {
26
26
  useTweakStyles,
27
27
  } from '../../hooks';
28
28
  import { IDropdownWithPopperOptions } from '../../types';
29
- import { getTestId, hasExactParent, isNotEmpty } from '../../helpers';
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';
47
+ import { renderIcon } from '../../helpers/snippets';
38
48
 
39
49
  export interface ISelectProps<Value>
40
50
  extends Omit<IInputProps, 'value' | 'onChange' | 'onBlur' | 'type'> {
41
51
  tweakStyles?: SelectStyles;
42
52
  defaultOptionLabel?: string;
53
+ allOptionsLabel?: string;
43
54
  noMatchesLabel?: string;
44
55
  loadingLabel?: ReactNode;
45
56
  optionsMode?: 'search' | 'dynamic' | 'normal';
@@ -50,17 +61,18 @@ export interface ISelectProps<Value>
50
61
  options: Value[];
51
62
  value: Value | undefined;
52
63
  shouldScrollToList?: boolean;
64
+ isMultiSelect?: boolean;
53
65
  searchInput?: { shouldRenderInList: true } & Pick<
54
66
  ISearchInputProps,
55
67
  'placeholder'
56
68
  >;
57
69
  isOptionDisabled?(option: Value): boolean;
58
- onChange(value: Value | undefined): void; // подумать как возвращать индекс
70
+ onChange(value?: Value): void; // подумать как возвращать индекс
59
71
  onBlur?(event: Event | SyntheticEvent): void;
60
72
  onType?(value: string): Promise<void>;
61
73
  optionsFilter?(options: Value[], query: string): Value[];
62
74
  onOpen?(): void;
63
- compareValuesOnChange?(v1: Value | undefined, v2: Value | undefined): boolean;
75
+ compareValuesOnChange?(v1?: Value, v2?: Value): boolean;
64
76
  // Для избежания проблем юзайте useCallback на эти функции
65
77
  // или выносите их из компонента (чтобы не было сайдэфектов от перерендеринга их)
66
78
  convertValueToString?(value: Value): string | undefined;
@@ -68,45 +80,64 @@ export interface ISelectProps<Value>
68
80
  convertValueToId?(value: Value): string | undefined;
69
81
  }
70
82
 
71
- export function Select<Value>({
72
- options,
73
- value,
74
- defaultOptionLabel,
75
- debounceTime = 400,
76
- optionsMode = 'normal',
77
- noMatchesLabel,
78
- loadingLabel,
79
- tweakStyles,
80
- testId,
81
- isReadonly,
82
- isDisabled,
83
- dropdownOptions,
84
- minSymbolsCountToOpenList = 0,
85
- dropdownIcon = 'chevron-down',
86
- shouldScrollToList = true,
87
- searchInput,
88
- onChange,
89
- onFocus,
90
- onBlur,
91
- onType,
92
- onOpen,
93
- isOptionDisabled = defaultIsOptionDisabled,
94
- compareValuesOnChange = defaultCompareFunction,
95
- convertValueToString = defaultConvertFunction,
96
- convertValueToId,
97
- convertValueToReactNode,
98
- optionsFilter,
99
- ...inputProps
100
- }: ISelectProps<Value>): JSX.Element {
83
+ export interface IMultipleSelectProps<Value>
84
+ extends Omit<
85
+ ISelectProps<Value>,
86
+ 'value' | 'onChange' | 'compareValuesOnChange'
87
+ > {
88
+ isMultiSelect: true;
89
+ value: IMultipleSelectValue<Value> | undefined;
90
+ onChange(value?: IMultipleSelectValue<Value>): void;
91
+ compareValuesOnChange?(
92
+ v1?: IMultipleSelectValue<Value>,
93
+ v2?: IMultipleSelectValue<Value>,
94
+ ): boolean;
95
+ }
96
+
97
+ export function Select<Value>(
98
+ props: ISelectProps<Value> | IMultipleSelectProps<Value>,
99
+ ): JSX.Element {
100
+ const {
101
+ options,
102
+ value,
103
+ defaultOptionLabel,
104
+ allOptionsLabel,
105
+ debounceTime = 400,
106
+ optionsMode = 'normal',
107
+ noMatchesLabel,
108
+ loadingLabel,
109
+ tweakStyles,
110
+ testId,
111
+ isReadonly,
112
+ isDisabled,
113
+ dropdownOptions,
114
+ minSymbolsCountToOpenList = 0,
115
+ dropdownIcon = 'chevron-down',
116
+ shouldScrollToList = true,
117
+ searchInput,
118
+ iconType,
119
+ onChange,
120
+ onFocus,
121
+ onBlur,
122
+ onType,
123
+ onOpen,
124
+ isOptionDisabled = defaultIsOptionDisabled,
125
+ compareValuesOnChange = defaultCompareFunction,
126
+ convertValueToString = defaultConvertFunction,
127
+ convertValueToId,
128
+ convertValueToReactNode,
129
+ optionsFilter,
130
+ ...inputProps
131
+ } = props;
101
132
  const { classes, componentStyles } = useTheme('Select', styles, tweakStyles);
102
133
  const isMounted = useIsMounted();
103
134
  const [isListOpen, setIsListOpen] = useState(false);
104
135
  const [areOptionsLoading, setAreOptionsLoading] = useState(false);
105
- const hasDefaultOption = defaultOptionLabel !== undefined;
136
+ const hasDefaultOption = isStringNotEmpty(defaultOptionLabel);
106
137
 
107
- const [focusedListCellIndex, setFocusedListCellIndex] = useState(-1);
138
+ const [focusedListCellIndex, setFocusedListCellIndex] =
139
+ useState(DEFAULT_OPTION_INDEX);
108
140
  const [searchValue, setSearchValue] = useState('');
109
-
110
141
  // если мы ввели что то в строку поиска - то этот булеан будет отключаться
111
142
  // вынесен отдельно, из-за проблем с дебаунсом при динамич. опциях
112
143
  const [shouldShowDefaultOption, setShouldShowDefaultOption] = useState(true);
@@ -117,36 +148,73 @@ export function Select<Value>({
117
148
 
118
149
  const shouldRenderSearchInputInList =
119
150
  searchInput?.shouldRenderInList === true;
120
-
121
151
  const hasSearchInputInList =
122
152
  optionsMode !== 'normal' && shouldRenderSearchInputInList;
123
153
 
124
- const stringValue = isNotEmpty(value)
125
- ? convertValueToString(value)
126
- : undefined;
154
+ const isMultiSelect = isMultiSelectValue(props, value);
155
+ const strValue = isMultiSelect ? value?.[0] : value;
156
+ const shouldShowAllOption =
157
+ isMultiSelect && isNotEmpty(allOptionsLabel) && searchValue === '';
127
158
 
128
159
  const filteredOptions = useMemo(() => {
129
160
  if (optionsMode !== 'search') {
130
161
  return options;
131
162
  }
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
163
 
145
- useEffect(() => {
146
- setFocusedListCellIndex(
147
- getActiveValueIndex(filteredOptions, value, convertValueToString),
164
+ const filter =
165
+ optionsFilter ??
166
+ createFilter<Value>((option) => [convertValueToString(option) ?? '']);
167
+
168
+ return filter(options, searchValue);
169
+ }, [optionsFilter, options, convertValueToString, searchValue, optionsMode]);
170
+
171
+ const availableOptions = useMemo(
172
+ () => options.filter((o) => !isOptionDisabled(o)),
173
+ [options, isOptionDisabled],
174
+ );
175
+
176
+ const areAllOptionsSelected =
177
+ isMultiSelect && value?.length === availableOptions.length;
178
+ const shouldShowMultiSelectCounter =
179
+ isMultiSelect &&
180
+ isNotEmpty(value) &&
181
+ value.length > 1 &&
182
+ !areAllOptionsSelected;
183
+
184
+ const optionsIndexesForNavigation = useMemo(() => {
185
+ const result: number[] = [];
186
+ if (shouldShowDefaultOption && hasDefaultOption) {
187
+ result.push(DEFAULT_OPTION_INDEX);
188
+ }
189
+ if (shouldShowAllOption) {
190
+ result.push(ALL_OPTION_INDEX);
191
+ }
192
+ return result.concat(
193
+ filteredOptions.reduce((acc, cur, i) => {
194
+ if (!isOptionDisabled(cur)) {
195
+ acc.push(i);
196
+ }
197
+ return acc;
198
+ }, [] as number[]),
148
199
  );
149
- }, [filteredOptions, value, convertValueToString]);
200
+ }, [filteredOptions]);
201
+
202
+ const stringValue = isNotEmpty(strValue)
203
+ ? convertValueToString(strValue)
204
+ : undefined;
205
+ // Для мультиселекта пытаемся показать "Все опции" если выбраны все опции
206
+ const showedStringValue =
207
+ areAllOptionsSelected && isNotEmpty(allOptionsLabel)
208
+ ? allOptionsLabel
209
+ : stringValue;
210
+
211
+ const convertToId = useCallback(
212
+ (v: Value) =>
213
+ (convertValueToId ?? getDefaultConvertToIdFunction(convertValueToString))(
214
+ v,
215
+ ),
216
+ [convertValueToId, convertValueToString],
217
+ );
150
218
 
151
219
  const handleListClose = useCallback(
152
220
  (event: Event | SyntheticEvent) => {
@@ -200,25 +268,57 @@ export function Select<Value>({
200
268
  };
201
269
 
202
270
  const handleOnChange = useCallback(
203
- (newValue: Value | undefined) => {
204
- const areValuesEqual = compareValuesOnChange(value, newValue);
205
- if (areValuesEqual) {
206
- return;
271
+ (newValue: Value | IMultipleSelectValue<Value> | undefined) => {
272
+ // Тут беда с типами, сорри
273
+ if (!compareValuesOnChange(value as never, newValue as never)) {
274
+ onChange(newValue as (Value & IMultipleSelectValue<Value>) | undefined);
207
275
  }
208
- onChange(newValue);
209
276
  },
210
277
  [value, compareValuesOnChange, onChange],
211
278
  );
212
279
 
213
280
  const handleOptionSelect = useCallback(
214
281
  (index: number, event: MouseEvent<HTMLElement> | KeyboardEvent) => {
215
- handleOnChange(index === -1 ? undefined : filteredOptions[index]);
282
+ handleOnChange(
283
+ index === DEFAULT_OPTION_INDEX ? undefined : filteredOptions[index],
284
+ );
216
285
  handleListClose(event);
217
286
  input.current?.blur();
218
287
  },
219
288
  [handleOnChange, handleListClose, filteredOptions],
220
289
  );
221
290
 
291
+ // MultiSelect
292
+ const handleToggleOptionCheckbox = useCallback(
293
+ (index: number, isSelected: boolean) => {
294
+ if (!isMultiSelect) {
295
+ return;
296
+ }
297
+
298
+ // Если выбрана не дефолтная опция, которая сетит андеф
299
+ if (
300
+ index === DEFAULT_OPTION_INDEX ||
301
+ (index === ALL_OPTION_INDEX && !isSelected)
302
+ ) {
303
+ handleOnChange(undefined);
304
+ return;
305
+ }
306
+ if (index === ALL_OPTION_INDEX && isSelected) {
307
+ handleOnChange(availableOptions as IMultipleSelectValue<Value>);
308
+ return;
309
+ }
310
+ const option = filteredOptions[index];
311
+ handleOnChange(
312
+ isSelected
313
+ ? // Добавляем
314
+ ([...(value ?? []), option] as IMultipleSelectValue<Value>)
315
+ : // Убираем
316
+ value?.filter((o) => convertToId(o) !== convertToId(option)),
317
+ );
318
+ },
319
+ [handleOnChange, filteredOptions, isMultiSelect, value],
320
+ );
321
+
222
322
  const handleOnType = useCallback(
223
323
  async (v: string) => {
224
324
  if (onType === undefined) {
@@ -265,71 +365,65 @@ export function Select<Value>({
265
365
  }
266
366
 
267
367
  event.stopPropagation();
368
+ const curIndexInNavigation = optionsIndexesForNavigation.findIndex(
369
+ (index) => index === focusedListCellIndex,
370
+ );
268
371
 
269
372
  switch (event.code) {
270
373
  case 'Enter':
271
374
  case 'NumpadEnter': {
272
- let indexToClick = focusedListCellIndex;
375
+ let indexToSelect = focusedListCellIndex;
273
376
 
274
- // если осталось одна опция в списке,
377
+ // если осталась одна опция в списке,
275
378
  // то выбираем ее нажатием на enter
276
- if (indexToClick === -1 && filteredOptions.length === 1) {
277
- indexToClick = 0;
379
+ if (
380
+ indexToSelect === DEFAULT_OPTION_INDEX &&
381
+ filteredOptions.length === 1
382
+ ) {
383
+ indexToSelect = 0;
278
384
  }
279
385
 
280
- handleOptionSelect(indexToClick, event);
386
+ if (isMultiSelect) {
387
+ let isThisValueAlreadySelected: boolean;
388
+ if (indexToSelect === ALL_OPTION_INDEX) {
389
+ isThisValueAlreadySelected = areAllOptionsSelected;
390
+ } else {
391
+ // подумать над концептом реального фокуса на опциях, а не вот эти вот focusedCell
392
+ const valueIdToSelect = convertToId(filteredOptions[indexToSelect]);
393
+ isThisValueAlreadySelected =
394
+ value?.some((opt) => convertToId(opt) === valueIdToSelect) ??
395
+ false;
396
+ }
397
+ handleToggleOptionCheckbox(
398
+ indexToSelect,
399
+ !isThisValueAlreadySelected,
400
+ );
401
+ } else {
402
+ handleOptionSelect(indexToSelect, event);
403
+ }
281
404
  break;
282
405
  }
283
406
 
284
407
  case 'ArrowDown': {
285
408
  // чтобы убрать перемещение курсора в инпуте
286
409
  event.preventDefault();
287
- let newIndex: number = focusedListCellIndex ?? -1;
288
- // ищем первый незадизейбленный вариант, либо дефолтную опцию
289
- for (let i = newIndex; i < newIndex + filteredOptions.length; i++) {
290
- const targetIndex = (i + 1) % filteredOptions.length;
291
- if (
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);
410
+ const targetIndexInNavigation =
411
+ (curIndexInNavigation + 1) % optionsIndexesForNavigation.length;
412
+ setFocusedListCellIndex(
413
+ optionsIndexesForNavigation[targetIndexInNavigation],
414
+ );
306
415
  break;
307
416
  }
308
417
 
309
418
  case 'ArrowUp': {
310
419
  // чтобы убрать перемещение курсора в инпуте
311
420
  event.preventDefault();
312
- const newIndex: number =
313
- (focusedListCellIndex ?? -1) === -1 ? 0 : focusedListCellIndex;
314
- // ищем первый незадизейбленный вариант, либо дефолтную опцию
315
- for (let i = newIndex; i > newIndex - filteredOptions.length; i--) {
316
- const targetIndex = (i > 0 ? i : filteredOptions.length + i) - 1;
317
- if (
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
- }
421
+ const targetIndexInNavigation =
422
+ (curIndexInNavigation - 1 + optionsIndexesForNavigation.length) %
423
+ optionsIndexesForNavigation.length;
424
+ setFocusedListCellIndex(
425
+ optionsIndexesForNavigation[targetIndexInNavigation],
426
+ );
333
427
  break;
334
428
  }
335
429
  }
@@ -374,6 +468,7 @@ export function Select<Value>({
374
468
  {},
375
469
  componentStyles.tweakInput,
376
470
  { ...(hasReadonlyInput && { input: { cursor: 'pointer' } }) },
471
+ { ...(isMultiSelect && { inputIcon: { width: 'auto' } }) },
377
472
  tweakStyles?.tweakInput,
378
473
  ) as Styles,
379
474
  [tweakStyles?.tweakInput, hasReadonlyInput],
@@ -405,10 +500,17 @@ export function Select<Value>({
405
500
  });
406
501
 
407
502
  useEffect(() => {
408
- if (isOpen && onOpen !== undefined) {
409
- onOpen();
503
+ const val = isMultiSelect ? value?.[0] : value;
504
+ setFocusedListCellIndex(
505
+ optionsIndexesForNavigation.find(
506
+ (index) => filteredOptions[index] === val,
507
+ ) ?? optionsIndexesForNavigation[0],
508
+ );
509
+
510
+ if (isOpen) {
511
+ onOpen?.();
410
512
  }
411
- }, [isOpen, onOpen]);
513
+ }, [isOpen]);
412
514
 
413
515
  const listEl = (
414
516
  <div
@@ -429,6 +531,8 @@ export function Select<Value>({
429
531
  ? defaultOptionLabel
430
532
  : undefined
431
533
  }
534
+ allOptionsLabel={shouldShowAllOption ? allOptionsLabel : undefined}
535
+ areAllOptionsSelected={areAllOptionsSelected}
432
536
  customListHeader={
433
537
  hasSearchInputInList ? (
434
538
  <SearchInput
@@ -454,13 +558,28 @@ export function Select<Value>({
454
558
  isOptionDisabled={isOptionDisabled}
455
559
  convertValueToString={convertValueToString}
456
560
  convertValueToReactNode={convertValueToReactNode}
457
- convertValueToId={convertValueToId}
458
- onOptionClick={handleOptionSelect}
561
+ convertValueToId={convertToId}
562
+ onOptionSelect={handleOptionSelect}
563
+ onToggleCheckbox={
564
+ isMultiSelect ? handleToggleOptionCheckbox : undefined
565
+ }
459
566
  />
460
567
  )}
461
568
  </div>
462
569
  );
463
570
 
571
+ const multiSelectCounterWithIcon =
572
+ shouldShowMultiSelectCounter || isNotEmpty(iconType) ? (
573
+ <>
574
+ {shouldShowMultiSelectCounter && (
575
+ <div className={classes.counter}>(+{value.length - 1})</div>
576
+ )}
577
+ {isNotEmpty(iconType) && (
578
+ <div className={classes.icon}>{renderIcon(iconType)}</div>
579
+ )}
580
+ </>
581
+ ) : undefined;
582
+
464
583
  return (
465
584
  <div className={classes.root} onKeyDown={handleKeyDown}>
466
585
  <div
@@ -472,7 +591,7 @@ export function Select<Value>({
472
591
  value={
473
592
  searchValue !== '' && !shouldRenderSearchInputInList
474
593
  ? searchValue
475
- : stringValue
594
+ : showedStringValue
476
595
  }
477
596
  onChange={handleInputChange}
478
597
  isActive={isListOpen}
@@ -484,6 +603,7 @@ export function Select<Value>({
484
603
  isLoading={areOptionsLoading}
485
604
  tweakStyles={tweakInputStyles}
486
605
  testId={testId}
606
+ iconType={isMultiSelect ? multiSelectCounterWithIcon : iconType}
487
607
  {...inputProps}
488
608
  />
489
609
  <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
  },