@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.
- package/README.md +7 -7
- package/bundle/components/AmountField.md +222 -323
- package/bundle/components/Avatar.md +138 -146
- package/bundle/components/CardAction.md +615 -0
- package/bundle/components/CardStatic.md +402 -0
- package/bundle/components/CheckboxTree.md +157 -215
- package/bundle/components/CheckboxTreeExtended.md +7 -7
- package/bundle/components/Chip.md +7 -6
- package/bundle/components/ChipGroup.md +1 -1
- package/bundle/components/ChipMonthYearPicker.md +344 -0
- package/bundle/components/ChipMultiselect.md +1251 -0
- package/bundle/components/ChipOptions.md +329 -0
- package/bundle/components/ChipSort.md +333 -0
- package/bundle/components/ChipSuggest.md +588 -0
- package/bundle/components/CollapsibleTree.md +8 -4
- package/bundle/components/CollapsibleTreeExtended.md +8 -6
- package/bundle/components/Confirm.md +346 -281
- package/bundle/components/DateField.md +222 -303
- package/bundle/components/DatePickerExtended.md +3 -1
- package/bundle/components/DateRange.md +351 -210
- package/bundle/components/FooterPage.md +1 -0
- package/bundle/components/FormField.md +14 -5
- package/bundle/components/FormGroup.md +3 -2
- package/bundle/components/HelpBox.md +7 -1
- package/bundle/components/IslandAccordion.md +443 -152
- package/bundle/components/IslandWidget.md +622 -157
- package/bundle/components/LightBox.md +8 -0
- package/bundle/components/ListMaster.md +2 -2
- package/bundle/components/ListTableItem.md +6 -6
- package/bundle/components/MaskedField.md +2 -1
- package/bundle/components/MonthYearField.md +18 -5
- package/bundle/components/Slider.md +389 -97
- package/bundle/components/SliderRange.md +486 -105
- package/bundle/components/StatusTracker.md +386 -94
- package/bundle/components/Stepper.md +355 -102
- package/bundle/components/StepperExtended.md +633 -0
- package/bundle/components/SuggestField.md +1049 -96
- package/bundle/components/SwipeableArea.md +7 -7
- package/bundle/components/Tabs.md +265 -92
- package/bundle/components/TabsExtended.md +18 -18
- package/bundle/components/TabsLine.md +301 -127
- package/bundle/components/TagColor.md +187 -66
- package/bundle/components/TagGroup.md +184 -48
- package/bundle/components/TextareaField.md +278 -70
- package/bundle/components/TopOverlay.md +17 -1
- package/bundle/components/TreeView.md +16 -7
- package/bundle/components/TriggerClickOnKeyDownEvent.md +277 -0
- package/bundle/components/UploadZone.md +318 -0
- package/bundle/guides/codestyle.md +16 -1
- package/bundle/guides/commits.md +47 -1
- package/bundle/guides/tests.md +23 -2
- package/bundle/manifest.json +92 -24
- package/bundle/release-notes/1.46.0.mdx +59 -0
- package/bundle/release-notes/1.47.0.mdx +122 -0
- package/bundle/release-notes/1.48.0.mdx +68 -0
- package/bundle/release-notes/1.49.0.mdx +7 -0
- package/bundle/tokens.json +3 -3
- package/dist/index.js +220 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
component: SuggestField
|
|
3
|
-
category:
|
|
4
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
для кастомизации.
|
|
14
|
+
Готовое поле ввода с выпадающим списком и поиском по введённому значению: лейбл, статусы,
|
|
15
|
+
префикс/постфикс, лоадер, кнопка очистки и подсказка-Tooltip уже собраны. Компонент
|
|
16
|
+
**не фильтрует список сам** — он сообщает введённый текст через `onFilter`, а потребитель
|
|
17
|
+
возвращает новый `options`.
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
47
|
-
|
|
26
|
+
Не используй когда:
|
|
27
|
+
- Список фиксированный и вводом не фильтруется — возьми `SelectField`.
|
|
28
|
+
- Нужен suggest-фильтр в ряду чипсов — возьми `ChipSuggest`.
|
|
29
|
+
- Нужна нестандартная разметка управляющего элемента или списка — собери своё на headless
|
|
30
|
+
`Suggest`; `SuggestField` жёстко задаёт композицию `TextFieldBase` + `Dropdown`.
|
|
48
31
|
|
|
49
32
|
---
|
|
50
33
|
|
|
51
|
-
##
|
|
34
|
+
## Варианты и props
|
|
52
35
|
|
|
53
|
-
|
|
36
|
+
`ISuggestFieldProps<T>` расширяет `Omit<ITextFieldProps, "onSelect">`, поэтому всё из
|
|
37
|
+
`TextField` / `FormField` доступно: `status`, `size`, `active`, `label`, `description`,
|
|
38
|
+
`counter`, `prefix`, `postfix`, `className`, `data-*`, `inputProps`.
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Props = `Omit<ITextFieldProps, "onSelect"> & DataAttributes` + поля ниже:
|
|
40
|
+
### Обязательные props
|
|
58
41
|
|
|
59
42
|
| Prop | Тип | Описание |
|
|
60
43
|
|---|---|---|
|
|
61
|
-
| `value` | `T \| undefined` | Выбранная опция. |
|
|
62
|
-
| `options` | `T[]` | Список опций. |
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
|
72
|
-
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
`inputProps.onFocus` / `onBlur` / `onKeyDown` / `onMouseDown` / `onChange` вызываются
|
|
99
|
+
**после** внутренних обработчиков — подменить их через `inputProps` нельзя. С остальными
|
|
100
|
+
свойствами наоборот: `value`, `placeholder`, `data-test-id` из `inputProps` спредятся после
|
|
101
|
+
внутренних значений и перекрывают их.
|
|
94
102
|
|
|
95
|
-
|
|
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
|
-
|
|
105
|
+
Поле-триггер — `readOnly`, по фокусу открывается полноэкранный `Dropdown` с собственным полем
|
|
106
|
+
ввода (автофокус). При `tooltipOpen` вместо списка показывается `SuggestFieldMobileDropdownHint`
|
|
107
|
+
с текстом `tooltipHint`. Закрытие дропдауна с пустым полем ввода и заданным `value` вызывает
|
|
108
|
+
`onSelect(undefined)`; закрытие сразу после выбора опции значение не сбрасывает. После закрытия
|
|
109
|
+
поле-триггер скроллится в центр экрана (в iOS открытие дропдауна уводит страницу вверх).
|
|
102
110
|
|
|
103
|
-
|
|
104
|
-
- `SuggestFieldMobileDropdownHint` — текст-подсказка над списком.
|
|
105
|
-
- Рендерится только внутри `MobileView` (viewport <= SM).
|
|
111
|
+
---
|
|
106
112
|
|
|
107
|
-
|
|
113
|
+
## Дизайн-токены
|
|
108
114
|
|
|
109
|
-
|
|
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
|
-
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
-
|
|
130
|
-
|
|
131
|
-
- `
|
|
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-основа
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
- `
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
+
|