@cloud-ru/ds-fields 2.2.3-preview-a0540d7f.0 → 2.2.3

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 (61) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -3
  3. package/dist/cjs/components/FieldSelect/FieldSelect.js +43 -98
  4. package/dist/cjs/components/FieldSelect/constants.d.ts +0 -11
  5. package/dist/cjs/components/FieldSelect/constants.js +1 -20
  6. package/dist/cjs/components/FieldSelect/types.d.ts +3 -26
  7. package/dist/cjs/components/FieldSelect/utils.d.ts +19 -0
  8. package/dist/cjs/components/FieldSelect/utils.js +103 -0
  9. package/dist/esm/components/FieldSelect/FieldSelect.js +45 -100
  10. package/dist/esm/components/FieldSelect/constants.d.ts +0 -11
  11. package/dist/esm/components/FieldSelect/constants.js +0 -19
  12. package/dist/esm/components/FieldSelect/types.d.ts +3 -26
  13. package/dist/esm/components/FieldSelect/utils.d.ts +19 -0
  14. package/dist/esm/components/FieldSelect/utils.js +92 -0
  15. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  16. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  17. package/package.json +20 -20
  18. package/src/components/FieldSelect/FieldSelect.tsx +63 -151
  19. package/src/components/FieldSelect/constants.ts +0 -22
  20. package/src/components/FieldSelect/types.ts +48 -82
  21. package/src/components/FieldSelect/utils.ts +132 -0
  22. package/dist/cjs/components/FieldSelect/hooks.d.ts +0 -33
  23. package/dist/cjs/components/FieldSelect/hooks.js +0 -95
  24. package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +0 -21
  25. package/dist/cjs/components/FieldSelect/utils/customOption.js +0 -55
  26. package/dist/cjs/components/FieldSelect/utils/extract.d.ts +0 -11
  27. package/dist/cjs/components/FieldSelect/utils/extract.js +0 -39
  28. package/dist/cjs/components/FieldSelect/utils/filter.d.ts +0 -3
  29. package/dist/cjs/components/FieldSelect/utils/filter.js +0 -40
  30. package/dist/cjs/components/FieldSelect/utils/index.d.ts +0 -6
  31. package/dist/cjs/components/FieldSelect/utils/index.js +0 -22
  32. package/dist/cjs/components/FieldSelect/utils/items.d.ts +0 -9
  33. package/dist/cjs/components/FieldSelect/utils/items.js +0 -47
  34. package/dist/cjs/components/FieldSelect/utils/selection.d.ts +0 -11
  35. package/dist/cjs/components/FieldSelect/utils/selection.js +0 -18
  36. package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +0 -4
  37. package/dist/cjs/components/FieldSelect/utils/tagSize.js +0 -5
  38. package/dist/esm/components/FieldSelect/hooks.d.ts +0 -33
  39. package/dist/esm/components/FieldSelect/hooks.js +0 -92
  40. package/dist/esm/components/FieldSelect/utils/customOption.d.ts +0 -21
  41. package/dist/esm/components/FieldSelect/utils/customOption.js +0 -48
  42. package/dist/esm/components/FieldSelect/utils/extract.d.ts +0 -11
  43. package/dist/esm/components/FieldSelect/utils/extract.js +0 -34
  44. package/dist/esm/components/FieldSelect/utils/filter.d.ts +0 -3
  45. package/dist/esm/components/FieldSelect/utils/filter.js +0 -36
  46. package/dist/esm/components/FieldSelect/utils/index.d.ts +0 -6
  47. package/dist/esm/components/FieldSelect/utils/index.js +0 -6
  48. package/dist/esm/components/FieldSelect/utils/items.d.ts +0 -9
  49. package/dist/esm/components/FieldSelect/utils/items.js +0 -41
  50. package/dist/esm/components/FieldSelect/utils/selection.d.ts +0 -11
  51. package/dist/esm/components/FieldSelect/utils/selection.js +0 -14
  52. package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +0 -4
  53. package/dist/esm/components/FieldSelect/utils/tagSize.js +0 -2
  54. package/src/components/FieldSelect/hooks.ts +0 -177
  55. package/src/components/FieldSelect/utils/customOption.ts +0 -77
  56. package/src/components/FieldSelect/utils/extract.ts +0 -49
  57. package/src/components/FieldSelect/utils/filter.ts +0 -51
  58. package/src/components/FieldSelect/utils/index.ts +0 -6
  59. package/src/components/FieldSelect/utils/items.ts +0 -58
  60. package/src/components/FieldSelect/utils/selection.ts +0 -25
  61. package/src/components/FieldSelect/utils/tagSize.ts +0 -5
