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

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 (77) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -3
  3. package/dist/cjs/components/FieldDate/FieldDate.js +3 -3
  4. package/dist/cjs/components/FieldDate/mask.d.ts +3 -2
  5. package/dist/cjs/components/FieldDate/mask.js +4 -3
  6. package/dist/cjs/components/FieldSelect/FieldSelect.js +43 -98
  7. package/dist/cjs/components/FieldSelect/constants.d.ts +0 -11
  8. package/dist/cjs/components/FieldSelect/constants.js +1 -20
  9. package/dist/cjs/components/FieldSelect/types.d.ts +3 -26
  10. package/dist/cjs/components/FieldSelect/utils.d.ts +19 -0
  11. package/dist/cjs/components/FieldSelect/utils.js +103 -0
  12. package/dist/cjs/components/FieldTime/FieldTime.js +2 -2
  13. package/dist/cjs/segments/useSegmentedMask.d.ts +2 -0
  14. package/dist/cjs/segments/useSegmentedMask.js +145 -56
  15. package/dist/esm/components/FieldDate/FieldDate.js +3 -3
  16. package/dist/esm/components/FieldDate/mask.d.ts +3 -2
  17. package/dist/esm/components/FieldDate/mask.js +4 -3
  18. package/dist/esm/components/FieldSelect/FieldSelect.js +45 -100
  19. package/dist/esm/components/FieldSelect/constants.d.ts +0 -11
  20. package/dist/esm/components/FieldSelect/constants.js +0 -19
  21. package/dist/esm/components/FieldSelect/types.d.ts +3 -26
  22. package/dist/esm/components/FieldSelect/utils.d.ts +19 -0
  23. package/dist/esm/components/FieldSelect/utils.js +92 -0
  24. package/dist/esm/components/FieldTime/FieldTime.js +2 -2
  25. package/dist/esm/segments/useSegmentedMask.d.ts +2 -0
  26. package/dist/esm/segments/useSegmentedMask.js +145 -56
  27. package/dist/tsconfig.cjs.tsbuildinfo +1 -1
  28. package/dist/tsconfig.esm.tsbuildinfo +1 -1
  29. package/package.json +20 -20
  30. package/src/components/FieldDate/FieldDate.tsx +3 -3
  31. package/src/components/FieldDate/mask.ts +4 -3
  32. package/src/components/FieldSelect/FieldSelect.tsx +63 -151
  33. package/src/components/FieldSelect/constants.ts +0 -22
  34. package/src/components/FieldSelect/types.ts +48 -82
  35. package/src/components/FieldSelect/utils.ts +132 -0
  36. package/src/components/FieldTime/FieldTime.tsx +2 -2
  37. package/src/segments/useSegmentedMask.ts +171 -52
  38. package/dist/cjs/components/FieldSelect/hooks.d.ts +0 -33
  39. package/dist/cjs/components/FieldSelect/hooks.js +0 -95
  40. package/dist/cjs/components/FieldSelect/utils/customOption.d.ts +0 -21
  41. package/dist/cjs/components/FieldSelect/utils/customOption.js +0 -55
  42. package/dist/cjs/components/FieldSelect/utils/extract.d.ts +0 -11
  43. package/dist/cjs/components/FieldSelect/utils/extract.js +0 -39
  44. package/dist/cjs/components/FieldSelect/utils/filter.d.ts +0 -3
  45. package/dist/cjs/components/FieldSelect/utils/filter.js +0 -40
  46. package/dist/cjs/components/FieldSelect/utils/index.d.ts +0 -6
  47. package/dist/cjs/components/FieldSelect/utils/index.js +0 -22
  48. package/dist/cjs/components/FieldSelect/utils/items.d.ts +0 -9
  49. package/dist/cjs/components/FieldSelect/utils/items.js +0 -47
  50. package/dist/cjs/components/FieldSelect/utils/selection.d.ts +0 -11
  51. package/dist/cjs/components/FieldSelect/utils/selection.js +0 -18
  52. package/dist/cjs/components/FieldSelect/utils/tagSize.d.ts +0 -4
  53. package/dist/cjs/components/FieldSelect/utils/tagSize.js +0 -5
  54. package/dist/esm/components/FieldSelect/hooks.d.ts +0 -33
  55. package/dist/esm/components/FieldSelect/hooks.js +0 -92
  56. package/dist/esm/components/FieldSelect/utils/customOption.d.ts +0 -21
  57. package/dist/esm/components/FieldSelect/utils/customOption.js +0 -48
  58. package/dist/esm/components/FieldSelect/utils/extract.d.ts +0 -11
  59. package/dist/esm/components/FieldSelect/utils/extract.js +0 -34
  60. package/dist/esm/components/FieldSelect/utils/filter.d.ts +0 -3
  61. package/dist/esm/components/FieldSelect/utils/filter.js +0 -36
  62. package/dist/esm/components/FieldSelect/utils/index.d.ts +0 -6
  63. package/dist/esm/components/FieldSelect/utils/index.js +0 -6
  64. package/dist/esm/components/FieldSelect/utils/items.d.ts +0 -9
  65. package/dist/esm/components/FieldSelect/utils/items.js +0 -41
  66. package/dist/esm/components/FieldSelect/utils/selection.d.ts +0 -11
  67. package/dist/esm/components/FieldSelect/utils/selection.js +0 -14
  68. package/dist/esm/components/FieldSelect/utils/tagSize.d.ts +0 -4
  69. package/dist/esm/components/FieldSelect/utils/tagSize.js +0 -2
  70. package/src/components/FieldSelect/hooks.ts +0 -177
  71. package/src/components/FieldSelect/utils/customOption.ts +0 -77
  72. package/src/components/FieldSelect/utils/extract.ts +0 -49
  73. package/src/components/FieldSelect/utils/filter.ts +0 -51
  74. package/src/components/FieldSelect/utils/index.ts +0 -6
  75. package/src/components/FieldSelect/utils/items.ts +0 -58
  76. package/src/components/FieldSelect/utils/selection.ts +0 -25
  77. package/src/components/FieldSelect/utils/tagSize.ts +0 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-ru/ds-fields",
