@true-engineering/true-react-common-ui-kit 1.3.1 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@true-engineering/true-react-common-ui-kit",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "True Engineering React UI Kit with theming support",
5
5
  "author": "True Engineering (https://trueengineering.ru)",
6
6
  "keywords": [
@@ -63,6 +63,7 @@ const fetchOptionsMock = [
63
63
  'or vee444ee',
64
64
  ];
65
65
 
66
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
66
67
  type ConfigValues = {
67
68
  name: string;
68
69
  date: IPeriod;
@@ -9,17 +9,19 @@ import {
9
9
  KeyboardEvent,
10
10
  ClipboardEvent,
11
11
  InputHTMLAttributes,
12
+ ReactElement,
12
13
  } from 'react';
13
14
  import clsx from 'clsx';
14
15
  import InputMask, { Props as ReactInputMaskProps } from 'react-input-mask';
15
16
 
16
- import { Icon, IIconProps } from '../Icon';
17
+ import { Icon, IIconType } from '../Icon';
17
18
  import { ThemedPreloader } from '../ThemedPreloader';
18
19
  import { addDataAttributes, addDataTestId, isNotEmpty } from '../../helpers';
19
20
  import { ICommonProps } from '../../types';
20
21
  import { useTheme, useTweakStyles } from '../../hooks';
21
22
 
22
23
  import { InputStyles, styles } from './Input.styles';
24
+ import { renderIcon } from '../../helpers/snippets';
23
25
 
24
26
  export const DEFAULT_SIZE = 6;
25
27
 
@@ -70,7 +72,7 @@ export interface IInputProps extends ICommonProps {
70
72
  * @default 6
71
73
  */
72
74
  defaultSize?: number;
73
- iconType?: IIconProps['type'];
75
+ iconType?: IIconType | ReactElement;
74
76
  units?: string;
75
77
  name?: string;
76
78
  mask?: ReactInputMaskProps['mask'];
@@ -286,7 +288,7 @@ export const Input = forwardRef<HTMLInputElement, IInputProps>(
286
288
  })}
287
289
  onClick={!isDisabled ? onIconClick : undefined}
288
290
  >
289
- <Icon type={iconType} />
291
+ {renderIcon(iconType)}
290
292
  </div>
291
293
  )}
292
294
  </div>
@@ -1,6 +1,7 @@
1
1
  import { ReactNode, useEffect, useState } from 'react';
2
2
  import { Select, ISelectProps } from './Select';
3
3
  import { ComponentMeta, ComponentStory } from '@storybook/react';
4
+ import { isNotEmpty } from '../../helpers';
4
5
 
