@cloud-ru/ds-fields 2.2.3-preview-f436e22b.0 → 2.2.3-preview-a0540d7f.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 (60) hide show
  1. package/README.md +3 -1
  2. package/dist/cjs/components/FieldSelect/FieldSelect.js +98 -43
  3. package/dist/cjs/components/FieldSelect/constants.d.ts +11 -0
  4. package/dist/cjs/components/FieldSelect/constants.js +20 -1
  5. package/dist/cjs/components/FieldSelect/hooks.d.ts +33 -0
  6. package/dist/cjs/components/FieldSelect/hooks.js +95 -0
  7. package/dist/cjs/components/FieldSelect/types.d.ts +26 -3
  8. package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +21 -0
  9. package/dist/cjs/components/FieldSelect/utils/customOption.js +55 -0
  10. package/dist/cjs/components/FieldSelect/utils/extract.d.ts +11 -0
  11. package/dist/cjs/components/FieldSelect/utils/extract.js +39 -0
  12. package/dist/cjs/components/FieldSelect/utils/filter.d.ts +3 -0
  13. package/dist/cjs/components/FieldSelect/utils/filter.js +40 -0
  14. package/dist/cjs/components/FieldSelect/utils/index.d.ts +6 -0
  15. package/dist/cjs/components/FieldSelect/utils/index.js +22 -0
  16. package/dist/cjs/components/FieldSelect/utils/items.d.ts +9 -0
  17. package/dist/cjs/components/FieldSelect/utils/items.js +47 -0
  18. package/dist/cjs/components/FieldSelect/utils/selection.d.ts +11 -0
  19. package/dist/cjs/components/FieldSelect/utils/selection.js +18 -0
  20. package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +4 -0
  21. package/dist/cjs/components/FieldSelect/utils/tagSize.js +5 -0
  22. package/dist/esm/components/FieldSelect/FieldSelect.js +100 -45
  23. package/dist/esm/components/FieldSelect/constants.d.ts +11 -0
  24. package/dist/esm/components/FieldSelect/constants.js +19 -0
  25. package/dist/esm/components/FieldSelect/hooks.d.ts +33 -0
  26. package/dist/esm/components/FieldSelect/hooks.js +92 -0
  27. package/dist/esm/components/FieldSelect/types.d.ts +26 -3
  28. package/dist/esm/components/FieldSelect/utils/customOption.d.ts +21 -0
  29. package/dist/esm/components/FieldSelect/utils/customOption.js +48 -0
  30. package/dist/esm/components/FieldSelect/utils/extract.d.ts +11 -0
  31. package/dist/esm/components/FieldSelect/utils/extract.js +34 -0
  32. package/dist/esm/components/FieldSelect/utils/filter.d.ts +3 -0
  33. package/dist/esm/components/FieldSelect/utils/filter.js +36 -0
  34. package/dist/esm/components/FieldSelect/utils/index.d.ts +6 -0
  35. package/dist/esm/components/FieldSelect/utils/index.js +6 -0
  36. package/dist/esm/components/FieldSelect/utils/items.d.ts +9 -0
  37. package/dist/esm/components/FieldSelect/utils/items.js +41 -0
  38. package/dist/esm/components/FieldSelect/utils/selection.d.ts +11 -0
  39. package/dist/esm/components/FieldSelect/utils/selection.js +14 -0
  40. package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +4 -0
  41. package/dist/esm/components/FieldSelect/utils/tagSize.js +2 -0
  42. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  43. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  44. package/package.json +4 -4
  45. package/src/components/FieldSelect/FieldSelect.tsx +151 -63
  46. package/src/components/FieldSelect/constants.ts +22 -0
  47. package/src/components/FieldSelect/hooks.ts +177 -0
  48. package/src/components/FieldSelect/types.ts +82 -48
  49. package/src/components/FieldSelect/utils/customOption.ts +77 -0
  50. package/src/components/FieldSelect/utils/extract.ts +49 -0
  51. package/src/components/FieldSelect/utils/filter.ts +51 -0
  52. package/src/components/FieldSelect/utils/index.ts +6 -0
  53. package/src/components/FieldSelect/utils/items.ts +58 -0
  54. package/src/components/FieldSelect/utils/selection.ts +25 -0
  55. package/src/components/FieldSelect/utils/tagSize.ts +5 -0
  56. package/dist/cjs/components/FieldSelect/utils.d.ts +0 -19
  57. package/dist/cjs/components/FieldSelect/utils.js +0 -103
  58. package/dist/esm/components/FieldSelect/utils.d.ts +0 -19
  59. package/dist/esm/components/FieldSelect/utils.js +0 -92
  60. package/src/components/FieldSelect/utils.ts +0 -132
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isFuzzyMatch = isFuzzyMatch;
4
+ exports.filterItems = filterItems;
5
+ const extract_1 = require("./extract");
6
+ // Subsequence-fuzzy: символы запроса должны встречаться в строке в исходном порядке.
7
+ // Пример: query=`lge` matches `Large` (L → r → arg → e).
8
+ function isFuzzyMatch(haystack, needle) {
9
+ if (!needle) {
10
+ return true;
11
+ }
12
+ let i = 0;
13
+ for (const ch of haystack) {
14
+ if (ch === needle[i]) {
15
+ i += 1;
16
+ if (i === needle.length) {
17
+ return true;
18
+ }
19
+ }
20
+ }
21
+ return false;
22
+ }
23
+ function filterItems(items, query, fuzzy) {
24
+ if (!query) {
25
+ return items;
26
+ }
27
+ const q = query.toLowerCase();
28
+ const matches = (item) => {
29
+ const text = (0, extract_1.extractSearchText)(item).toLowerCase();
30
+ return fuzzy ? isFuzzyMatch(text, q) : text.includes(q);
31
+ };
32
+ const walk = (list) => list.flatMap(item => {
33
+ if ('items' in item && Array.isArray(item.items)) {
34
+ const filteredChildren = walk(item.items);
35
+ return filteredChildren.length > 0 ? [{ ...item, items: filteredChildren }] : [];
36
+ }
37
+ return matches(item) ? [item] : [];
38
+ });
39
+ return walk(items);
40
+ }
@@ -0,0 +1,6 @@
1
+ export * from './customOption';
2
+ export * from './extract';
3
+ export * from './filter';
4
+ export * from './items';
5
+ export * from './selection';
6
+ export * from './tagSize';
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./customOption"), exports);
18
+ __exportStar(require("./extract"), exports);
19
+ __exportStar(require("./filter"), exports);
20
+ __exportStar(require("./items"), exports);
21
+ __exportStar(require("./selection"), exports);
22
+ __exportStar(require("./tagSize"), exports);
@@ -0,0 +1,9 @@
1
+ import { ItemId } from '@cloud-ru/ds-list';
2
+ import { FieldSelectItem } from '../types';
3
+ import { WithIdContent } from './extract';
4
+ export declare function flatten(items: FieldSelectItem[]): WithIdContent[];
5
+ export declare function findItem(items: FieldSelectItem[], id: ItemId): WithIdContent | undefined;
6
+ /** Айтем для выбранного значения, которого нет в `items` (кастомная опция, ленивая подгрузка). */
7
+ export declare function createPlaceholderItem(id: ItemId): FieldSelectItem;
8
+ /** Выбранные id, которых нет в дереве айтемов — в дроплист их нужно добавить отдельно. */
9
+ export declare function getMissingSelectedItems(selectedIds: ItemId[], allItems: FieldSelectItem[]): FieldSelectItem[];
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.flatten = flatten;
4
+ exports.findItem = findItem;
5
+ exports.createPlaceholderItem = createPlaceholderItem;
6
+ exports.getMissingSelectedItems = getMissingSelectedItems;
7
+ function flatten(items) {
8
+ const out = [];
9
+ for (const item of items) {
10
+ out.push(item);
11
+ if ('items' in item && Array.isArray(item.items)) {
12
+ out.push(...flatten(item.items));
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+ function findItem(items, id) {
18
+ for (const item of flatten(items)) {
19
+ if (item.id === id) {
20
+ return item;
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+ /** Айтем для выбранного значения, которого нет в `items` (кастомная опция, ленивая подгрузка). */
26
+ function createPlaceholderItem(id) {
27
+ return { id, content: { label: String(id) } };
28
+ }
29
+ /** Выбранные id, которых нет в дереве айтемов — в дроплист их нужно добавить отдельно. */
30
+ function getMissingSelectedItems(selectedIds, allItems) {
31
+ if (selectedIds.length === 0) {
32
+ return [];
33
+ }
34
+ const existingIds = new Set();
35
+ for (const item of flatten(allItems)) {
36
+ if (item.id !== undefined) {
37
+ existingIds.add(item.id);
38
+ }
39
+ }
40
+ const missing = [];
41
+ for (const id of selectedIds) {
42
+ if (id !== '' && !existingIds.has(id)) {
43
+ missing.push(createPlaceholderItem(id));
44
+ }
45
+ }
46
+ return missing;
47
+ }
@@ -0,0 +1,11 @@
1
+ import { ItemId } from '@cloud-ru/ds-list';
2
+ import { FieldSelectMultipleProps, Selection } from '../types';
3
+ export declare function isMultiple(props: {
4
+ selection?: Selection;
5
+ }): props is FieldSelectMultipleProps;
6
+ /** Выбранные id в нормализованном виде: массив и для `single`, и для `multiple`. */
7
+ export declare function getSelectedIds(params: {
8
+ multiple: boolean;
9
+ multipleValue: ItemId[];
10
+ singleValue: ItemId | undefined;
11
+ }): ItemId[];
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isMultiple = isMultiple;
4
+ exports.getSelectedIds = getSelectedIds;
5
+ const constants_1 = require("../constants");
6
+ function isMultiple(props) {
7
+ return props.selection === constants_1.SELECTION_MODE.Multiple;
8
+ }
9
+ /** Выбранные id в нормализованном виде: массив и для `single`, и для `multiple`. */
10
+ function getSelectedIds(params) {
11
+ if (params.multiple) {
12
+ return params.multipleValue;
13
+ }
14
+ if (params.singleValue === undefined) {
15
+ return [];
16
+ }
17
+ return [params.singleValue];
18
+ }
@@ -0,0 +1,4 @@
1
+ import { Size } from '@cloud-ru/ds-field-decorator';
2
+ import { Size as TagSize } from '@cloud-ru/ds-tag';
3
+ /** Соответствие размера поля размеру чипа выбранного значения (`selection='multiple'`). */
4
+ export declare const TAG_SIZE_MAP: Record<Size, TagSize>;
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TAG_SIZE_MAP = void 0;
4
+ /** Соответствие размера поля размеру чипа выбранного значения (`selection='multiple'`). */
5
+ exports.TAG_SIZE_MAP = { s: 'xs', m: 'xs', l: 's' };
@@ -3,7 +3,7 @@ import { ChevronDownSVG } from '@cloud-ru/ds-icons/interface/system';
3
3
  import { InputPrivate, useButtonNavigation, useClearButton } from '@cloud-ru/ds-input-private';
4
4
  import { Droplist } from '@cloud-ru/ds-list';
5
5
  import { Tag } from '@cloud-ru/ds-tag';
6
- import { useLayoutEffect } from '@cloud-ru/ds-utils';
6
+ import { isBrowser, useLayoutEffect } from '@cloud-ru/ds-utils';
7
7
  import cn from 'classnames';
8
8
  import mergeRefs from 'merge-refs';
9
9
  import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
@@ -14,18 +14,22 @@ import { TEST_IDS } from '../../constants.js';
14
14
  import { useAdaptiveAutoFocus } from '../../hooks/index.js';
15
15
  import { copyTextToClipboard, getAcrylicProps, preventSlotMouseDown, stopSlotClickPropagation, toInputSize, useCopyButton, } from '../shared/index.js';
16
16
  import fieldStyles from '../shared/styles.module.css';
17
+ import { useCustomOption } from './hooks.js';
17
18
  import styles from './styles.module.css';
18
- import { extractAppearance, extractLabel, filterItems, findItem, flatten, isMultiple, TAG_SIZE_MAP, } from './utils.js';
19
+ import { extractAppearance, extractLabel, filterItems, findItem, flatten, getMissingSelectedItems, getSelectedIds, isMultiple, TAG_SIZE_MAP, } from './utils/index.js';
19
20
  export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
20
21
  const { label = '', labelTooltip, caption, hint, error, validationState = VALIDATION_STATE.Default, showHintIcon, length, required, size = SIZE.M, className, fieldClassName, items, pinTop, pinBottom, placeholder = '', iconBefore, prefix, postfix, disabled, readonly: readOnly, background = true, open: openProp, onOpenChange, placement, widthStrategy = 'eq', searchable = true, search, autocomplete = false, addOptionByEnter = false, resetSearchOnOptionSelection = true, showClearButton = true, showCopyButton = true, onCopyButtonClick, id, name, autoFocus, layoutPresets, onFocus: onFocusProp, onBlur: onBlurProp, enableFuzzySearch = true, selectedOptionFormatter, footer, footerActiveElementsRefs, loading, noDataState, noResultsState, errorDataState, dataError, dataFiltered, virtualized, scrollToSelectedItem, limitedScrollHeight, untouchableScrollbars, closeOnPopstate, onKeyDown: onKeyDownProp, 'data-test-id': dataTestId = TEST_IDS.fieldSelect, } = props;
21
22
  const multiple = isMultiple(props);
22
23
  const chips = multiple ? (props.chips ?? true) : false;
23
24
  const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
24
25
  const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
26
+ const allowCustomOption = props.allowCustomOption === true;
27
+ const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
25
28
  const resolvedAutoFocus = useAdaptiveAutoFocus(autoFocus, layoutPresets);
26
29
  const inputRef = useRef(null);
27
30
  // Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
28
31
  const inputElementRef = useRef(null);
32
+ const restoreInputFocusRef = useRef(false);
29
33
  const clearButtonRef = useRef(null);
30
34
  const copyButtonRef = useRef(null);
31
35
  const contentRef = useRef(null);
@@ -67,16 +71,20 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
67
71
  }, [multiple, props.value, multipleLocal]);
68
72
  const effectiveValidationState = useMemo(() => (error ? VALIDATION_STATE.Error : validationState), [error, validationState]);
69
73
  const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
74
+ const selectedIds = useMemo(() => getSelectedIds({ multiple, multipleValue, singleValue }), [multiple, multipleValue, singleValue]);
75
+ // Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
76
+ const missingSelectedItems = useMemo(() => getMissingSelectedItems(selectedIds, allItems), [selectedIds, allItems]);
77
+ const itemsWithPlaceholders = useMemo(() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items), [missingSelectedItems, items]);
70
78
  // Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
71
79
  const seenItems = useRef(new Map());
72
80
  const resolveItem = useMemo(() => {
73
- for (const item of flatten(allItems)) {
81
+ for (const item of flatten([...missingSelectedItems, ...allItems])) {
74
82
  if (item.id !== undefined) {
75
83
  seenItems.current.set(item.id, item);
76
84
  }
77
85
  }
78
- return (id) => findItem(allItems, id) ?? seenItems.current.get(id);
79
- }, [allItems]);
86
+ return (id) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
87
+ }, [allItems, missingSelectedItems]);
80
88
  const selectedPairs = useMemo(() => {
81
89
  if (!multiple || multipleValue.length === 0) {
82
90
  return [];
@@ -131,13 +139,13 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
131
139
  syncedLabelRef.current = selectedLabel;
132
140
  setInputValue(selectedLabel);
133
141
  }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
134
- // При autocomplete фильтрует сервер — items берутся как есть.
142
+ // При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
135
143
  const filteredItems = useMemo(() => {
136
144
  if (!searchable || autocomplete || !typing) {
137
- return items;
145
+ return itemsWithPlaceholders;
138
146
  }
139
- return filterItems(items, inputValue, enableFuzzySearch);
140
- }, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
147
+ return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
148
+ }, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
141
149
  const filteredPinTop = useMemo(() => {
142
150
  if (!searchable || autocomplete || !typing || !pinTop) {
143
151
  return pinTop;
@@ -150,22 +158,7 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
150
158
  }
151
159
  return filterItems(pinBottom, inputValue, enableFuzzySearch);
152
160
  }, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
153
- const handleOpenChange = useCallback((next) => {
154
- // readonly/disabled поле открывать нельзя, закрывать — можно.
155
- if (next && (disabled || readOnly))
156
- return;
157
- if (openProp === undefined) {
158
- setOpenLocal(next);
159
- }
160
- onOpenChange?.(next);
161
- if (!next) {
162
- setTyping(false);
163
- if (resetSearchOnOptionSelection) {
164
- setInputValue(selectedLabel);
165
- }
166
- }
167
- }, [openProp, onOpenChange, disabled, readOnly, resetSearchOnOptionSelection, setInputValue, selectedLabel]);
168
- const handleSingleChange = (next) => {
161
+ const handleSingleChange = useCallback((next) => {
169
162
  if (multiple) {
170
163
  return;
171
164
  }
@@ -174,11 +167,12 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
174
167
  }
175
168
  props.onChange?.(next);
176
169
  setTyping(false);
177
- };
178
- const handleMultipleChange = (next) => {
170
+ }, [multiple, props]);
171
+ const handleMultipleChange = useCallback((next) => {
179
172
  if (!multiple) {
180
173
  return;
181
174
  }
175
+ restoreInputFocusRef.current = isBrowser() && inputElementRef.current === document.activeElement;
182
176
  if (props.value === undefined) {
183
177
  setMultipleLocal(next);
184
178
  }
@@ -189,8 +183,57 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
189
183
  setInputValue('');
190
184
  }
191
185
  }
192
- };
186
+ }, [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue]);
187
+ const resetCustomOptionSearch = useCallback(() => {
188
+ setTyping(false);
189
+ if (resetSearchOnOptionSelection) {
190
+ setInputValue(multiple ? '' : selectedLabel);
191
+ }
192
+ }, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
193
+ const { listRef, suppressCloseCommit, enableCloseCommit, tryCommitOnClose, tryCommitOnBlur, tryCommitFromKeyboard, handleListSingleChange, handleListMultipleChange, } = useCustomOption({
194
+ allowCustomOption,
195
+ addCustomOptionTriggers,
196
+ addOptionByEnter,
197
+ multiple,
198
+ inputValue,
199
+ typing,
200
+ multipleValue,
201
+ commitSingle: handleSingleChange,
202
+ commitMultiple: handleMultipleChange,
203
+ resetSearch: resetCustomOptionSearch,
204
+ inputElementRef,
205
+ clearButtonRef,
206
+ copyButtonRef,
207
+ footerActiveElementsRefs,
208
+ });
209
+ const handleOpenChange = useCallback((next) => {
210
+ // readonly/disabled поле открывать нельзя, закрывать — можно.
211
+ if (next && (disabled || readOnly))
212
+ return;
213
+ if (openProp === undefined) {
214
+ setOpenLocal(next);
215
+ }
216
+ onOpenChange?.(next);
217
+ if (!next) {
218
+ setTyping(false);
219
+ const committed = tryCommitOnClose();
220
+ // Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
221
+ if (resetSearchOnOptionSelection && !committed) {
222
+ setInputValue(selectedLabel);
223
+ }
224
+ }
225
+ }, [
226
+ openProp,
227
+ onOpenChange,
228
+ disabled,
229
+ readOnly,
230
+ resetSearchOnOptionSelection,
231
+ setInputValue,
232
+ selectedLabel,
233
+ tryCommitOnClose,
234
+ ]);
193
235
  const handleInputChange = (next) => {
236
+ enableCloseCommit();
194
237
  setInputValue(next);
195
238
  setTyping(true);
196
239
  if (!open) {
@@ -289,19 +332,12 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
289
332
  }
290
333
  if (event.key === 'Escape' && open) {
291
334
  event.preventDefault();
335
+ suppressCloseCommit();
292
336
  handleOpenChange(false);
293
337
  }
294
- if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
295
- // Введённый текст становится новым значением (создание опции «на лету»).
338
+ if (tryCommitFromKeyboard(event)) {
296
339
  event.preventDefault();
297
- if (multiple) {
298
- if (!multipleValue.includes(inputValue)) {
299
- handleMultipleChange([...multipleValue, inputValue]);
300
- }
301
- }
302
- else {
303
- handleSingleChange(inputValue);
304
- }
340
+ event.stopPropagation();
305
341
  return;
306
342
  }
307
343
  if (event.key === 'Enter' && !open) {
@@ -314,6 +350,11 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
314
350
  removeChip(last);
315
351
  }
316
352
  };
353
+ const handleTriggerKeyDownCapture = (event) => {
354
+ if (event.key === 'Escape') {
355
+ suppressCloseCommit();
356
+ }
357
+ };
317
358
  const handleTriggerMouseEnter = useCallback(() => {
318
359
  if (!readOnly && !disabled) {
319
360
  setHover(true);
@@ -338,14 +379,28 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
338
379
  }
339
380
  onFocusProp?.(event);
340
381
  }, [searchable, onFocusProp]);
341
- const handleInputBlur = useCallback((event) => {
382
+ const handleInputBlur = (event) => {
342
383
  setFocusVisible(false);
384
+ const blurResult = tryCommitOnBlur(event);
385
+ // Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
386
+ // Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
387
+ if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
388
+ setTyping(false);
389
+ setInputValue(selectedLabel);
390
+ }
343
391
  onBlurProp?.(event);
344
- }, [onBlurProp]);
392
+ };
345
393
  const droplistSelection = multiple
346
- ? { mode: 'multiple', value: multipleValue, onChange: handleMultipleChange }
347
- : { mode: 'single', value: singleValue, onChange: handleSingleChange };
394
+ ? { mode: 'multiple', value: multipleValue, onChange: handleListMultipleChange }
395
+ : { mode: 'single', value: singleValue, onChange: handleListSingleChange };
348
396
  const hasChips = chips && selectedPairs.length > 0;
397
+ useLayoutEffect(() => {
398
+ if (!restoreInputFocusRef.current) {
399
+ return;
400
+ }
401
+ restoreInputFocusRef.current = false;
402
+ inputElementRef.current?.focus();
403
+ }, [multipleValue.length]);
349
404
  // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
350
405
  // Замер до paint, иначе input мигает при вводе.
351
406
  useLayoutEffect(() => {
@@ -358,11 +413,11 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
358
413
  setChipInputMinWidth(Math.min(content, Math.max(plug, SEARCH_INPUT_PLUG_MIN_WIDTH)));
359
414
  }, [hasChips, inputValue, selectedPairs.length]);
360
415
  const searchInputField = (_jsx(InputPrivate, { ref: mergeRefs(ref, inputRef, inputElementRef), className: cn(fieldStyles.fieldInput, !searchable && styles.readonlyCursor), value: inputValue, onChange: searchable ? handleInputChange : undefined, placeholder: selectedLabel || hasChips ? '' : placeholder, disabled: disabled, readonly: !searchable || readOnly, id: id, name: name, autoFocus: resolvedAutoFocus, tabIndex: inputTabIndex, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleTriggerKeyDown, "data-test-id": TEST_IDS.fieldSelectInput }));
361
- const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField })) : (_jsx("div", { className: fieldStyles.inputArea, children: searchInputField }));
416
+ const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField }, 'search-input')) : (_jsx("div", { className: fieldStyles.inputArea, children: searchInputField }));
362
417
  const trigger = (
363
418
  // Focusable-элемент — InputPrivate внутри, обёртка только собирает combobox.
364
419
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
365
- _jsxs("div", { className: cn(fieldStyles.fieldWrapper, styles.trigger, fieldClassName), "data-size": size, "data-validation-state": effectiveValidationState, "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, "data-withbackground": background || undefined, "data-focusvisible": focusVisible || open || undefined, "data-hover": !readOnly && !disabled && hover ? true : undefined, "data-test-id": dataTestId, onMouseEnter: handleTriggerMouseEnter, onMouseLeave: handleTriggerMouseLeave, onClick: handleTriggerClick, children: [_jsxs("div", { className: fieldStyles.backgroundWrapper, children: [_jsx("div", { className: fieldStyles.materialLayer, ...getAcrylicProps({
420
+ _jsxs("div", { className: cn(fieldStyles.fieldWrapper, styles.trigger, fieldClassName), "data-size": size, "data-validation-state": effectiveValidationState, "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, "data-withbackground": background || undefined, "data-focusvisible": focusVisible || open || undefined, "data-hover": !readOnly && !disabled && hover ? true : undefined, "data-test-id": dataTestId, onMouseEnter: handleTriggerMouseEnter, onMouseLeave: handleTriggerMouseLeave, onKeyDownCapture: handleTriggerKeyDownCapture, onClick: handleTriggerClick, children: [_jsxs("div", { className: fieldStyles.backgroundWrapper, children: [_jsx("div", { className: fieldStyles.materialLayer, ...getAcrylicProps({
366
421
  validationState: effectiveValidationState,
367
422
  disabled,
368
423
  readonly: readOnly,
@@ -371,5 +426,5 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
371
426
  }), children: _jsx("div", { className: fieldStyles.acrylicBg, "aria-hidden": true }) }), _jsx("div", { className: fieldStyles.borderStateLayer, "data-state": 'borderOnBackground' }), _jsx("div", { className: fieldStyles.focusLayer })] }), _jsx("div", { className: fieldStyles.fieldContainer, children: _jsxs("div", { className: fieldStyles.contentWrapper, children: [iconBefore && _jsx("div", { className: fieldStyles.iconSlot, children: iconBefore }), _jsxs("div", { className: fieldStyles.inputLine, children: [prefix && _jsx("span", { className: fieldStyles.prefix, children: prefix }), hasChips ? (_jsxs("div", { className: styles.chipsRow, ref: contentRef, "data-test-id": TEST_IDS.fieldSelectChips, children: [selectedPairs.map(pair => (_jsx(Tag, { label: formatPair(pair), size: TAG_SIZE_MAP[size], appearance: pair.appearance ?? 'neutral', onDelete: disabled || readOnly || pair.disabled ? undefined : handleRemoveChip(pair.id) }, String(pair.id)))), searchInput, _jsx("span", { ref: inputPlugRef, className: styles.inputPlug, "aria-hidden": true, children: inputValue })] })) : (searchInput), postfixButtons && (
372
427
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
373
428
  _jsx("span", { className: fieldStyles.postfixButtonsSlot, onMouseDown: preventSlotMouseDown, onClick: stopSlotClickPropagation, children: postfixButtons })), postfix && _jsx("span", { className: fieldStyles.postfix, children: postfix })] }), _jsx("div", { className: fieldStyles.iconSlot, children: _jsx("span", { className: styles.chevron, "data-open": open || undefined, "aria-hidden": true, children: _jsx(ChevronDownSVG, {}) }) })] }) })] }));
