@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
@@ -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' };
@@ -14,14 +14,17 @@ 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.
@@ -67,16 +70,20 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
67
70
  }, [multiple, props.value, multipleLocal]);
68
71
  const effectiveValidationState = useMemo(() => (error ? VALIDATION_STATE.Error : validationState), [error, validationState]);
69
72
  const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
73
+ const selectedIds = useMemo(() => getSelectedIds({ multiple, multipleValue, singleValue }), [multiple, multipleValue, singleValue]);
74
+ // Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
75
+ const missingSelectedItems = useMemo(() => getMissingSelectedItems(selectedIds, allItems), [selectedIds, allItems]);
76
+ const itemsWithPlaceholders = useMemo(() => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items), [missingSelectedItems, items]);
70
77
  // Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
71
78
  const seenItems = useRef(new Map());
72
79
  const resolveItem = useMemo(() => {
73
- for (const item of flatten(allItems)) {
80
+ for (const item of flatten([...missingSelectedItems, ...allItems])) {
74
81
  if (item.id !== undefined) {
75
82
  seenItems.current.set(item.id, item);
76
83
  }
77
84
  }
78
- return (id) => findItem(allItems, id) ?? seenItems.current.get(id);
79
- }, [allItems]);
85
+ return (id) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
86
+ }, [allItems, missingSelectedItems]);
80
87
  const selectedPairs = useMemo(() => {
81
88
  if (!multiple || multipleValue.length === 0) {
82
89
  return [];
@@ -131,13 +138,13 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
131
138
  syncedLabelRef.current = selectedLabel;
132
139
  setInputValue(selectedLabel);
133
140
  }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
134
- // При autocomplete фильтрует сервер — items берутся как есть.
141
+ // При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
135
142
  const filteredItems = useMemo(() => {
136
143
  if (!searchable || autocomplete || !typing) {
137
- return items;
144
+ return itemsWithPlaceholders;
138
145
  }
139
- return filterItems(items, inputValue, enableFuzzySearch);
140
- }, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
146
+ return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
147
+ }, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
141
148
  const filteredPinTop = useMemo(() => {
142
149
  if (!searchable || autocomplete || !typing || !pinTop) {
143
150
  return pinTop;
@@ -150,22 +157,7 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
150
157
  }
151
158
  return filterItems(pinBottom, inputValue, enableFuzzySearch);
152
159
  }, [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) => {
160
+ const handleSingleChange = useCallback((next) => {
169
161
  if (multiple) {
170
162
  return;
171
163
  }
@@ -174,8 +166,8 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
174
166
  }
175
167
  props.onChange?.(next);
176
168
  setTyping(false);
177
- };
178
- const handleMultipleChange = (next) => {
169
+ }, [multiple, props]);
170
+ const handleMultipleChange = useCallback((next) => {
179
171
  if (!multiple) {
180
172
  return;
181
173
  }
@@ -189,8 +181,56 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
189
181
  setInputValue('');
190
182
  }
191
183
  }
192
- };
184
+ }, [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue]);
185
+ const resetCustomOptionSearch = useCallback(() => {
186
+ setTyping(false);
187
+ if (resetSearchOnOptionSelection) {
188
+ setInputValue(multiple ? '' : selectedLabel);
189
+ }
190
+ }, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
191
+ const { listRef, suppressCloseCommit, enableCloseCommit, tryCommitOnClose, tryCommitOnBlur, tryCommitFromKeyboard, handleListSingleChange, handleListMultipleChange, } = useCustomOption({
192
+ allowCustomOption,
193
+ addCustomOptionTriggers,
194
+ addOptionByEnter,
195
+ multiple,
196
+ inputValue,
197
+ multipleValue,
198
+ commitSingle: handleSingleChange,
199
+ commitMultiple: handleMultipleChange,
200
+ resetSearch: resetCustomOptionSearch,
201
+ inputElementRef,
202
+ clearButtonRef,
203
+ copyButtonRef,
204
+ footerActiveElementsRefs,
205
+ });
206
+ const handleOpenChange = useCallback((next) => {
207
+ // readonly/disabled поле открывать нельзя, закрывать — можно.
208
+ if (next && (disabled || readOnly))
209
+ return;
210
+ if (openProp === undefined) {
211
+ setOpenLocal(next);
212
+ }
213
+ onOpenChange?.(next);
214
+ if (!next) {
215
+ setTyping(false);
216
+ const committed = tryCommitOnClose();
217
+ // Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
218
+ if (resetSearchOnOptionSelection && !committed) {
219
+ setInputValue(selectedLabel);
220
+ }
221
+ }
222
+ }, [
223
+ openProp,
224
+ onOpenChange,
225
+ disabled,
226
+ readOnly,
227
+ resetSearchOnOptionSelection,
228
+ setInputValue,
229
+ selectedLabel,
230
+ tryCommitOnClose,
231
+ ]);
193
232
  const handleInputChange = (next) => {
233
+ enableCloseCommit();
194
234
  setInputValue(next);
195
235
  setTyping(true);
196
236
  if (!open) {
@@ -289,19 +329,12 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
289
329
  }
290
330
  if (event.key === 'Escape' && open) {
291
331
  event.preventDefault();
332
+ suppressCloseCommit();
292
333
  handleOpenChange(false);
293
334
  }
294
- if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
295
- // Введённый текст становится новым значением (создание опции «на лету»).
335
+ if (tryCommitFromKeyboard(event)) {
296
336
  event.preventDefault();
297
- if (multiple) {
298
- if (!multipleValue.includes(inputValue)) {
299
- handleMultipleChange([...multipleValue, inputValue]);
300
- }
301
- }
302
- else {
303
- handleSingleChange(inputValue);
304
- }
337
+ event.stopPropagation();
305
338
  return;
306
339
  }
307
340
  if (event.key === 'Enter' && !open) {
@@ -338,13 +371,20 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
338
371
  }
339
372
  onFocusProp?.(event);
340
373
  }, [searchable, onFocusProp]);
