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