@cloud-ru/ds-fields 2.1.2 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-ru/ds-fields",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Поля ввода с единой обёрткой label/caption/hint/error и общими осями size/validationState — текст, пароль, число, ползунок, дата, время, выбор, цвет.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,18 +45,18 @@
45
45
  "@cloud-ru/ds-calendar": "^1.0.13",
46
46
  "@cloud-ru/ds-color-picker": "^1.0.5",
47
47
  "@cloud-ru/ds-divider": "^1.0.1",
48
- "@cloud-ru/ds-dropdown": "^1.0.8",
49
48
  "@cloud-ru/ds-field-decorator": "^1.0.5",
49
+ "@cloud-ru/ds-dropdown": "^1.0.8",
50
50
  "@cloud-ru/ds-icons": "^1.1.0",
51
51
  "@cloud-ru/ds-input-private": "^1.0.4",
52
52
  "@cloud-ru/ds-list": "^2.1.2",
53
53
  "@cloud-ru/ds-loader": "^1.0.1",
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",
56
+ "@cloud-ru/ds-skeleton": "^1.0.1",
57
57
  "@cloud-ru/ds-tag": "^1.0.9",
58
- "@cloud-ru/ds-utils": "^1.1.0",
59
58
  "@cloud-ru/ds-tooltip": "^1.0.4",
60
- "@cloud-ru/ds-skeleton": "^1.0.1"
59
+ "@cloud-ru/ds-utils": "^1.1.0",
60
+ "@cloud-ru/ds-slider": "^1.0.4"
61
61
  }
62
62
  }
@@ -18,8 +18,7 @@ import {
18
18
  useState,
19
19
  } from 'react';
20
20
 
21
- // Минимальная ширина поискового input'а в режиме чипов: при пустом вводе input «схлопывается»
22
- // к этому значению и прижимается к последнему чипу (паритет с легаси BASE_MIN_WIDTH).
21
+ // Минимальная ширина поискового input'а в режиме чипов (паритет с легаси BASE_MIN_WIDTH).
23
22
  const SEARCH_INPUT_PLUG_MIN_WIDTH = 4;
24
23
 
25
24
  import { FieldDecorator, SIZE, VALIDATION_STATE } from '@cloud-ru/ds-field-decorator';
@@ -116,14 +115,10 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
116
115
  const resolvedAutoFocus = useAdaptiveAutoFocus(autoFocus, layoutPresets);
117
116
 
118
117
  const inputRef = useRef<HTMLInputElement>(null);
119
- // Реальный <input>: `inputRef` уходит в Droplist как `triggerElemRef`, а `PopoverPrivate`
120
- // перезаписывает его `.current` своей span-обёрткой (см. MR!101). Отдельный ref на сам input
121
- // нужен для `useButtonNavigation` (курсор/фокус) и возврата фокуса после очистки.
118
+ // Отдельный ref на реальный <input>: `inputRef.current` перезаписывается span-обёрткой PopoverPrivate.
122
119
  const inputElementRef = useRef<HTMLInputElement>(null);
123
120
  const clearButtonRef = useRef<HTMLButtonElement>(null);
124
121
  const copyButtonRef = useRef<HTMLButtonElement>(null);
125
- // Контейнер чипов+input (замер доступной ширины) и скрытый плаг (замер ширины введённого текста)
126
- // для расчёта minWidth поискового input'а в режиме чипов — см. searchInput ниже.
127
122
  const contentRef = useRef<HTMLDivElement>(null);
128
123
  const inputPlugRef = useRef<HTMLSpanElement>(null);
129
124
  const [chipInputMinWidth, setChipInputMinWidth] = useState<number | undefined>(undefined);
@@ -136,15 +131,11 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
136
131
  !multiple ? (props.defaultValue as ItemId | undefined) : undefined,
137
132
  );
138
133
  const [multipleLocal, setMultipleLocal] = useState<ItemId[]>(
139
- // Array.isArray-страховка: при multiple defaultValue обязан быть массивом по типам,
140
- // но «расхлябанный» вызов (например, Storybook-spread, где single-default остаётся
141
- // строкой при переключении selection) не должен ронять компонент на `.map` строки.
134
+ // Страховка от вызова без типов (Storybook-spread): при multiple defaultValue может прийти строкой.
142
135
  multiple && Array.isArray(props.defaultValue) ? (props.defaultValue as ItemId[]) : [],
143
136
  );
144
137
 