374
- return (_jsx(FieldDecorator, { className: className, label: label, labelTooltip: labelTooltip, caption: caption, hint: hint, error: error, size: size, validationState: validationState, showHintIcon: showHintIcon, length: length, required: required, labelFor: id, disabled: disabled, readonly: readOnly, children: _jsx(Droplist, { items: filteredItems, label: label, pinTop: filteredPinTop, pinBottom: filteredPinBottom, trigger: 'click', placement: placement, widthStrategy: widthStrategy, triggerElemRef: inputRef, size: size, open: open, onOpenChange: handleOpenChange, selection: droplistSelection, closeDroplistOnItemClick: closeDroplistOnItemClick, footer: footer, footerActiveElementsRefs: footerActiveElementsRefs, loading: loading, noDataState: noDataState, noResultsState: noResultsState, errorDataState: errorDataState, dataError: dataError, dataFiltered: dataFiltered ?? (searchable && typing && inputValue !== ''), virtualized: virtualized, scroll: true, scrollToSelectedItem: scrollToSelectedItem, limitedScrollHeight: limitedScrollHeight, untouchableScrollbars: untouchableScrollbars, closeOnPopstate: closeOnPopstate, children: trigger }) }));
429
+ return (_jsx(FieldDecorator, { className: className, label: label, labelTooltip: labelTooltip, caption: caption, hint: hint, error: error, size: size, validationState: validationState, showHintIcon: showHintIcon, length: length, required: required, labelFor: id, disabled: disabled, readonly: readOnly, children: _jsx(Droplist, { items: filteredItems, label: label, pinTop: filteredPinTop, pinBottom: filteredPinBottom, trigger: 'click', placement: placement, widthStrategy: widthStrategy, triggerElemRef: inputRef, listRef: listRef, size: size, open: open, onOpenChange: handleOpenChange, selection: droplistSelection, closeDroplistOnItemClick: closeDroplistOnItemClick, footer: footer, footerActiveElementsRefs: footerActiveElementsRefs, loading: loading, noDataState: noDataState, noResultsState: noResultsState, errorDataState: errorDataState, dataError: dataError, dataFiltered: dataFiltered ?? (searchable && typing && inputValue !== ''), virtualized: virtualized, scroll: true, scrollToSelectedItem: scrollToSelectedItem, limitedScrollHeight: limitedScrollHeight, untouchableScrollbars: untouchableScrollbars, closeOnPopstate: closeOnPopstate, children: trigger }) }));
375
430
  });
