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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +4 -2
  2. package/dist/cjs/components/FieldDate/FieldDate.js +3 -3
  3. package/dist/cjs/components/FieldDate/mask.d.ts +2 -3
  4. package/dist/cjs/components/FieldDate/mask.js +3 -4
  5. package/dist/cjs/components/FieldSelect/FieldSelect.js +98 -43
  6. package/dist/cjs/components/FieldSelect/constants.d.ts +11 -0
  7. package/dist/cjs/components/FieldSelect/constants.js +20 -1
  8. package/dist/cjs/components/FieldSelect/hooks.d.ts +33 -0
  9. package/dist/cjs/components/FieldSelect/hooks.js +95 -0
  10. package/dist/cjs/components/FieldSelect/types.d.ts +26 -3
  11. package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +21 -0
  12. package/dist/cjs/components/FieldSelect/utils/customOption.js +55 -0
  13. package/dist/cjs/components/FieldSelect/utils/extract.d.ts +11 -0
  14. package/dist/cjs/components/FieldSelect/utils/extract.js +39 -0
  15. package/dist/cjs/components/FieldSelect/utils/filter.d.ts +3 -0
  16. package/dist/cjs/components/FieldSelect/utils/filter.js +40 -0
  17. package/dist/cjs/components/FieldSelect/utils/index.d.ts +6 -0
  18. package/dist/cjs/components/FieldSelect/utils/index.js +22 -0
  19. package/dist/cjs/components/FieldSelect/utils/items.d.ts +9 -0
  20. package/dist/cjs/components/FieldSelect/utils/items.js +47 -0
  21. package/dist/cjs/components/FieldSelect/utils/selection.d.ts +11 -0
  22. package/dist/cjs/components/FieldSelect/utils/selection.js +18 -0
  23. package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +4 -0
  24. package/dist/cjs/components/FieldSelect/utils/tagSize.js +5 -0
  25. package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
  26. package/dist/cjs/segments/useSegmentedMask.d.ts +0 -2
  27. package/dist/cjs/segments/useSegmentedMask.js +56 -146
  28. package/dist/esm/components/FieldDate/FieldDate.js +3 -3
  29. package/dist/esm/components/FieldDate/mask.d.ts +2 -3
  30. package/dist/esm/components/FieldDate/mask.js +3 -4
  31. package/dist/esm/components/FieldSelect/FieldSelect.js +100 -45
  32. package/dist/esm/components/FieldSelect/constants.d.ts +11 -0
  33. package/dist/esm/components/FieldSelect/constants.js +19 -0
  34. package/dist/esm/components/FieldSelect/hooks.d.ts +33 -0
  35. package/dist/esm/components/FieldSelect/hooks.js +92 -0
  36. package/dist/esm/components/FieldSelect/types.d.ts +26 -3
  37. package/dist/esm/components/FieldSelect/utils/customOption.d.ts +21 -0
  38. package/dist/esm/components/FieldSelect/utils/customOption.js +48 -0
  39. package/dist/esm/components/FieldSelect/utils/extract.d.ts +11 -0
  40. package/dist/esm/components/FieldSelect/utils/extract.js +34 -0
  41. package/dist/esm/components/FieldSelect/utils/filter.d.ts +3 -0
  42. package/dist/esm/components/FieldSelect/utils/filter.js +36 -0
  43. package/dist/esm/components/FieldSelect/utils/index.d.ts +6 -0
  44. package/dist/esm/components/FieldSelect/utils/index.js +6 -0
  45. package/dist/esm/components/FieldSelect/utils/items.d.ts +9 -0
  46. package/dist/esm/components/FieldSelect/utils/items.js +41 -0
  47. package/dist/esm/components/FieldSelect/utils/selection.d.ts +11 -0
  48. package/dist/esm/components/FieldSelect/utils/selection.js +14 -0
  49. package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +4 -0
  50. package/dist/esm/components/FieldSelect/utils/tagSize.js +2 -0
  51. package/dist/esm/components/FieldTime/FieldTime.js +2 -2
  52. package/dist/esm/segments/useSegmentedMask.d.ts +0 -2
  53. package/dist/esm/segments/useSegmentedMask.js +56 -146
  54. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  55. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  56. package/package.json +8 -8
  57. package/src/components/FieldDate/FieldDate.tsx +3 -3
  58. package/src/components/FieldDate/mask.ts +3 -4
  59. package/src/components/FieldSelect/FieldSelect.tsx +151 -63
  60. package/src/components/FieldSelect/constants.ts +22 -0
  61. package/src/components/FieldSelect/hooks.ts +177 -0
  62. package/src/components/FieldSelect/types.ts +82 -48
  63. package/src/components/FieldSelect/utils/customOption.ts +77 -0
  64. package/src/components/FieldSelect/utils/extract.ts +49 -0
  65. package/src/components/FieldSelect/utils/filter.ts +51 -0
  66. package/src/components/FieldSelect/utils/index.ts +6 -0
  67. package/src/components/FieldSelect/utils/items.ts +58 -0
  68. package/src/components/FieldSelect/utils/selection.ts +25 -0
  69. package/src/components/FieldSelect/utils/tagSize.ts +5 -0
  70. package/src/components/FieldTime/FieldTime.tsx +2 -2
  71. package/src/segments/useSegmentedMask.ts +52 -172
  72. package/dist/cjs/components/FieldSelect/utils.d.ts +0 -19
  73. package/dist/cjs/components/FieldSelect/utils.js +0 -103
  74. package/dist/esm/components/FieldSelect/utils.d.ts +0 -19
  75. package/dist/esm/components/FieldSelect/utils.js +0 -92
  76. package/src/components/FieldSelect/utils.ts +0 -132
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-ru/ds-fields",
3
- "version": "2.2.3-preview-8025692f.0",
3
+ "version": "2.2.3-preview-a0540d7f.0",
4
4
  "description": "Поля ввода с единой обёрткой label/caption/hint/error и общими осями size/validationState — текст, пароль, число, ползунок, дата, время, выбор, цвет.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,24 +39,24 @@