5
6
  interface ObjectValue {
6
7
  name: string;
@@ -79,6 +80,7 @@ interface ISelectWithCustomProps<T> extends ISelectProps<T> {
79
80
  shouldRenderInBody?: boolean;
80
81
  shouldHideOnScroll?: boolean;
81
82
  shouldUseCustomIsDisabledFunction?: boolean;
83
+ shouldRenderSearchInputInList?: boolean;
82
84
  canBeFlipped?: boolean;
83
85
  scrollParent?: 'document' | 'auto';
84
86
  }
@@ -91,17 +93,21 @@ function SelectWithCustomProps<T>({
91
93
  shouldRenderInBody,
92
94
  shouldHideOnScroll,
93
95
  shouldUseCustomIsDisabledFunction,
96
+ shouldRenderSearchInputInList,
94
97
  canBeFlipped,
95
98
  scrollParent,
99
+ noMatchesLabel,
96
100
  ...rest
97
101
  }: ISelectWithCustomProps<T>) {
98
102
  const [stringValue, setStringValue] = useState<string>();
99
103
  const stringHandler = (newValue?: string) => {
104
+ console.log('change');
100
105
  setStringValue(newValue);
101
106
  };
102
107
 
103
108
  const [objectValue, setObjectValue] = useState<ObjectValue>();
104
109
  const objectHandler = (newValue?: ObjectValue) => {
110
+ console.log('change');
105
111
  setObjectValue(newValue);
106
112
  };
107
113
 
@@ -133,6 +139,10 @@ function SelectWithCustomProps<T>({
133
139
  console.log('isOpen');
134
140
  };
135
141
 
142
+ const handleBlur = () => {
143
+ console.log('blur');
144
+ };
145
+
136
146
  useEffect(() => {
137
147
  const api = async () => {
138
148
  setDynamicOptions(await getOptions());
@@ -172,9 +182,14 @@ function SelectWithCustomProps<T>({
172
182
  <Select
173
183
  {...rest}
174
184
  {...(props as unknown as ISelectProps<any>)}
185
+ {...(shouldRenderSearchInputInList && {
186
+ searchInput: { shouldRenderInList: true },
187
+ })}
188
+ noMatchesLabel={isNotEmpty(noMatchesLabel) ? noMatchesLabel : undefined}
175
189
  optionsMode={optionsMode}
176
190
  onType={async () => setDynamicOptions(await getOptions())}
177
191
  onOpen={handleOpen}
192
+ onBlur={handleBlur}
178
193
  dropdownOptions={{
179
194
  shouldUsePopper,
180
195
  shouldRenderInBody,
@@ -236,6 +251,7 @@ Default.args = {
236
251
  shouldRenderInBody: false,
237
252
  shouldHideOnScroll: false,
238
253
  shouldUseCustomIsDisabledFunction: false,
254
+ shouldRenderSearchInputInList: false,
239
255
  shouldScrollToList: true,
240
256
  canBeFlipped: false,
241
257
  scrollParent: 'document',
@@ -69,6 +69,17 @@ export const styles = {
69
69
  },
70
70
 
71
71
  tweakSelectList: {},
72
+
73
+ tweakSearchInput: {
74
+ tweakInput: {
75
+ inputWrapper: {
76
+ height: 48,
77
+ borderRadius: 0,
78
+ border: 'none',
79
+ backgroundColor: 'transparent',
80
+ },
81
+ },
82
+ },
72
83
  };
73
84
 
74
85
  export type SelectStyles = ComponentStyles<typeof styles>;
@@ -8,6 +8,7 @@ import {
8
8
  useMemo,
9
9
  useRef,
10
10
  useState,
11
+ SyntheticEvent,
11
12
  } from 'react';
12
13
  import { Styles } from 'jss';
13
14
  import clsx from 'clsx';
@@ -22,9 +23,10 @@ import {
22
23
  useTheme,
23
24
  useOnClickOutsideWithRef,
24
25
  useDropdown,
26
+ useTweakStyles,
25
27
  } from '../../hooks';
26
28
  import { IDropdownWithPopperOptions } from '../../types';
27
- import { isNotEmpty } from '../../helpers';
29
+ import { getTestId, hasExactParent, isNotEmpty } from '../../helpers';
28
30
  import {
29
31
  defaultConvertFunction,
30
32
  defaultCompareFunction,
@@ -32,9 +34,10 @@ import {
32
34
  defaultIsOptionDisabled,
33
35
  } from './helpers';
34
36
  import { SelectStyles, styles } from './Select.styles';
37
+ import { ISearchInputProps, SearchInput } from '../SearchInput';
35
38
 
36
39
  export interface ISelectProps<Value>
37
- extends Omit<IInputProps, 'value' | 'onChange' | 'type'> {
40
+ extends Omit<IInputProps, 'value' | 'onChange' | 'onBlur' | 'type'> {
38
41
  tweakStyles?: SelectStyles;
39
42
  defaultOptionLabel?: string;
40
43
  noMatchesLabel?: string;
@@ -47,8 +50,13 @@ export interface ISelectProps<Value>
47
50
  options: Value[];
48
51
  value: Value | undefined;
49
52
  shouldScrollToList?: boolean;
53
+ searchInput?: { shouldRenderInList: true } & Pick<
54
+ ISearchInputProps,
55
+ 'placeholder'
56
+ >;
50
57
  isOptionDisabled?(option: Value): boolean;
51
58
  onChange(value: Value | undefined): void; // подумать как возвращать индекс
59
+ onBlur?(event: Event | SyntheticEvent): void;
52
60
  onType?(value: string): Promise<void>;
53
61
  optionsFilter?(options: Value[], query: string): Value[];
54
62
  onOpen?(): void;
@@ -75,6 +83,7 @@ export function Select<Value>({
75
83
  minSymbolsCountToOpenList = 0,
76
84
  dropdownIcon = 'chevron-down',
77
85
  shouldScrollToList = true,
86
+ searchInput,
78
87
  onChange,
79
88
  onFocus,
80
89
  onBlur,
@@ -105,6 +114,12 @@ export function Select<Value>({
105
114
  const list = useRef<HTMLDivElement>(null);
106
115
  const input = useRef<HTMLInputElement>(null); // TODO ref снаружи?
107
116
 
117
+ const shouldRenderSearchInputInList =
118
+ searchInput?.shouldRenderInList === true;
119
+
120
+ const hasSearchInputInList =
121
+ optionsMode !== 'normal' && shouldRenderSearchInputInList;
122
+
108
123
  const stringValue = isNotEmpty(value)
109
124
  ? convertValueToString(value)
110
125
  : undefined;
@@ -132,23 +147,21 @@ export function Select<Value>({
132
147
  );
133
148
  }, [filteredOptions, value, convertValueToString]);
134
149
 
135
- const handleListClose = () => {
150
+ const handleListClose = (event: Event | SyntheticEvent) => {
136
151
  setIsListOpen(false);
137
152
  setSearchValue('');
138
153
  setShouldShowDefaultOption(true);
154
+ onBlur?.(event);
139
155
  };
140
156
 
141
- const handleListOpen = async () => {
142
- if (isListOpen) {
143
- return;
157
+ const handleListOpen = () => {
158
+ if (!isListOpen) {
159
+ setIsListOpen(true);
144
160
  }
145
- setIsListOpen(true);
146
161
  };
147
162
 
148
163
  const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
149
- if (onFocus !== undefined) {
150
- onFocus(event);
151
- }
164
+ onFocus?.(event);
152
165
  handleListOpen();
153
166
  };
154
167
 
@@ -157,10 +170,19 @@ export function Select<Value>({
157
170
  };
158
171
 
159
172
  const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
160
- if (onBlur !== undefined) {
161
- onBlur(event);
173
+ if (!isNotEmpty(event.relatedTarget) || !isNotEmpty(list.current)) {
174
+ return;
175
+ }
176
+
177
+ const isActionInsideList = hasExactParent(
178
+ event.relatedTarget,
179
+ list.current,
180
+ );
181
+
182
+ // Ниче не делаем если клик был внутри селекта
183
+ if (!isActionInsideList) {
184
+ handleListClose(event);
162
185
  }
163
- handleListClose();
164
186
  };
165
187
 
166
188
  const handleOnChange = useCallback(
@@ -174,10 +196,10 @@ export function Select<Value>({
174
196
  [value, onChange],
175
197
  );
176
198
 
177
- const handleOptionClick = useCallback(
178
- (index: number) => {
199
+ const handleOptionSelect = useCallback(
200
+ (index: number, event: MouseEvent<HTMLElement> | KeyboardEvent) => {
179
201
  handleOnChange(index === -1 ? undefined : filteredOptions[index]);
180
- handleListClose();
202
+ handleListClose(event);
181
203
  input.current?.blur();
182
204
  },
183
205
  [handleOnChange, filteredOptions],
@@ -216,7 +238,7 @@ export function Select<Value>({
216
238
  setShouldShowDefaultOption(v === '');
217
239
  }
218
240
 
219
- if (v === '') {
241
+ if (v === '' && !hasSearchInputInList) {
220
242
  handleOnChange(undefined);
221
243
  }
222
244
 
@@ -241,7 +263,7 @@ export function Select<Value>({
241
263
  indexToClick = 0;
242
264
  }
243
265
 
244
- handleOptionClick(indexToClick);
266
+ handleOptionSelect(indexToClick, event);
245
267
  break;
246
268
  }
247
269
 
@@ -307,24 +329,31 @@ export function Select<Value>({
307
329
  }
308
330
  };
309
331
 
310
- useOnClickOutsideWithRef(
311
- list,
312
- () => {
313
- handleListClose();
314
- },
315
- inputWrapper,
316
- );
332
+ useOnClickOutsideWithRef(list, handleListClose, inputWrapper);
333
+
334
+ const hasEnoughSymbolsToSearch =
335
+ searchValue.trim().length >= minSymbolsCountToOpenList;
317
336
 
318
337
  const isOpen =
338
+ // Пользователь пытается открыть лист
319
339
  isListOpen &&
320
- (inputProps.isLoading ||
321
- filteredOptions.length > 0 ||
322
- (defaultOptionLabel !== undefined && searchValue === '') ||
323
- noMatchesLabel !== undefined) &&
324
- searchValue.trim().length >= minSymbolsCountToOpenList;
340
+ // Нам есть что показать:
341
+ // Есть опции
342
+ (filteredOptions.length > 0 ||
343
+ // Дефолтная опция
344
+ (defaultOptionLabel !== undefined && !hasEnoughSymbolsToSearch) ||
345
+ // Текст "Загрузка..."
346
+ inputProps.isLoading ||
347
+ // Текст "Совпадений не найдено"
348
+ noMatchesLabel !== undefined ||
349
+ // У нас есть инпут с поиском внутри листа
350
+ hasSearchInputInList) &&
351
+ // Последняя проверка на случай, если мы че то ищем в опциях
352
+ (optionsMode === 'normal' || hasEnoughSymbolsToSearch);
325
353
 
326
354
  const { isReadonly = true } = inputProps;
327
- const shouldUsePointerCursor = optionsMode === 'normal' && isReadonly;
355
+ const shouldUsePointerCursor =
356
+ (optionsMode === 'normal' || shouldRenderSearchInputInList) && isReadonly;
328
357
 
329
358
  const tweakInputStyles = useMemo(
330
359
  () =>
@@ -341,6 +370,12 @@ export function Select<Value>({
341
370
  [tweakStyles?.tweakInput, shouldUsePointerCursor],
342
371
  );
343
372
 
373
+ const tweakSearchInputStyles = useTweakStyles(
374
+ componentStyles,
375
+ tweakStyles,
376
+ 'tweakSearchInput',
377
+ );
378
+
344
379
  // Эти значения ставятся в false по дефолту также в useDropdown
345
380
  const {
346
381
  shouldUsePopper = false,
@@ -373,9 +408,8 @@ export function Select<Value>({
373
408
  [classes.listWrapperInBody]: shouldRenderInBody,
374
409
  })}
375
410
  ref={list}
376
- // чтобы предотвратить onBlur на инпуте
377
- onMouseDown={(event) => event.preventDefault()}
378
411
  style={popperData?.styles.popper as Styles}
412
+ onBlur={handleBlur} // обработка для Tab из списка
379
413
  {...popperData?.attributes.popper}
380
414
  >
381
415
  {isOpen && (
@@ -386,13 +420,24 @@ export function Select<Value>({
386
420
  ? defaultOptionLabel
387
421
  : undefined
388
422
  }
423
+ customListHeader={
424
+ hasSearchInputInList ? (
425
+ <SearchInput
426
+ value={searchValue}
427
+ onChange={handleInputChange}
428
+ tweakStyles={tweakSearchInputStyles}
429
+ placeholder="Поиск"
430
+ {...searchInput}
431
+ />
432
+ ) : undefined
433
+ }
389
434
  noMatchesLabel={noMatchesLabel}
390
435
  focusedIndex={focusedListCellIndex}
391
436
  activeValue={value}
392
437
  isLoading={inputProps.isLoading}
393
438
  loadingLabel={loadingLabel}
394
439
  tweakStyles={tweakStyles?.tweakSelectList as Styles}
395
- testId={testId !== undefined ? `${testId}-list` : undefined}
440
+ testId={getTestId(testId, 'list')}
396
441
  // скролл не работает с включеным поппером
397
442
  shouldScrollToList={
398
443
  shouldScrollToList && !shouldUsePopper && !shouldHideOnScroll
@@ -401,7 +446,7 @@ export function Select<Value>({
401
446
  convertValueToString={convertValueToString}
402
447
  convertValueToReactNode={convertValueToReactNode}
403
448
  convertValueToId={convertValueToId}
404
- onOptionClick={handleOptionClick}
449
+ onOptionClick={handleOptionSelect}
405
450
  />
406
451
  )}
407
452
  </div>
@@ -415,10 +460,14 @@ export function Select<Value>({
415
460
  ref={inputWrapper}
416
461
  >
417
462
  <Input
418
- value={searchValue !== '' ? searchValue : stringValue}
463
+ value={
464
+ searchValue !== '' && !shouldRenderSearchInputInList
465
+ ? searchValue
466
+ : stringValue
467
+ }
419
468
  onChange={handleInputChange}
420
469
  isActive={isListOpen}
421
- isReadonly={optionsMode === 'normal'}
470
+ isReadonly={optionsMode === 'normal' || shouldRenderSearchInputInList}
422
471
  onFocus={handleFocus}
423
472
  onBlur={handleBlur}
424
473
  isDisabled={isDisabled}
@@ -12,6 +12,17 @@ export const styles = {
12
12
  boxSizing: 'border-box',
13
13
  padding: [CONTAINER_PADDING, 0],
14
14
  fontSize: 16,
15
+ overflow: 'hidden',
16
+ },
17
+
18
+ withListHeader: {
19
+ paddingTop: 0,
20
+ },
21
+
22
+ listHeader: {
23
+ '& + $list': {
24
+ borderTop: [1, 'solid', colors.BORDER_LIGHT],
25
+ },
15
26
  },
16
27
 
17
28
  list: {
@@ -1,4 +1,4 @@
1
- import { ReactNode, useMemo } from 'react';
1
+ import { ReactNode, useMemo, MouseEvent } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { ScrollIntoViewIfNeeded } from '../../ScrollIntoViewIfNeeded';
4
4
  import { useTheme } from '../../../hooks';
@@ -17,7 +17,8 @@ export interface ISelectListProps<Value> extends ICommonProps {
17
17
  defaultOptionLabel?: string;
18
18
  testId?: string;
19
19
  shouldScrollToList?: boolean;
20
- onOptionClick(index: number): void;
20
+ customListHeader?: ReactNode;
21
+ onOptionClick(index: number, event: MouseEvent<HTMLElement>): void;
21
22
  isOptionDisabled(value: Value): boolean;
22
23
  convertValueToString(value: Value): string | undefined;
23
24
  convertValueToReactNode?(value: Value, isDisabled: boolean): ReactNode;
@@ -37,6 +38,7 @@ export function SelectList<Value>({
37
38
  tweakStyles,
38
39
  testId,
39
40
  shouldScrollToList = true,
41
+ customListHeader,
40
42
  isOptionDisabled,
41
43
  onOptionClick,
42
44
  convertValueToString,
@@ -66,8 +68,13 @@ export function SelectList<Value>({
66
68
  return (
67
69
  <ScrollIntoViewIfNeeded
68
70
  active={shouldScrollToList}
69
- className={classes.root}
71
+ className={clsx(classes.root, {
72
+ [classes.withListHeader]: isNotEmpty(customListHeader),
73
+ })}
70
74
  >
75
+ {isNotEmpty(customListHeader) && (
76
+ <div className={classes.listHeader}>{customListHeader}</div>
77
+ )}
71
78
  <div className={classes.list} data-testid={testId}>
72
79
  {isLoading ? (
73
80
  <div className={clsx(classes.cell, classes.loading)}>
@@ -84,7 +91,7 @@ export function SelectList<Value>({
84
91
  classes.defaultCell,
85
92
  focusedIndex === DEFAULT_OPTION_INDEX && classes.focused,
86
93
  )}
87
- onClick={() => onOptionClick(DEFAULT_OPTION_INDEX)}
94
+ onClick={(event) => onOptionClick(DEFAULT_OPTION_INDEX, event)}
88
95
  >
89
96
  {defaultOptionLabel}
90
97
  </ScrollIntoViewIfNeeded>
@@ -111,7 +118,9 @@ export function SelectList<Value>({
111
118
  active: isActive,
112
119
  focused: isFocused,
113
120
  })}
114
- onClick={!isDisabled ? () => onOptionClick(i) : undefined}
121
+ onClick={
122
+ !isDisabled ? (event) => onOptionClick(i, event) : undefined
123
+ }
115
124
  >
116
125
  {opt}
117
126
  </ScrollIntoViewIfNeeded>
@@ -0,0 +1,5 @@
1
+ import { Icon, IIconType } from '../components';
2
+ import { ReactElement, ReactNode } from 'react';
3
+
4
+ export const renderIcon = (icon: IIconType | ReactElement): ReactNode =>
5
+ typeof icon === 'string' ? <Icon type={icon} /> : icon;
@@ -11,6 +11,20 @@ export const transformToKebab = (string: string): string => {
11
11
  return result;
12
12
  };
13
13
 
14
+ export const hasExactParent = (element: Element, parent: Element): boolean => {
15
+ if (element === parent) {
16
+ return true; // Found the exact parent
17
+ }
18
+
19
+ const parentNode = getParentNode(element);
20
+
21
+ if (parentNode === element) {
22
+ return false; // Reached the top-level HTML element or Shadow DOM host
23
+ }
24
+
25
+ return hasExactParent(parentNode, parent);
26
+ };
27
+
14
28
  export const getParentNode = (element: Element | ShadowRoot): Element =>
15
29
  element.nodeName === 'HTML'
16
30
  ? (element as Element)
@@ -13,11 +13,11 @@ export const useDropdown = ({
13
13
  dependenciesForPositionUpdating = [],
14
14
  }: {
15
15
  isOpen: boolean;
16
- onDropdownClose: () => void;
17
16
  referenceElement: VirtualElement | null | undefined;
18
17
  dropdownElement: HTMLElement | null | undefined;
19
18
  options?: IDropdownWithPopperOptions;
20
19
  dependenciesForPositionUpdating?: DependencyList;
20
+ onDropdownClose(event: Event): void;
21
21
  }): ReturnType<typeof usePopper> | undefined => {
22
22
  const {
23
23
  shouldUsePopper = false,