145
- // Controlled-режим «залипает»: как только потребитель прислал `value`, локальный стейт больше
146
- // не читается. Иначе очистка (потребитель ставит `undefined`) откатывалась бы на устаревший
147
- // локальный выбор и воскрешала прошлое значение.
138
+ // Controlled-режим «залипает»: иначе очистка через `value: undefined` откатится на локальный стейт.
148
139
  const singleControlled = useRef(false);
149
140
  const multipleControlled = useRef(false);
150
141
 
@@ -245,30 +236,36 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
245
236
  return selectedPairs.map(formatPair).join(', ');
246
237
  }, [multiple, singleValue, selectedPairs, chips, props, formatPair, resolveItem]);
247
238
 
248
- // Строка поиска: controlled через `search.value`, иначе локальный state. Auto-sync к выбранному
249
- // значению делаем через СТАБИЛЬНЫЙ setLocalInput, а не через setInputValue в deps эффекта —
250
- // иначе нестабильная ссылка сеттера зацикливает эффект (сбрасывала бы значение постоянно).
251
239
  const [localInput, setLocalInput] = useState(search?.defaultValue ?? selectedLabel);
252
240
  const inputValue = search?.value !== undefined ? search.value : localInput;
253
241
  const [typing, setTyping] = useState(false);
254
242
 
255
- const setInputValue = useCallback(
256
- (next: string) => {
257
- if (search?.value === undefined) {
258
- setLocalInput(next);
259
- }
260
- search?.onChange?.(next);
261
- },
262
- [search],
263
- );
243
+ // `search` — объектный литерал с новой идентичностью каждый рендер; ref делает `setInputValue` стабильным.
244
+ const searchRef = useRef(search);
245
+ searchRef.current = search;
246
+
247
+ const setInputValue = useCallback((next: string) => {
248
+ const currentSearch = searchRef.current;
249
+
250
+ if (currentSearch?.value === undefined) {
251
+ setLocalInput(next);
252
+ }
253
+
254
+ currentSearch?.onChange?.(next);
255
+ }, []);
256
+
257
+ const syncedLabelRef = useRef(selectedLabel);
264
258
 
