@cloud-ru/ds-fields 2.2.2 → 2.2.3-preview-0c4119af.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 +81 -41
  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 +32 -0
  6. package/dist/cjs/components/FieldSelect/hooks.js +83 -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 +82 -42
  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 +32 -0
  26. package/dist/esm/components/FieldSelect/hooks.js +80 -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 +20 -20
  45. package/src/components/FieldSelect/FieldSelect.tsx +129 -61
  46. package/src/components/FieldSelect/constants.ts +22 -0
  47. package/src/components/FieldSelect/hooks.ts +162 -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
package/README.md CHANGED
@@ -435,7 +435,9 @@ export function Select() {
435
435
 
436
436
  | Prop | Type | Default | Description |
437
437
  |------|------|---------|-------------|
438
- | `addOptionByEnter` | `boolean` | `false` | Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»). |
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`). |
439
441
  | `autoFocus` | `boolean` | — | Автофокус input при монтировании. На mobile выключается адаптивно (см. `layoutPresets`) |
440
442
  | `autocomplete` | `boolean` | `false` | Не фильтровать список на клиенте — фильтрацию обеспечивает потребитель (серверный поиск). <br/> Введённый текст уходит в `search.onChange`, список берётся из `items` как есть. |
441
443
  | `background` | `boolean` | `true` | Фон поля (acrylic) |
@@ -20,6 +20,7 @@ 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");
23
24
  const styles_module_scss_2 = __importDefault(require('./styles.module.css'));
24
25
  const utils_2 = require("./utils");
25
26
  exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
@@ -28,6 +29,8 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
28
29
  const chips = multiple ? (props.chips ?? true) : false;
29
30
  const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
30
31
  const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
32
+ const allowCustomOption = props.allowCustomOption === true;
33
+ const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
31
34
  const resolvedAutoFocus = (0, hooks_1.useAdaptiveAutoFocus)(autoFocus, layoutPresets);
32
35
  const inputRef = (0, react_1.useRef)(null);
33
36
  // Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
@@ -73,16 +76,20 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
73
76
  }, [multiple, props.value, multipleLocal]);
74
77
  const effectiveValidationState = (0, react_1.useMemo)(() => (error ? field_decorator_1.VALIDATION_STATE.Error : validationState), [error, validationState]);
75
78
  const allItems = (0, react_1.useMemo)(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
79
+ const selectedIds = (0, react_1.useMemo)(() => (0, utils_2.getSelectedIds)({ multiple, multipleValue, singleValue }), [multiple, multipleValue, singleValue]);
80
+ // Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
81
+ const missingSelectedItems = (0, react_1.useMemo)(() => (0, utils_2.getMissingSelectedItems)(selectedIds, allItems), [selectedIds, allItems]);
82
+ const itemsWithPlaceholders = (0, react_1.useMemo)(() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items), [missingSelectedItems, items]);
76
83
  // Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
77
84
  const seenItems = (0, react_1.useRef)(new Map());
78
85
  const resolveItem = (0, react_1.useMemo)(() => {
79
- for (const item of (0, utils_2.flatten)(allItems)) {
86
+ for (const item of (0, utils_2.flatten)([...missingSelectedItems, ...allItems])) {
80
87
  if (item.id !== undefined) {
81
88
  seenItems.current.set(item.id, item);
82
89
  }
83
90
  }
84
- return (id) => (0, utils_2.findItem)(allItems, id) ?? seenItems.current.get(id);
85
- }, [allItems]);
91
+ return (id) => (0, utils_2.findItem)(allItems, id) ?? (0, utils_2.findItem)(missingSelectedItems, id) ?? seenItems.current.get(id);
92
+ }, [allItems, missingSelectedItems]);
86
93
  const selectedPairs = (0, react_1.useMemo)(() => {
87
94
  if (!multiple || multipleValue.length === 0) {
88
95
  return [];
@@ -137,13 +144,13 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
137
144
  syncedLabelRef.current = selectedLabel;
138
145
  setInputValue(selectedLabel);
139
146
  }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
140
- // При autocomplete фильтрует сервер — items берутся как есть.
147
+ // При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
141
148
  const filteredItems = (0, react_1.useMemo)(() => {
142
149
  if (!searchable || autocomplete || !typing) {
143
- return items;
150
+ return itemsWithPlaceholders;
144
151
  }
145
- return (0, utils_2.filterItems)(items, inputValue, enableFuzzySearch);
146
- }, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
152
+ return (0, utils_2.filterItems)(itemsWithPlaceholders, inputValue, enableFuzzySearch);
153
+ }, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
147
154
  const filteredPinTop = (0, react_1.useMemo)(() => {
148
155
  if (!searchable || autocomplete || !typing || !pinTop) {
149
156
  return pinTop;
@@ -156,22 +163,7 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
156
163
  }
157
164
  return (0, utils_2.filterItems)(pinBottom, inputValue, enableFuzzySearch);
158
165
  }, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
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) => {
166
+ const handleSingleChange = (0, react_1.useCallback)((next) => {
175
167
  if (multiple) {
176
168
  return;
177
169
  }
@@ -180,8 +172,8 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
180
172
  }
181
173
  props.onChange?.(next);
182
174
  setTyping(false);
183
- };
184
- const handleMultipleChange = (next) => {
175
+ }, [multiple, props]);
176
+ const handleMultipleChange = (0, react_1.useCallback)((next) => {
185
177
  if (!multiple) {
186
178
  return;
187
179
  }
@@ -195,8 +187,56 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
195
187
  setInputValue('');
196
188
  }
197
189
  }
198
- };
190
+ }, [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue]);
191
+ const resetCustomOptionSearch = (0, react_1.useCallback)(() => {
192
+ setTyping(false);
193
+ if (resetSearchOnOptionSelection) {
194
+ setInputValue(multiple ? '' : selectedLabel);
195
+ }
196
+ }, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
197
+ const { listRef, suppressCloseCommit, enableCloseCommit, tryCommitOnClose, tryCommitOnBlur, tryCommitFromKeyboard, handleListSingleChange, handleListMultipleChange, } = (0, hooks_2.useCustomOption)({
198
+ allowCustomOption,
199
+ addCustomOptionTriggers,
200
+ addOptionByEnter,
201
+ multiple,
202
+ inputValue,
203
+ multipleValue,
204
+ commitSingle: handleSingleChange,
205
+ commitMultiple: handleMultipleChange,
206
+ resetSearch: resetCustomOptionSearch,
207
+ inputElementRef,
208
+ clearButtonRef,
209
+ copyButtonRef,
210
+ footerActiveElementsRefs,
211
+ });
212
+ const handleOpenChange = (0, react_1.useCallback)((next) => {
213
+ // readonly/disabled поле открывать нельзя, закрывать — можно.
214
+ if (next && (disabled || readOnly))
215
+ return;
216
+ if (openProp === undefined) {
217
+ setOpenLocal(next);
218
+ }
219
+ onOpenChange?.(next);
220
+ if (!next) {
221
+ setTyping(false);
222
+ const committed = tryCommitOnClose();
223
+ // Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
224
+ if (resetSearchOnOptionSelection && !committed) {
225
+ setInputValue(selectedLabel);
226
+ }
227
+ }
228
+ }, [
229
+ openProp,
230
+ onOpenChange,
231
+ disabled,
232
+ readOnly,
233
+ resetSearchOnOptionSelection,
234
+ setInputValue,
235
+ selectedLabel,
236
+ tryCommitOnClose,
237
+ ]);
199
238
  const handleInputChange = (next) => {
239
+ enableCloseCommit();
200
240
  setInputValue(next);
201
241
  setTyping(true);
202
242
  if (!open) {
@@ -295,19 +335,12 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
295
335
  }
296
336
  if (event.key === 'Escape' && open) {
297
337
  event.preventDefault();
338
+ suppressCloseCommit();
298
339
  handleOpenChange(false);
299
340
  }
300
- if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
301
- // Введённый текст становится новым значением (создание опции «на лету»).
341
+ if (tryCommitFromKeyboard(event)) {
302
342
  event.preventDefault();
303
- if (multiple) {
304
- if (!multipleValue.includes(inputValue)) {
305
- handleMultipleChange([...multipleValue, inputValue]);
306
- }
307
- }
308
- else {
309
- handleSingleChange(inputValue);
310
- }
343
+ event.stopPropagation();
311
344
  return;
312
345
  }
313
346
  if (event.key === 'Enter' && !open) {
@@ -344,13 +377,20 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
344
377
  }
345
378
  onFocusProp?.(event);
346
379
  }, [searchable, onFocusProp]);
347
- const handleInputBlur = (0, react_1.useCallback)((event) => {
380
+ const handleInputBlur = (event) => {
348
381
  setFocusVisible(false);
382
+ const blurResult = tryCommitOnBlur(event);
383
+ // Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
384
+ // Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
385
+ if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
386
+ setTyping(false);
387
+ setInputValue(selectedLabel);
388
+ }
349
389
  onBlurProp?.(event);
350
- }, [onBlurProp]);
390
+ };
351
391
  const droplistSelection = multiple
352
- ? { mode: 'multiple', value: multipleValue, onChange: handleMultipleChange }
353
- : { mode: 'single', value: singleValue, onChange: handleSingleChange };
392
+ ? { mode: 'multiple', value: multipleValue, onChange: handleListMultipleChange }
393
+ : { mode: 'single', value: singleValue, onChange: handleListSingleChange };
354
394
  const hasChips = chips && selectedPairs.length > 0;
355
395
  // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
356
396
  // Замер до paint, иначе input мигает при вводе.
@@ -377,5 +417,5 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
377
417
  }), 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 && (
378
418
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
379
419
  (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, {}) }) })] }) })] }));
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 }) }));
420
+ 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 }) }));
381
421
  });
@@ -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"];
@@ -1,7 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SELECTION_MODE = void 0;
3
+ exports.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = exports.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = exports.ADD_CUSTOM_OPTION_TRIGGER = 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
+ ];
@@ -0,0 +1,32 @@
1
+ import { ItemId } from '@cloud-ru/ds-list';
2
+ import { FocusEvent, KeyboardEvent, RefObject } from 'react';
3
+ import { FieldSelectAddCustomOptionTrigger } from './types';
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
+ multipleValue: ItemId[];
13
+ commitSingle(value: ItemId | undefined): void;
14
+ commitMultiple(value: ItemId[]): void;
15
+ resetSearch(): void;
16
+ inputElementRef: RefObject<HTMLInputElement | null>;
17
+ clearButtonRef: RefObject<HTMLButtonElement | null>;
18
+ copyButtonRef: RefObject<HTMLButtonElement | null>;
19
+ footerActiveElementsRefs?: RefObject<HTMLElement>[];
20
+ };
21
+ /** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
22
+ export declare function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }: UseCustomOptionParams): {
23
+ listRef: RefObject<HTMLElement>;
24
+ suppressCloseCommit: () => void;
25
+ enableCloseCommit: () => void;
26
+ tryCommitOnClose: () => boolean;
27
+ tryCommitOnBlur: (event: FocusEvent<HTMLInputElement>) => CustomOptionBlurResult;
28
+ tryCommitFromKeyboard: (event: KeyboardEvent<HTMLInputElement>) => boolean;
29
+ handleListSingleChange: (next: ItemId | undefined) => void;
30
+ handleListMultipleChange: (next: ItemId[]) => void;
31
+ };
32
+ export {};
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useCustomOption = useCustomOption;
4
+ const utils_1 = require("@cloud-ru/ds-utils");
5
+ const react_1 = require("react");
6
+ const constants_1 = require("./constants");
7
+ const utils_2 = require("./utils");
8
+ /** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
9
+ function useCustomOption({ allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple, inputValue, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }) {
10
+ const listRef = (0, react_1.useRef)(null);
11
+ const skipCloseCommitRef = (0, react_1.useRef)(false);
12
+ const resolvedTriggers = (0, react_1.useMemo)(() => (0, utils_2.resolveAddCustomOptionTriggers)({
13
+ allowCustomOption,
14
+ addCustomOptionTriggers,
15
+ addOptionByEnter,
16
+ defaultTriggers: multiple ? constants_1.ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : constants_1.ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
17
+ }), [allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple]);
18
+ const tryCommit = (0, react_1.useCallback)((trigger) => {
19
+ if (!(0, utils_2.shouldHandleCustomOptionTrigger)(trigger, resolvedTriggers) || inputValue === '') {
20
+ return false;
21
+ }
22
+ if (multiple) {
23
+ if (multipleValue.includes(inputValue)) {
24
+ resetSearch();
25
+ }
26
+ else {
27
+ commitMultiple([...multipleValue, inputValue]);
28
+ }
29
+ }
30
+ else {
31
+ commitSingle(inputValue);
32
+ }
33
+ return true;
34
+ }, [resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch]);
35
+ const suppressCloseCommit = (0, react_1.useCallback)(() => {
36
+ skipCloseCommitRef.current = true;
37
+ }, []);
38
+ const enableCloseCommit = (0, react_1.useCallback)(() => {
39
+ skipCloseCommitRef.current = false;
40
+ }, []);
41
+ const tryCommitOnClose = (0, react_1.useCallback)(() => {
42
+ const shouldCommit = (0, utils_2.shouldCommitCustomOptionOnClose)({
43
+ skip: skipCloseCommitRef.current,
44
+ inputElement: inputElementRef.current,
45
+ activeElement: (0, utils_1.isBrowser)() ? document.activeElement : null,
46
+ });
47
+ skipCloseCommitRef.current = false;
48
+ return shouldCommit && tryCommit(constants_1.ADD_CUSTOM_OPTION_TRIGGER.Blur);
49
+ }, [inputElementRef, tryCommit]);
50
+ const tryCommitOnBlur = (0, react_1.useCallback)((event) => {
51
+ if ((0, utils_2.isCustomOptionBlurInsideField)(event.relatedTarget, [
52
+ listRef.current,
53
+ clearButtonRef.current,
54
+ copyButtonRef.current,
55
+ ...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
56
+ ])) {
57
+ return 'inside';
58
+ }
59
+ return tryCommit(constants_1.ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
60
+ }, [clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit]);
61
+ const tryCommitFromKeyboard = (0, react_1.useCallback)((event) => {
62
+ const trigger = (0, utils_2.getCustomOptionTriggerByCode)(event.code, event.key);
63
+ return trigger !== undefined && tryCommit(trigger);
64
+ }, [tryCommit]);
65
+ const handleListSingleChange = (0, react_1.useCallback)((next) => {
66
+ skipCloseCommitRef.current = true;
67
+ commitSingle(next);
68
+ }, [commitSingle]);
69
+ const handleListMultipleChange = (0, react_1.useCallback)((next) => {
70
+ skipCloseCommitRef.current = true;
71
+ commitMultiple(next);
72
+ }, [commitMultiple]);
73
+ return {
74
+ listRef,
75
+ suppressCloseCommit,
76
+ enableCloseCommit,
77
+ tryCommitOnClose,
78
+ tryCommitOnBlur,
79
+ tryCommitFromKeyboard,
80
+ handleListSingleChange,
81
+ handleListMultipleChange,
82
+ };
83
+ }
@@ -4,9 +4,30 @@ 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 { SELECTION_MODE } from './constants';
7
+ import { ADD_CUSTOM_OPTION_TRIGGER, ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE, ADD_CUSTOM_OPTION_TRIGGERS_SINGLE, 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
+ };
10
31
  type WithTagAppearance<T> = T extends {
11
32
  items: ItemProps[];
12
33
  } ? Omit<T, 'items'> & {
@@ -98,6 +119,8 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
98
119
  autocomplete?: boolean;
99
120
  /**
100
121
  * Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»).
122
+ *
123
+ * @deprecated Используйте `allowCustomOption`. `true` эквивалентен `allowCustomOption` с триггером `enter`.
101
124
  * @default false
102
125
  */
103
126
  addOptionByEnter?: boolean;
@@ -154,7 +177,7 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
154
177
  /** Тестовый id корня */
155
178
  'data-test-id'?: string;
156
179
  };
157
- export type FieldSelectSingleProps = CommonSelectProps & {
180
+ export type FieldSelectSingleProps = CommonSelectProps & CustomOptionProps<FieldSelectSingleAddCustomOptionTrigger> & {
158
181
  /** Режим выбора. По умолчанию `'single'`. */
159
182
  selection?: typeof SELECTION_MODE.Single;
160
183
  /** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
@@ -169,7 +192,7 @@ export type FieldSelectSingleProps = CommonSelectProps & {
169
192
  */
170
193
  closeDroplistOnItemClick?: boolean;
171
194
  };
172
- export type FieldSelectMultipleProps = CommonSelectProps & {
195
+ export type FieldSelectMultipleProps = CommonSelectProps & CustomOptionProps<FieldSelectMultipleAddCustomOptionTrigger> & {
173
196
  /** Режим выбора */
174
197
  selection: typeof SELECTION_MODE.Multiple;
175
198
  /** Управляемые значения */
@@ -0,0 +1,21 @@
1
+ import { FieldSelectAddCustomOptionTrigger, FieldSelectMultipleAddCustomOptionTrigger } from '../types';
2
+ /**
3
+ * Сопоставляет клавишу с триггером кастомной опции (`enter` / `space` / `comma`).
4
+ * Сначала `KeyboardEvent.code`, затем `key` — если `code` пустой или не из словаря (`','` без `Comma`).
5
+ */
6
+ export declare function getCustomOptionTriggerByCode(code: string, key?: string): FieldSelectMultipleAddCustomOptionTrigger | undefined;
7
+ export declare function shouldHandleCustomOptionTrigger(trigger: FieldSelectAddCustomOptionTrigger | undefined, availableTriggers: readonly FieldSelectAddCustomOptionTrigger[]): trigger is FieldSelectAddCustomOptionTrigger;
8
+ export declare function resolveAddCustomOptionTriggers(params: {
9
+ allowCustomOption: boolean;
10
+ addCustomOptionTriggers: readonly FieldSelectAddCustomOptionTrigger[] | undefined;
11
+ addOptionByEnter: boolean;
12
+ defaultTriggers: readonly FieldSelectAddCustomOptionTrigger[];
13
+ }): FieldSelectAddCustomOptionTrigger[];
14
+ /** Фокус ушёл на дроплист, футер или postfix — кастомную опцию на blur фиксировать не нужно. */
15
+ export declare function isCustomOptionBlurInsideField(relatedTarget: EventTarget | null, nodes: (Node | null | undefined)[]): boolean;
16
+ /** Клик снаружи закрывает список, не снимая фокус с input — blur не придёт. */
17
+ export declare function shouldCommitCustomOptionOnClose(params: {
18
+ skip: boolean;
19
+ inputElement: Element | null;
20
+ activeElement: Element | null;
21
+ }): boolean;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCustomOptionTriggerByCode = getCustomOptionTriggerByCode;
4
+ exports.shouldHandleCustomOptionTrigger = shouldHandleCustomOptionTrigger;
5
+ exports.resolveAddCustomOptionTriggers = resolveAddCustomOptionTriggers;
6
+ exports.isCustomOptionBlurInsideField = isCustomOptionBlurInsideField;
7
+ exports.shouldCommitCustomOptionOnClose = shouldCommitCustomOptionOnClose;
8
+ const constants_1 = require("../constants");
9
+ /**
10
+ * Сопоставляет клавишу с триггером кастомной опции (`enter` / `space` / `comma`).
11
+ * Сначала `KeyboardEvent.code`, затем `key` — если `code` пустой или не из словаря (`','` без `Comma`).
12
+ */
13
+ function getCustomOptionTriggerByCode(code, key) {
14
+ switch (code) {
15
+ case 'Enter':
16
+ return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter;
17
+ case 'Space':
18
+ return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Space;
19
+ case 'Comma':
20
+ return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Comma;
21
+ default:
22
+ break;
23
+ }
24
+ switch (key) {
25
+ case 'Enter':
26
+ return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter;
27
+ case ' ':
28
+ return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Space;
29
+ case ',':
30
+ return constants_1.ADD_CUSTOM_OPTION_TRIGGER.Comma;
31
+ default:
32
+ return undefined;
33
+ }
34
+ }
35
+ function shouldHandleCustomOptionTrigger(trigger, availableTriggers) {
36
+ return trigger !== undefined && availableTriggers.includes(trigger);
37
+ }
38
+ function resolveAddCustomOptionTriggers(params) {
39
+ if (params.allowCustomOption) {
40
+ const source = params.addCustomOptionTriggers !== undefined ? params.addCustomOptionTriggers : params.defaultTriggers;
41
+ return source.filter(trigger => params.defaultTriggers.includes(trigger));
42
+ }
43
+ if (params.addOptionByEnter) {
44
+ return [constants_1.ADD_CUSTOM_OPTION_TRIGGER.Enter];
45
+ }
46
+ return [];
47
+ }
48
+ /** Фокус ушёл на дроплист, футер или postfix — кастомную опцию на blur фиксировать не нужно. */
49
+ function isCustomOptionBlurInsideField(relatedTarget, nodes) {
50
+ return relatedTarget instanceof Node && nodes.some(node => Boolean(node?.contains(relatedTarget)));
51
+ }
52
+ /** Клик снаружи закрывает список, не снимая фокус с input — blur не придёт. */
53
+ function shouldCommitCustomOptionOnClose(params) {
54
+ return !params.skip && params.activeElement !== null && params.activeElement === params.inputElement;
55
+ }
@@ -0,0 +1,11 @@
1
+ import { ItemId } from '@cloud-ru/ds-list';
2
+ import { Appearance as TagAppearance } from '@cloud-ru/ds-tag';
3
+ export type WithIdContent = {
4
+ id?: ItemId;
5
+ content?: unknown;
6
+ disabled?: boolean;
7
+ appearance?: TagAppearance;
8
+ };
9
+ export declare function extractLabel(item: WithIdContent): string;
10
+ export declare function extractSearchText(item: WithIdContent): string;
11
+ export declare function extractAppearance(item?: WithIdContent): TagAppearance | undefined;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractLabel = extractLabel;
4
+ exports.extractSearchText = extractSearchText;
5
+ exports.extractAppearance = extractAppearance;
6
+ function extractLabel(item) {
7
+ const { content, id } = item;
8
+ if (!content) {
9
+ return String(id ?? '');
10
+ }
11
+ if (typeof content === 'string' || typeof content === 'number') {
12
+ return String(content);
13
+ }
14
+ if (typeof content === 'object' && content !== null && 'label' in content) {
15
+ return String(content.label);
16
+ }
17
+ return String(id ?? '');
18
+ }
19
+ // Текст для поиска: лейбл (option) + caption + description — паритет с легаси `useSearch`,
20
+ // который матчил запрос по всем трём полям контента, а не только по лейблу.
21
+ function extractSearchText(item) {
22
+ const parts = [extractLabel(item)];
23
+ const { content } = item;
24
+ if (content && typeof content === 'object') {
25
+ const c = content;
26
+ if (typeof c.caption === 'string') {
27
+ parts.push(c.caption);
28
+ }
29
+ if (typeof c.description === 'string') {
30
+ parts.push(c.description);
31
+ }
32
+ }
33
+ return parts.join(' ');
34
+ }
35
+ // Цвет тега выбранного значения (multiple) — паритет с легаси `option.appearance`.
36
+ // Проверка типа нужна и при типизированном поле: items часто приходят из ответа бэкенда.
37
+ function extractAppearance(item) {
38
+ return typeof item?.appearance === 'string' ? item.appearance : undefined;
39
+ }
@@ -0,0 +1,3 @@
1
+ import { FieldSelectItem } from '../types';
2
+ export declare function isFuzzyMatch(haystack: string, needle: string): boolean;
3
+ export declare function filterItems(items: FieldSelectItem[], query: string, fuzzy: boolean): FieldSelectItem[];
@@ -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';