3
- "version": "2.2.3-preview-a0540d7f.0",
3
+ "version": "2.2.4",
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-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",
46
- "@cloud-ru/ds-color-picker": "1.0.6",
47
- "@cloud-ru/ds-divider": "1.0.1",
48
- "@cloud-ru/ds-dropdown": "1.0.8",
49
- "@cloud-ru/ds-icons": "1.1.0",
50
- "@cloud-ru/ds-field-decorator": "1.0.5",
51
- "@cloud-ru/ds-input-private": "1.0.4",
52
- "@cloud-ru/ds-loader": "1.0.1",
53
- "@cloud-ru/ds-list": "2.1.4-preview-a0540d7f.0",
54
- "@cloud-ru/ds-materials": "1.0.1",
55
- "@cloud-ru/ds-scroll": "1.0.2",
56
- "@cloud-ru/ds-skeleton": "1.0.1",
57
- "@cloud-ru/ds-slider": "1.0.4",
58
- "@cloud-ru/ds-tag": "1.1.1",
59
- "@cloud-ru/ds-tooltip": "1.0.4",
60
- "@cloud-ru/ds-utils": "1.1.0"
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.16",
46
+ "@cloud-ru/ds-color-picker": "^1.0.6",
47
+ "@cloud-ru/ds-dropdown": "^1.0.8",
48
+ "@cloud-ru/ds-field-decorator": "^1.0.5",
49
+ "@cloud-ru/ds-icons": "^1.1.0",
50
+ "@cloud-ru/ds-input-private": "^1.0.4",
51
+ "@cloud-ru/ds-list": "^2.1.4",
52
+ "@cloud-ru/ds-loader": "^1.0.1",
53
+ "@cloud-ru/ds-divider": "^1.0.1",
54
+ "@cloud-ru/ds-materials": "^1.0.1",
55
+ "@cloud-ru/ds-scroll": "^1.0.2",
56
+ "@cloud-ru/ds-slider": "^1.0.4",
57
+ "@cloud-ru/ds-tag": "^1.1.1",
58
+ "@cloud-ru/ds-tooltip": "^1.0.4",
59
+ "@cloud-ru/ds-skeleton": "^1.0.1",
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)}
523
+ ref={mergeRefs(ref, fromInputRef, singleSeg.nativeInputRef)}
524
524
  className={fieldStyles.fieldInput}