package/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## 2.2.3 (2026-09-14)
7
+
8
+ **Note:** Version bump only for package @ds/fields
9
+
6
10
  ## 2.2.2 (2026-09-10)
7
11
 
8
12
  ### Bug Fixes
package/README.md CHANGED
@@ -435,9 +435,7 @@ export function Select() {
435
435
 
436
436
  | Prop | Type | Default | Description |
437
437
  |------|------|---------|-------------|
438
- | `addCustomOptionTriggers` | `("enter" \| "blur")[] \| ("enter" \| "blur" \| "space" \| "comma")[]` | — | События, по которым произвольный ввод фиксируется как выбранная опция. <br/> Учитывается только при `allowCustomOption={true}`. <br/> Если не задан — используются все триггеры режима (`single`: enter, blur; `multiple`: enter, blur, space, comma). <br/> Триггеры, недоступные в текущем `selection`, игнорируются. |
439
- | `addOptionByEnter` | `boolean` | `false` | Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»). <br/> @deprecated Используйте `allowCustomOption`. `true` эквивалентен `allowCustomOption` с триггером `enter`. |
440
- | `allowCustomOption` | `boolean` | `false` | Разрешить фиксировать произвольный ввод из строки поиска как выбранную опцию <br/> (значение, которого нет в `items`). |
438
+ | `addOptionByEnter` | `boolean` | `false` | Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»). |
441
439
  | `autoFocus` | `boolean` | — | Автофокус input при монтировании. На mobile выключается адаптивно (см. `layoutPresets`) |
442
440
  | `autocomplete` | `boolean` | `false` | Не фильтровать список на клиенте — фильтрацию обеспечивает потребитель (серверный поиск). <br/> Введённый текст уходит в `search.onChange`, список берётся из `items` как есть. |
443
441
  | `background` | `boolean` | `true` | Фон поля (acrylic) |
@@ -20,7 +20,6 @@ const constants_1 = require("../../constants");
20
20
  const hooks_1 = require("../../hooks");
21
21
  const shared_1 = require("../shared");
22
22
  const styles_module_scss_1 = __importDefault(require('../shared/styles.module.css'));
23
- const hooks_2 = require("./hooks");
24
23
  const styles_module_scss_2 = __importDefault(require('./styles.module.css'));
25
24
  const utils_2 = require("./utils");
26
25
  exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
@@ -29,13 +28,10 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
29
28
  const chips = multiple ? (props.chips ?? true) : false;
30
29
  const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
31
30
  const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
32
- const allowCustomOption = props.allowCustomOption === true;
33
- const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
34
31
  const resolvedAutoFocus = (0, hooks_1.useAdaptiveAutoFocus)(autoFocus, layoutPresets);
35
32
  const inputRef = (0, react_1.useRef)(null);
36
33
  // Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
37
34
  const inputElementRef = (0, react_1.useRef)(null);
38
- const restoreInputFocusRef = (0, react_1.useRef)(false);
39
35
  const clearButtonRef = (0, react_1.useRef)(null);
40
36
  const copyButtonRef = (0, react_1.useRef)(null);
41
37
  const contentRef = (0, react_1.useRef)(null);
