@snack-uikit/fields 0.16.1 → 0.17.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.
@@ -1,17 +1,18 @@
1
1
  import mergeRefs from 'merge-refs';
2
- import { forwardRef, KeyboardEvent, KeyboardEventHandler, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { FocusEvent, forwardRef, KeyboardEvent, KeyboardEventHandler, useEffect, useMemo, useRef } from 'react';
3
3
 
4
4
  import { InputPrivate } from '@snack-uikit/input-private';
5
- import { Droplist, SelectionSingleValueType, useFuzzySearch } from '@snack-uikit/list';
5
+ import { Droplist, ItemProps, SelectionSingleValueType, useFuzzySearch } from '@snack-uikit/list';
6
6
  import { extractSupportProps } from '@snack-uikit/utils';
7
7
 
8
8
  import { FieldContainerPrivate } from '../../helperComponents';
9
9
  import { useValueControl } from '../../hooks';
10
10
  import { FieldDecorator } from '../FieldDecorator';
11
+ import { extractFieldDecoratorProps } from '../FieldDecorator/utils';
11
12
  import { useButtons, useHandleOnKeyDown, useSearchInput } from './hooks';
12
13
  import styles from './styles.module.scss';
13
14
  import { FieldSelectSingleProps } from './types';
14
- import { extractSelectedOptions, getArrowIcon, transformOptionsToItems } from './utils';
15
+ import { extractListProps, findSelectedOption, getArrowIcon, transformOptionsToItems } from './utils';
15
16
 
16
17
  export const FieldSelectSingle = forwardRef<HTMLInputElement, FieldSelectSingleProps>(
17
18
  (
@@ -24,31 +25,26 @@ export const FieldSelectSingle = forwardRef<HTMLInputElement, FieldSelectSingleP
24
25
  value: valueProp,
25
26
  defaultValue,
26
27
  onChange: onChangeProp,
27
- loading,
28
28
  disabled = false,
29
29
  readonly = false,
30
30
  searchable = true,
31
31
  showCopyButton = true,
32
32
  showClearButton = true,
33
33
  onKeyDown: onInputKeyDownProp,
34
- label,
35
- labelTooltip,
36
- labelTooltipPlacement,
37
34
  required = false,
38
- hint,
39
- showHintIcon,
40
35
  validationState = 'default',
41
- footer,
42
36
  search,
43
37
  autocomplete = false,
44
38
  prefixIcon,
45
- error,
39
+ addOptionByEnter = false,
40
+ open: openProp,
41
+ onOpenChange,
46
42
  ...rest
47
43
  },
48
44
  ref,
49
45
  ) => {
50
46
  const localRef = useRef<HTMLInputElement>(null);
51
- const [open, setOpen] = useState<boolean>(false);
47
+ const [open = false, setOpen] = useValueControl<boolean>({ value: openProp, onChange: onOpenChange });
52
48
  const [value, setValue] = useValueControl<SelectionSingleValueType>({
53
49
  value: valueProp,
54
50
  defaultValue,
@@ -56,22 +52,34 @@ export const FieldSelectSingle = forwardRef<HTMLInputElement, FieldSelectSingleP
56
52
  });
57
53
 
58
54
  const items = useMemo(() => transformOptionsToItems(options), [options]);
59
- const selectedOption = useMemo(() => extractSelectedOptions(options, value), [options, value]);
55
+ const { selected, itemsWithPlaceholder } = useMemo(() => {
56
+ const [fonded, placeholder] = findSelectedOption(items, value);
57
+
58
+ return {
59
+ selected: fonded ?? placeholder,
60
+ itemsWithPlaceholder: ((placeholder ? [placeholder] : []) as ItemProps[]).concat(items),
61
+ };
62
+ }, [items, value]);
60
63
 
61
64
  const { inputValue, onInputValueChange, prevInputValue } = useSearchInput({
62
65
  ...search,
63
- defaultValue: String(selectedOption ?? ''),
66
+ defaultValue: selected?.content.option ?? '',
64
67
  });
65
68
 
66
69
  useEffect(() => {
67
- !open && onInputValueChange(String(selectedOption?.option ?? ''));
68
- }, [onInputValueChange, open, selectedOption]);
70
+ if (selected?.content.option && prevInputValue.current !== selected?.content.option) {
71
+ onInputValueChange(selected.content.option);
72
+ prevInputValue.current = selected?.content.option;
73
+ }
74
+ }, [onInputValueChange, selected?.content.option, prevInputValue]);
69
75
 