39
39
  "classnames": "^2.5.1",
40
40
  "merge-refs": "^2.0.0",
41
41
  "react-textarea-autosize": "^8.5.9",
42
- "@cloud-ru/ds-bottom-sheet": "1.1.1",
43
42
  "@cloud-ru/ds-adaptive": "1.0.1",
43
+ "@cloud-ru/ds-bottom-sheet": "1.1.1",
44
+ "@cloud-ru/ds-button": "1.0.2",
45
+ "@cloud-ru/ds-calendar": "1.0.15-preview-a0540d7f.0",
44
46
  "@cloud-ru/ds-color-picker": "1.0.6",
45
- "@cloud-ru/ds-calendar": "1.0.15-preview-8025692f.0",
46
47
  "@cloud-ru/ds-divider": "1.0.1",
47
48
  "@cloud-ru/ds-dropdown": "1.0.8",
48
- "@cloud-ru/ds-button": "1.0.2",
49
- "@cloud-ru/ds-field-decorator": "1.0.5",
50
49
  "@cloud-ru/ds-icons": "1.1.0",
51
- "@cloud-ru/ds-list": "2.1.4-preview-8025692f.0",
50
+ "@cloud-ru/ds-field-decorator": "1.0.5",
52
51
  "@cloud-ru/ds-input-private": "1.0.4",
53
52
  "@cloud-ru/ds-loader": "1.0.1",
53
+ "@cloud-ru/ds-list": "2.1.4-preview-a0540d7f.0",
54
54
  "@cloud-ru/ds-materials": "1.0.1",
55
55
  "@cloud-ru/ds-scroll": "1.0.2",
56
- "@cloud-ru/ds-slider": "1.0.4",
57
56
  "@cloud-ru/ds-skeleton": "1.0.1",
58
- "@cloud-ru/ds-tooltip": "1.0.4",
57
+ "@cloud-ru/ds-slider": "1.0.4",
59
58
  "@cloud-ru/ds-tag": "1.1.1",
59
+ "@cloud-ru/ds-tooltip": "1.0.4",
60
60
  "@cloud-ru/ds-utils": "1.1.0"