@@ -77,20 +73,16 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
77
73
  }, [multiple, props.value, multipleLocal]);
78
74
  const effectiveValidationState = (0, react_1.useMemo)(() => (error ? field_decorator_1.VALIDATION_STATE.Error : validationState), [error, validationState]);
79
75
  const allItems = (0, react_1.useMemo)(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
80
- const selectedIds = (0, react_1.useMemo)(() => (0, utils_2.getSelectedIds)({ multiple, multipleValue, singleValue }), [multiple, multipleValue, singleValue]);
81
- // Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
82
- const missingSelectedItems = (0, react_1.useMemo)(() => (0, utils_2.getMissingSelectedItems)(selectedIds, allItems), [selectedIds, allItems]);
83
- const itemsWithPlaceholders = (0, react_1.useMemo)(() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items), [missingSelectedItems, items]);
84
76
  // Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
85
77
  const seenItems = (0, react_1.useRef)(new Map());
86
78
  const resolveItem = (0, react_1.useMemo)(() => {
87
- for (const item of (0, utils_2.flatten)([...missingSelectedItems, ...allItems])) {
79
+ for (const item of (0, utils_2.flatten)(allItems)) {
88
80
  if (item.id !== undefined) {
89
81
  seenItems.current.set(item.id, item);
90
82
  }
91
83
  }
92
- return (id) => (0, utils_2.findItem)(allItems, id) ?? (0, utils_2.findItem)(missingSelectedItems, id) ?? seenItems.current.get(id);
93
- }, [allItems, missingSelectedItems]);
84
+ return (id) => (0, utils_2.findItem)(allItems, id) ?? seenItems.current.get(id);
85
+ }, [allItems]);
94
86
  const selectedPairs = (0, react_1.useMemo)(() => {
95
87
  if (!multiple || multipleValue.length === 0) {
96
88
  return [];
@@ -145,13 +137,13 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
145
137
  syncedLabelRef.current = selectedLabel;
146
138
  setInputValue(selectedLabel);
147
139
  }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
148
- // При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
140
+ // При autocomplete фильтрует сервер — items берутся как есть.
149
141
  const filteredItems = (0, react_1.useMemo)(() => {
150
142
  if (!searchable || autocomplete || !typing) {
151
- return itemsWithPlaceholders;
143
+ return items;
152
144
  }
153
- return (0, utils_2.filterItems)(itemsWithPlaceholders, inputValue, enableFuzzySearch);
154
- }, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
145
+ return (0, utils_2.filterItems)(items, inputValue, enableFuzzySearch);
146
+ }, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
155
147
  const filteredPinTop = (0, react_1.useMemo)(() => {
156
148
  if (!searchable || autocomplete || !typing || !pinTop) {
157
149
  return pinTop;
@@ -164,7 +156,22 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
164
156
  }
165
157
  return (0, utils_2.filterItems)(pinBottom, inputValue, enableFuzzySearch);
166
158
  }, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
167
- const handleSingleChange = (0, react_1.useCallback)((next) => {
159
+ const handleOpenChange = (0, react_1.useCallback)((next) => {
160
+ // readonly/disabled поле открывать нельзя, закрывать — можно.
161
+ if (next && (disabled || readOnly))
162
+ return;
163
+ if (openProp === undefined) {
164
+ setOpenLocal(next);
165
+ }
166
+ onOpenChange?.(next);
167
+ if (!next) {
168
+ setTyping(false);
169
+ if (resetSearchOnOptionSelection) {
170
+ setInputValue(selectedLabel);
171
+ }
172
+ }
173
+ }, [openProp, onOpenChange, disabled, readOnly, resetSearchOnOptionSelection, setInputValue, selectedLabel]);
174
+ const handleSingleChange = (next) => {
168
175
  if (multiple) {
169
176
  return;
170
177
  }
@@ -173,12 +180,11 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
173
180
  }
174
181
  props.onChange?.(next);
175
182
  setTyping(false);
176
- }, [multiple, props]);
177
- const handleMultipleChange = (0, react_1.useCallback)((next) => {
183
+ };
184
+ const handleMultipleChange = (next) => {
178
185
  if (!multiple) {
179
186
  return;
180
187
  }
181
- restoreInputFocusRef.current = (0, utils_1.isBrowser)() && inputElementRef.current === document.activeElement;
182
188
  if (props.value === undefined) {
183
189
  setMultipleLocal(next);
184
190
  }
@@ -189,57 +195,8 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
189
195
  setInputValue('');
190
196
  }
191
197
  }
192
- }, [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue]);
193
- const resetCustomOptionSearch = (0, react_1.useCallback)(() => {
194
- setTyping(false);
195
- if (resetSearchOnOptionSelection) {
196
- setInputValue(multiple ? '' : selectedLabel);
197
- }
198
- }, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
199
- const { listRef, suppressCloseCommit, enableCloseCommit, tryCommitOnClose, tryCommitOnBlur, tryCommitFromKeyboard, handleListSingleChange, handleListMultipleChange, } = (0, hooks_2.useCustomOption)({
200
- allowCustomOption,
201
- addCustomOptionTriggers,
202
- addOptionByEnter,
203
- multiple,
204
- inputValue,
205
- typing,
206
- multipleValue,
207
- commitSingle: handleSingleChange,
208
- commitMultiple: handleMultipleChange,
209
- resetSearch: resetCustomOptionSearch,
210
- inputElementRef,
211
- clearButtonRef,
212
- copyButtonRef,
213
- footerActiveElementsRefs,
214
- });
215
- const handleOpenChange = (0, react_1.useCallback)((next) => {
216
- // readonly/disabled поле открывать нельзя, закрывать — можно.
217
- if (next && (disabled || readOnly))
218
- return;
219
- if (openProp === undefined) {
220
- setOpenLocal(next);
221
- }
222
- onOpenChange?.(next);
223
- if (!next) {
224
- setTyping(false);
225
- const committed = tryCommitOnClose();
226
- // Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
227
- if (resetSearchOnOptionSelection && !committed) {
228
- setInputValue(selectedLabel);
229
- }
230
- }
231
- }, [
232
- openProp,
233
- onOpenChange,
234
- disabled,
235
- readOnly,
236
- resetSearchOnOptionSelection,
237
- setInputValue,
238
- selectedLabel,
239
- tryCommitOnClose,
240
- ]);
198
+ };
241
199
  const handleInputChange = (next) => {
242
- enableCloseCommit();
243
200
  setInputValue(next);
244
201
  setTyping(true);
245
202
  if (!open) {
@@ -338,12 +295,19 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
338
295
  }
339
296
  if (event.key === 'Escape' && open) {
340
297
  event.preventDefault();
341
- suppressCloseCommit();
342
298
  handleOpenChange(false);
343
299
  }
344
- if (tryCommitFromKeyboard(event)) {
300
+ if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
301
+ // Введённый текст становится новым значением (создание опции «на лету»).
345
302
  event.preventDefault();
346
- event.stopPropagation();
303
+ if (multiple) {
304
+ if (!multipleValue.includes(inputValue)) {
305
+ handleMultipleChange([...multipleValue, inputValue]);
306
+ }
307
+ }
308
+ else {
309
+ handleSingleChange(inputValue);
310
+ }
347
311
  return;
348
312
  }
349
313
  if (event.key === 'Enter' && !open) {
@@ -356,11 +320,6 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
356
320
  removeChip(last);
357
321
  }
358
322
  };
359
- const handleTriggerKeyDownCapture = (event) => {
360
- if (event.key === 'Escape') {
361
- suppressCloseCommit();
362
- }
363
- };
364
323
  const handleTriggerMouseEnter = (0, react_1.useCallback)(() => {
365
324
  if (!readOnly && !disabled) {
366
325
  setHover(true);
@@ -385,28 +344,14 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
385
344
  }
386
345
  onFocusProp?.(event);
387
346
  }, [searchable, onFocusProp]);
388
- const handleInputBlur = (event) => {
347
+ const handleInputBlur = (0, react_1.useCallback)((event) => {
389
348
  setFocusVisible(false);
390
- const blurResult = tryCommitOnBlur(event);
391
- // Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
392
- // Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
393
- if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
394
- setTyping(false);
395
- setInputValue(selectedLabel);
396
- }
397
349
  onBlurProp?.(event);
398
- };
350
+ }, [onBlurProp]);
399
351
  const droplistSelection = multiple