265
259
  useEffect(() => {
266
- if (!typing && resetSearchOnOptionSelection && search?.value === undefined) {
267
- setLocalInput(selectedLabel);
260
+ if (typing || !resetSearchOnOptionSelection || syncedLabelRef.current === selectedLabel) {
261
+ return;
268
262
  }
269
- }, [selectedLabel, typing, resetSearchOnOptionSelection, search]);
270
263
 
271
- // autocomplete — клиентскую фильтрацию не делаем, список берётся из items как есть (серверный поиск).
264
+ syncedLabelRef.current = selectedLabel;
265
+ setInputValue(selectedLabel);
266
+ }, [selectedLabel, typing, resetSearchOnOptionSelection, setInputValue]);
267
+
268
+ // При autocomplete фильтрует сервер — items берутся как есть.
272
269
  const filteredItems = useMemo(() => {
273
270
  if (!searchable || autocomplete || !typing) {
274
271
  return items;
@@ -295,8 +292,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
295
292
 
296
293
  const handleOpenChange = useCallback(
297
294
  (next: boolean) => {
298
- // Дроплист открывается кликом по полю (Droplist дёргает onOpenChange(true)).
299
- // readonly/disabled поле открывать нельзя — блокируем открытие, закрытие разрешаем.
295
+ // readonly/disabled поле открывать нельзя, закрывать можно.
300
296
  if (next && (disabled || readOnly)) return;
301
297
  if (openProp === undefined) {
302
298
  setOpenLocal(next);
@@ -428,9 +424,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
428
424
  return copied;
429
425
  }, [onCopyButtonClick, valueToCopy]);
430
426
 
431
- // Clear/Copy как roving-postfix: ArrowRight из input (курсор в конце или readonly) уводит на
432
- // кнопку, ArrowLeft — обратно в поле. Паритет с FieldCombo. `inputElementRef` — реальный input,
433
- // т.к. `inputRef` клобберится popover'ом.
427
+ // Clear/Copy roving-postfix (ArrowRight/ArrowLeft), паритет с FieldCombo.
434
428
  const clearButtonSettings = useClearButton({
435
429
  clearButtonRef,
436
430
  showClearButton: showClear,
@@ -458,10 +452,9 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
458
452
  });
459
453
 
460
454
  const handleTriggerKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
461
- // Пробрасываем consumer onKeyDown до внутренней обработки (паритет с легаси useHandleOnKeyDown).
455
+ // Consumer-обработчик идёт до внутренней обработки (паритет с легаси useHandleOnKeyDown).
462
456
  onKeyDownProp?.(event);
463
457
 
464
- // Roving-навигация по clear/copy (ArrowRight/ArrowLeft). ArrowDown/Enter ниже не перехватываются.
465
458
  onInputKeyDown(event);
466
459
 
467
460
  if (disabled || readOnly) {
@@ -479,7 +472,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
479
472
  }
480
473
 
481
474
  if (addOptionByEnter && event.key === 'Enter' && inputValue !== '') {
482
- // Зафиксировать введённый текст как новый выбор (создание опции «на лету»).
475
+ // Введённый текст становится новым значением (создание опции «на лету»).
483
476
  event.preventDefault();
484
477
 
485
478
  if (multiple) {
@@ -554,10 +547,8 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
554
547
 
555
548
  const hasChips = chips && selectedPairs.length > 0;
556
549
 
557
- // minWidth поискового input'а в режиме чипов: ширина введённого текста (скрытый `.inputPlug`),
558
- // но не больше ширины строки чипов (`contentRef`). Замер в layout-эффекте (до paint, без мигания):
559
- // пустой ввод → ~4px (input прижат к последнему чипу), длинный ввод → перенос на новую строку
560
- // через flex-wrap. Без чипов minWidth не задаётся (input занимает всю строку как single-select).
550
+ // minWidth поискового input'а = ширина введённого текста (`.inputPlug`), но не больше строки чипов.
551
+ // Замер до paint, иначе input мигает при вводе.
561
552
  useLayoutEffect(() => {
562
553
  if (!hasChips) {
563
554
  setChipInputMinWidth(undefined);
@@ -588,8 +579,6 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
588
579
  />
589
580
  );
590
581
 
591
- // В режиме чипов input заворачивается в `.inputWrapper` с динамической minWidth (флоу за чипами);
592
- // без чипов — обычный `inputArea` на всю строку.
593
582
  const searchInput = hasChips ? (
594
583
  <div className={styles.inputWrapper} style={{ minWidth: chipInputMinWidth } as CSSProperties}>
595
584
  {searchInputField}
@@ -599,7 +588,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
599
588
  );
600
589
 
601
590
  const trigger = (
602
- // Семантика: combobox-обёртка с InputPrivate внутри (input = focusable element).
591
+ // Focusable-элемент InputPrivate внутри, обёртка только собирает combobox.
603
592
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
604
593
  <div
605
594
  className={cn(fieldStyles.fieldWrapper, styles.trigger, fieldClassName)}
@@ -636,9 +625,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
636
625
  {iconBefore && <div className={fieldStyles.iconSlot}>{iconBefore}</div>}
637
626
  <div className={fieldStyles.inputLine}>
638
627
  {prefix && <span className={fieldStyles.prefix}>{prefix}</span>}
639
- {/* Чипы и поисковый input живут в одном wrap-контейнере (паритет с легаси
640
- FieldSelect .contentWrapper): input течёт сразу за последним чипом и
641
- переносится на новую строку только когда не влезает. */}
628
+ {/* Чипы и input в одном wrap-контейнере: input течёт за последним чипом. */}
642
629
  {hasChips ? (
643
630
  <div className={styles.chipsRow} ref={contentRef} data-test-id={TEST_IDS.fieldSelectChips}>
644
631
  {selectedPairs.map(pair => (
@@ -651,8 +638,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
651
638
  />
652
639
  ))}
653
640
  {searchInput}
654
- {/* Скрытый плаг: рендерит введённый текст той же типографикой, замеряем его ширину
655
- для minWidth поискового input'а (см. layout-эффект выше). aria-hidden — вне дерева. */}
641
+ {/* Скрытая копия введённого текста по её ширине считается minWidth input'а. */}
656
642
  <span ref={inputPlugRef} className={styles.inputPlug} aria-hidden>
657
643
  {inputValue}
658
644
  </span>
@@ -660,9 +646,7 @@ export const FieldSelect = forwardRef<HTMLInputElement, FieldSelectProps>(functi
660
646
  ) : (
661
647
  searchInput
662
648
  )}
663
- {/* Clear/Copy roving-postfix через useButtonNavigation (ArrowRight/ArrowLeft).
664
- Слот-обёртка гасит всплытие mousedown/click к Droplist-триггеру; test-id и onClick
665
- живут на <Button> внутри (адресуемый = интерактивный). */}
649
+ {/* Обёртка гасит всплытие mousedown/click к Droplist-триггеру. */}
666
650
  {postfixButtons && (
667
651
  // eslint-disable-next-line jsx-a11y/no-static-element-interactions
668
652
  <span