70
- useEffect(() => {
71
- onInputValueChange(String(selectedOption?.option ?? ''));
76
+ const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
77
+ if (!open && selected?.content.option !== inputValue) {
78
+ onInputValueChange(selected?.content.option ?? '');
79
+ }
72
80
 
73
- prevInputValue.current = String(selectedOption?.option ?? '');
74
- }, [prevInputValue, onInputValueChange, selectedOption]);
81
+ rest?.onBlur?.(e);
82
+ };
75
83
 
76
84
  const onClear = () => {
77
85
  setValue('');
@@ -86,11 +94,11 @@ export const FieldSelectSingle = forwardRef<HTMLInputElement, FieldSelectSingleP
86
94
  const { buttons, inputKeyDownNavigationHandler, buttonsRefs } = useButtons({
87
95
  readonly,
88
96
  size,
89
- showClearButton: showClearButton && Boolean(value),
97
+ showClearButton: showClearButton && !disabled && !readonly && Boolean(value),
90
98
  showCopyButton,
91
99
  inputRef: localRef,
92
100
  onClear,
93
- valueToCopy: String(selectedOption?.option ?? ''),
101
+ valueToCopy: selected?.content.option ?? '',
94
102
  });
95
103
 
96
104
  const commonHandleOnKeyDown = useHandleOnKeyDown({
@@ -104,6 +112,15 @@ export const FieldSelectSingle = forwardRef<HTMLInputElement, FieldSelectSingleP
104
112
  setOpen(true);
105
113
  }
106
114
 
115
+ if (e.code === 'Enter') {
116
+ e.stopPropagation();
117
+ e.preventDefault();
118
+ }
119
+
120
+ if (addOptionByEnter && e.code === 'Enter' && inputValue !== '') {
121
+ setValue(inputValue);
122
+ }
123
+
107
124
  commonHandleOnKeyDown(onKeyDown)(e);
108
125
  };
109
126
 
@@ -119,37 +136,34 @@ export const FieldSelectSingle = forwardRef<HTMLInputElement, FieldSelectSingleP
119
136
  const handleOpenChange = (open: boolean) => {
120
137
  if (!readonly && !disabled && !buttonsRefs.includes(document.activeElement)) {
121
138
  setOpen(open);
139
+
140
+ if (!open) {
141
+ onInputValueChange(selected?.content.option ?? '');
142
+ prevInputValue.current = selected?.content.option ?? '';
143
+ }
122
144
  }
123
145
  };
124
146
 
125
- const fuzzySearch = useFuzzySearch(items);
126
- const result = autocomplete ? items : fuzzySearch(prevInputValue.current !== inputValue ? inputValue : '');
147
+ const fuzzySearch = useFuzzySearch(itemsWithPlaceholder);
148
+ const result =
149
+ autocomplete || !searchable || prevInputValue.current === inputValue
150
+ ? itemsWithPlaceholder
151
+ : fuzzySearch(inputValue);
127
152
 
128
153
  return (
129
154
  <FieldDecorator
130
155
  {...extractSupportProps(rest)}
131
- error={error}
156
+ {...extractFieldDecoratorProps(rest)}
157
+ validationState={validationState}
132
158
  required={required}
133
159
  readonly={readonly}
134
- label={label}
135
- labelTooltip={labelTooltip}
136
- labelTooltipPlacement={labelTooltipPlacement}
137
160
  labelFor={id}
138
- hint={hint}
139
161
  disabled={disabled}
140
- showHintIcon={showHintIcon}
141
162
  size={size}
142
- validationState={validationState}
143
163
  >
144
164
  <Droplist
145
- trigger='clickAndFocusVisible'
146
- placement='bottom'
147
- data-test-id='field-select__list'
165
+ {...extractListProps(rest)}
148
166
  items={result}
149
- triggerElemRef={localRef}
150
- scroll
151
- marker
152
- footer={footer}
153
167
  selection={{
154
168
  mode: 'single',
155
169
  value: value,
@@ -158,7 +172,7 @@ export const FieldSelectSingle = forwardRef<HTMLInputElement, FieldSelectSingleP
158
172
  size={size}
159
173
  open={open}
160
174
  onOpenChange={handleOpenChange}
161
- loading={loading}
175
+ triggerElemRef={localRef}
162
176
  >
163
177
  {({ onKeyDown }) => (
164
178
  <FieldContainerPrivate
@@ -184,6 +198,7 @@ export const FieldSelectSingle = forwardRef<HTMLInputElement, FieldSelectSingleP
184
198
  readonly={!searchable || readonly}
185
199
  data-test-id='field-select__input'
186
200
  onKeyDown={handleOnKeyDown(onKeyDown)}
201
+ onBlur={handleBlur}
187
202
  />
188
203
 
189
204
  <div className={styles.postfix}>
@@ -1,13 +1,17 @@
1
1
  import { KeyboardEvent, KeyboardEventHandler, RefObject, useCallback, useMemo, useRef } from 'react';
2
- import { Handler, useUncontrolledProp } from 'uncontrollable';
2
+ import { Handler } from 'uncontrollable';
3
3
 
4
4
  import { useButtonNavigation, useClearButton } from '@snack-uikit/input-private';
5
- import { SelectionSingleValueType } from '@snack-uikit/list';
6
- import { extractChildIds } from '@snack-uikit/list/dist/utils';
5
+ import {
6
+ extractChildIds,
7
+ isAccordionItemProps,
8
+ isNextListItemProps,
9
+ SelectionSingleValueType,
10
+ } from '@snack-uikit/list';
7
11
 
8
- import { useCopyButton } from '../../hooks';
9
- import { OptionProps, SearchState } from './types';
10
- import { isAccordionOptionProps, isBaseOptionProps, isNextListOptionProps, transformOptionsToItems } from './utils';
12
+ import { useCopyButton, useValueControl } from '../../hooks';
13
+ import { ItemWithId, SearchState } from './types';
14
+ import { isBaseOptionProps } from './utils';
11
15
 
12
16
  type UseHandleOnKeyDownProps = {
13
17
  inputKeyDownNavigationHandler: KeyboardEventHandler<HTMLInputElement>;
@@ -95,7 +99,7 @@ export function useButtons({
95
99
  }
96
100
 
97
101
  export function useSearchInput({ value, onChange, defaultValue }: SearchState) {
98
- const [inputValue, onInputValueChange] = useUncontrolledProp<string>(value, defaultValue ?? '', onChange);
102
+ const [inputValue = '', onInputValueChange] = useValueControl<string>({ value, onChange, defaultValue });
99
103
 
100
104
  const prevInputValue = useRef<string>(inputValue);
101
105
 
@@ -104,20 +108,20 @@ export function useSearchInput({ value, onChange, defaultValue }: SearchState) {
104
108
 
105
109
  export function useHandleDeleteItem(setValue: Handler) {
106
110
  return useCallback(
107
- (option?: OptionProps) => () => {
108
- if (!option) {
111
+ (item?: ItemWithId) => () => {
112
+ if (!item) {
109
113
  return;
110
114
  }
111
115
 
112
- if (isAccordionOptionProps(option) || isNextListOptionProps(option)) {
113
- const removeIds = extractChildIds({ items: transformOptionsToItems(option.options) }).concat(option.value);
116
+ if (isAccordionItemProps(item) || isNextListItemProps(item)) {
117
+ const removeIds = extractChildIds({ items: item.items }).concat(item.id ?? '');
114
118
 
115
119
  setValue((value: SelectionSingleValueType[]) => value?.filter(v => !removeIds.includes(v ?? '')));
116
120
  return;
117
121
  }
118
122
 
119
- if (isBaseOptionProps(option)) {
120
- setValue((value: SelectionSingleValueType[]) => value?.filter(v => v !== option.value));
123
+ if (isBaseOptionProps(item)) {
124
+ setValue((value: SelectionSingleValueType[]) => value?.filter(v => v !== item.id));
121
125
  }
122
126
  },
123
127
  [setValue],
@@ -10,6 +10,7 @@ import {
10
10
  SelectionMultipleState,
11
11
  SelectionSingleState,
12
12
  } from '@snack-uikit/list';
13
+ import { TagProps } from '@snack-uikit/tag';
13
14
  import { WithSupportProps } from '@snack-uikit/utils';
14
15
 
15
16
  import { FieldDecoratorProps } from '../FieldDecorator';
@@ -21,7 +22,7 @@ export type OptionProps = BaseOptionProps | AccordionOptionProps | GroupOptionPr
21
22
  export type OptionWithoutGroup = BaseOptionProps | AccordionOptionProps | NestListOptionProps;
22
23
 
23
24
  export type BaseOptionProps = Pick<BaseItemProps, 'beforeContent' | 'afterContent' | 'disabled'> &
24
- BaseItemProps['content'] & { value: string | number };
25
+ BaseItemProps['content'] & { value: string | number } & Pick<TagProps, 'appearance'>;
25
26
 
26
27
  export type AccordionOptionProps = Pick<AccordionItemProps, 'type'> & BaseOptionProps & { options: OptionProps[] };
27
28
  export type GroupOptionProps = Omit<GroupItemProps, 'items' | 'id'> & { options: OptionProps[] };
@@ -78,17 +79,30 @@ type FiledSelectCommonProps = WithSupportProps<{
78
79
  search?: SearchState;
79
80
 
80
81
  autocomplete?: boolean;
81
- }>;
82
+
83
+ addOptionByEnter?: boolean;
84
+
85
+ open?: boolean;
86
+ onOpenChange?(open: boolean): void;
87
+ }> &
88
+ Pick<
89
+ ListProps,
90
+ 'dataError' | 'noDataState' | 'noResultsState' | 'errorDataState' | 'pinTop' | 'pinBottom' | 'dataFiltered'
91
+ >;
82
92
 
83
93
  export type FieldSelectSingleProps = FieldSelectPrivateProps &
84
94
  Omit<SelectionSingleState, 'mode'> &
85
95
  WrapperProps &
86
96
  FiledSelectCommonProps;
87
97
 
88
- export type FieldSelectMultipleProps = FieldSelectPrivateProps &
89
- Omit<SelectionMultipleState, 'mode'> &
98
+ export type FieldSelectMultipleProps = FieldSelectPrivateProps & { removeByBackspace?: boolean } & Omit<
99
+ SelectionMultipleState,
100
+ 'mode'
101
+ > &
90
102
  Omit<FiledSelectCommonProps, 'showCopyButton'>;
91
103
 
92
104
  export type FieldSelectProps =
93
105
  | (FieldSelectSingleProps & { selection?: 'single' })
94
106
  | (FieldSelectMultipleProps & { selection: 'multiple' });
107
+
108
+ export type ItemWithId = BaseItemProps | AccordionItemProps | NextListItemProps;
@@ -1,16 +1,18 @@
1
1
  import { ChevronDownSVG, ChevronUpSVG } from '@snack-uikit/icons';
2
2
  import { ICON_SIZE, SIZE, Size } from '@snack-uikit/input-private';
3
- import { ItemProps } from '@snack-uikit/list';
3
+ import { DroplistProps, flattenItems, ItemProps, SelectionSingleValueType } from '@snack-uikit/list';
4
+ import { TagProps } from '@snack-uikit/tag';
4
5
 
5
6
  import {
6
7
  AccordionOptionProps,
7
8
  BaseOptionProps,
8
9
  FieldSelectMultipleProps,
10
+ FieldSelectProps,
9
11
  FieldSelectSingleProps,
10
12
  GroupOptionProps,
13
+ ItemWithId,
11
14
  NestListOptionProps,
12
15
  OptionProps,
13
- OptionWithoutGroup,
14
16
  } from './types';
15
17
 
16
18
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -33,6 +35,34 @@ export function isGroupOptionProps(option: any): option is GroupOptionProps {
33
35
  return 'options' in option && option['type'] === undefined;
34
36
  }
35
37
 
38
+ export function mapOptionToAppearance(
39
+ options: OptionProps[],
40
+ ): Record<string | number, TagProps['appearance'] | undefined> {
41
+ let mapOption2Appearance: Record<string | number, TagProps['appearance'] | undefined> = {};
42
+
43
+ options.forEach(option => {
44
+ if (isAccordionOptionProps(option) || isNextListOptionProps(option)) {
45
+ const { options, value, appearance } = option;
46
+
47
+ mapOption2Appearance = { ...mapOption2Appearance, [value]: appearance, ...mapOptionToAppearance(options) };
48
+ }
49
+
50
+ if (isGroupOptionProps(option)) {
51
+ const { options } = option;
52
+
53
+ mapOption2Appearance = { ...mapOption2Appearance, ...mapOptionToAppearance(options) };
54
+ }
55
+
56
+ const { value, appearance } = option as BaseOptionProps;
57
+
58
+ if (value !== undefined) {
59
+ mapOption2Appearance = { ...mapOption2Appearance, [value]: appearance };
60
+ }
61
+ });
62
+
63
+ return mapOption2Appearance;
64
+ }
65
+
36
66
  export function transformOptionsToItems(options: OptionProps[]): ItemProps[] {
37
67
  return options.map(option => {
38
68
  if (isAccordionOptionProps(option) || isNextListOptionProps(option)) {
@@ -67,82 +97,49 @@ export function transformOptionsToItems(options: OptionProps[]): ItemProps[] {
67
97
  });
68
98
  }
69
99
 
70
- export function extractSelectedOptions(
71
- options: OptionProps[],
72
- value: string | number | undefined,
73
- ): OptionWithoutGroup | undefined {
74
- for (let i = 0; i < options.length; i++) {
75
- const option = options[i];
76
- if (isAccordionOptionProps(option) || isNextListOptionProps(option)) {
77
- const { value: optionValue } = option;
78
-
79
- if (optionValue === value) {
80
- return option;
81
- }
82
-
83
- const selectedOptionFromNestedOptions = extractSelectedOptions(option.options, value);
84
-
85
- if (selectedOptionFromNestedOptions) {
86
- return selectedOptionFromNestedOptions;
87
- }
88
- }
100
+ export function findSelectedOption(
101
+ items: ItemProps[],
102
+ value: SelectionSingleValueType,
103
+ ): [ItemWithId | undefined, ItemWithId | undefined] {
104
+ const flatten: ItemWithId[] = flattenItems(items);
89
105
 
90
- if (isGroupOptionProps(option)) {
91
- const selectedOptionFromNestedOptions = extractSelectedOptions(option.options, value);
92
-
93
- if (selectedOptionFromNestedOptions) {
94
- return selectedOptionFromNestedOptions;
95
- }
96
- }
97
-
98
- if (isBaseOptionProps(option)) {
99
- if (option.value === value) {
100
- return option;
101
- }
102
- }
106
+ if (!value) {
107
+ return [undefined, undefined];
103
108
  }
104
109
 
105
- return undefined;
106
- }
107
-
108
- export function extractSelectedMultipleOptions(
109
- options: OptionProps[],
110
- value?: (string | number | undefined)[],
111
- ): OptionWithoutGroup[] | undefined {
112
- let selectedOptions: OptionWithoutGroup[] = [];
110
+ const foundItem = flatten.find(item => String(item.id) === String(value));
111
+ const placeholderItem = { id: value, content: { option: String(value) } };
113
112
 
114
- for (let i = 0; i < options.length; i++) {
115
- const option = options[i];
116
- if (isAccordionOptionProps(option) || isNextListOptionProps(option)) {
117
- const { value: optionValue } = option;
118
-
119
- if (value?.includes(optionValue)) {
120
- selectedOptions.push(option);
121
- }
113
+ return [foundItem, !foundItem ? placeholderItem : undefined];
114
+ }
122
115
 
123
- const selectedOptionFromNestedOptions = extractSelectedMultipleOptions(option.options, value);
116
+ export function findSelectedOptions(
117
+ items: ItemProps[],
118
+ value: SelectionSingleValueType[] | undefined,
119
+ ): [ItemWithId[] | undefined, ItemWithId[] | undefined] {
120
+ const flatten: ItemWithId[] | undefined = flattenItems(items);
124
121
 
125
- if (selectedOptionFromNestedOptions) {
126
- selectedOptions = selectedOptions.concat(selectedOptionFromNestedOptions);
127
- }
128
- }
122
+ if (!value || !value?.length) {
123
+ return [undefined, undefined];
124
+ }
129
125
 
130
- if (isGroupOptionProps(option)) {
131
- const selectedOptionFromNestedOptions = extractSelectedMultipleOptions(option.options, value);
126
+ let foundItems: ItemWithId[] | undefined;
127
+ let placeholderItems: ItemWithId[] | undefined;
132
128
 
133
- if (selectedOptionFromNestedOptions) {
134
- selectedOptions = selectedOptions.concat(selectedOptionFromNestedOptions);
129
+ value.forEach(value => {
130
+ if (flatten) {
131
+ const [found, placeholder] = findSelectedOption(flatten, value);
132
+ if (found || foundItems) {
133
+ foundItems = (foundItems ?? []).concat(found ?? []);
135
134
  }
136
- }
137
135
 
138
- if (isBaseOptionProps(option)) {
139
- if (value?.includes(option.value)) {
140
- selectedOptions.push(option);
136
+ if (placeholder || placeholderItems) {
137
+ placeholderItems = (placeholderItems ?? []).concat(placeholder ?? []);
141
138
  }
142
139
  }
143
- }
140
+ });
144
141
 
145
- return selectedOptions.length ? selectedOptions : undefined;
142
+ return [foundItems, placeholderItems];
146
143
  }
147
144
 
148
145
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -161,3 +158,30 @@ export function getArrowIcon({ size, open }: { size: Size; open: boolean }) {
161
158
  arrowIconSize: size === SIZE.S ? ICON_SIZE.Xs : ICON_SIZE.S,
162
159
  };
163
160
  }
161
+
162
+ export function extractListProps({
163
+ dataError,
164
+ noDataState,
165
+ noResultsState,
166
+ errorDataState,
167
+ pinTop,
168
+ pinBottom,
169
+ dataFiltered,
170
+ loading,
171
+ }: Partial<FieldSelectProps>): Partial<DroplistProps> {
172
+ return {
173
+ dataError,
174
+ noDataState,
175
+ noResultsState,
176
+ errorDataState,
177
+ pinTop,
178
+ pinBottom,
179
+ dataFiltered,
180
+ loading,
181
+ trigger: 'clickAndFocusVisible',
182
+ placement: 'bottom',
183
+ 'data-test-id': 'field-select__list',
184
+ scroll: true,
185
+ marker: true,
186
+ };
187
+ }
@@ -3,7 +3,7 @@ export const generateAllowedValues = (min: number, max: number, step: number): n
3
3
 
4
4
  let current = min;
5
5
 
6
- while (current < max) {
6
+ while (current <= max) {
7
7
  values.push(current);
8
8
  current += step;
9
9
  }
@@ -7,7 +7,7 @@ type UseValueControl<TValue> = {
7
7
  };
8
8
 
9
9
  export function useValueControl<TValue>({ value, onChange, defaultValue }: UseValueControl<TValue>) {
10
- return useUncontrolledProp<TValue>(value, value ?? defaultValue, (newValue: TValue) => {
10
+ return useUncontrolledProp<TValue>(value, defaultValue, (newValue: TValue) => {
11
11
  const newState = typeof newValue === 'function' ? newValue(value) : newValue;
12
12
 
13
13
  onChange?.(newState);