400
- ? { mode: 'multiple', value: multipleValue, onChange: handleListMultipleChange }
401
- : { mode: 'single', value: singleValue, onChange: handleListSingleChange };
352
+ ? { mode: 'multiple', value: multipleValue, onChange: handleMultipleChange }
353
+ : { mode: 'single', value: singleValue, onChange: handleSingleChange };
402
354
  const hasChips = chips && selectedPairs.length > 0;
403
- (0, utils_1.useLayoutEffect)(() => {
404
- if (!restoreInputFocusRef.current) {
405
- return;
406
- }
407
- restoreInputFocusRef.current = false;
408
- inputElementRef.current?.focus();
409
- }, [multipleValue.length]);
410
355
  // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
411
356
  // Замер до paint, иначе input мигает при вводе.
412
357
  (0, utils_1.useLayoutEffect)(() => {
@@ -419,11 +364,11 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
419
364
  setChipInputMinWidth(Math.min(content, Math.max(plug, SEARCH_INPUT_PLUG_MIN_WIDTH)));
420
365
  }, [hasChips, inputValue, selectedPairs.length]);
421
366
  const searchInputField = ((0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, inputRef, inputElementRef), className: (0, classnames_1.default)(styles_module_scss_1.default.fieldInput, !searchable && styles_module_scss_2.default.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": constants_1.TEST_IDS.fieldSelectInput }));
422
- const searchInput = hasChips ? ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_2.default.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField }, 'search-input')) : ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputArea, children: searchInputField }));
367
+ const searchInput = hasChips ? ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_2.default.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField })) : ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputArea, children: searchInputField }));
423
368
  const trigger = (
424
369
  // Focusable-элемент — InputPrivate внутри, обёртка только собирает combobox.
425
370
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
426
- (0, jsx_runtime_1.jsxs)("div", { className: (0, classnames_1.default)(styles_module_scss_1.default.fieldWrapper, styles_module_scss_2.default.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: [(0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.backgroundWrapper, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.materialLayer, ...(0, shared_1.getAcrylicProps)({
371
+ (0, jsx_runtime_1.jsxs)("div", { className: (0, classnames_1.default)(styles_module_scss_1.default.fieldWrapper, styles_module_scss_2.default.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: [(0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.backgroundWrapper, children: [(0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.materialLayer, ...(0, shared_1.getAcrylicProps)({
427
372
  validationState: effectiveValidationState,
428
373
  disabled,
429
374
  readonly: readOnly,
@@ -432,5 +377,5 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
432
377
  }), children: (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.acrylicBg, "aria-hidden": true }) }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.borderStateLayer, "data-state": 'borderOnBackground' }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.focusLayer })] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.fieldContainer, children: (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.contentWrapper, children: [iconBefore && (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, children: iconBefore }), (0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_1.default.inputLine, children: [prefix && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.prefix, children: prefix }), hasChips ? ((0, jsx_runtime_1.jsxs)("div", { className: styles_module_scss_2.default.chipsRow, ref: contentRef, "data-test-id": constants_1.TEST_IDS.fieldSelectChips, children: [selectedPairs.map(pair => ((0, jsx_runtime_1.jsx)(tag_1.Tag, { label: formatPair(pair), size: utils_2.TAG_SIZE_MAP[size], appearance: pair.appearance ?? 'neutral', onDelete: disabled || readOnly || pair.disabled ? undefined : handleRemoveChip(pair.id) }, String(pair.id)))), searchInput, (0, jsx_runtime_1.jsx)("span", { ref: inputPlugRef, className: styles_module_scss_2.default.inputPlug, "aria-hidden": true, children: inputValue })] })) : (searchInput), postfixButtons && (
433
378
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
434
379
  (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfixButtonsSlot, onMouseDown: shared_1.preventSlotMouseDown, onClick: shared_1.stopSlotClickPropagation, children: postfixButtons })), postfix && (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_1.default.postfix, children: postfix })] }), (0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.iconSlot, children: (0, jsx_runtime_1.jsx)("span", { className: styles_module_scss_2.default.chevron, "data-open": open || undefined, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(system_1.ChevronDownSVG, {}) }) })] }) })] }));
