@sberbusiness/triplex-next-mcp-server 1.46.0-beta → 1.50.0-beta

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 (110) hide show
  1. package/README.md +7 -7
  2. package/bundle/components/Amount.md +2 -1
  3. package/bundle/components/AmountField.md +222 -323
  4. package/bundle/components/Avatar.md +138 -146
  5. package/bundle/components/Badge.md +2 -1
  6. package/bundle/components/BodyPage.md +2 -1
  7. package/bundle/components/Button.md +5 -1
  8. package/bundle/components/ButtonDropdownExtended.md +3 -1
  9. package/bundle/components/ButtonIcon.md +3 -1
  10. package/bundle/components/Calendar.md +3 -1
  11. package/bundle/components/CardAction.md +1 -0
  12. package/bundle/components/CardStatic.md +402 -0
  13. package/bundle/components/CheckboxTree.md +157 -215
  14. package/bundle/components/CheckboxTreeExtended.md +7 -7
  15. package/bundle/components/CheckboxXGroup.md +2 -1
  16. package/bundle/components/Chip.md +2 -1
  17. package/bundle/components/ChipGroup.md +1 -1
  18. package/bundle/components/ChipMonthYearPicker.md +6 -5
  19. package/bundle/components/ChipMultiselect.md +4 -1
  20. package/bundle/components/ChipOptions.md +2 -1
  21. package/bundle/components/ChipSort.md +4 -1
  22. package/bundle/components/ChipSuggest.md +7 -6
  23. package/bundle/components/CollapsibleTree.md +10 -5
  24. package/bundle/components/CollapsibleTreeExtended.md +8 -6
  25. package/bundle/components/ComposedKeyDownListener.md +4 -3
  26. package/bundle/components/Confirm.md +346 -281
  27. package/bundle/components/DateField.md +7 -1
  28. package/bundle/components/DateRange.md +351 -210
  29. package/bundle/components/Divider.md +85 -138
  30. package/bundle/components/DocumentNumberEdit.md +173 -214
  31. package/bundle/components/Dropdown.md +11 -1
  32. package/bundle/components/Ellipsis.md +131 -147
  33. package/bundle/components/FooterPage.md +4 -1
  34. package/bundle/components/FormField.md +13 -5
  35. package/bundle/components/FormGroup.md +3 -2
  36. package/bundle/components/Gap.md +1 -1
  37. package/bundle/components/Header.md +1 -0
  38. package/bundle/components/HeaderPage.md +3 -1
  39. package/bundle/components/HelpBox.md +2 -1
  40. package/bundle/components/IconWrapper.md +10 -6
  41. package/bundle/components/ImageGallery.md +2 -1
  42. package/bundle/components/ImageGalleryExtended.md +2 -1
  43. package/bundle/components/IslandAccordion.md +6 -1
  44. package/bundle/components/KeyDownListener.md +3 -2
  45. package/bundle/components/LightBox.md +15 -1
  46. package/bundle/components/List.md +2 -1
  47. package/bundle/components/ListActionItem.md +2 -1
  48. package/bundle/components/ListItem.md +3 -1
  49. package/bundle/components/ListItemControlsButton.md +2 -1
  50. package/bundle/components/ListItemSelectable.md +2 -1
  51. package/bundle/components/ListMaster.md +2 -2
  52. package/bundle/components/ListMasterFooter.md +2 -1
  53. package/bundle/components/ListMasterHeader.md +2 -1
  54. package/bundle/components/ListSortable.md +2 -1
  55. package/bundle/components/ListSortableItem.md +2 -1
  56. package/bundle/components/ListTableItem.md +10 -7
  57. package/bundle/components/LoaderMiddle.md +5 -1
  58. package/bundle/components/LoaderScreen.md +7 -1
  59. package/bundle/components/LoaderSmall.md +10 -4
  60. package/bundle/components/MaskedField.md +3 -1
  61. package/bundle/components/MediaWidth.md +2 -1
  62. package/bundle/components/MobileView.md +2 -1
  63. package/bundle/components/ModalWindow.md +4 -1
  64. package/bundle/components/MonthYearField.md +19 -5
  65. package/bundle/components/Notification.md +1 -0
  66. package/bundle/components/Overlay.md +7 -2
  67. package/bundle/components/Pagination.md +1 -7
  68. package/bundle/components/Radio.md +238 -115
  69. package/bundle/components/RadioXGroup.md +259 -0
  70. package/bundle/components/RadioYGroup.md +266 -0
  71. package/bundle/components/SegmentedControl.md +505 -158
  72. package/bundle/components/SelectExtendedField.md +2 -1
  73. package/bundle/components/SelectField.md +4 -1
  74. package/bundle/components/Skeleton.md +140 -69
  75. package/bundle/components/Slider.md +389 -97
  76. package/bundle/components/SliderRange.md +486 -105
  77. package/bundle/components/Spoiler.md +245 -63
  78. package/bundle/components/StatusTracker.md +386 -94
  79. package/bundle/components/Step.md +257 -68
  80. package/bundle/components/Stepper.md +355 -102
  81. package/bundle/components/StepperExtended.md +633 -0
  82. package/bundle/components/SuggestField.md +1061 -96
  83. package/bundle/components/SwipeableArea.md +7 -7
  84. package/bundle/components/Tabs.md +266 -92
  85. package/bundle/components/TabsExtended.md +18 -18
  86. package/bundle/components/TabsLine.md +302 -127
  87. package/bundle/components/TagColor.md +187 -66
  88. package/bundle/components/TagGroup.md +184 -48
  89. package/bundle/components/TextField.md +8 -4
  90. package/bundle/components/TextareaField.md +279 -70
  91. package/bundle/components/ThemeProvider.md +2 -1
  92. package/bundle/components/Tooltip.md +6 -3
  93. package/bundle/components/TopOverlay.md +17 -1
  94. package/bundle/components/TreeView.md +16 -7
  95. package/bundle/components/TriggerClickOnKeyDownEvent.md +277 -0
  96. package/bundle/components/UploadZone.md +318 -0
  97. package/bundle/guides/codestyle.md +16 -1
  98. package/bundle/guides/commits.md +47 -1
  99. package/bundle/guides/context.md +4 -1
  100. package/bundle/guides/tests.md +23 -2
  101. package/bundle/manifest.json +78 -29
  102. package/bundle/release-notes/1.47.0.mdx +115 -0
  103. package/bundle/release-notes/1.48.0.mdx +68 -0
  104. package/bundle/release-notes/1.49.0.mdx +85 -0
  105. package/bundle/release-notes/1.50.0.mdx +35 -0
  106. package/bundle/release-notes/1.51.0.mdx +7 -0
  107. package/bundle/tokens.json +163 -13
  108. package/dist/index.js +220 -6
  109. package/dist/index.js.map +1 -1
  110. package/package.json +1 -1
@@ -1,9 +1,7 @@
1
1
  ---
2
2
  component: SuggestField
3
- category: Form Controls
4
- figma-node: ""
5
- figma-file: ""
6
- related: [Suggest, SelectField, Dropdown, TextField, MobileView, FormFieldInput]
3
+ category: TextFields
4
+ related: [Suggest, SelectField, MultiselectField, SelectExtendedField, TextField, FormField, Dropdown, LoaderSmall]
7
5
  tokens: []
8
6
  stories: stories/SuggestField/SuggestField.stories.tsx
9
7
  version: "1.0"
@@ -13,140 +11,233 @@ version: "1.0"
13
11
 
14
12
  ## Назначение
15
13
 
16
- Поле autocomplete: input + dropdown с фильтрацией опций. Адаптивное
17
- переключение desktop/mobile через `MobileView` — desktop показывает Dropdown,
18
- mobile — полноэкранный лист с hint. Поддерживает render-функции
19
- (`renderInput`, `renderDropdown`, `renderDropdownList`, `renderDropdownListItem`)
20
- для кастомизации.
14
+ Готовое поле ввода с выпадающим списком и поиском по введённому значению: лейбл, статусы,
15
+ префикс/постфикс, лоадер, кнопка очистки и подсказка-Tooltip уже собраны. Компонент
16
+ **не фильтрует список сам** — он сообщает введённый текст через `onFilter`, а потребитель
17
+ возвращает новый `options`.
21
18
 