525
525
  value={singleInputValue}
526
526
  placeholder={placeholderMask}
527
- // Single/date-time ведёт сегментный движок (keydown, useSegmentedMask), нативный
528
- // onChange игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
527
+ // Single/date-time ведёт сегментный движок (useSegmentedMask), нативный onChange
528
+ // игнорируется. Range — обычный ввод-по-маске (formatMask в handleRangeInput).
529
529
  onChange={() => {}}
530
530
  onKeyDown={handleSingleKeyDown}
531
531
  onClick={singleSeg.handleClick}
@@ -55,8 +55,9 @@ 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. **Clamp полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
59
- * значение прижимается к верхней границе. Пример: `55` для day → `31`, `99` для month → `12`.
58
+ * 2. **Переполнение полной двойки**: если набрано две цифры и значение превышает `max` сегмента —
59
+ * сегмент начинается заново с последней цифры (как посегментный ввод в `useSegmentedMask`).
60
+ * Пример: `39` для day → `09`, `13` для month → `03`.
60
61
  */
61
62
  export function formatMask(input: string, mode: MaskMode, showSeconds = true): string {
62
63
  const maxLen = totalLen(mode, showSeconds);
@@ -81,7 +82,7 @@ export function formatMask(input: string, mode: MaskMode, showSeconds = true): s
81
82
  stream = stream.slice(seg.len);
82
83
  if (raw.length === seg.len) {
83
84
  const n = Number(raw);
84
- out += n > seg.max ? String(seg.max).padStart(seg.len, '0') : raw;
85
+ out += n > seg.max ? raw.slice(-1).padStart(seg.len, '0') : raw;
85
86
  } else {
86
87
  out += raw;
87
88
  }
@@ -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 { isBrowser, useLayoutEffect } from '@cloud-ru/ds-utils';
5
+ import { useLayoutEffect } from '@cloud-ru/ds-utils';
6
6
  import cn from 'classnames';
7
7
  import mergeRefs from 'merge-refs';
8
8
  import {
@@ -34,7 +34,6 @@ import {
34
34
  useCopyButton,
35
35
  } from '../shared';
36
36
  import fieldStyles from '../shared/styles.module.scss';
37
- import { useCustomOption } from './hooks';
38
37
  import styles from './styles.module.scss';
39
38
  import { FieldSelectProps } from './types';
40
39
  import {
@@ -43,8 +42,6 @@ import {
43
42
  filterItems,
44
43
  findItem,
45
44
  flatten,
46
- getMissingSelectedItems,
47
- getSelectedIds,
48
45
  isMultiple,
49
46
  TAG_SIZE_MAP,
50
47
  WithIdContent,
@@ -114,15 +111,12 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
114
111
  const chips = multiple ? (props.chips ?? true) : false;
115
112
  const removeByBackspace = multiple ? (props.removeByBackspace ?? true) : false;
116
113
  const closeDroplistOnItemClick = props.closeDroplistOnItemClick ?? !multiple;
117
- const allowCustomOption = props.allowCustomOption === true;
118
- const addCustomOptionTriggers = allowCustomOption ? props.addCustomOptionTriggers : undefined;
119
114
 
120
115
  const resolvedAutoFocus = useAdaptiveAutoFocus(autoFocus, layoutPresets);
121
116
 
122
117
  const inputRef = useRef<HTMLInputElement>(null);
123
118
  // Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
124
119
  const inputElementRef = useRef<HTMLInputElement>(null);
125
- const restoreInputFocusRef = useRef(false);
126
120
  const clearButtonRef = useRef<HTMLButtonElement>(null);
127
121
  const copyButtonRef = useRef<HTMLButtonElement>(null);
128
122
  const contentRef = useRef<HTMLDivElement>(null);
@@ -183,31 +177,18 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
183
177
 
184
178
  const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
185
179
 
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
-
199
180
  // Выбранное значение переживает смену `items` (серверный поиск, ленивая подгрузка).
200
181
  const seenItems = useRef(new Map<ItemId, WithIdContent>());
201
182
 
202
183
  const resolveItem = useMemo(() => {
203
- for (const item of flatten([...missingSelectedItems, ...allItems])) {
184
+ for (const item of flatten(allItems)) {
204
185
  if (item.id !== undefined) {
205
186
  seenItems.current.set(item.id, item);
206
187
  }
207
188
  }
208
189
 
209
- return (id: ItemId) => findItem(allItems, id) ?? findItem(missingSelectedItems, id) ?? seenItems.current.get(id);
210
- }, [allItems, missingSelectedItems]);
190
+ return (id: ItemId) => findItem(allItems, id) ?? seenItems.current.get(id);
191
+ }, [allItems]);
211
192
 
212
193
  const selectedPairs = useMemo(() => {
213
194
  if (!multiple || multipleValue.length === 0) {
@@ -284,14 +265,14 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
284
265
  setInputValue(selectedLabel);
285
266
  }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
286
267
 
287
- // При autocomplete фильтрует сервер — items берутся как есть, плюс выбранное вне списка.
268
+ // При autocomplete фильтрует сервер — items берутся как есть.
288
269
  const filteredItems = useMemo(() => {
289
270
  if (!searchable || autocomplete || !typing) {
290
- return itemsWithPlaceholders;
271
+ return items;
291
272
  }
292
273
 
293
- return filterItems(itemsWithPlaceholders, inputValue, enableFuzzySearch);
294
- }, [searchable, autocomplete, typing, itemsWithPlaceholders, inputValue, enableFuzzySearch]);
274
+ return filterItems(items, inputValue, enableFuzzySearch);
275
+ }, [searchable, autocomplete, typing, items, inputValue, enableFuzzySearch]);
295
276
 
296
277
  const filteredPinTop = useMemo(() => {
297
278
  if (!searchable || autocomplete || !typing || !pinTop) {
@@ -309,115 +290,61 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
309
290
  return filterItems(pinBottom, inputValue, enableFuzzySearch);
310
291
  }, [searchable, autocomplete, typing, pinBottom, inputValue, enableFuzzySearch]);
311
292
 
312
- const handleSingleChange = useCallback(
313
- (next: ItemId | undefined) => {
314
- if (multiple) {
315
- return;
316
- }
317
-
318
- if (props.value === undefined) {
319
- setSingleLocal(next);
320
- }
321
-
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);
293
+ const handleOpenChange = useCallback(
294
+ (next: boolean) => {
295
+ // readonly/disabled поле открывать нельзя, закрывать — можно.
296
+ if (next && (disabled || readOnly)) return;
297
+ if (openProp === undefined) {
298
+ setOpenLocal(next);
338
299
  }
339
300
 
340
- props.onChange?.(next);
301
+ onOpenChange?.(next);
341
302
 
342
- if (searchable) {
303
+ if (!next) {
343
304
  setTyping(false);
344
305
 
345
306
  if (resetSearchOnOptionSelection) {
346
- setInputValue('');
307
+ setInputValue(selectedLabel);
347
308
  }
348
309
  }
349
310
  },
350
- [multiple, props, searchable, resetSearchOnOptionSelection, setInputValue],
311
+ [openProp, onOpenChange, disabled, readOnly, resetSearchOnOptionSelection, setInputValue, selectedLabel],
351
312
  );
352
313
 
353
- const resetCustomOptionSearch = useCallback(() => {
354
- setTyping(false);
314
+ const handleSingleChange = (next: ItemId | undefined) => {
315
+ if (multiple) {
316
+ return;
317
+ }
355
318
 
356
- if (resetSearchOnOptionSelection) {
357
- setInputValue(multiple ? '' : selectedLabel);
319
+ if (props.value === undefined) {
320
+ setSingleLocal(next);
358
321
  }
359
- }, [resetSearchOnOptionSelection, setInputValue, multiple, selectedLabel]);
360
322
 
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
- });
323
+ props.onChange?.(next);
324
+ setTyping(false);
325
+ };
386
326
 
387
- const handleOpenChange = useCallback(
388
- (next: boolean) => {
389
- // readonly/disabled поле открывать нельзя, закрывать — можно.
390
- if (next && (disabled || readOnly)) return;
391
- if (openProp === undefined) {
392
- setOpenLocal(next);
393
- }
327
+ const handleMultipleChange = (next: ItemId[]) => {
328
+ if (!multiple) {
329
+ return;
330
+ }
394
331
 
395
- onOpenChange?.(next);
332
+ if (props.value === undefined) {
333
+ setMultipleLocal(next);
334
+ }
396
335
 
397
- if (!next) {
398
- setTyping(false);
399
- const committed = tryCommitOnClose();
336
+ props.onChange?.(next);
400
337
 
401
- // Кастомная опция не зафиксирована — вернуть лейбл выбранного значения.
402
- if (resetSearchOnOptionSelection && !committed) {
403
- setInputValue(selectedLabel);
404
- }
338
+ if (searchable) {
339
+ setTyping(false);
340
+
341
+ if (resetSearchOnOptionSelection) {
342
+ setInputValue('');
405
343
  }
406
- },
407
- [
408
- openProp,
409
- onOpenChange,
410
- disabled,
411
- readOnly,
412
- resetSearchOnOptionSelection,
413
- setInputValue,
414
- selectedLabel,
415
- tryCommitOnClose,
416
- ],
417
- );
344
+ }
345
+ };
418
346
 
419
347
  const handleInputChange = (next: string) => {
420
- enableCloseCommit();
421
348
  setInputValue(next);
422
349
  setTyping(true);
423
350
 
@@ -544,13 +471,21 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
544
471
 
545
472
  if (event.key === 'Escape' && open) {
546
473
  event.preventDefault();
547
- suppressCloseCommit();
548
474
  handleOpenChange(false);
549
475
  }
550
476
 
551
- if (tryCommitFromKeyboard(event)) {
477
+ if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
478
+ // Введённый текст становится новым значением (создание опции «на лету»).
552
479
  event.preventDefault();
553
- event.stopPropagation();
480
+
481
+ if (multiple) {
482
+ if (!multipleValue.includes(inputValue)) {
483
+ handleMultipleChange([...multipleValue, inputValue]);
484
+ }
485
+ } else {
486
+ handleSingleChange(inputValue);
487
+ }
488
+
554
489
  return;
555
490
  }
556
491
 
@@ -566,12 +501,6 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
566
501
  }
567
502
  };
568
503
 
569
- const handleTriggerKeyDownCapture = (event: KeyboardEvent<HTMLElement>) => {
570
- if (event.key === 'Escape') {
571
- suppressCloseCommit();
572
- }
573
- };
574
-
575
504
  const handleTriggerMouseEnter = useCallback(() => {
576
505
  if (!readOnly && !disabled) {
577
506
  setHover(true);
@@ -607,35 +536,20 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
607
536
  [searchable, onFocusProp],
608
537
  );
609
538
 
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
- };
539
+ const handleInputBlur = useCallback(
540
+ (event: FocusEvent<HTMLInputElement>) => {
541
+ setFocusVisible(false);
542
+ onBlurProp?.(event);
543
+ },
544
+ [onBlurProp],
545
+ );
623
546
 
624
547
  const droplistSelection = multiple
625
- ? { mode: 'multiple' as const, value: multipleValue, onChange: handleListMultipleChange }
626
- : { mode: 'single' as const, value: singleValue, onChange: handleListSingleChange };
548
+ ? { mode: 'multiple' as const, value: multipleValue, onChange: handleMultipleChange }
549
+ : { mode: 'single' as const, value: singleValue, onChange: handleSingleChange };
627
550
 
628
551
  const hasChips = chips && selectedPairs.length > 0;
629
552
 
630
- useLayoutEffect(() => {
631
- if (!restoreInputFocusRef.current) {
632
- return;
633
- }
634
-
635
- restoreInputFocusRef.current = false;
636
- inputElementRef.current?.focus();
637
- }, [multipleValue.length]);
638
-
639
553
  // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
640
554
  // Замер до paint, иначе input мигает при вводе.
641
555
  useLayoutEffect(() => {
@@ -669,7 +583,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
669
583
  );
670
584
 
671
585
  const searchInput = hasChips ? (
672
- <div key='search-input' className={styles.inputWrapper} style={{ minWidth: chipInputMinWidth } as CSSProperties}>
586
+ <div className={styles.inputWrapper} style={{ minWidth: chipInputMinWidth } as CSSProperties}>
673
587
  {searchInputField}
674
588
  </div>
675
589
  ) : (
@@ -691,7 +605,6 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
691
605
  data-test-id={dataTestId}
692
606
  onMouseEnter={handleTriggerMouseEnter}
693
607
  onMouseLeave={handleTriggerMouseLeave}
694
- onKeyDownCapture={handleTriggerKeyDownCapture}
695
608
  onClick={handleTriggerClick}
696
609
  >
697
610
  <div className={fieldStyles.backgroundWrapper}>
@@ -785,7 +698,6 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
785
698
  placement={placement}
786
699
  widthStrategy={widthStrategy}
787
700
  triggerElemRef={inputRef}
788
- listRef={listRef}
789
701
  size={size}
790
702
  open={open}
791
703
  onOpenChange={handleOpenChange}
@@ -2,25 +2,3 @@ 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;
@@ -5,41 +5,11 @@ import { ValueOf } from '@cloud-ru/ds-utils';
5
5
  import { FocusEvent, KeyboardEvent, ReactNode } from 'react';
6
6
 
7
7
  import { FieldLayoutPresets } from '../../hooks';
8
- import {
9
- ADD_CUSTOM_OPTION_TRIGGER,
10
- ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE,
11
- ADD_CUSTOM_OPTION_TRIGGERS_SINGLE,
12
- SELECTION_MODE,
13
- } from './constants';
8
+ import { SELECTION_MODE } from './constants';
14
9
 
15
10
  /** Режим выбора FieldSelect: одно значение или несколько. */
16
11
  export type Selection = ValueOf<typeof SELECTION_MODE>;
17
12
 
18
- /** Событие, по которому произвольный ввод фиксируется как выбранная опция. */
19
- export type FieldSelectAddCustomOptionTrigger = ValueOf<typeof ADD_CUSTOM_OPTION_TRIGGER>;
20
-
21
- /** Триггеры кастомной опции в режиме `single`. */
22
- export type FieldSelectSingleAddCustomOptionTrigger = (typeof ADD_CUSTOM_OPTION_TRIGGERS_SINGLE)[number];
23
-
24
- /** Триггеры кастомной опции в режиме `multiple`. */
25
- export type FieldSelectMultipleAddCustomOptionTrigger = (typeof ADD_CUSTOM_OPTION_TRIGGERS_MULTIPLE)[number];
26
-
27
- type CustomOptionProps<TTrigger extends FieldSelectAddCustomOptionTrigger> = {
28
- /**
29
- * Разрешить фиксировать произвольный ввод из строки поиска как выбранную опцию
30
- * (значение, которого нет в `items`).
31
- * @default false
32
- */
33
- allowCustomOption?: boolean;
34
- /**
35
- * События, по которым произвольный ввод фиксируется как выбранная опция.
36
- * Учитывается только при `allowCustomOption={true}`.
37
- * Если не задан — используются все триггеры режима (`single`: enter, blur; `multiple`: enter, blur, space, comma).
38
- * Триггеры, недоступные в текущем `selection`, игнорируются.
39
- */
40
- addCustomOptionTriggers?: TTrigger[];
41
- };
42
-
43
13
  // ItemProps — union с рекурсивными группами, поэтому раздаём поле по всем веткам.
44
14
  type WithTagAppearance<T> = T extends { items: ItemProps[] }
45
15
  ? Omit<T, 'items'> & { appearance?: TagAppearance; items: WithTagAppearance<ItemProps>[] }
@@ -145,8 +115,6 @@ type CommonSelectProps = FieldSelectDecoratorProps &
145
115
  autocomplete?: boolean;
146
116
  /**
147
117
  * Зафиксировать введённый текст как новый выбор по `Enter` (создание опции «на лету»).
148
- *
149
- * @deprecated Используйте `allowCustomOption`. `true` эквивалентен `allowCustomOption` с триггером `enter`.
150
118
  * @default false
151
119
  */
152
120
  addOptionByEnter?: boolean;
@@ -201,55 +169,53 @@ type CommonSelectProps = FieldSelectDecoratorProps &
201
169
  'data-test-id'?: string;
202
170
  };
203
171
 
204
- export type FieldSelectSingleProps = CommonSelectProps &
205
- CustomOptionProps<FieldSelectSingleAddCustomOptionTrigger> & {
206
- /** Режим выбора. По умолчанию `'single'`. */
207
- selection?: typeof SELECTION_MODE.Single;
208
- /** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
209
- value?: ItemId;
210
- /** Неуправляемое значение по умолчанию. Пустая строка трактуется как «значение не выбрано». */
211
- defaultValue?: ItemId;
212
- /** Колбек смены значения */
213
- onChange?(value: ItemId | undefined): void;
214
- /**
215
- * Закрывать дроплист после клика на айтем.
216
- * @default true
217
- */
218
- closeDroplistOnItemClick?: boolean;
219
- };
172
+ export type FieldSelectSingleProps = CommonSelectProps & {
173
+ /** Режим выбора. По умолчанию `'single'`. */
174
+ selection?: typeof SELECTION_MODE.Single;
175
+ /** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
176
+ value?: ItemId;
177
+ /** Неуправляемое значение по умолчанию. Пустая строка трактуется как «значение не выбрано». */
178
+ defaultValue?: ItemId;
179
+ /** Колбек смены значения */
180
+ onChange?(value: ItemId | undefined): void;
181
+ /**
182
+ * Закрывать дроплист после клика на айтем.
183
+ * @default true
184
+ */
185
+ closeDroplistOnItemClick?: boolean;
186
+ };
220
187
 
221
- export type FieldSelectMultipleProps = CommonSelectProps &
222
- CustomOptionProps<FieldSelectMultipleAddCustomOptionTrigger> & {
223
- /** Режим выбора */
224
- selection: typeof SELECTION_MODE.Multiple;
225
- /** Управляемые значения */
226
- value?: ItemId[];
227
- /** Неуправляемые значения по умолчанию */
228
- defaultValue?: ItemId[];
229
- /** Колбек смены значений */
230
- onChange?(value: ItemId[]): void;
231
- /**
232
- * Форматтер строки выбранных значений (используется, если `chips=false`).
233
- * @default — список лейблов через `, `
234
- */
235
- formatSelected?(selected: { id: ItemId; label: string }[]): string;
236
- /**
237
- * Отображать выбранные значения как чипы (`@cloud-ru/ds-tag`) внутри поля. Если `false`,
238
- * показывает строку из `formatSelected` либо comma-joined.
239
- * @default true
240
- */
241
- chips?: boolean;
242
- /**
243
- * Удалять последний чип по `Backspace`, когда строка ввода пустая.
244
- * Работает только при `chips=true` и `searchable=true`.
245
- * @default true
246
- */
247
- removeByBackspace?: boolean;
248
- /**
249
- * Закрывать дроплист после клика на айтем.
250
- * @default false
251
- */
252
- closeDroplistOnItemClick?: boolean;
253
- };
188
+ export type FieldSelectMultipleProps = CommonSelectProps & {
189
+ /** Режим выбора */
190
+ selection: typeof SELECTION_MODE.Multiple;
191
+ /** Управляемые значения */
192
+ value?: ItemId[];
193
+ /** Неуправляемые значения по умолчанию */
194
+ defaultValue?: ItemId[];
195
+ /** Колбек смены значений */
196
+ onChange?(value: ItemId[]): void;
197
+ /**
198
+ * Форматтер строки выбранных значений (используется, если `chips=false`).
199
+ * @default — список лейблов через `, `
200
+ */
201
+ formatSelected?(selected: { id: ItemId; label: string }[]): string;
202
+ /**
203
+ * Отображать выбранные значения как чипы (`@cloud-ru/ds-tag`) внутри поля. Если `false`,
204
+ * показывает строку из `formatSelected` либо comma-joined.
205
+ * @default true
206
+ */
207
+ chips?: boolean;
208
+ /**
209
+ * Удалять последний чип по `Backspace`, когда строка ввода пустая.
210
+ * Работает только при `chips=true` и `searchable=true`.
211
+ * @default true
212
+ */
213
+ removeByBackspace?: boolean;
214
+ /**
215
+ * Закрывать дроплист после клика на айтем.
216
+ * @default false
217
+ */
218
+ closeDroplistOnItemClick?: boolean;
219
+ };
254
220
 
255
221
  export type FieldSelectProps = FieldSelectSingleProps | FieldSelectMultipleProps;