435
- return ((0, jsx_runtime_1.jsx)(field_decorator_1.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: (0, jsx_runtime_1.jsx)(list_1.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 }) }));
380
+ return ((0, jsx_runtime_1.jsx)(field_decorator_1.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: (0, jsx_runtime_1.jsx)(list_1.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 }) }));
436
381
  });
@@ -2,14 +2,3 @@ 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"];
@@ -1,26 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = exports.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = exports.ADD_CUSTOM_OPTION_TRIGGER = exports.SELECTION_MODE = void 0;
3
+ exports.SELECTION_MODE = void 0;
4
4
  exports.SELECTION_MODE = {
5
5
  Single: 'single',
6
6
  Multiple: 'multiple',
7
7
  };
8
- /** События, по которым произвольный ввод фиксируется как выбранная опция. */
9
- exports.ADD_CUSTOM_OPTION_TRIGGER = {
10
- Enter: 'enter',
11
- Blur: 'blur',
12
- Space: 'space',
13
- Comma: 'comma',
14
- };
15
- /** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
16
- exports.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = [
17
- exports.ADD_CUSTOM_OPTION_TRIGGER.Enter,
18
- exports.ADD_CUSTOM_OPTION_TRIGGER.Blur,
19
- ];
20
- /** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
21
- exports.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = [
22
- exports.ADD_CUSTOM_OPTION_TRIGGER.Enter,
23
- exports.ADD_CUSTOM_OPTION_TRIGGER.Blur,
24
- exports.ADD_CUSTOM_OPTION_TRIGGER.Space,
25
- exports.ADD_CUSTOM_OPTION_TRIGGER.Comma,
26
- ];
@@ -4,30 +4,9 @@ import { Appearance as TagAppearance } from '@cloud-ru/ds-tag';
4
4
  import { ValueOf } from '@cloud-ru/ds-utils';
5
5
  import { FocusEvent, KeyboardEvent, ReactNode } from 'react';
6
6
  import { FieldLayoutPresets } from '../../hooks';
7
- import { ADD_CUSTOM_OPTION_TRIGGER, ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE, ADD_CUSTOM_OPTION_TRIGGERS_SINGLE, SELECTION_MODE } from './constants';
7
+ import { SELECTION_MODE } from './constants';
8
8
  /** Режим выбора FieldSelect: одно значение или несколько. */