22
- Используй когда: нужен готовый UX autocomplete-поля с label/tooltip/loading/
23
- noOptionsText. Для headless-логики — `Suggest` (+ свой UI).
24
- Для статического dropdown без фильтрации — `SelectField`.
19
+ Адаптивный: на ширине экрана < 768px вместо десктопного поля с выпадающим списком рендерится
20
+ мобильный вариант — поле только для чтения, по фокусу открывающее полноэкранный дропдаун с
21
+ собственным полем ввода. Переключение делает `MobileView`, потребителю ничего делать не нужно.
25
22
 
26
- ---
27
-
28
- ## Файловая структура
29
-
30
- ```text
31
- src/components/SuggestField/
32
- ├── SuggestField.tsx # корневой — MobileView switcher
33
- ├── types.ts # ISuggestFieldProps, ISuggestFieldOption
34
- ├── desktop/
35
- │ ├── SuggestFieldDesktop.tsx
36
- │ ├── SuggestFieldDesktopDropdown.tsx
37
- │ └── types.ts
38
- ├── mobile/
39
- │ ├── SuggestFieldMobile.tsx
40
- │ ├── SuggestFieldMobileDropdown.tsx
41
- │ ├── SuggestFieldMobileDropdownHint.tsx
42
- │ └── types.ts
43
- └── index.ts
44
- ```
23
+ Используй когда: нужно поле с подсказками-опциями — поиск контрагента, счёта, адреса, в том
24
+ числе с асинхронной догрузкой (`loading`, `dropdownListLoading`, `onScrollEnd`).
45
25
 
46
- **Примечание:** в `SuggestFieldDesktop.tsx` — TODO «переписать через useSuggest».
47
- Desktop-вариант дублирует логику из `useSuggest` (свой state). Mobile тоже.
26
+ Не используй когда:
27
+ - Список фиксированный и вводом не фильтруется — возьми `SelectField`.
28
+ - Нужен suggest-фильтр в ряду чипсов — возьми `ChipSuggest`.
29
+ - Нужна нестандартная разметка управляющего элемента или списка — собери своё на headless
30
+ `Suggest`; `SuggestField` жёстко задаёт композицию `TextFieldBase` + `Dropdown`.
48
31
 
49
32
  ---
50
33
 
51
- ## Публичный API
34
+ ## Варианты и props
52
35
 
53
- ### `SuggestField<T extends ISuggestFieldOption>`
36
+ `ISuggestFieldProps<T>` расширяет `Omit<ITextFieldProps, "onSelect">`, поэтому всё из
37
+ `TextField` / `FormField` доступно: `status`, `size`, `active`, `label`, `description`,
38
+ `counter`, `prefix`, `postfix`, `className`, `data-*`, `inputProps`.
54
39
 
55
- Generic. Корневой компонент-switcher.
56
-
57
- Props = `Omit<ITextFieldProps, "onSelect"> & DataAttributes` + поля ниже:
40
+ ### Обязательные props
58
41
 
59
42
  | Prop | Тип | Описание |
60
43
  |---|---|---|
61
- | `value` | `T \| undefined` | Выбранная опция. |
62
- | `options` | `T[]` | Список опций. |
63
- | `label?` | `string` | Лейбл над полем. |
64
- | `placeholder?` | `string` | Подсказка в пустом поле. |
65
- | `tooltipHint` | `string` | Текст Tooltip (обязателен). |
66
- | `tooltipOpen` | `boolean` | Контроль видимости Tooltip (обязателен). |
67
- | `loading?` | `boolean` | Общий loading. |
68
- | `dropdownListLoading?` | `boolean` | Loading хвоста списка (при пагинации). |
69
- | `clearInputOnFocus?` | `boolean` | Очищать input при focus. |
70
- | `onSelect` | `(value: T \| undefined) => void` | Колбэк выбора. |
71
- | `onFilter` | `(value: string) => void` | Колбэк ввода. |
72
- | `onScrollEnd?` | `() => void` | Пагинация. |
73
- | `onClear?` | `MouseEventHandler<HTMLButtonElement>` | Колбэк кнопки очистки. |
74
- | `renderInput?` | `(props: IFormFieldInputProps) => JSX.Element` | Кастомный input. |
75
- | `inputProps` | `IFormFieldInputProps` | Обязателен (наследовано из `TextField`). |
76
-
77
- Desktop дополнительно принимает `renderDropdown`, `renderDropdownList`, `renderDropdownListItem`.
78
-
79
- ### `ISuggestFieldOption`
80
-
81
- ```ts
82
- { id: string; label: string; content?: ReactNode; showNotificationIcon?: boolean }
83
- ```
44
+ | `value` | `T \| undefined` | Выбранная опция. Контролируемое значение: при смене `value.id` поле ввода перезаписывается на `value.label` |
45
+ | `options` | `T[]` | Список опций. Фильтрует потребитель в ответ на `onFilter` — компонент список не фильтрует и не сортирует |
46
+ | `tooltipHint` | `string` | Текст подсказки («Ничего не найдено», «Введите более 3 символов»). Обязателен, даже если `tooltipOpen` всегда `false` |
47
+ | `tooltipOpen` | `boolean` | Показывать подсказку. Управляется снаружи: компонент сам не решает, когда список «пустой по делу» |
48
+ | `onSelect` | `(value: T \| undefined) => void` | Опция выбрана. Вызывается с `undefined`, когда значение сбрасывается, — на обеих платформах только кнопкой очистки (см. «Инварианты»). Blur на десктопе и закрытие дропдауна на мобильном значение не сбрасывают даже при пустом вводе. Не сбрасывай значение в `onFilter("")`: при `clearInputOnFocus` десктоп зовёт его уже на фокусе |
49
+ | `onFilter` | `(value: string) => void` | Текст поля ввода изменился. Ответ — обновлённый `options` |
50
+ | `inputProps` | `IFormFieldInputProps & { ref?: React.Ref<HTMLInputElement> }` | Свойства поля ввода. **Не опционально:** передавай хотя бы `{}`. Через `inputProps.ref` потребитель получает ref на `<input>` — собственного `forwardRef` у компонента нет |
51
+
52
+ ### Опциональные props
53
+
54
+ | Prop | Тип | По умолчанию | Описание |
55
+ |---|---|---|---|
56
+ | `size` | `EComponentSize` | `EComponentSize.LG` | Размер поля и выпадающего списка |
57
+ | `status` | `EFormFieldStatus` | `EFormFieldStatus.DEFAULT` | `DISABLED` блокирует ввод и подавляет Tooltip |
58
+ | `label` | `string` | — | Лейбл над полем ввода |
59
+ | `placeholder` | `string` | — | Подсказка в пустом поле |
60
+ | `loading` | `boolean` | — | Лоадер в постфиксе поля (десктоп) и в шапке дропдауна (мобильный) |
61
+ | `dropdownListLoading` | `boolean` | — | Догрузка списка: лоадер под уже загруженными опциями. Пока `true`, `onScrollEnd` не вызывается |
62
+ | `clearInputOnFocus` | `boolean` | `false` | Очищать поле ввода при получении фокуса. На десктопе дополнительно вызывает `onFilter("")` |
63
+ | `onScrollEnd` | `() => void` | — | Список прокручен до конца — грузи следующую страницу опций |
64
+ | `onClear` | `React.MouseEventHandler<HTMLButtonElement>` | — | Обработчик очистки. **Кнопка очистки рендерится, только если он передан** |
65
+ | `renderInput` | `(props: IFormFieldInputProps) => JSX.Element` | `FormFieldInput` | Замена поля ввода |
66
+
67
+ Только десктопный `SuggestFieldDesktop` дополнительно принимает `renderDropdown`,
68
+ `renderDropdownList`, `renderDropdownListItem`.
69
+
70
+ ### Опция (`ISuggestFieldOption`)
71
+
72
+ `id` (уникальный; по нему сравнивается с `value` и строится ключ списка), `label` (текст в
73
+ поле ввода и в списке), `content` (кастомная разметка опции в списке вместо `label`),
74
+ `showNotificationIcon` (значок новых уведомлений).
75
+
76
+ Тип опции расширяется дженериком: `<SuggestField<IMyOption> ...>` протаскивает `IMyOption`
77
+ в `value`, `options` и `onSelect`.
84
78
 
85
79
  ### `SuggestField.Input`
86
80
 