341
- const handleInputBlur = useCallback((event) => {
374
+ const handleInputBlur = (event) => {
342
375
  setFocusVisible(false);
376
+ const blurResult = tryCommitOnBlur(event);
377
+ // Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
378
+ // Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
379
+ if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
380
+ setTyping(false);
381
+ setInputValue(selectedLabel);
382
+ }
343
383
  onBlurProp?.(event);
344
- }, [onBlurProp]);
384
+ };
345
385
  const droplistSelection = multiple
346
- ? { mode: 'multiple', value: multipleValue, onChange: handleMultipleChange }
347
- : { mode: 'single', value: singleValue, onChange: handleSingleChange };
386
+ ? { mode: 'multiple', value: multipleValue, onChange: handleListMultipleChange }
387
+ : { mode: 'single', value: singleValue, onChange: handleListSingleChange };
348
388
  const hasChips = chips && selectedPairs.length > 0;
349
389
  // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
350
390
  // Замер до paint, иначе input мигает при вводе.
@@ -371,5 +411,5 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
371
411
  }), 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
412
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
373
413
  _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 }) }));
414
+ 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
415
  });
@@ -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,32 @@
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
+ 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,80 @@
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, multipleValue, commitSingle, commitMultiple, resetSearch, inputElementRef, clearButtonRef, copyButtonRef, footerActiveElementsRefs, }) {
7
+ const listRef = useRef(null);
8
+ const skipCloseCommitRef = useRef(false);
9
+ const resolvedTriggers = useMemo(() => resolveAddCustomOptionTriggers({
10
+ allowCustomOption,
11
+ addCustomOptionTriggers,
12
+ addOptionByEnter,
13
+ defaultTriggers: multiple ? ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
14
+ }), [allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple]);
15
+ const tryCommit = useCallback((trigger) => {
16
+ if (!shouldHandleCustomOptionTrigger(trigger, resolvedTriggers) || inputValue === '') {
17
+ return false;
18
+ }
19
+ if (multiple) {
20
+ if (multipleValue.includes(inputValue)) {
21
+ resetSearch();
22
+ }
23
+ else {
24
+ commitMultiple([...multipleValue, inputValue]);
25
+ }
26
+ }
27
+ else {
28
+ commitSingle(inputValue);
29
+ }
30
+ return true;
31
+ }, [resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch]);
32
+ const suppressCloseCommit = useCallback(() => {
33
+ skipCloseCommitRef.current = true;
34
+ }, []);
35
+ const enableCloseCommit = useCallback(() => {
36
+ skipCloseCommitRef.current = false;
37
+ }, []);
38
+ const tryCommitOnClose = useCallback(() => {
39
+ const shouldCommit = shouldCommitCustomOptionOnClose({
40
+ skip: skipCloseCommitRef.current,
41
+ inputElement: inputElementRef.current,
42
+ activeElement: isBrowser() ? document.activeElement : null,
43
+ });
44
+ skipCloseCommitRef.current = false;
45
+ return shouldCommit && tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur);
46
+ }, [inputElementRef, tryCommit]);
47
+ const tryCommitOnBlur = useCallback((event) => {
48
+ if (isCustomOptionBlurInsideField(event.relatedTarget, [
49
+ listRef.current,
50
+ clearButtonRef.current,
51
+ copyButtonRef.current,
52
+ ...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
53
+ ])) {
54
+ return 'inside';
55
+ }
56
+ return tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
57
+ }, [clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit]);
58
+ const tryCommitFromKeyboard = useCallback((event) => {
59
+ const trigger = getCustomOptionTriggerByCode(event.code, event.key);
60
+ return trigger !== undefined && tryCommit(trigger);
61
+ }, [tryCommit]);
62
+ const handleListSingleChange = useCallback((next) => {
63
+ skipCloseCommitRef.current = true;
64
+ commitSingle(next);
65
+ }, [commitSingle]);
66
+ const handleListMultipleChange = useCallback((next) => {
67
+ skipCloseCommitRef.current = true;
68
+ commitMultiple(next);
69
+ }, [commitMultiple]);
70
+ return {
71
+ listRef,
72
+ suppressCloseCommit,
73
+ enableCloseCommit,
74
+ tryCommitOnClose,
75
+ tryCommitOnBlur,
76
+ tryCommitFromKeyboard,
77
+ handleListSingleChange,
78
+ handleListMultipleChange,
79
+ };
80
+ }
@@ -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/index.js';
7
- import { SELECTION_MODE } from './constants.js';
7
+ import { ADD_CUSTOM_OPTION_TRIGGER, ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE, ADD_CUSTOM_OPTION_TRIGGERS_SINGLE, SELECTION_MODE } from './constants.js';
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.js';
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,48 @@
1
+ import { ADD_CUSTOM_OPTION_TRIGGER } from '../constants.js';
2
+ /**
3
+ * Сопоставляет клавишу с триггером кастомной опции (`enter` / `space` / `comma`).
4
+ * Сначала `KeyboardEvent.code`, затем `key` — если `code` пустой или не из словаря (`','` без `Comma`).
5
+ */
6
+ export function getCustomOptionTriggerByCode(code, key) {
7
+ switch (code) {
8
+ case 'Enter':
9
+ return ADD_CUSTOM_OPTION_TRIGGER.Enter;
10
+ case 'Space':
11
+ return ADD_CUSTOM_OPTION_TRIGGER.Space;
12
+ case 'Comma':
13
+ return ADD_CUSTOM_OPTION_TRIGGER.Comma;
14
+ default:
15
+ break;
16
+ }
17
+ switch (key) {
18
+ case 'Enter':
19
+ return ADD_CUSTOM_OPTION_TRIGGER.Enter;
20
+ case ' ':
21
+ return ADD_CUSTOM_OPTION_TRIGGER.Space;
22
+ case ',':
23
+ return ADD_CUSTOM_OPTION_TRIGGER.Comma;
24
+ default:
25
+ return undefined;
26
+ }
27
+ }
28
+ export function shouldHandleCustomOptionTrigger(trigger, availableTriggers) {
29
+ return trigger !== undefined && availableTriggers.includes(trigger);
30
+ }
31
+ export function resolveAddCustomOptionTriggers(params) {
32
+ if (params.allowCustomOption) {
33
+ const source = params.addCustomOptionTriggers !== undefined ? params.addCustomOptionTriggers : params.defaultTriggers;
34
+ return source.filter(trigger => params.defaultTriggers.includes(trigger));
35
+ }
36
+ if (params.addOptionByEnter) {
37
+ return [ADD_CUSTOM_OPTION_TRIGGER.Enter];
38
+ }
39
+ return [];
40
+ }
41
+ /** Фокус ушёл на дроплист, футер или postfix — кастомную опцию на blur фиксировать не нужно. */
42
+ export function isCustomOptionBlurInsideField(relatedTarget, nodes) {
43
+ return relatedTarget instanceof Node && nodes.some(node => Boolean(node?.contains(relatedTarget)));
44
+ }
45
+ /** Клик снаружи закрывает список, не снимая фокус с input — blur не придёт. */
46
+ export function shouldCommitCustomOptionOnClose(params) {
47
+ return !params.skip && params.activeElement !== null && params.activeElement === params.inputElement;
48
+ }
@@ -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;