9
9
  export type Selection = ValueOf<typeof SELECTION_MODE>;
10
- /** Событие, по которому произвольный ввод фиксируется как выбранная опция. */
11
- export type FieldSelectAddCustomOptionTrigger = ValueOf<typeof ADD_CUSTOM_OPTION_TRIGGER>;
12
- /** Триггеры кастомной опции в режиме `single`. */
13
- export type FieldSelectSingleAddCustomOptionTrigger = (typeof ADD_CUSTOM_OPTION_TRIGGERS_SINGLE)[number];
14
- /** Триггеры кастомной опции в режиме `multiple`. */
15
- export type FieldSelectMultipleAddCustomOptionTrigger = (typeof ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE)[number];
16
- type CustomOptionProps<TTrigger extends FieldSelectAddCustomOptionTrigger> = {
17
- /**
18
- * Разрешить фиксировать произвольный ввод из строки поиска как выбранную опцию
19
- * (значение, которого нет в `items`).
20
- * @default false
21
- */
22
- allowCustomOption?: boolean;
23
- /**
24
- * События, по которым произвольный ввод фиксируется как выбранная опция.
25
- * Учитывается только при `allowCustomOption={true}`.
26
- * Если не задан — используются все триггеры режима (`single`: enter, blur; `multiple`: enter, blur, space, comma).
27
- * Триггеры, недоступные в текущем `selection`, игнорируются.
28
- */
29
- addCustomOptionTriggers?: TTrigger[];
30
- };
31
10
  type WithTagAppearance<T> = T extends {
32
11
  items: ItemProps[];
33
12
  } ? Omit<T, 'items'> & {
@@ -119,8 +98,6 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
119
98
  autocomplete?: boolean;
120
99
  /**
121
100
  * Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»).
122
- *
123
- * @deprecated Используйте `allowCustomOption`. `true` эквивалентен `allowCustomOption` с триггером `enter`.
124
101
  * @default false
125
102
  */
126
103
  addOptionByEnter?: boolean;
@@ -177,7 +154,7 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
177
154
  /** Тестовый id корня */
178
155
  'data-test-id'?: string;
179
156
  };