@@ -2,3 +2,14 @@ export declare const SELECTION_MODE: {
2
2
  readonly Single: "single";
3
3
  readonly Multiple: "multiple";
4
4
  };
5
+ /** События, по которым произвольный ввод фиксируется как выбранная опция. */
6
+ export declare const ADD_CUSTOM_OPTION_TRIGGER: {
7
+ readonly Enter: "enter";
8
+ readonly Blur: "blur";
9
+ readonly Space: "space";
10
+ readonly Comma: "comma";
11
+ };
12
+ /** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
13
+ export declare const ADD_CUSTOM_OPTION_TRIGGERS_SINGLE: readonly ["enter", "blur"];
14
+ /** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
15
+ export declare const ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE: readonly ["enter", "blur", "space", "comma"];
@@ -2,3 +2,22 @@ export const SELECTION_MODE = {
2
2
  Single: 'single',
3
3
  Multiple: 'multiple',
4
4
  };
5
+ /** События, по которым произвольный ввод фиксируется как выбранная опция. */
6
+ export const ADD_CUSTOM_OPTION_TRIGGER = {
7
+ Enter: 'enter',
8
+ Blur: 'blur',
9
+ Space: 'space',
10
+ Comma: 'comma',
11
+ };
12
+ /** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
13
+ export const ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = [
14
+ ADD_CUSTOM_OPTION_TRIGGER.Enter,
15
+ ADD_CUSTOM_OPTION_TRIGGER.Blur,
16
+ ];
17
+ /** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
18
+ export const ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = [
19
+ ADD_CUSTOM_OPTION_TRIGGER.Enter,
20
+ ADD_CUSTOM_OPTION_TRIGGER.Blur,
21
+ ADD_CUSTOM_OPTION_TRIGGER.Space,
22
+ ADD_CUSTOM_OPTION_TRIGGER.Comma,
23
+ ];
@@ -0,0 +1,33 @@
1
+ import { ItemId } from '@cloud-ru/ds-list';
2
+ import { FocusEvent, KeyboardEvent, RefObject } from 'react';
3
+ import { FieldSelectAddCustomOptionTrigger } from './types.js';
4
+ /** Результат blur: фокус остался внутри поля, ввод зафиксирован, или коммитить было нечего. */
5
+ type CustomOptionBlurResult = 'inside' | 'committed' | 'skipped';
6
+ type UseCustomOptionParams = {
7
+ allowCustomOption: boolean;
8
+ addCustomOptionTriggers: readonly FieldSelectAddCustomOptionTrigger[] | undefined;
9
+ addOptionByEnter: boolean;
10
+ multiple: boolean;
11
+ inputValue: string;
12
+ typing: boolean;
13
+ multipleValue: ItemId[];
14
+ commitSingle(value: ItemId | undefined): void;
15
+ commitMultiple(value: ItemId[]): void;
16
+ resetSearch(): void;
17
+ inputElementRef: RefObject<HTMLInputElement | null>;
18
+ clearButtonRef: RefObject<HTMLButtonElement | null>;
19
+ copyButtonRef: RefObject<HTMLButtonElement | null>;
20
+ footerActiveElementsRefs?: RefObject<HTMLElement>[];
21
+ };
22
+ /** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
23
+ export declare function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, typing, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }: UseCustomOptionParams): {
24
+ listRef: RefObject<HTMLElement>;
25
+ suppressCloseCommit: () => void;
26
+ enableCloseCommit: () => void;
27
+ tryCommitOnClose: () => boolean;
28
+ tryCommitOnBlur: (event: FocusEvent<HTMLInputElement>) => CustomOptionBlurResult;
29
+ tryCommitFromKeyboard: (event: KeyboardEvent<HTMLInputElement>) => boolean;
30
+ handleListSingleChange: (next: ItemId | undefined) => void;
31
+ handleListMultipleChange: (next: ItemId[]) => void;
32
+ };
33
+ export {};
@@ -0,0 +1,92 @@
1
+ import { isBrowser } from '@cloud-ru/ds-utils';
2
+ import { useCallback, useMemo, useRef } from 'react';
3
+ import { ADD_CUSTOM_OPTION_TRIGGER, ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE, ADD_CUSTOM_OPTION_TRIGGERS_SINGLE, } from './constants.js';
4
+ import { getCustomOptionTriggerByCode, isCustomOptionBlurInsideField, resolveAddCustomOptionTriggers, shouldCommitCustomOptionOnClose, shouldHandleCustomOptionTrigger, } from './utils/index.js';
5
+ /** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
6
+ export function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, typing, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }) {
7
+ const listRef = useRef(null);
8
+ const skipCloseCommitRef = useRef(false);
9
+ const skipBlurCommitRef = useRef(false);
10
+ const resolvedTriggers = useMemo(() => resolveAddCustomOptionTriggers({
11
+ allowCustomOption,
12
+ addCustomOptionTriggers,
13
+ addOptionByEnter,
14
+ defaultTriggers: multiple ? ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
15
+ }), [allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple]);
16
+ const tryCommit = useCallback((trigger) => {
17
+ if (!typing || !shouldHandleCustomOptionTrigger(trigger, resolvedTriggers) || inputValue === '') {
18
+ return false;
19
+ }
20
+ if (multiple) {
21
+ if (multipleValue.includes(inputValue)) {
22
+ resetSearch();
23
+ }
24
+ else {
25
+ commitMultiple([...multipleValue, inputValue]);
26
+ }
27
+ }
28
+ else {
29
+ commitSingle(inputValue);
30
+ }
31
+ return true;
32
+ }, [typing, resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch]);
33
+ const suppressCloseCommit = useCallback(() => {
34
+ skipCloseCommitRef.current = true;
35
+ skipBlurCommitRef.current = true;
36
+ }, []);
37
+ const enableCloseCommit = useCallback(() => {
38
+ skipCloseCommitRef.current = false;
39
+ skipBlurCommitRef.current = false;
40
+ }, []);
41
+ const tryCommitOnClose = useCallback(() => {
42
+ const skipped = skipCloseCommitRef.current;
43
+ const shouldCommit = shouldCommitCustomOptionOnClose({
44
+ skip: skipped,
45
+ inputElement: inputElementRef.current,
46
+ activeElement: isBrowser() ? document.activeElement : null,
47
+ });
48
+ if (skipped) {
49
+ queueMicrotask(() => {
50
+ skipCloseCommitRef.current = false;
51
+ });
52
+ }
53
+ return shouldCommit && tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur);
54
+ }, [inputElementRef, tryCommit]);
55
+ const tryCommitOnBlur = useCallback((event) => {
56
+ if (skipBlurCommitRef.current) {
57
+ skipBlurCommitRef.current = false;
58
+ return 'skipped';
59
+ }
60
+ if (isCustomOptionBlurInsideField(event.relatedTarget, [
61
+ listRef.current,
62
+ clearButtonRef.current,
63
+ copyButtonRef.current,
64
+ ...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
65
+ ])) {
66
+ return 'inside';
67
+ }
68
+ return tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
69
+ }, [clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit]);
70
+ const tryCommitFromKeyboard = useCallback((event) => {
71
+ const trigger = getCustomOptionTriggerByCode(event.code, event.key);
72
+ return trigger !== undefined && tryCommit(trigger);
73
+ }, [tryCommit]);
74
+ const handleListSingleChange = useCallback((next) => {
75
+ skipCloseCommitRef.current = true;
76
+ commitSingle(next);
77
+ }, [commitSingle]);
78
+ const handleListMultipleChange = useCallback((next) => {
79
+ skipCloseCommitRef.current = true;
80
+ commitMultiple(next);
81
+ }, [commitMultiple]);
82
+ return {
83
+ listRef,
84
+ suppressCloseCommit,
85
+ enableCloseCommit,
86
+ tryCommitOnClose,
87
+ tryCommitOnBlur,
88
+ tryCommitFromKeyboard,
89
+ handleListSingleChange,
90
+ handleListMultipleChange,
91
+ };
92
+ }