@cloud-ru/ds-fields 1.0.8-preview-04507fc5.0 → 2.0.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 2.0.0 (2026-08-27)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **FF-8948:** keep FieldSelect cleared when value is controlled ([6637026](https://github.com/cloud-ru-tech/snack-v2/commit/66370266294b3d7043c1458a7ba2f4eb629c11dc))
11
+ - **FF-8948:** show textarea copy button only in readonly by default ([a81d9bd](https://github.com/cloud-ru-tech/snack-v2/commit/a81d9bda8a13cdaff5f68a6e9f51b41c58dd1fd9))
12
+ - **FF-8948:** treat empty string as no value in FieldSelect single ([338dbef](https://github.com/cloud-ru-tech/snack-v2/commit/338dbefcbbd3832956ca23f3c5838c46d4fdb7d5))
13
+
14
+ ### BREAKING CHANGES
15
+
16
+ - **FF-8680:** rename AiFieldBanner children to bottomContent and type axis to variant ([79a6b2c](https://github.com/cloud-ru-tech/snack-v2/commit/79a6b2c55cca8c4dc3dad23031f7fd4cfe0a311a))
17
+
6
18
  ## 1.0.7 (2026-08-25)
7
19
 
8
20
  **Note:** Version bump only for package @ds/fields
package/README.md CHANGED
@@ -447,7 +447,7 @@ export function Select() {
447
447
  | `data-test-id` | `string` | — | Тестовый id корня |
448
448
  | `dataError` | `DroplistProps` | — | Флаг «ошибка загрузки данных» — при `true` дроплист рендерит `errorDataState` <br/> вместо списка (для асинхронной подгрузки с провалившимся запросом). |
449
449
  | `dataFiltered` | `DroplistProps` | — | Флаг «список отфильтрован» — при `true` и пустом результате дроплист рендерит <br/> `noResultsState`. По умолчанию выводится из строки поиска (`searchable` + ввод). |
450
- | `defaultValue` | `ItemId` | — | Неуправляемое значение по умолчанию <br/> Неуправляемые значения по умолчанию |
450
+ | `defaultValue` | `ItemId` | — | Неуправляемое значение по умолчанию. Пустая строка трактуется как «значение не выбрано». <br/> Неуправляемые значения по умолчанию |
451
451
  | `disabled` | `boolean` | — | Поле выключено <br/> Деактивировано |
452
452
  | `enableFuzzySearch` | `boolean` | `true` | Включить нечёткий поиск: символы запроса должны встречаться в лейбле в том же порядке <br/> (например, `lge` найдёт `Large`). Если `false` — простой substring-match. |
453
453
  | `error` | `string` | — | Ошибка (приоритетнее `hint`; форсит `validationState=error`) |
@@ -499,7 +499,7 @@ export function Select() {
499
499
  | `size` | `"l"` \| `"m"` \| `"s"` | — | Размер |
500
500
  | `untouchableScrollbars` | `boolean` | — | Отключает возможность взаимодействовать со скролбарами мышью. |
501
501
  | `validationState` | `"default"` \| `"error"` \| `"success"` \| `"warning"` | — | Состояние валидации |
502
- | `value` | `ItemId` | — | Управляемое значение <br/> Управляемые значения |
502
+ | `value` | `ItemId` | — | Управляемое значение. Пустая строка трактуется как «значение не выбрано». <br/> Управляемые значения |
503
503
  | `virtualized` | `boolean` | — | Включить виртуализацию элементов списка. Рекомендуется при количестве элементов от 1000. |
504
504
  | `widthStrategy` | `"auto"` \| `"eq"` \| `"gte"` | `'eq' — равна ширине триггера` | Стратегия ширины дроплиста. |
505
505
 
@@ -804,7 +804,8 @@ export function TextArea() {
804
804
  | `required` | `boolean` | — | Показать знак обязательности `*` |
805
805
  | `resizable` | `boolean` | `false` | Можно ли менять высоту мышкой за нижний угол. Игнорируется при `disabled` или `readonly`. |
806
806
  | `showClearButton` | `boolean` | `true` | Кнопка очистки (видна при value && !readonly) |
807
- | `showCopyButton` | `boolean` | `true` | Кнопка копирования (видна при value && !disabled, независимо от readonly) |
807
+ | `showCopyButton` | `boolean` | `true` | Кнопка копирования (видна при непустом value в режиме readonly, как у остальных полей) |
808
+ | `showCopyButtonInEditMode` | `boolean` | `false` | Показывать кнопку копирования и в обычном (не readonly) режиме — рядом с кнопкой очистки. <br/> Опция только для textarea: в многострочном поле копирование значения осмысленно и при вводе. |
808
809
  | `showHintIcon` | `boolean` | — | Отображение статус-иконки у подсказки (по умолчанию `true`) |
809
810
  | `size` | `"l"` \| `"m"` \| `"s"` | `m` | Размер |
810
811
  | `spellCheck` | `boolean` | — | Проверка орфографии |
@@ -52,12 +52,33 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
52
52
  // но «расхлябанный» вызов (например, Storybook-spread, где single-default остаётся
53
53
  // строкой при переключении selection) не должен ронять компонент на `.map` строки.
54
54
  multiple && Array.isArray(props.defaultValue) ? props.defaultValue : []);
55
- const singleValue = (0, react_1.useMemo)(() => (!multiple ? (props.value ?? singleLocal) : undefined), [multiple, props.value, singleLocal]);
55
+ // Controlled-режим «залипает»: как только потребитель прислал `value`, локальный стейт больше
56
+ // не читается. Иначе очистка (потребитель ставит `undefined`) откатывалась бы на устаревший
57
+ // локальный выбор и воскрешала прошлое значение.
58
+ const singleControlled = (0, react_1.useRef)(false);
59
+ const multipleControlled = (0, react_1.useRef)(false);
60
+ if (!multiple && props.value !== undefined) {
61
+ singleControlled.current = true;
62
+ }
63
+ if (multiple && Array.isArray(props.value)) {
64
+ multipleControlled.current = true;
65
+ }
66
+ const singleValue = (0, react_1.useMemo)(() => {
67
+ if (multiple) {
68
+ return undefined;
69
+ }
70
+ const raw = singleControlled.current ? props.value : singleLocal;
71
+ // `''` = «значение не выбрано»: формы отдают пустое значение строкой.
72
+ return raw === '' ? undefined : raw;
73
+ }, [multiple, props.value, singleLocal]);
56
74
  const multipleValue = (0, react_1.useMemo)(() => {
57
75
  if (!multiple) {
58
76
  return [];
59
77
  }
60
- return Array.isArray(props.value) ? props.value : multipleLocal;
78
+ if (multipleControlled.current) {
79
+ return Array.isArray(props.value) ? props.value : [];
80
+ }
81
+ return multipleLocal;
61
82
  }, [multiple, props.value, multipleLocal]);
62
83
  const effectiveValidationState = (0, react_1.useMemo)(() => (error ? field_decorator_1.VALIDATION_STATE.Error : validationState), [error, validationState]);
63
84
  const allItems = (0, react_1.useMemo)(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
@@ -350,7 +371,7 @@ exports.FieldSelect = (0, react_1.forwardRef)(function FieldSelect(props, ref) {
350
371
  const plug = inputPlugRef.current?.clientWidth ?? SEARCH_INPUT_PLUG_MIN_WIDTH;
351
372
  setChipInputMinWidth(Math.min(content, Math.max(plug, SEARCH_INPUT_PLUG_MIN_WIDTH)));
352
373
  }, [hasChips, inputValue, selectedPairs.length]);
353
- const searchInputField = ((0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, inputRef, inputElementRef), className: (0, classnames_1.default)(styles_module_scss_1.default.fieldInput, !searchable && styles_module_scss_2.default.readonlyCursor), value: inputValue, onChange: searchable ? handleInputChange : undefined, placeholder: selectedLabel ? '' : placeholder, disabled: disabled, readonly: !searchable || readOnly, id: id, name: name, autoFocus: resolvedAutoFocus, tabIndex: inputTabIndex, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleTriggerKeyDown, "data-test-id": constants_1.TEST_IDS.fieldSelectInput }));
374
+ const searchInputField = ((0, jsx_runtime_1.jsx)(input_private_1.InputPrivate, { ref: (0, merge_refs_1.default)(ref, inputRef, inputElementRef), className: (0, classnames_1.default)(styles_module_scss_1.default.fieldInput, !searchable && styles_module_scss_2.default.readonlyCursor), value: inputValue, onChange: searchable ? handleInputChange : undefined, placeholder: selectedLabel || hasChips ? '' : placeholder, disabled: disabled, readonly: !searchable || readOnly, id: id, name: name, autoFocus: resolvedAutoFocus, tabIndex: inputTabIndex, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleTriggerKeyDown, "data-test-id": constants_1.TEST_IDS.fieldSelectInput }));
354
375
  // В режиме чипов input заворачивается в `.inputWrapper` с динамической minWidth (флоу за чипами);
355
376
  // без чипов — обычный `inputArea` на всю строку.
356
377
  const searchInput = hasChips ? ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_2.default.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField })) : ((0, jsx_runtime_1.jsx)("div", { className: styles_module_scss_1.default.inputArea, children: searchInputField }));
@@ -146,9 +146,9 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
146
146
  export type FieldSelectSingleProps = CommonSelectProps & {
147
147
  /** Режим выбора. По умолчанию `'single'`. */
148
148
  selection?: typeof SELECTION_MODE.Single;
149
- /** Управляемое значение */
149
+ /** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
150
150
  value?: ItemId;
151
- /** Неуправляемое значение по умолчанию */
151
+ /** Неуправляемое значение по умолчанию. Пустая строка трактуется как «значение не выбрано». */
152
152
  defaultValue?: ItemId;
153
153
  /** Колбек смены значения */
154
154
  onChange?(value: ItemId | undefined): void;
@@ -77,10 +77,16 @@ type FieldTextAreaOwnProps = {
77
77
  */
78
78
  showClearButton?: boolean;
79
79
  /**
80
- * Кнопка копирования (видна при value && !disabled, независимо от readonly)
80
+ * Кнопка копирования (видна при непустом value в режиме readonly, как у остальных полей)
81
81
  * @default true
82
82
  */
83
83
  showCopyButton?: boolean;
84
+ /**
85
+ * Показывать кнопку копирования и в обычном (не readonly) режиме — рядом с кнопкой очистки.
86
+ * Опция только для textarea: в многострочном поле копирование значения осмысленно и при вводе.
87
+ * @default false
88
+ */
89
+ showCopyButtonInEditMode?: boolean;
84
90
  /** Колбек после копирования */
85
91
  onCopyButtonClick?(): void;
86
92
  /** Колбек фокуса */
@@ -18,7 +18,7 @@ const hooks_1 = require("../../hooks");
18
18
  const shared_1 = require("../shared");
19
19
  const styles_module_scss_1 = __importDefault(require('../shared/styles.module.css'));
20
20
  const styles_module_scss_2 = __importDefault(require('./styles.module.css'));
21
- exports.FieldTextArea = (0, react_1.forwardRef)(function FieldTextArea({ label = '', labelTooltip, caption, hint, error, validationState = field_decorator_1.VALIDATION_STATE.Default, showHintIcon, length, required, size = field_decorator_1.SIZE.M, className, fieldClassName, background = true, value: valueProp, defaultValue = '', onChange, placeholder, id, name, maxLength, disabled, readonly: readOnly, autoFocus, layoutPresets, inputMode, spellCheck, minRows = 3, maxRows = 1000, resizable = false, allowMoreThanMaxLength = true, header, footer, showClearButton: showClearButtonProp = true, showCopyButton: showCopyButtonProp = true, onCopyButtonClick, onFocus: onFocusProp, onBlur: onBlurProp, onKeyDown: onKeyDownProp, 'data-test-id': dataTestId, ...rest }, ref) {
21
+ exports.FieldTextArea = (0, react_1.forwardRef)(function FieldTextArea({ label = '', labelTooltip, caption, hint, error, validationState = field_decorator_1.VALIDATION_STATE.Default, showHintIcon, length, required, size = field_decorator_1.SIZE.M, className, fieldClassName, background = true, value: valueProp, defaultValue = '', onChange, placeholder, id, name, maxLength, disabled, readonly: readOnly, autoFocus, layoutPresets, inputMode, spellCheck, minRows = 3, maxRows = 1000, resizable = false, allowMoreThanMaxLength = true, header, footer, showClearButton: showClearButtonProp = true, showCopyButton: showCopyButtonProp = true, showCopyButtonInEditMode = false, onCopyButtonClick, onFocus: onFocusProp, onBlur: onBlurProp, onKeyDown: onKeyDownProp, 'data-test-id': dataTestId, ...rest }, ref) {
22
22
  const localRef = (0, react_1.useRef)(null);
23
23
  const clearButtonRef = (0, react_1.useRef)(null);
24
24
  const copyButtonRef = (0, react_1.useRef)(null);
@@ -37,9 +37,9 @@ exports.FieldTextArea = (0, react_1.forwardRef)(function FieldTextArea({ label =
37
37
  const stringValue = String(value ?? '');
38
38
  const isResizable = resizable && !disabled && !readOnly;
39
39
  const showClearUi = Boolean(showClearButtonProp && stringValue && !readOnly && !disabled);
40
- // Copy не завязан на readonly (паритет с snack-uikit master): виден при любом непустом значении.
41
- // В editable-режиме показываются обе кнопки (clear + copy), в readonly — только copy.
42
- const showCopyUi = Boolean(showCopyButtonProp && stringValue && !disabled);
40
+ // По умолчанию copy только в readonly, как у остальных полей. В editable-режиме кнопка
41
+ // появляется лишь по явному `showCopyButtonInEditMode`; тогда видны обе (clear + copy).
42
+ const showCopyUi = Boolean(showCopyButtonProp && stringValue && !disabled && (readOnly || showCopyButtonInEditMode));
43
43
  const onClear = (0, react_1.useCallback)(() => {
44
44
  setValue('');
45
45
  onChange?.('');
@@ -46,12 +46,33 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
46
46
  // но «расхлябанный» вызов (например, Storybook-spread, где single-default остаётся
47
47
  // строкой при переключении selection) не должен ронять компонент на `.map` строки.
48
48
  multiple && Array.isArray(props.defaultValue) ? props.defaultValue : []);
49
- const singleValue = useMemo(() => (!multiple ? (props.value ?? singleLocal) : undefined), [multiple, props.value, singleLocal]);
49
+ // Controlled-режим «залипает»: как только потребитель прислал `value`, локальный стейт больше
50
+ // не читается. Иначе очистка (потребитель ставит `undefined`) откатывалась бы на устаревший
51
+ // локальный выбор и воскрешала прошлое значение.
52
+ const singleControlled = useRef(false);
53
+ const multipleControlled = useRef(false);
54
+ if (!multiple && props.value !== undefined) {
55
+ singleControlled.current = true;
56
+ }
57
+ if (multiple && Array.isArray(props.value)) {
58
+ multipleControlled.current = true;
59
+ }
60
+ const singleValue = useMemo(() => {
61
+ if (multiple) {
62
+ return undefined;
63
+ }
64
+ const raw = singleControlled.current ? props.value : singleLocal;
65
+ // `''` = «значение не выбрано»: формы отдают пустое значение строкой.
66
+ return raw === '' ? undefined : raw;
67
+ }, [multiple, props.value, singleLocal]);
50
68
  const multipleValue = useMemo(() => {
51
69
  if (!multiple) {
52
70
  return [];
53
71
  }
54
- return Array.isArray(props.value) ? props.value : multipleLocal;
72
+ if (multipleControlled.current) {
73
+ return Array.isArray(props.value) ? props.value : [];
74
+ }
75
+ return multipleLocal;
55
76
  }, [multiple, props.value, multipleLocal]);
56
77
  const effectiveValidationState = useMemo(() => (error ? VALIDATION_STATE.Error : validationState), [error, validationState]);
57
78
  const allItems = useMemo(() => [...(pinTop ?? []), ...items, ...(pinBottom ?? [])], [items, pinTop, pinBottom]);
@@ -344,7 +365,7 @@ export const FieldSelect = forwardRef(function FieldSelect(props, ref) {
344
365
  const plug = inputPlugRef.current?.clientWidth ?? SEARCH_INPUT_PLUG_MIN_WIDTH;
345
366
  setChipInputMinWidth(Math.min(content, Math.max(plug, SEARCH_INPUT_PLUG_MIN_WIDTH)));
346
367
  }, [hasChips, inputValue, selectedPairs.length]);
347
- const searchInputField = (_jsx(InputPrivate, { ref: mergeRefs(ref, inputRef, inputElementRef), className: cn(fieldStyles.fieldInput, !searchable && styles.readonlyCursor), value: inputValue, onChange: searchable ? handleInputChange : undefined, placeholder: selectedLabel ? '' : placeholder, disabled: disabled, readonly: !searchable || readOnly, id: id, name: name, autoFocus: resolvedAutoFocus, tabIndex: inputTabIndex, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleTriggerKeyDown, "data-test-id": TEST_IDS.fieldSelectInput }));
368
+ const searchInputField = (_jsx(InputPrivate, { ref: mergeRefs(ref, inputRef, inputElementRef), className: cn(fieldStyles.fieldInput, !searchable && styles.readonlyCursor), value: inputValue, onChange: searchable ? handleInputChange : undefined, placeholder: selectedLabel || hasChips ? '' : placeholder, disabled: disabled, readonly: !searchable || readOnly, id: id, name: name, autoFocus: resolvedAutoFocus, tabIndex: inputTabIndex, onFocus: handleInputFocus, onBlur: handleInputBlur, onKeyDown: handleTriggerKeyDown, "data-test-id": TEST_IDS.fieldSelectInput }));
348
369
  // В режиме чипов input заворачивается в `.inputWrapper` с динамической minWidth (флоу за чипами);
349
370
  // без чипов — обычный `inputArea` на всю строку.
350
371
  const searchInput = hasChips ? (_jsx("div", { className: styles.inputWrapper, style: { minWidth: chipInputMinWidth }, children: searchInputField })) : (_jsx("div", { className: fieldStyles.inputArea, children: searchInputField }));
@@ -146,9 +146,9 @@ type CommonSelectProps = FieldSelectDecoratorProps & DroplistPassthrough & {
146
146
  export type FieldSelectSingleProps = CommonSelectProps & {
147
147
  /** Режим выбора. По умолчанию `'single'`. */
148
148
  selection?: typeof SELECTION_MODE.Single;
149
- /** Управляемое значение */
149
+ /** Управляемое значение. Пустая строка трактуется как «значение не выбрано». */
150
150
  value?: ItemId;
151
- /** Неуправляемое значение по умолчанию */
151
+ /** Неуправляемое значение по умолчанию. Пустая строка трактуется как «значение не выбрано». */
152
152
  defaultValue?: ItemId;
153
153
  /** Колбек смены значения */
154
154
  onChange?(value: ItemId | undefined): void;
@@ -77,10 +77,16 @@ type FieldTextAreaOwnProps = {
77
77
  */
78
78
  showClearButton?: boolean;
79
79
  /**
80
- * Кнопка копирования (видна при value && !disabled, независимо от readonly)
80
+ * Кнопка копирования (видна при непустом value в режиме readonly, как у остальных полей)
81
81
  * @default true
82
82
  */
83
83
  showCopyButton?: boolean;
84
+ /**
85
+ * Показывать кнопку копирования и в обычном (не readonly) режиме — рядом с кнопкой очистки.
86
+ * Опция только для textarea: в многострочном поле копирование значения осмысленно и при вводе.
87
+ * @default false
88
+ */
89
+ showCopyButtonInEditMode?: boolean;
84
90
  /** Колбек после копирования */
85
91
  onCopyButtonClick?(): void;
86
92
  /** Колбек фокуса */
@@ -12,7 +12,7 @@ import { useAdaptiveAutoFocus } from '../../hooks/index.js';
12
12
  import { copyTextToClipboard, FieldShell, toInputSize, useCopyButton } from '../shared/index.js';
13
13
  import fieldStyles from '../shared/styles.module.css';
14
14
  import styles from './styles.module.css';
15
- export const FieldTextArea = forwardRef(function FieldTextArea({ label = '', labelTooltip, caption, hint, error, validationState = VALIDATION_STATE.Default, showHintIcon, length, required, size = SIZE.M, className, fieldClassName, background = true, value: valueProp, defaultValue = '', onChange, placeholder, id, name, maxLength, disabled, readonly: readOnly, autoFocus, layoutPresets, inputMode, spellCheck, minRows = 3, maxRows = 1000, resizable = false, allowMoreThanMaxLength = true, header, footer, showClearButton: showClearButtonProp = true, showCopyButton: showCopyButtonProp = true, onCopyButtonClick, onFocus: onFocusProp, onBlur: onBlurProp, onKeyDown: onKeyDownProp, 'data-test-id': dataTestId, ...rest }, ref) {
15
+ export const FieldTextArea = forwardRef(function FieldTextArea({ label = '', labelTooltip, caption, hint, error, validationState = VALIDATION_STATE.Default, showHintIcon, length, required, size = SIZE.M, className, fieldClassName, background = true, value: valueProp, defaultValue = '', onChange, placeholder, id, name, maxLength, disabled, readonly: readOnly, autoFocus, layoutPresets, inputMode, spellCheck, minRows = 3, maxRows = 1000, resizable = false, allowMoreThanMaxLength = true, header, footer, showClearButton: showClearButtonProp = true, showCopyButton: showCopyButtonProp = true, showCopyButtonInEditMode = false, onCopyButtonClick, onFocus: onFocusProp, onBlur: onBlurProp, onKeyDown: onKeyDownProp, 'data-test-id': dataTestId, ...rest }, ref) {
16
16
  const localRef = useRef(null);
17
17
  const clearButtonRef = useRef(null);
18
18
  const copyButtonRef = useRef(null);
@@ -31,9 +31,9 @@ export const FieldTextArea = forwardRef(function FieldTextArea({ label = '', lab
31
31
  const stringValue = String(value ?? '');
32
32
  const isResizable = resizable && !disabled && !readOnly;
33
33
  const showClearUi = Boolean(showClearButtonProp && stringValue && !readOnly && !disabled);
34
- // Copy не завязан на readonly (паритет с snack-uikit master): виден при любом непустом значении.
35
- // В editable-режиме показываются обе кнопки (clear + copy), в readonly — только copy.
36
- const showCopyUi = Boolean(showCopyButtonProp && stringValue && !disabled);
34
+ // По умолчанию copy только в readonly, как у остальных полей. В editable-режиме кнопка
35
+ // появляется лишь по явному `showCopyButtonInEditMode`; тогда видны обе (clear + copy).
36
+ const showCopyUi = Boolean(showCopyButtonProp && stringValue && !disabled && (readOnly || showCopyButtonInEditMode));
37
37
  const onClear = useCallback(() => {
38
38
  setValue('');
39
39
  onChange?.('');