180
- export type FieldSelectSingleProps = CommonSelectProps & CustomOptionProps<FieldSelectSingleAddCustomOptionTrigger> & {
157
+ export type FieldSelectSingleProps = CommonSelectProps & {
181
158
  /** Режим выбора. По умолчанию `'single'`. */
182
159
  selection?: typeof SELECTION_MODE.Single;
183
160
  /** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
@@ -192,7 +169,7 @@ export type FieldSelectSingleProps = CommonSelectProps & CustomOptionProps<Field
192
169
  */
193
170
  closeDroplistOnItemClick?: boolean;
194
171
  };
195
- export type FieldSelectMultipleProps = CommonSelectProps & CustomOptionProps<FieldSelectMultipleAddCustomOptionTrigger> & {
172
+ export type FieldSelectMultipleProps = CommonSelectProps & {
196
173
  /** Режим выбора */
197
174
  selection: typeof SELECTION_MODE.Multiple;
198
175
  /** Управляемые значения */
@@ -0,0 +1,19 @@
1
+ import { Size } from '@cloud-ru/ds-field-decorator';
2
+ import { ItemId } from '@cloud-ru/ds-list';
3
+ import { Appearance as TagAppearance, Size as TagSize } from '@cloud-ru/ds-tag';
4
+ import { FieldSelectItem, FieldSelectMultipleProps, FieldSelectProps } from './types';
5
+ export declare const TAG_SIZE_MAP: Record<Size, TagSize>;
6
+ export type WithIdContent = {
7
+ id?: ItemId;
8
+ content?: unknown;
9
+ disabled?: boolean;
10
+ appearance?: TagAppearance;
11
+ };
12
+ export declare function extractLabel(item: WithIdContent): string;
13
+ export declare function extractSearchText(item: WithIdContent): string;
14
+ export declare function extractAppearance(item?: WithIdContent): TagAppearance | undefined;
15
+ export declare function flatten(items: FieldSelectItem[]): WithIdContent[];
16
+ export declare function findItem(items: FieldSelectItem[], id: ItemId): WithIdContent | undefined;
17
+ export declare function isFuzzyMatch(haystack: string, needle: string): boolean;
18
+ export declare function filterItems(items: FieldSelectItem[], query: string, fuzzy: boolean): FieldSelectItem[];
19
+ export declare function isMultiple(props: FieldSelectProps): props is FieldSelectMultipleProps;
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TAG_SIZE_MAP = void 0;
4
+ exports.extractLabel = extractLabel;
5
+ exports.extractSearchText = extractSearchText;
6
+ exports.extractAppearance = extractAppearance;
7
+ exports.flatten = flatten;
8
+ exports.findItem = findItem;
9
+ exports.isFuzzyMatch = isFuzzyMatch;
10
+ exports.filterItems = filterItems;
11
+ exports.isMultiple = isMultiple;
12
+ const constants_1 = require("./constants");
13
+ exports.TAG_SIZE_MAP = { s: 'xs', m: 'xs', l: 's' };
14
+ function extractLabel(item) {
15
+ const { content, id } = item;
16
+ if (!content) {
17
+ return String(id ?? '');
18
+ }
19
+ if (typeof content === 'string' || typeof content === 'number') {
20
+ return String(content);
21
+ }
22
+ if (typeof content === 'object' && content !== null && 'label' in content) {
23
+ return String(content.label);
24
+ }
25
+ return String(id ?? '');
26
+ }
27
+ // Текст для поиска: лейбл (option) + caption + description — паритет с легаси `useSearch`,
28
+ // который матчил запрос по всем трём полям контента, а не только по лейблу.
29
+ function extractSearchText(item) {
30
+ const parts = [extractLabel(item)];
31
+ const { content } = item;
32
+ if (content && typeof content === 'object') {
33
+ const c = content;
34
+ if (typeof c.caption === 'string') {
35
+ parts.push(c.caption);
36
+ }
37
+ if (typeof c.description === 'string') {
38
+ parts.push(c.description);
39
+ }
40
+ }
41
+ return parts.join(' ');
42
+ }
43
+ // Цвет тега выбранного значения (multiple) — паритет с легаси `option.appearance`.
44
+ // Проверка типа нужна и при типизированном поле: items часто приходят из ответа бэкенда.
45
+ function extractAppearance(item) {
46
+ return typeof item?.appearance === 'string' ? item.appearance : undefined;
47
+ }
48
+ function flatten(items) {
49
+ const out = [];
50
+ for (const item of items) {
51
+ out.push(item);
52
+ if ('items' in item && Array.isArray(item.items)) {
53
+ out.push(...flatten(item.items));
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ function findItem(items, id) {
59
+ for (const item of flatten(items)) {
60
+ if (item.id === id) {
61
+ return item;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ // Subsequence-fuzzy: символы запроса должны встречаться в строке в исходном порядке.
67
+ // Пример: query=`lge` matches `Large` (L → r → arg → e).
68
+ function isFuzzyMatch(haystack, needle) {
69
+ if (!needle) {
70
+ return true;
71
+ }
72
+ let i = 0;
73
+ for (const ch of haystack) {
74
+ if (ch === needle[i]) {
75
+ i += 1;
76
+ if (i === needle.length) {
77
+ return true;
78
+ }
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+ function filterItems(items, query, fuzzy) {
84
+ if (!query) {
85
+ return items;
86
+ }
87
+ const q = query.toLowerCase();
88
+ const matches = (item) => {
89
+ const text = extractSearchText(item).toLowerCase();
90
+ return fuzzy ? isFuzzyMatch(text, q) : text.includes(q);
91
+ };
92
+ const walk = (list) => list.flatMap(item => {
93
+ if ('items' in item && Array.isArray(item.items)) {
94
+ const filteredChildren = walk(item.items);
95
+ return filteredChildren.length > 0 ? [{ ...item, items: filteredChildren }] : [];
96
+ }
97
+ return matches(item) ? [item] : [];
98
+ });
99
+ return walk(items);
100
+ }
101
+ function isMultiple(props) {
102
+ return props.selection === constants_1.SELECTION_MODE.Multiple;
103
+ }