87
- Re-export `FormFieldInput` — для удобства доступа.
81
+ Статическое поле — это `FormFieldInput`. Нужно, чтобы потребитель собирал собственный
82
+ `renderInput` из того же компонента: `renderInput={(props) => <SuggestField.Input {...props} />}`.
88
83
 
89
- ---
84
+ ### Поведение десктопного варианта
90
85
 
91
- ## Поведение
86
+ | Действие | Что происходит |
87
+ |---|---|
88
+ | Фокус / mousedown по полю | Список открывается, если `options` не пуст |
89
+ | Появились опции, пока поле в фокусе | Список открывается автоматически |
90
+ | `options` стал пустым | Список закрывается |
91
+ | Ввод текста | `onFilter(value)`, сброс `aria-activedescendant`, список снова разрешён к автооткрытию |
92
+ | `Escape` при открытом списке | Список закрывается, всплытие останавливается, автооткрытие подавлено до следующего ввода или mousedown |
93
+ | Выбор опции | `onSelect(option)`, в поле подставляется `label`, автооткрытие подавлено |
94
+ | Blur | В поле возвращается `label` текущего `value` (пустая строка, если `value` не задан). `onSelect` не вызывается: введённый, но не выбранный текст отбрасывается, в том числе пустой |
95
+ | Клик по кнопке очистки | `onSelect(undefined)` (если было значение), `onFilter("")` (если поле не пустое), затем `onClear(event)` |
92
96
 
93
- ### Desktop
97
+ `inputProps.onFocus` / `onBlur` / `onKeyDown` / `onMouseDown` / `onChange` вызываются
98
+ **после** внутренних обработчиков — подменить их через `inputProps` нельзя. С остальными
99
+ свойствами наоборот: `value`, `placeholder`, `data-test-id` из `inputProps` спредятся после
100
+ внутренних значений и перекрывают их.
94
101
 
95
- - Собственный state: `inputValue`, `inputFocused`, `dropdownOpen`, `ignoreAutoOpen`, `activeDescendant`, `dropdownListId`.
96
- - `ignoreAutoOpen` — предотвращает немедленное переоткрытие dropdown сразу после выбора (иначе селектнутый item → focus остаётся на input → dropdown открывается снова).
97
- - Синхронизация `value?.id !== prevValue?.id` → `setInputValue(value?.label)`.
98
- - `clearInputOnFocus` + focus с непустым input → `setInputValue("")` + `onFilter("")`.
99
- - `activeDescendant` — id подсвеченного элемента списка (для `aria-activedescendant`).
102
+ ### Поведение мобильного варианта
100
103
 
101
- ### Mobile (`SuggestFieldMobile`)
104
+ Поле-триггер — `readOnly`, по фокусу открывается полноэкранный `Dropdown` с собственным полем
105
+ ввода (автофокус). При `tooltipOpen` вместо списка показывается `SuggestFieldMobileDropdownHint`
106
+ с текстом `tooltipHint`. Закрытие дропдауна без выбора опции значение не сбрасывает, даже при
107
+ пустом поле ввода: на закрытии поле ввода дропдауна возвращается к `label` текущего `value`
108
+ (через `onClose` у `Dropdown`, а не только на автофокусе — при повторном открытии во время
109
+ анимации закрытия содержимое ещё смонтировано и автофокус не срабатывает). При следующем
110
+ открытии с `clearInputOnFocus` поле очищается автофокусом. Кнопка очистки работает как на
111
+ десктопе: `onSelect(undefined)` (если было значение), `onFilter("")` (если у значения непустой
112
+ `label` — его и показывает поле-триггер), затем `onClear(event)`. После закрытия поле-триггер
113
+ скроллится в центр экрана (в iOS открытие дропдауна уводит страницу вверх).
102
114
 
103
- - Открывает полноэкранный лист с input'ом и списком.
104
- - `SuggestFieldMobileDropdownHint` — текст-подсказка над списком.
105
- - Рендерится только внутри `MobileView` (viewport <= SM).
115
+ ---
106
116
 
107
- ### `MobileView`
117
+ ## Дизайн-токены
108
118
 
109
- Корневой `SuggestField.tsx` рендерит `<MobileView fallback={SuggestFieldDesktop}>SuggestFieldMobile</MobileView>` — выбор по media query.
119
+ Собственных токенов нет. Единственный стиль компонента — `styles/SuggestFieldMobile.module.less` —
120
+ задаёт высоту тела мобильного дропдауна и отступ подсказки фиксированными значениями. Внешний вид
121
+ целиком определяют `FormField` / `TextField`, `Dropdown`, `Tooltip` и `Loader` — их токены
122
+ переопределяются через `ThemeProvider` в соответствующих AI.md.
110
123
 
111
124
  ---
112
125
 
113
126
  ## Инварианты
114
127
 
115
- - **Generic `<T extends ISuggestFieldOption>`** — сохранять. `SuggestField.Input = FormFieldInput`.
116
- - **`tooltipHint` и `tooltipOpen` — обязательные** (не опциональные, как может казаться). Tooltip — часть UX.
117
- - **Синхронизация `value` → `inputValue`** через render-сравнение (по `id`, не по ссылке).
118
- - **`inputProps` обязателен** (прокидывается в `FormFieldInput`) — наследство из `TextField`. Не default'ить на `{}` без причины.
119
- - **`onSelect(undefined)`** допустимо (сбрасывает).
120
- - **Два разных state-дерева** (desktop и mobile) — не объединять без refactor'а под `useSuggest`.
121
- - **`MobileView` определяет ветку по media-query** — не по прямому props. Для force desktop/mobile в тестах — имитировать viewport.
128
+ - **`forwardRef` на публичных компонентах семейства нет.** `SuggestField`, `SuggestFieldDesktop`
129
+ и `SuggestFieldMobile` — обычные дженерик-функции: внутренний `ref` занят корневым
130
+ `TextFieldBase`, и он же служит `targetRef` для `Dropdown` и `Tooltip`. `forwardRef` есть
131
+ только у `SuggestFieldMobileDropdown`. Добавление `forwardRef` остальным — изменение
132
+ публичного API, решение мейнтейнера. По той же причине `SuggestFieldMobileDropdown` намеренно
133
+ оставлен без `displayName`: в React DevTools он виден как `ForwardRef(SuggestFieldMobileDropdownBase)`,
134
+ и переименование наблюдаемо для потребителя.
135
+ - `SuggestField` — `Object.assign(SuggestFieldBase, { Input: FormFieldInput })`. Дженерик
136
+ сохраняется только у самой функции; `SuggestField.Input` — часть публичного API.
137
+ - **Оба варианта получают все props.** `SuggestField.tsx` отдаёт `{...props}` и в
138
+ `SuggestFieldDesktop`, и в `SuggestFieldMobile`. Не возвращай явный список props для мобильной
139
+ ветки: пропущенный prop молча терялся бы на мобильной ширине (так до 1.49.0 терялись
140
+ `className`, `id`, `data-test-id`, `active` и `renderInput`).
141
+ - **Значение сбрасывает только кнопка очистки — на обеих платформах.** Blur на десктопе и
142
+ закрытие дропдауна на мобильном `onSelect(undefined)` не зовут, даже при пустом вводе: иначе
143
+ при `clearInputOnFocus` значение терялось от простого фокуса без выбора. Кнопка очистки
144
+ рендерится только при `onClear`, так что без него встроенного сброса нет. Возврат сброса в
145
+ blur / закрытие дропдауна — изменение поведения, решение мейнтейнера.
146
+ - Видимость десктопного списка синхронизируется **во время рендера** (`if (inputFocused) {...}`),
147
+ а не в `useEffect`. Так требует правило `react-hooks/set-state-in-effect`; возврат к эффекту
148
+ вернёт и ошибку линтера, и лишний кадр рендера.
149
+ - **`options` — жёсткий инвариант, рантайм-подстраховки нет.** Оба дропдауна делают `options.map`
150
+ без `?.`: prop обязателен по типу, а родитель ещё до рендера списка читает `options.length`, так
151
+ что `undefined` упал бы всё равно выше. JS-потребитель без типов, передавший `options={undefined}`,
152
+ получит TypeError, а не пустой список — это сознательное решение, а не упущение.
153
+ - `id` выпадающего списка генерируется через `uniqueId()` из `lodash-es`, а не `React.useId` —
154
+ ветка `release-0` собирается на React 17.
155
+ - Ширина выпадающего списка жёстко `EDropdownWidth.TARGET` — список всегда по ширине поля.
156
+ - Выбор клавиатурой — **только `Enter`**: `keyCodesForSelection={[EVENT_KEY_CODES.ENTER]}`
157
+ сужает дефолт `DropdownListItem` (`Space` + `Enter`), иначе пробел в поисковом запросе
158
+ выбирал бы опцию вместо ввода пробела.
159
+ - `mousedown` по выпадающему списку отменяется (`preventDefault`) — иначе поле ввода потеряло бы
160
+ фокус до того, как отработает выбор опции.
161
+ - Суффиксы `data-test-id` берутся из `DataTestId` и используются в e2e — не менять:
162
+ поле ввода `{dataTestId}__input`, выпадающий список `{dataTestId}__dropdown`, элемент списка
163
+ `{dataTestId}__dropdown__item`, тело подсказки `{dataTestId}__tooltip`. Все они появляются,
164
+ только если потребитель передал `data-test-id`.
165
+ - Кастомный `renderDropdownListItem` **не получит ref**: тип рендер-функции — обычная функция,
166
+ а `DropdownList` клонирует детей и вешает на них ref для клавиатурной навигации. React напишет
167
+ предупреждение, стрелочная навигация по такому списку работать не будет.
122
168
 