61
61
  }
62
62
  }
@@ -520,12 +520,12 @@ export const FieldDate = forwardRef<HTMLInputElement, FieldDateProps>(function F
520
520
  </span>
521
521
  ) : (
522
522
  <InputPrivate
523
- ref={mergeRefs(ref, fromInputRef, singleSeg.nativeInputRef)}
523
+ ref={mergeRefs(ref, fromInputRef)}
524
524
  className={fieldStyles.fieldInput}
525
525
  value={singleInputValue}
526
526
  placeholder={placeholderMask}
527
- // Single/date-time ведёт сегментный движок (useSegmentedMask), нативный onChange
528
- // игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
527
+ // Single/date-time ведёт сегментный движок (keydown, useSegmentedMask), нативный
528
+ // onChange игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
529
529
  onChange={() => {}}
530
530
  onKeyDown={handleSingleKeyDown}
531
531
  onClick={singleSeg.handleClick}
@@ -55,9 +55,8 @@ function buildSegments(mode: MaskMode, showSeconds: boolean): Segment[] {
55
55
  * «десятку» (`day` → > 3, `month` → > 1, `hour` → > 2, `minute`/`second` → > 5), цифра трактуется
56
56
  * как «единицы» и в начало сегмента добавляется ноль. Пример: `5` для day → `05.`,
57
57
  * фокус автоматически уезжает на следующий сегмент.
58
- * 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
59
- * сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
60
- * Пример: `39` для day → `09`, `13` для month → `03`.
58
+ * 2. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
59
+ * значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
61
60
  */
62
61
  export function formatMask(input: string, mode: MaskMode, showSeconds = true): string {
63
62
  const maxLen = totalLen(mode, showSeconds);
@@ -82,7 +81,7 @@ export function formatMask(input: string, mode: MaskMode, showSeconds = true): s
82
81
  stream = stream.slice(seg.len);
83
82
  if (raw.length === seg.len) {
84
83
  const n = Number(raw);
85
- out += n > seg.max ? raw.slice(-1).padStart(seg.len, '0') : raw;
84
+ out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
86
85
  } else {
87
86
  out += raw;
88
87
  }
@@ -2,7 +2,7 @@ import { ChevronDownSVG } from '@cloud-ru/ds-icons/interface/system';
2
2
  import { InputPrivate, useButtonNavigation, useClearButton } from '@cloud-ru/ds-input-private';
3
3
  import { Droplist, ItemId } from '@cloud-ru/ds-list';
4
4
  import { Tag } from '@cloud-ru/ds-tag';
5
- import { useLayoutEffect } from '@cloud-ru/ds-utils';
5
+ import { isBrowser, useLayoutEffect } from '@cloud-ru/ds-utils';
6
6
  import cn from 'classnames';
7
7
  import mergeRefs from 'merge-refs';
8
8
  import {
@@ -34,6 +34,7 @@ import {
34
34
  useCopyButton,
35
35
  } from '../shared';
36
36
  import fieldStyles from '../shared/styles.module.scss';
37
+ import { useCustomOption } from './hooks';
37
38
  import styles from './styles.module.scss';
38
39
  import { FieldSelectProps } from './types';
39
40
  import {
@@ -42,6 +43,8 @@ import {
42
43
  filterItems,
43
44
  findItem,
44
45
  flatten,
46
+ getMissingSelectedItems,
47
+ getSelectedIds,
45
48
  isMultiple,
46
49
  TAG_SIZE_MAP,
47
50
  WithIdContent,
@@ -111,12 +114,15 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
111
114
  const chips = multiple ? (props.chips ?? true) : false;
112
115
  const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
113
116
  const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
117
+ const allowCustomOption = props.allowCustomOption === true;
118
+ const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
114
119
 
115
120
  const resolvedAutoFocus = useAdaptiveAutoFocus(autoFocus, layoutPresets);
116
121
 
117
122
  const inputRef = useRef<HTMLInputElement>(null);
118
123
  // Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
119
124
  const inputElementRef = useRef<HTMLInputElement>(null);
125
+ const restoreInputFocusRef = useRef(false);
120
126
  const clearButtonRef = useRef<HTMLButtonElement>(null);
121
127
  const copyButtonRef = useRef<HTMLButtonElement>(null);
122
128
  const contentRef = useRef<HTMLDivElement>(null);
@@ -177,18 +183,31 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
177
183
 
178
184
  const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
179
185
 
186
+ const selectedIds = useMemo(
187
+ () => getSelectedIds({ multiple, multipleValue, singleValue }),
188
+ [multiple, multipleValue, singleValue],
189
+ );
190
+
191
+ // Выбранное, которого нет в `items` (кастом по allowCustomOption, ленивая подгрузка) — в список, не только в чип.
192
+ const missingSelectedItems = useMemo(() => getMissingSelectedItems(selectedIds, allItems), [selectedIds, allItems]);
193
+
194
+ const itemsWithPlaceholders = useMemo(
195
+ () => (missingSelectedItems.length > 0 ? [...missingSelectedItems, ...items] : items),
196
+ [missingSelectedItems, items],
197
+ );
198
+
180
199
  // Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
181
200
  const seenItems = useRef(new Map<ItemId, WithIdContent>());
182
201
 
183
202
  const resolveItem = useMemo(() => {
184
- for (const item of flatten(allItems)) {
203
+ for (const item of flatten([...missingSelectedItems, ...allItems])) {
185
204
  if (item.id !== undefined) {
186
205
  seenItems.current.set(item.id, item);
187
206
  }
188
207
  }
189
208
 
190
- return (id: ItemId) => findItem(allItems, id) ?? seenItems.current.get(id);
191
- }, [allItems]);
209
+ return (id: ItemId) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
210
+ }, [allItems, missingSelectedItems]);
192
211
 
193
212
  const selectedPairs = useMemo(() => {
194
213
  if (!multiple || multipleValue.length === 0) {
@@ -265,14 +284,14 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
265
284
  setInputValue(selectedLabel);
266
285
  }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
267
286
 
268
- // При autocomplete фильтрует сервер — items берутся как есть.
287
+ // При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
269
288
  const filteredItems = useMemo(() => {
270
289
  if (!searchable || autocomplete || !typing) {
271
- return items;
290
+ return itemsWithPlaceholders;
272
291
  }
273
292
 
274
- return filterItems(items, inputValue, enableFuzzySearch);
275
- }, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
293
+ return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
294
+ }, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
276
295
 
277
296
  const filteredPinTop = useMemo(() => {
278
297
  if (!searchable || autocomplete || !typing || !pinTop) {
@@ -290,61 +309,115 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
290
309
  return filterItems(pinBottom, inputValue, enableFuzzySearch);
291
310
  }, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
292
311
 
293
- const handleOpenChange = useCallback(
294
- (next: boolean) => {
295
- // readonly/disabled поле открывать нельзя, закрывать — можно.
296
- if (next && (disabled || readOnly)) return;
297
- if (openProp === undefined) {
298
- setOpenLocal(next);
312
+ const handleSingleChange = useCallback(
313
+ (next: ItemId | undefined) => {
314
+ if (multiple) {
315
+ return;
299
316
  }
300
317
 
301
- onOpenChange?.(next);
318
+ if (props.value === undefined) {
319
+ setSingleLocal(next);
320
+ }
302
321
 
303
- if (!next) {
322
+ props.onChange?.(next);
323
+ setTyping(false);
324
+ },
325
+ [multiple, props],
326
+ );
327
+
328
+ const handleMultipleChange = useCallback(
329
+ (next: ItemId[]) => {
330
+ if (!multiple) {
331
+ return;
332
+ }
333
+
334
+ restoreInputFocusRef.current = isBrowser() && inputElementRef.current === document.activeElement;
335
+
336
+ if (props.value === undefined) {
337
+ setMultipleLocal(next);
338
+ }
339
+
340
+ props.onChange?.(next);
341
+
342
+ if (searchable) {
304
343
  setTyping(false);
305
344
 
306
345
  if (resetSearchOnOptionSelection) {
307
- setInputValue(selectedLabel);
346
+ setInputValue('');
308
347
  }
309
348
  }
310
349
  },
311
- [openProp, onOpenChange, disabled, readOnly, resetSearchOnOptionSelection, setInputValue, selectedLabel],
350
+ [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue],
312
351
  );
313
352
 
314
- const handleSingleChange = (next: ItemId | undefined) => {
315
- if (multiple) {
316
- return;
317
- }
318
-
319
- if (props.value === undefined) {
320
- setSingleLocal(next);
321
- }
322
-
323
- props.onChange?.(next);
353
+ const resetCustomOptionSearch = useCallback(() => {
324
354
  setTyping(false);
325
- };
326
355
 
327
- const handleMultipleChange = (next: ItemId[]) => {
328
- if (!multiple) {
329
- return;
356
+ if (resetSearchOnOptionSelection) {
357
+ setInputValue(multiple ? '' : selectedLabel);
330
358
  }
359
+ }, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
331
360
 
332
- if (props.value === undefined) {
333
- setMultipleLocal(next);
334
- }
361
+ const {
362
+ listRef,
363
+ suppressCloseCommit,
364
+ enableCloseCommit,
365
+ tryCommitOnClose,
366
+ tryCommitOnBlur,
367
+ tryCommitFromKeyboard,
368
+ handleListSingleChange,
369
+ handleListMultipleChange,
370
+ } = useCustomOption({
371
+ allowCustomOption,
372
+ addCustomOptionTriggers,
373
+ addOptionByEnter,
374
+ multiple,
375
+ inputValue,
376
+ typing,
377
+ multipleValue,
378
+ commitSingle: handleSingleChange,
379
+ commitMultiple: handleMultipleChange,
380
+ resetSearch: resetCustomOptionSearch,
381
+ inputElementRef,
382
+ clearButtonRef,
383
+ copyButtonRef,
384
+ footerActiveElementsRefs,
385
+ });
335
386
 
336
- props.onChange?.(next);
387
+ const handleOpenChange = useCallback(
388
+ (next: boolean) => {
389
+ // readonly/disabled поле открывать нельзя, закрывать — можно.
390
+ if (next && (disabled || readOnly)) return;
391
+ if (openProp === undefined) {
392
+ setOpenLocal(next);
393
+ }
337
394
 
338
- if (searchable) {
339
- setTyping(false);
395
+ onOpenChange?.(next);
396
+
397
+ if (!next) {
398
+ setTyping(false);
399
+ const committed = tryCommitOnClose();
340
400
 
341
- if (resetSearchOnOptionSelection) {
342
- setInputValue('');
401
+ // Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
402
+ if (resetSearchOnOptionSelection && !committed) {
403
+ setInputValue(selectedLabel);
404
+ }
343
405
  }
344
- }
345
- };
406
+ },
407
+ [
408
+ openProp,
409
+ onOpenChange,
410
+ disabled,
411
+ readOnly,
412
+ resetSearchOnOptionSelection,
413
+ setInputValue,
414
+ selectedLabel,
415
+ tryCommitOnClose,
416
+ ],
417
+ );
346
418
 
347
419
  const handleInputChange = (next: string) => {
420
+ enableCloseCommit();
348
421
  setInputValue(next);
349
422
  setTyping(true);
350
423
 
@@ -471,21 +544,13 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
471
544
 
472
545
  if (event.key === 'Escape' && open) {
473
546
  event.preventDefault();
547
+ suppressCloseCommit();
474
548
  handleOpenChange(false);
475
549
  }
476
550
 
477
- if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
478
- // Введённый текст становится новым значением (создание опции «на лету»).
551
+ if (tryCommitFromKeyboard(event)) {
479
552
  event.preventDefault();
480
-
481
- if (multiple) {
482
- if (!multipleValue.includes(inputValue)) {
483
- handleMultipleChange([...multipleValue, inputValue]);
484
- }
485
- } else {
486
- handleSingleChange(inputValue);
487
- }
488
-
553
+ event.stopPropagation();
489
554
  return;
490
555
  }
491
556
 
@@ -501,6 +566,12 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
501
566
  }
502
567
  };
503
568
 
569
+ const handleTriggerKeyDownCapture = (event: KeyboardEvent<HTMLElement>) => {
570
+ if (event.key === 'Escape') {
571
+ suppressCloseCommit();
572
+ }
573
+ };
574
+
504
575
  const handleTriggerMouseEnter = useCallback(() => {
505
576
  if (!readOnly && !disabled) {
506
577
  setHover(true);
@@ -536,20 +607,35 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
536
607
  [searchable, onFocusProp],
537
608
  );
538
609
 
539
- const handleInputBlur = useCallback(
540
- (event: FocusEvent<HTMLInputElement>) => {
541
- setFocusVisible(false);
542
- onBlurProp?.(event);
543
- },
544
- [onBlurProp],
545
- );
610
+ const handleInputBlur = (event: FocusEvent<HTMLInputElement>) => {
611
+ setFocusVisible(false);
612
+ const blurResult = tryCommitOnBlur(event);
613
+
614
+ // Закрытый список не получит onOpenChange — вернуть лейбл выбранного значения.
615
+ // Клик по айтему открытого списка даёт relatedTarget === null, восстановление делает закрытие.
616
+ if (resetSearchOnOptionSelection && blurResult === 'skipped' && (!open || event.relatedTarget !== null)) {
617
+ setTyping(false);
618
+ setInputValue(selectedLabel);
619
+ }
620
+
621
+ onBlurProp?.(event);
622
+ };
546
623
 
547
624
  const droplistSelection = multiple
548
- ? { mode: 'multiple' as const, value: multipleValue, onChange: handleMultipleChange }
549
- : { mode: 'single' as const, value: singleValue, onChange: handleSingleChange };
625
+ ? { mode: 'multiple' as const, value: multipleValue, onChange: handleListMultipleChange }
626
+ : { mode: 'single' as const, value: singleValue, onChange: handleListSingleChange };
550
627
 
551
628
  const hasChips = chips && selectedPairs.length > 0;
552
629
 
630
+ useLayoutEffect(() => {
631
+ if (!restoreInputFocusRef.current) {
632
+ return;
633
+ }
634
+
635
+ restoreInputFocusRef.current = false;
636
+ inputElementRef.current?.focus();
637
+ }, [multipleValue.length]);
638
+
553
639
  // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
554
640
  // Замер до paint, иначе input мигает при вводе.
555
641
  useLayoutEffect(() => {
@@ -583,7 +669,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
583
669
  );
584
670
 
585
671
  const searchInput = hasChips ? (
586
- <div className={styles.inputWrapper} style={{ minWidth: chipInputMinWidth } as CSSProperties}>
672
+ <div key='search-input' className={styles.inputWrapper} style={{ minWidth: chipInputMinWidth } as CSSProperties}>
587
673
  {searchInputField}
588
674
  </div>
589
675
  ) : (
@@ -605,6 +691,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
605
691
  data-test-id={dataTestId}
606
692
  onMouseEnter={handleTriggerMouseEnter}
607
693
  onMouseLeave={handleTriggerMouseLeave}
694
+ onKeyDownCapture={handleTriggerKeyDownCapture}
608
695
  onClick={handleTriggerClick}
609
696
  >
610
697
  <div className={fieldStyles.backgroundWrapper}>
@@ -698,6 +785,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
698
785
  placement={placement}
699
786
  widthStrategy={widthStrategy}
700
787
  triggerElemRef={inputRef}
788
+ listRef={listRef}
701
789
  size={size}
702
790
  open={open}
703
791
  onOpenChange={handleOpenChange}
@@ -2,3 +2,25 @@ export const SELECTION_MODE = {
2
2
  Single: 'single',
3
3
  Multiple: 'multiple',
4
4
  } as const;
5
+
6
+ /** События, по которым произвольный ввод фиксируется как выбранная опция. */
7
+ export const ADD_CUSTOM_OPTION_TRIGGER = {
8
+ Enter: 'enter',
9
+ Blur: 'blur',
10
+ Space: 'space',
11
+ Comma: 'comma',
12
+ } as const;
13
+
14
+ /** Триггеры по умолчанию для `selection='single'` при `allowCustomOption`. */
15
+ export const ADD_CUSTOM_OPTION_TRIGGERS_SINGLE = [
16
+ ADD_CUSTOM_OPTION_TRIGGER.Enter,
17
+ ADD_CUSTOM_OPTION_TRIGGER.Blur,
18
+ ] as const;
19
+
20
+ /** Триггеры по умолчанию для `selection='multiple'` при `allowCustomOption`. */
21
+ export const ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE = [
22
+ ADD_CUSTOM_OPTION_TRIGGER.Enter,
23
+ ADD_CUSTOM_OPTION_TRIGGER.Blur,
24
+ ADD_CUSTOM_OPTION_TRIGGER.Space,
25
+ ADD_CUSTOM_OPTION_TRIGGER.Comma,
26
+ ] as const;
@@ -0,0 +1,177 @@
1
+ import { ItemId } from '@cloud-ru/ds-list';
2
+ import { isBrowser } from '@cloud-ru/ds-utils';
3
+ import { FocusEvent, KeyboardEvent, RefObject, useCallback, useMemo, useRef } from 'react';
4
+
5
+ import {
6
+ ADD_CUSTOM_OPTION_TRIGGER,
7
+ ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE,
8
+ ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
9
+ } from './constants';
10
+ import { FieldSelectAddCustomOptionTrigger } from './types';
11
+ import {
12
+ getCustomOptionTriggerByCode,
13
+ isCustomOptionBlurInsideField,
14
+ resolveAddCustomOptionTriggers,
15
+ shouldCommitCustomOptionOnClose,
16
+ shouldHandleCustomOptionTrigger,
17
+ } from './utils';
18
+
19
+ /** Результат blur: фокус остался внутри поля, ввод зафиксирован, или коммитить было нечего. */
20
+ type CustomOptionBlurResult = 'inside' | 'committed' | 'skipped';
21
+
22
+ type UseCustomOptionParams = {
23
+ allowCustomOption: boolean;
24
+ addCustomOptionTriggers: readonly FieldSelectAddCustomOptionTrigger[] | undefined;
25
+ addOptionByEnter: boolean;
26
+ multiple: boolean;
27
+ inputValue: string;
28
+ typing: boolean;
29
+ multipleValue: ItemId[];
30
+ commitSingle(value: ItemId | undefined): void;
31
+ commitMultiple(value: ItemId[]): void;
32
+ resetSearch(): void;
33
+ inputElementRef: RefObject<HTMLInputElement | null>;
34
+ clearButtonRef: RefObject<HTMLButtonElement | null>;
35
+ copyButtonRef: RefObject<HTMLButtonElement | null>;
36
+ footerActiveElementsRefs?: RefObject<HTMLElement>[];
37
+ };
38
+
39
+ /** Фиксация произвольного ввода как выбранной опции: триггеры, blur, закрытие списка. */
40
+ export function useCustomOption({
41
+ allowCustomOption,
42
+ addCustomOptionTriggers,
43
+ addOptionByEnter,
44
+ multiple,
45
+ inputValue,
46
+ typing,
47
+ multipleValue,
48
+ commitSingle,
49
+ commitMultiple,
50
+ resetSearch,
51
+ inputElementRef,
52
+ clearButtonRef,
53
+ copyButtonRef,
54
+ footerActiveElementsRefs,
55
+ }: UseCustomOptionParams) {
56
+ const listRef = useRef<HTMLElement>(null);
57
+ const skipCloseCommitRef = useRef(false);
58
+ const skipBlurCommitRef = useRef(false);
59
+
60
+ const resolvedTriggers = useMemo(
61
+ () =>
62
+ resolveAddCustomOptionTriggers({
63
+ allowCustomOption,
64
+ addCustomOptionTriggers,
65
+ addOptionByEnter,
66
+ defaultTriggers: multiple ? ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE : ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
67
+ }),
68
+ [allowCustomOption, addCustomOptionTriggers, addOptionByEnter, multiple],
69
+ );
70
+
71
+ const tryCommit = useCallback(
72
+ (trigger: FieldSelectAddCustomOptionTrigger) => {
73
+ if (!typing || !shouldHandleCustomOptionTrigger(trigger, resolvedTriggers) || inputValue === '') {
74
+ return false;
75
+ }
76
+
77
+ if (multiple) {
78
+ if (multipleValue.includes(inputValue)) {
79
+ resetSearch();
80
+ } else {
81
+ commitMultiple([...multipleValue, inputValue]);
82
+ }
83
+ } else {
84
+ commitSingle(inputValue);
85
+ }
86
+
87
+ return true;
88
+ },
89
+ [typing, resolvedTriggers, inputValue, multiple, multipleValue, commitMultiple, commitSingle, resetSearch],
90
+ );
91
+
92
+ const suppressCloseCommit = useCallback(() => {
93
+ skipCloseCommitRef.current = true;
94
+ skipBlurCommitRef.current = true;
95
+ }, []);
96
+
97
+ const enableCloseCommit = useCallback(() => {
98
+ skipCloseCommitRef.current = false;
99
+ skipBlurCommitRef.current = false;
100
+ }, []);
101
+
102
+ const tryCommitOnClose = useCallback(() => {
103
+ const skipped = skipCloseCommitRef.current;
104
+ const shouldCommit = shouldCommitCustomOptionOnClose({
105
+ skip: skipped,
106
+ inputElement: inputElementRef.current,
107
+ activeElement: isBrowser() ? document.activeElement : null,
108
+ });
109
+
110
+ if (skipped) {
111
+ queueMicrotask(() => {
112
+ skipCloseCommitRef.current = false;
113
+ });
114
+ }
115
+
116
+ return shouldCommit && tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur);
117
+ }, [inputElementRef, tryCommit]);
118
+
119
+ const tryCommitOnBlur = useCallback(
120
+ (event: FocusEvent<HTMLInputElement>): CustomOptionBlurResult => {
121
+ if (skipBlurCommitRef.current) {
122
+ skipBlurCommitRef.current = false;
123
+ return 'skipped';
124
+ }
125
+
126
+ if (
127
+ isCustomOptionBlurInsideField(event.relatedTarget, [
128
+ listRef.current,
129
+ clearButtonRef.current,
130
+ copyButtonRef.current,
131
+ ...(footerActiveElementsRefs?.map(ref => ref.current) ?? []),
132
+ ])
133
+ ) {
134
+ return 'inside';
135
+ }
136
+
137
+ return tryCommit(ADD_CUSTOM_OPTION_TRIGGER.Blur) ? 'committed' : 'skipped';
138
+ },
139
+ [clearButtonRef, copyButtonRef, footerActiveElementsRefs, tryCommit],
140
+ );
141
+
142
+ const tryCommitFromKeyboard = useCallback(
143
+ (event: KeyboardEvent<HTMLInputElement>) => {
144
+ const trigger = getCustomOptionTriggerByCode(event.code, event.key);
145
+
146
+ return trigger !== undefined && tryCommit(trigger);
147
+ },
148
+ [tryCommit],
149
+ );
150
+
151
+ const handleListSingleChange = useCallback(
152
+ (next: ItemId | undefined) => {
153
+ skipCloseCommitRef.current = true;
154
+ commitSingle(next);
155
+ },
156
+ [commitSingle],
157
+ );
158
+
159
+ const handleListMultipleChange = useCallback(
160
+ (next: ItemId[]) => {
161
+ skipCloseCommitRef.current = true;
162
+ commitMultiple(next);
163
+ },
164
+ [commitMultiple],
165
+ );
166
+
167
+ return {
168
+ listRef,
169
+ suppressCloseCommit,
170
+ enableCloseCommit,
171
+ tryCommitOnClose,
172
+ tryCommitOnBlur,
173
+ tryCommitFromKeyboard,
174
+ handleListSingleChange,
175
+ handleListMultipleChange,
176
+ };
177
+ }