123
169
  ---
124
170
 
125
171
  ## Accessibility
126
172
 
127
- - `role="combobox"` / `aria-controls` / `aria-expanded` / `aria-activedescendant` проставляются в Desktop-реализации (`SuggestFieldDesktop`) на input'е.
128
- - `dropdownListId` передаётся в `DropdownList` (`role="listbox"`) и используется как `aria-controls`.
129
- - Escape закрывает dropdown (`isKey(event.key, "ESCAPE")`).
130
- - Mobile: полноэкранный лист с явным focus management на input.
131
- - `onClear` — опционально; если передан, рисуется `FormFieldClear` кнопка с осмысленной ariaLabel (переопределяется через `inputProps`).
173
+ Поле ввода — `role="combobox"` с `aria-autocomplete="list"`. Компонент сам проставляет:
174
+
175
+ - `aria-expanded` — открыт ли список. Считается как `dropdownOpen && options.length !== 0`,
176
+ поэтому при пустом `options` остаётся `false`, даже если внутреннее состояние «открыто».
177
+ - `aria-controls` — `id` выпадающего списка, общий с `DropdownList`.
178
+ - `aria-activedescendant` — `id` опции, выделенной стрелками. Значение приходит из
179
+ `DropdownListContext`, который `SuggestFieldDesktop` предоставляет, а `DropdownList` заполняет.
180
+ Сбрасывается при вводе текста и при закрытии списка.
181
+
182
+ Клавиатура: `ArrowUp` / `ArrowDown` — перемещение по списку (`DropdownList`), `Enter` — выбор
183
+ опции, `Escape` — закрытие списка со `stopPropagation`, чтобы событие не закрыло ещё и модальное
184
+ окно вокруг. `Escape` при закрытом списке всплывает наверх.
185
+
186
+ Подсказка (`tooltipHint`) рендерится в `Tooltip` и на десктопе показывается только пока поле
187
+ в фокусе и `status !== EFormFieldStatus.DISABLED`. Текст подсказки, `label` и `placeholder`
188
+ передаёт потребитель — библиотека мультиязычная и строк не хардкодит.
189
+
190
+ Мобильный вариант: поле-триггер `readOnly` с тем же `role="combobox"` и `aria-expanded`;
191
+ список внутри дропдауна — `role="listbox"` с опциями `role="option"` и `aria-selected`.
132
192
 
133
193
  ---
134
194
 
135
195
  ## Связанные компоненты
136
196
 
137
- - `Suggest` — headless-основа (TODO: SuggestField должен переехать на него).
138
- - `SelectField` — статический dropdown.
139
- - `Dropdown` / `DropdownList` / `DropdownListItem` — UI списка.
140
- - `TextField` — родительский тип props.
141
- - `MobileView` — адаптивная обёртка.
142
- - `FormFieldInput`, `FormFieldClear`, `Tooltip`, `LoaderSmall` — внутренние зависимости.
197
+ - `Suggest` — headless-основа той же задачи: состояние и контекст без разметки. `SuggestField`
198
+ на него **не переведён** и держит собственную реализацию состояния (в `SuggestField.tsx`
199
+ стоит `TODO: Переписать через useSuggest`).
200
+ - `SelectField` — та же задача с другим компромиссом: список фиксированный, ввода и фильтрации
201
+ нет. Сюда же примыкают `MultiselectField` и `SelectExtendedField` — все трое ссылаются на
202
+ `SuggestField` со своей стороны.
203
+ - `TextField` — `ISuggestFieldProps` наследует его props (`Omit<ITextFieldProps, "onSelect">`),
204
+ а разметку поля даёт общий `TextFieldBase`.
205
+ - `FormField` — источник `FormFieldInput` (поле ввода и `SuggestField.Input`), `FormFieldClear`
206
+ (кнопка очистки) и статусов `EFormFieldStatus`.
207
+ - `Dropdown` — выпадающий список: `DropdownDesktop` + `DropdownList` + `DropdownListItem` на
208
+ десктопе, `DropdownMobile*` в полноэкранном мобильном варианте. `ISuggestFieldDesktopDropdownProps`
209
+ наследует `IDropdownDesktopProps`.
210
+ - `LoaderSmall` — индикатор подгрузки подсказок: `SuggestFieldDesktop` и `SuggestFieldMobile`
211
+ показывают его с темой `BRAND`, пока `loading`.
212
+ - `Tooltip` — подсказка `tooltipHint` у десктопного поля. В `related` не вынесен: связь узкая,
213
+ а список адресатов и так шире ориентира 2–5.
214
+ - `MobileView` — переключает десктопный и мобильный варианты по ширине экрана. В `related` не
215
+ вынесен по той же причине.
216
+ - `SuggestFieldDesktop` / `SuggestFieldMobile` — платформенные варианты, экспортируются из barrel
217
+ и принимают те же props. Отдельных AI.md не имеют: их поведение описано выше. Бери их напрямую,
218
+ только если адаптивное переключение `MobileView` не нужно.
219
+ - `SuggestFieldDesktopDropdown`, `SuggestFieldMobileDropdown`, `SuggestFieldMobileDropdownHint` —
220
+ внутренние части композиции. Экспортируются из barrel, чтобы их можно было переиспользовать
221
+ в `renderDropdown`, собственных AI.md не имеют.
143
222
 
144
223
  ---
145
224
 
146
225
  ## Stories
147
226
 
148
227
  Основные истории: `stories/SuggestField/SuggestField.stories.tsx`
149
- Примеры: `stories/SuggestField/examples/`
228
+ Файлы примеров: `stories/SuggestField/examples/`
229
+
230
+ | Story | Example file | Что демонстрирует |
231
+ |---|---|---|
232
+ | `Playground` | `PlaygroundExample.tsx` | Интерактивный контроль `size`, `status`, `label`, `placeholder`, `tooltipHint`, `loading`, `dropdownListLoading`, `clearInputOnFocus` и наличия префикса / постфикса / описания |
233
+ | `Default` | `DefaultExample.tsx` | Базовая связка: фильтрация `options` по вводу и `tooltipOpen` при пустом результате |
234
+ | `Sizes` | `SizesExample.tsx` | Размеры SM / MD / LG |
235
+ | `Statuses` | `StatusesExample.tsx` | Все значения `EFormFieldStatus`: default, disabled, error, warning |
236
+ | `Loading` | `LoadingExample.tsx` | Лоадер в постфиксе поля при `loading` |
237
+ | `Production` | `ProductionExample.tsx` | Боевая композиция: `HelpBox` в постфиксе, подсказка со ссылкой |
238
+ | `CustomOptions` | `CustomOptionsExample.tsx` | Опции с `content`: иконка, название и категория в строке списка |
239
+ | `Async` | `AsyncExample.tsx` | Асинхронный поиск: debounce, `loading`, постраничная догрузка по `onScrollEnd` и `dropdownListLoading` |
240
+ | `VisualTests` | `DefaultExample.tsx` | Скриншот-регрессия: рендерит `DefaultExample`, а `play` открывает выпадающий список кликом по полю |
150
241
 
151
242
  ---
152
243
 
@@ -154,4 +245,878 @@ Re-export `FormFieldInput` — для удобства доступа.
154
245
 
155
246
  | Дата | Изменение |
156
247
  |---|---|
157
- | 2026-04-18 | Создан документ |
248
+ | 2026-09-23 | Создан документ |
249
+ | 2026-09-29 | Аудит симметрии `related` (TRI-156): добавлены обратные ссылки (`MultiselectField`, `SelectExtendedField`) и контракт по рендеру `LoaderSmall`. Публичный API и поведение не затронуты. |
250
+ | 2026-10-02 | Сброс значения только кнопкой очистки на обеих платформах: при `clearInputOnFocus` значение терялось от фокуса без выбора. Blur на десктопе и закрытие дропдауна на мобильном больше не вызывают `onSelect(undefined)`; мобильная кнопка очистки сама зовёт `onSelect(undefined)` и `onFilter("")` (последний — при непустом `label`), как десктопная; на закрытии мобильного дропдауна его поле ввода возвращается к `label`. `SuggestField` отдаёт мобильному варианту все props, как десктопному: на мобильной ширине больше не теряются `className`, `id`, `data-test-id`, `active` и `renderInput`. Обновлены `onSelect`, поведение обеих платформ и «Инварианты» |
251
+
252
+ ---
253
+
254
+ ## Примеры использования
255
+
256
+ ### Default
257
+
258
+ `stories/SuggestField/examples/DefaultExample.tsx`
259
+
260
+ ```tsx
261
+ import React, { useState } from "react";
262
+ import {
263
+ SuggestField,
264
+ useMobileView,
265
+ ISuggestFieldOption,
266
+ EComponentSize,
267
+ EFormFieldStatus,
268
+ } from "@sberbusiness/triplex-next";
269
+
270
+ const FOOD_NAMES = [
271
+ "Mushroom",
272
+ "Tomato",
273
+ "Eggplant",
274
+ "Grapes",
275
+ "Melon",
276
+ "Watermelon",
277
+ "Tangerine",
278
+ "Lemon",
279
+ "Banana",
280
+ "Pineapple",
281
+ "Red Apple",
282
+ "Green Apple",
283
+ "Pear",
284
+ "Peach",
285
+ "Cherries",
286
+ "Strawberry",
287
+ ] as const;
288
+
289
+ const FOOD_OPTIONS: ISuggestFieldOption[] = FOOD_NAMES.map((item, index) => ({
290
+ id: `suggest-field-option-${index}`,
291
+ label: item,
292
+ }));
293
+
294
+ const getFilteredOptions = (query: string): ISuggestFieldOption[] =>
295
+ FOOD_OPTIONS.filter(({ label }) => label.toLowerCase().includes(query.toLowerCase()));
296
+
297
+ export const DefaultExample = () => {
298
+ const [value, setValue] = useState<ISuggestFieldOption | undefined>(undefined);
299
+ const [options, setOptions] = useState<ISuggestFieldOption[]>([]);
300
+ const [inputPristine, setInputPristine] = useState(true);
301
+ const [tooltipOpen, setTooltipOpen] = useState<boolean>(false);
302
+
303
+ const adaptive = useMobileView();
304
+
305
+ const reset = (newOptions: typeof options) => {
306
+ setOptions(newOptions);
307
+ setInputPristine(true);
308
+ setTooltipOpen(false);
309
+ };
310
+
311
+ const handleFilter = (inputValue: string) => {
312
+ setInputPristine(false);
313
+
314
+ if (inputValue.length === 0) {
315
+ setOptions(FOOD_OPTIONS);
316
+ setTooltipOpen(false);
317
+ } else {
318
+ const filteredOptions = getFilteredOptions(inputValue);
319
+
320
+ setOptions(filteredOptions);
321
+ setTooltipOpen(filteredOptions.length === 0);
322
+ }
323
+ };
324
+
325
+ const handleSelect = (nextValue: typeof value) => {
326
+ setValue(nextValue);
327
+ reset([]);
328
+ };
329
+
330
+ const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = () => {
331
+ if (!adaptive) {
332
+ reset([]);
333
+ }
334
+ };
335
+
336
+ const handleInputMouseDown: React.MouseEventHandler<HTMLInputElement> = (event) => {
337
+ if (options.length === 0) {
338
+ const query = event.currentTarget.value;
339
+ const filteredOptions = getFilteredOptions(inputPristine ? "" : query);
340
+
341
+ setOptions(filteredOptions);
342
+ setTooltipOpen(filteredOptions.length === 0 && query.length !== 0);
343
+ } else if (adaptive) {
344
+ reset(FOOD_OPTIONS);
345
+ }
346
+ };
347
+
348
+ return (
349
+ <div style={{ maxWidth: 300 }}>
350
+ <SuggestField
351
+ value={value}
352
+ options={options}
353
+ size={EComponentSize.LG}
354
+ status={EFormFieldStatus.DEFAULT}
355
+ label="Label"
356
+ placeholder="Type to proceed"
357
+ tooltipHint="No matches found."
358
+ tooltipOpen={tooltipOpen}
359
+ onFilter={handleFilter}
360
+ onSelect={handleSelect}
361
+ inputProps={{
362
+ onBlur: handleInputBlur,
363
+ onMouseDown: handleInputMouseDown,
364
+ }}
365
+ />
366
+ </div>
367
+ );
368
+ };
369
+ ```
370
+
371
+ ### Sizes
372
+
373
+ `stories/SuggestField/examples/SizesExample.tsx`
374
+
375
+ ```tsx
376
+ import React, { useState } from "react";
377
+ import {
378
+ SuggestField,
379
+ useMobileView,
380
+ ISuggestFieldOption,
381
+ EComponentSize,
382
+ EFormFieldStatus,
383
+ } from "@sberbusiness/triplex-next";
384
+
385
+ interface ISizeItemProps {
386
+ size: EComponentSize;
387
+ }
388
+
389
+ const FOOD_NAMES = [
390
+ "Mushroom",
391
+ "Tomato",
392
+ "Eggplant",
393
+ "Grapes",
394
+ "Melon",
395
+ "Watermelon",
396
+ "Tangerine",
397
+ "Lemon",
398
+ "Banana",
399
+ "Pineapple",
400
+ "Red Apple",
401
+ "Green Apple",
402
+ "Pear",
403
+ "Peach",
404
+ "Cherries",
405
+ "Strawberry",
406
+ ] as const;
407
+
408
+ const FOOD_OPTIONS: ISuggestFieldOption[] = FOOD_NAMES.map((item, index) => ({
409
+ id: `suggest-field-option-${index}`,
410
+ label: item,
411
+ }));
412
+
413
+ const getFilteredOptions = (query: string): ISuggestFieldOption[] =>
414
+ FOOD_OPTIONS.filter(({ label }) => label.toLowerCase().includes(query.toLowerCase()));
415
+
416
+ const SizeItem = ({ size }: ISizeItemProps) => {
417
+ const [value, setValue] = useState<ISuggestFieldOption | undefined>(undefined);
418
+ const [options, setOptions] = useState<ISuggestFieldOption[]>([]);
419
+ const [inputPristine, setInputPristine] = useState(true);
420
+ const [tooltipOpen, setTooltipOpen] = useState<boolean>(false);
421
+
422
+ const adaptive = useMobileView();
423
+
424
+ const reset = (newOptions: typeof options) => {
425
+ setOptions(newOptions);
426
+ setInputPristine(true);
427
+ setTooltipOpen(false);
428
+ };
429
+
430
+ const handleFilter = (inputValue: string) => {
431
+ setInputPristine(false);
432
+
433
+ if (inputValue.length === 0) {
434
+ setOptions(FOOD_OPTIONS);
435
+ setTooltipOpen(false);
436
+ } else {
437
+ const filteredOptions = getFilteredOptions(inputValue);
438
+
439
+ setOptions(filteredOptions);
440
+ setTooltipOpen(filteredOptions.length === 0);
441
+ }
442
+ };
443
+
444
+ const handleSelect = (nextValue: typeof value) => {
445
+ setValue(nextValue);
446
+ reset([]);
447
+ };
448
+
449
+ const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = () => {
450
+ if (!adaptive) {
451
+ reset([]);
452
+ }
453
+ };
454
+
455
+ const handleInputMouseDown: React.MouseEventHandler<HTMLInputElement> = (event) => {
456
+ if (options.length === 0) {
457
+ const query = event.currentTarget.value;
458
+ const filteredOptions = getFilteredOptions(inputPristine ? "" : query);
459
+
460
+ setOptions(filteredOptions);
461
+ setTooltipOpen(filteredOptions.length === 0 && query.length !== 0);
462
+ } else if (adaptive) {
463
+ reset(FOOD_OPTIONS);
464
+ }
465
+ };
466
+
467
+ return (
468
+ <div style={{ maxWidth: 300 }}>
469
+ <div style={{ marginBottom: 8, fontSize: 16, fontWeight: 700 }}>{size.toUpperCase()}</div>
470
+ <SuggestField
471
+ value={value}
472
+ options={options}
473
+ size={size}
474
+ status={EFormFieldStatus.DEFAULT}
475
+ label="Label"
476
+ placeholder="Type to proceed"
477
+ tooltipHint="No matches found."
478
+ tooltipOpen={tooltipOpen}
479
+ onFilter={handleFilter}
480
+ onSelect={handleSelect}
481
+ inputProps={{
482
+ onBlur: handleInputBlur,
483
+ onMouseDown: handleInputMouseDown,
484
+ }}
485
+ />
486
+ </div>
487
+ );
488
+ };
489
+
490
+ const SIZES = Object.values(EComponentSize);
491
+
492
+ export const SizesExample = () => (
493
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
494
+ {SIZES.map((size) => (
495
+ <SizeItem key={size} size={size} />
496
+ ))}
497
+ </div>
498
+ );
499
+ ```
500
+
501
+ ### Statuses
502
+
503
+ `stories/SuggestField/examples/StatusesExample.tsx`
504
+
505
+ ```tsx
506
+ import React, { useState } from "react";
507
+ import {
508
+ SuggestField,
509
+ useMobileView,
510
+ ISuggestFieldOption,
511
+ EComponentSize,
512
+ EFormFieldStatus,
513
+ } from "@sberbusiness/triplex-next";
514
+
515
+ interface IStatusItemProps {
516
+ status: EFormFieldStatus;
517
+ }
518
+
519
+ const FOOD_NAMES = [
520
+ "Mushroom",
521
+ "Tomato",
522
+ "Eggplant",
523
+ "Grapes",
524
+ "Melon",
525
+ "Watermelon",
526
+ "Tangerine",
527
+ "Lemon",
528
+ "Banana",
529
+ "Pineapple",
530
+ "Red Apple",
531
+ "Green Apple",
532
+ "Pear",
533
+ "Peach",
534
+ "Cherries",
535
+ "Strawberry",
536
+ ] as const;
537
+
538
+ const FOOD_OPTIONS: ISuggestFieldOption[] = FOOD_NAMES.map((item, index) => ({
539
+ id: `suggest-field-option-${index}`,
540
+ label: item,
541
+ }));
542
+
543
+ const getFilteredOptions = (query: string): ISuggestFieldOption[] =>
544
+ FOOD_OPTIONS.filter(({ label }) => label.toLowerCase().includes(query.toLowerCase()));
545
+
546
+ const StatusItem = ({ status }: IStatusItemProps) => {
547
+ const [value, setValue] = useState<ISuggestFieldOption | undefined>(undefined);
548
+ const [options, setOptions] = useState<ISuggestFieldOption[]>([]);
549
+ const [inputPristine, setInputPristine] = useState(true);
550
+ const [tooltipOpen, setTooltipOpen] = useState<boolean>(false);
551
+
552
+ const adaptive = useMobileView();
553
+
554
+ const reset = (newOptions: typeof options) => {
555
+ setOptions(newOptions);
556
+ setInputPristine(true);
557
+ setTooltipOpen(false);
558
+ };
559
+
560
+ const handleFilter = (inputValue: string) => {
561
+ setInputPristine(false);
562
+
563
+ if (inputValue.length === 0) {
564
+ setOptions(FOOD_OPTIONS);
565
+ setTooltipOpen(false);
566
+ } else {
567
+ const filteredOptions = getFilteredOptions(inputValue);
568
+
569
+ setOptions(filteredOptions);
570
+ setTooltipOpen(filteredOptions.length === 0);
571
+ }
572
+ };
573
+
574
+ const handleSelect = (nextValue: typeof value) => {
575
+ setValue(nextValue);
576
+ reset([]);
577
+ };
578
+
579
+ const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = () => {
580
+ if (!adaptive) {
581
+ reset([]);
582
+ }
583
+ };
584
+
585
+ const handleInputMouseDown: React.MouseEventHandler<HTMLInputElement> = (event) => {
586
+ if (options.length === 0) {
587
+ const query = event.currentTarget.value;
588
+ const filteredOptions = getFilteredOptions(inputPristine ? "" : query);
589
+
590
+ setOptions(filteredOptions);
591
+ setTooltipOpen(filteredOptions.length === 0 && query.length !== 0);
592
+ } else if (adaptive) {
593
+ reset(FOOD_OPTIONS);
594
+ }
595
+ };
596
+
597
+ return (
598
+ <div style={{ maxWidth: 300 }}>
599
+ <div style={{ marginBottom: 8, fontSize: 16, fontWeight: 700 }}>{status.toUpperCase()}</div>
600
+ <SuggestField
601
+ value={value}
602
+ options={options}
603
+ size={EComponentSize.LG}
604
+ status={status}
605
+ label="Label"
606
+ placeholder="Type to proceed"
607
+ tooltipHint="No matches found."
608
+ tooltipOpen={tooltipOpen}
609
+ onFilter={handleFilter}
610
+ onSelect={handleSelect}
611
+ inputProps={{
612
+ onBlur: handleInputBlur,
613
+ onMouseDown: handleInputMouseDown,
614
+ }}
615
+ />
616
+ </div>
617
+ );
618
+ };
619
+
620
+ const STATUSES = Object.values(EFormFieldStatus);
621
+
622
+ export const StatusesExample = () => (
623
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
624
+ {STATUSES.map((status) => (
625
+ <StatusItem key={status} status={status} />
626
+ ))}
627
+ </div>
628
+ );
629
+ ```
630
+
631
+ ### Loading
632
+
633
+ `stories/SuggestField/examples/LoadingExample.tsx`
634
+
635
+ ```tsx
636
+ import React from "react";
637
+ import { SuggestField, EComponentSize, EFormFieldStatus } from "@sberbusiness/triplex-next";
638
+
639
+ export const LoadingExample = () => {
640
+ return (
641
+ <div style={{ maxWidth: 300 }}>
642
+ <SuggestField
643
+ value={undefined}
644
+ options={[]}
645
+ size={EComponentSize.LG}
646
+ status={EFormFieldStatus.DEFAULT}
647
+ label="Label"
648
+ placeholder="Type to proceed"
649
+ tooltipHint="No matches found."
650
+ tooltipOpen={false}
651
+ loading={true}
652
+ onFilter={() => {}}
653
+ onSelect={() => {}}
654
+ inputProps={{}}
655
+ />
656
+ </div>
657
+ );
658
+ };
659
+ ```
660
+
661
+ ### Production
662
+
663
+ `stories/SuggestField/examples/ProductionExample.tsx`
664
+
665
+ ```tsx
666
+ import React, { useState, useRef } from "react";
667
+ import {
668
+ SuggestField,
669
+ HelpBox,
670
+ Text,
671
+ Link,
672
+ useMobileView,
673
+ ISuggestFieldOption,
674
+ EComponentSize,
675
+ EFormFieldStatus,
676
+ ETooltipSize,
677
+ ETextSize,
678
+ EFontType,
679
+ } from "@sberbusiness/triplex-next";
680
+ import { DefaulticonStrokePrdIcon24 } from "@sberbusiness/icons-next";
681
+
682
+ const FOOD_NAMES = [
683
+ "Mushroom",
684
+ "Tomato",
685
+ "Eggplant",
686
+ "Grapes",
687
+ "Melon",
688
+ "Watermelon",
689
+ "Tangerine",
690
+ "Lemon",
691
+ "Banana",
692
+ "Pineapple",
693
+ "Red Apple",
694
+ "Green Apple",
695
+ "Pear",
696
+ "Peach",
697
+ "Cherries",
698
+ "Strawberry",
699
+ ] as const;
700
+
701
+ const FOOD_OPTIONS: ISuggestFieldOption[] = FOOD_NAMES.map((item, index) => ({
702
+ id: `suggest-field-option-${index}`,
703
+ label: item,
704
+ }));
705
+
706
+ const getFilteredOptions = (query: string): ISuggestFieldOption[] =>
707
+ FOOD_OPTIONS.filter(({ label }) => label.toLowerCase().includes(query.toLowerCase()));
708
+
709
+ export const ProductionExample = () => {
710
+ const [value, setValue] = useState<ISuggestFieldOption | undefined>(undefined);
711
+ const [options, setOptions] = useState<ISuggestFieldOption[]>([]);
712
+ const [inputPristine, setInputPristine] = useState(true);
713
+ const [tooltipOpen, setTooltipOpen] = useState<boolean>(false);
714
+ const inputRef = useRef<HTMLInputElement>(null);
715
+
716
+ const adaptive = useMobileView();
717
+
718
+ const reset = (newOptions: typeof options) => {
719
+ setOptions(newOptions);
720
+ setInputPristine(true);
721
+ setTooltipOpen(false);
722
+ };
723
+
724
+ const handleFilter = (inputValue: string) => {
725
+ setInputPristine(false);
726
+
727
+ if (inputValue.length === 0) {
728
+ setOptions(FOOD_OPTIONS);
729
+ setTooltipOpen(false);
730
+ } else {
731
+ const filteredOptions = getFilteredOptions(inputValue);
732
+
733
+ setOptions(filteredOptions);
734
+ setTooltipOpen(filteredOptions.length === 0);
735
+ }
736
+ };
737
+
738
+ const handleSelect = (nextValue: typeof value) => {
739
+ setValue(nextValue);
740
+ reset([]);
741
+ };
742
+
743
+ const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = () => {
744
+ if (!adaptive) {
745
+ reset([]);
746
+ }
747
+ };
748
+
749
+ const handleInputMouseDown: React.MouseEventHandler<HTMLInputElement> = (event) => {
750
+ if (options.length === 0) {
751
+ const query = event.currentTarget.value;
752
+ const filteredOptions = getFilteredOptions(inputPristine ? "" : query);
753
+
754
+ setOptions(filteredOptions);
755
+ setTooltipOpen(filteredOptions.length === 0 && query.length !== 0);
756
+ } else if (adaptive) {
757
+ reset(FOOD_OPTIONS);
758
+ }
759
+ };
760
+
761
+ const handleClear: React.MouseEventHandler<HTMLButtonElement> = () => {
762
+ // На мобильном фокус открыл бы полноэкранный дропдаун сразу после очистки.
763
+ if (!adaptive) {
764
+ inputRef.current?.focus();
765
+ }
766
+ };
767
+
768
+ const handleLinkClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
769
+ event.preventDefault();
770
+ };
771
+
772
+ return (
773
+ <div style={{ maxWidth: 300 }}>
774
+ <SuggestField
775
+ value={value}
776
+ options={options}
777
+ size={EComponentSize.LG}
778
+ status={EFormFieldStatus.DEFAULT}
779
+ label="Label"
780
+ placeholder="Type to proceed"
781
+ tooltipHint="No matches found."
782
+ tooltipOpen={tooltipOpen}
783
+ onFilter={handleFilter}
784
+ onSelect={handleSelect}
785
+ onClear={handleClear}
786
+ inputProps={{
787
+ onBlur: handleInputBlur,
788
+ onMouseDown: handleInputMouseDown,
789
+ ref: inputRef,
790
+ }}
791
+ prefix={<DefaulticonStrokePrdIcon24 paletteIndex={5} />}
792
+ postfix={
793
+ <React.Fragment>
794
+ <DefaulticonStrokePrdIcon24 paletteIndex={5} />
795
+ <HelpBox tooltipSize={ETooltipSize.SM}>Helpful details appear here</HelpBox>
796
+ </React.Fragment>
797
+ }
798
+ description={
799
+ <Text tag="div" size={ETextSize.B4} type={EFontType.SECONDARY}>
800
+ (21) Description{" "}
801
+ <Link href="#" onClick={handleLinkClick}>
802
+ Link text
803
+ </Link>
804
+ </Text>
805
+ }
806
+ />
807
+ </div>
808
+ );
809
+ };
810
+ ```
811
+
812
+ ### CustomOptions
813
+
814
+ `stories/SuggestField/examples/CustomOptionsExample.tsx`
815
+
816
+ ```tsx
817
+ import React, { useState } from "react";
818
+ import {
819
+ SuggestField,
820
+ Text,
821
+ useMobileView,
822
+ ISuggestFieldOption,
823
+ EComponentSize,
824
+ EFormFieldStatus,
825
+ ETextSize,
826
+ EFontType,
827
+ } from "@sberbusiness/triplex-next";
828
+
829
+ const FOOD_DATA = [
830
+ { label: "Mushroom", icon: "🍄", category: "Vegetable" },
831
+ { label: "Tomato", icon: "🍅", category: "Vegetable" },
832
+ { label: "Eggplant", icon: "🍆", category: "Vegetable" },
833
+ { label: "Grapes", icon: "🍇", category: "Fruit" },
834
+ { label: "Melon", icon: "🍈", category: "Fruit" },
835
+ { label: "Watermelon", icon: "🍉", category: "Fruit" },
836
+ { label: "Tangerine", icon: "🍊", category: "Fruit" },
837
+ { label: "Lemon", icon: "🍋", category: "Fruit" },
838
+ { label: "Banana", icon: "🍌", category: "Fruit" },
839
+ { label: "Pineapple", icon: "🍍", category: "Fruit" },
840
+ { label: "Red apple", icon: "🍎", category: "Fruit" },
841
+ { label: "Green apple", icon: "🍏", category: "Fruit" },
842
+ { label: "Pear", icon: "🍐", category: "Fruit" },
843
+ { label: "Peach", icon: "🍑", category: "Fruit" },
844
+ { label: "Cherries", icon: "🍒", category: "Fruit" },
845
+ { label: "Strawberry", icon: "🍓", category: "Fruit" },
846
+ ] as const;
847
+
848
+ const FOOD_OPTIONS: ISuggestFieldOption[] = FOOD_DATA.map((item, index) => ({
849
+ id: `suggest-field-option-${index}`,
850
+ label: item.label,
851
+ content: (
852
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
853
+ <span>{item.icon}</span>
854
+ <Text size={ETextSize.B2} type={EFontType.PRIMARY}>
855
+ {item.label}
856
+ </Text>
857
+ <Text size={ETextSize.B3} type={EFontType.SECONDARY} style={{ marginLeft: "auto" }}>
858
+ {item.category}
859
+ </Text>
860
+ </div>
861
+ ),
862
+ }));
863
+
864
+ const getFilteredOptions = (query: string): ISuggestFieldOption[] =>
865
+ FOOD_OPTIONS.filter(({ label }) => label.toLowerCase().includes(query.toLowerCase()));
866
+
867
+ export const CustomOptionsExample = () => {
868
+ const [value, setValue] = useState<ISuggestFieldOption | undefined>(undefined);
869
+ const [options, setOptions] = useState<ISuggestFieldOption[]>([]);
870
+ const [inputPristine, setInputPristine] = useState(true);
871
+ const [tooltipOpen, setTooltipOpen] = useState<boolean>(false);
872
+
873
+ const adaptive = useMobileView();
874
+
875
+ const reset = (newOptions: typeof options) => {
876
+ setOptions(newOptions);
877
+ setInputPristine(true);
878
+ setTooltipOpen(false);
879
+ };
880
+
881
+ const handleFilter = (inputValue: string) => {
882
+ setInputPristine(false);
883
+
884
+ if (inputValue.length === 0) {
885
+ setOptions(FOOD_OPTIONS);
886
+ setTooltipOpen(false);
887
+ } else {
888
+ const filteredOptions = getFilteredOptions(inputValue);
889
+
890
+ setOptions(filteredOptions);
891
+ setTooltipOpen(filteredOptions.length === 0);
892
+ }
893
+ };
894
+
895
+ const handleSelect = (nextValue: typeof value) => {
896
+ setValue(nextValue);
897
+ reset([]);
898
+ };
899
+
900
+ const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = () => {
901
+ if (!adaptive) {
902
+ reset([]);
903
+ }
904
+ };
905
+
906
+ const handleInputMouseDown: React.MouseEventHandler<HTMLInputElement> = (event) => {
907
+ if (options.length === 0) {
908
+ const query = event.currentTarget.value;
909
+ const filteredOptions = getFilteredOptions(inputPristine ? "" : query);
910
+
911
+ setOptions(filteredOptions);
912
+ setTooltipOpen(filteredOptions.length === 0 && query.length !== 0);
913
+ } else if (adaptive) {
914
+ reset(FOOD_OPTIONS);
915
+ }
916
+ };
917
+
918
+ return (
919
+ <div style={{ maxWidth: 300 }}>
920
+ <SuggestField
921
+ value={value}
922
+ options={options}
923
+ size={EComponentSize.LG}
924
+ status={EFormFieldStatus.DEFAULT}
925
+ label="Label"
926
+ placeholder="Type to proceed"
927
+ tooltipHint="No matches found."
928
+ tooltipOpen={tooltipOpen}
929
+ onFilter={handleFilter}
930
+ onSelect={handleSelect}
931
+ inputProps={{
932
+ onBlur: handleInputBlur,
933
+ onMouseDown: handleInputMouseDown,
934
+ }}
935
+ />
936
+ </div>
937
+ );
938
+ };
939
+ ```
940
+
941
+ ### Async
942
+
943
+ `stories/SuggestField/examples/AsyncExample.tsx`
944
+
945
+ ```tsx
946
+ import React, { useState, useRef, useCallback, useEffect } from "react";
947
+ import {
948
+ SuggestField,
949
+ useMobileView,
950
+ ISuggestFieldOption,
951
+ EComponentSize,
952
+ EFormFieldStatus,
953
+ } from "@sberbusiness/triplex-next";
954
+ import { debounce } from "lodash-es";
955
+
956
+ const FOOD_NAMES = [
957
+ "Mushroom",
958
+ "Tomato",
959
+ "Eggplant",
960
+ "Grapes",
961
+ "Melon",
962
+ "Watermelon",
963
+ "Tangerine",
964
+ "Lemon",
965
+ "Banana",
966
+ "Pineapple",
967
+ "Red Apple",
968
+ "Green Apple",
969
+ "Pear",
970
+ "Peach",
971
+ "Cherries",
972
+ "Strawberry",
973
+ ] as const;
974
+
975
+ const FOOD_OPTIONS: ISuggestFieldOption[] = FOOD_NAMES.map((item, index) => ({
976
+ id: `suggest-field-option-${index}`,
977
+ label: item,
978
+ }));
979
+
980
+ const getFilteredOptions = (query: string): ISuggestFieldOption[] =>
981
+ FOOD_OPTIONS.filter(({ label }) => label.toLowerCase().includes(query.toLowerCase()));
982
+
983
+ export const AsyncExample = () => {
984
+ const [value, setValue] = useState<ISuggestFieldOption | undefined>(undefined);
985
+ const [options, setOptions] = useState<ISuggestFieldOption[]>([]);
986
+ const [loading, setLoading] = useState<boolean>(false);
987
+ const [tooltipOpen, setTooltipOpen] = useState<boolean>(false);
988
+ const [dropdownListLoading, setDropdownListLoading] = useState<boolean>(false);
989
+
990
+ const filteredOptionsRef = useRef<typeof options>([]);
991
+ const lastSearchQueryRef = useRef<string>("");
992
+ const activeRequestRef = useRef<boolean>(false);
993
+ const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
994
+
995
+ const adaptive = useMobileView();
996
+
997
+ const debouncedFilter = useRef(
998
+ // eslint-disable-next-line react-hooks/refs
999
+ debounce((inputValue: string) => {
1000
+ if (!activeRequestRef.current) return;
1001
+
1002
+ const filteredOptions = getFilteredOptions(inputValue);
1003
+
1004
+ filteredOptionsRef.current = filteredOptions;
1005
+ lastSearchQueryRef.current = inputValue;
1006
+
1007
+ setOptions(filteredOptions.slice(0, 6));
1008
+ setLoading(false);
1009
+ setTooltipOpen(filteredOptions.length === 0);
1010
+ }, 1000),
1011
+ ).current;
1012
+
1013
+ const stopTimers = useCallback(() => {
1014
+ debouncedFilter.cancel();
1015
+ clearTimeout(scrollTimeoutRef.current);
1016
+ }, [debouncedFilter]);
1017
+
1018
+ const stopLoading = () => {
1019
+ stopTimers();
1020
+ setLoading(false);
1021
+ setDropdownListLoading(false);
1022
+ };
1023
+
1024
+ const reset = () => {
1025
+ filteredOptionsRef.current = [];
1026
+ lastSearchQueryRef.current = "";
1027
+ activeRequestRef.current = false;
1028
+ stopLoading();
1029
+ setOptions([]);
1030
+ setTooltipOpen(false);
1031
+ };
1032
+
1033
+ const startSearch = (query: string) => {
1034
+ stopTimers();
1035
+ activeRequestRef.current = true;
1036
+
1037
+ setTooltipOpen(false);
1038
+ setLoading(true);
1039
+ setDropdownListLoading(false);
1040
+ debouncedFilter(query);
1041
+ };
1042
+
1043
+ const handleFilter = (inputValue: string) => {
1044
+ if (inputValue.length === 0) {
1045
+ return reset();
1046
+ }
1047
+
1048
+ if (inputValue !== lastSearchQueryRef.current) {
1049
+ startSearch(inputValue);
1050
+ }
1051
+ };
1052
+
1053
+ const handleSelect = (nextValue: typeof value) => {
1054
+ setValue(nextValue);
1055
+ reset();
1056
+ };
1057
+
1058
+ const handleScrollEnd = () => {
1059
+ const filteredOptions = filteredOptionsRef.current;
1060
+
1061
+ if (dropdownListLoading || options.length === 0 || options.length === filteredOptions.length) {
1062
+ return;
1063
+ }
1064
+
1065
+ clearTimeout(scrollTimeoutRef.current);
1066
+ setDropdownListLoading(true);
1067
+
1068
+ scrollTimeoutRef.current = setTimeout(() => {
1069
+ setOptions((prevOptions) => {
1070
+ const nextChunk = filteredOptions.slice(prevOptions.length, prevOptions.length + 6);
1071
+ return [...prevOptions, ...nextChunk];
1072
+ });
1073
+ setDropdownListLoading(false);
1074
+ }, 1000);
1075
+ };
1076
+
1077
+ const handleInputBlur: React.FocusEventHandler<HTMLInputElement> = () => {
1078
+ if (adaptive === false) {
1079
+ reset();
1080
+ }
1081
+ };
1082
+
1083
+ const handleInputMouseDown: React.MouseEventHandler<HTMLInputElement> = (event) => {
1084
+ const query = event.currentTarget.value;
1085
+
1086
+ if (query.length !== 0 && !loading && query !== lastSearchQueryRef.current) {
1087
+ startSearch(query);
1088
+ }
1089
+ };
1090
+
1091
+ useEffect(() => {
1092
+ return () => {
1093
+ activeRequestRef.current = false;
1094
+ stopTimers();
1095
+ };
1096
+ }, [stopTimers]);
1097
+
1098
+ return (
1099
+ <div style={{ maxWidth: 300 }}>
1100
+ <SuggestField
1101
+ value={value}
1102
+ options={options}
1103
+ size={EComponentSize.LG}
1104
+ status={EFormFieldStatus.DEFAULT}
1105
+ label="Label"
1106
+ placeholder="Type to proceed"
1107
+ tooltipHint="No matches found."
1108
+ loading={loading}
1109
+ tooltipOpen={tooltipOpen}
1110
+ onFilter={handleFilter}
1111
+ onSelect={handleSelect}
1112
+ onScrollEnd={handleScrollEnd}
1113
+ inputProps={{
1114
+ onBlur: handleInputBlur,
1115
+ onMouseDown: handleInputMouseDown,
1116
+ }}
1117
+ />
1118
+ </div>
1119
+ );
1120
+ };
1121
+ ```
1122
+