@sberbusiness/triplex-next-mcp-server 1.44.0-beta → 1.45.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 -6
- package/bundle/components/AlertContext.md +11 -8
- package/bundle/components/AlertProcess.md +13 -10
- package/bundle/components/Badge.md +6 -3
- package/bundle/components/Button.md +46 -43
- package/bundle/components/ButtonDropdownExtended.md +3 -2
- package/bundle/components/ButtonIcon.md +7 -4
- package/bundle/components/Calendar.md +62 -59
- package/bundle/components/Caption.md +26 -23
- package/bundle/components/Checkbox.md +32 -29
- package/bundle/components/CheckboxTreeExtended.md +9 -5
- package/bundle/components/Chip.md +44 -41
- package/bundle/components/CodeText.md +26 -23
- package/bundle/components/Col.md +1 -2
- package/bundle/components/CollapsibleTree.md +8 -5
- package/bundle/components/Dropdown.md +33 -28
- package/bundle/components/EmptyView.md +12 -5
- package/bundle/components/FooterPage.md +6 -2
- package/bundle/components/FormField.md +44 -41
- package/bundle/components/FormGroup.md +192 -52
- package/bundle/components/Header.md +491 -211
- package/bundle/components/HeaderPage.md +6 -2
- package/bundle/components/ImageGallery.md +33 -34
- package/bundle/components/ImageGalleryExtended.md +33 -34
- package/bundle/components/Island.md +16 -13
- package/bundle/components/LightBox.md +14 -12
- package/bundle/components/Link.md +211 -96
- package/bundle/components/ListActionItem.md +7 -4
- package/bundle/components/ListItem.md +11 -8
- package/bundle/components/ListItemControlsButton.md +16 -13
- package/bundle/components/ListItemControlsButtonDropdown.md +9 -6
- package/bundle/components/ListItemSelectable.md +7 -4
- package/bundle/components/ListMasterFooter.md +8 -5
- package/bundle/components/ListMasterHeader.md +8 -5
- package/bundle/components/ListSortableItem.md +11 -8
- package/bundle/components/ListTableItem.md +7 -4
- package/bundle/components/LoaderMiddle.md +7 -4
- package/bundle/components/LoaderScreen.md +11 -8
- package/bundle/components/LoaderSmall.md +7 -4
- package/bundle/components/Marker.md +184 -69
- package/bundle/components/MarkerStatus.md +206 -88
- package/bundle/components/MaskedField.md +2 -4
- package/bundle/components/MasterTable.md +602 -0
- package/bundle/components/ModalWindow.md +7 -4
- package/bundle/components/MultiselectField.md +2183 -132
- package/bundle/components/Notification.md +12 -9
- package/bundle/components/Overlay.md +9 -4
- package/bundle/components/Page.md +2 -2
- package/bundle/components/Pagination.md +15 -8
- package/bundle/components/Row.md +1 -1
- package/bundle/components/SelectExtendedField.md +3 -3
- package/bundle/components/SelectField.md +466 -148
- package/bundle/components/SliderExtended.md +39 -36
- package/bundle/components/SmallInput.md +194 -51
- package/bundle/components/Suggest.md +535 -99
- package/bundle/components/SwipeableArea.md +428 -79
- package/bundle/components/TableBasic.md +19 -11
- package/bundle/components/TabsExtended.md +24 -21
- package/bundle/components/Tag.md +6 -2
- package/bundle/components/Text.md +28 -25
- package/bundle/components/TextField.md +3 -4
- package/bundle/components/ThemeProvider.md +50 -1
- package/bundle/components/Title.md +26 -23
- package/bundle/components/Tooltip.md +24 -21
- package/bundle/components/TopOverlay.md +7 -4
- package/bundle/guides/codestyle.md +3 -0
- package/bundle/guides/context.md +84 -12
- package/bundle/guides/guidelines.md +321 -0
- package/bundle/guides/template.md +22 -12
- package/bundle/manifest.json +31 -14
- package/bundle/release-notes/1.45.0.mdx +117 -0
- package/bundle/release-notes/1.46.0.mdx +7 -0
- package/bundle/tokens.json +6362 -0
- package/dist/index.js +177 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
component: MultiselectField
|
|
3
|
-
category:
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
tokens: []
|
|
3
|
+
category: MultiselectField
|
|
4
|
+
related: [SelectField, SuggestField, SelectExtendedField, Dropdown, LoaderScreen]
|
|
5
|
+
tokens:
|
|
6
|
+
- MultiselectField.Divider_Color
|
|
8
7
|
stories: stories/MultiselectField/MultiselectField.stories.tsx
|
|
9
8
|
version: "1.0"
|
|
10
9
|
---
|
|
@@ -13,201 +12,2253 @@ version: "1.0"
|
|
|
13
12
|
|
|
14
13
|
## Назначение
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(Header/Content/Footer). Список опций (чекбоксы), кнопки применения,
|
|
20
|
-
заголовок с поиском — реализуются потребителем в Header/Content/Footer.
|
|
15
|
+
Поле выбора нескольких значений: поле с тегами выбранного и выпадающий блок с
|
|
16
|
+
произвольным содержимым — списком чекбоксов, деревом, фильтром и кнопками
|
|
17
|
+
подтверждения.
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
Собственной разметки у компонента почти нет. Это надстройка над
|
|
20
|
+
`SelectExtendedField`, которая добавляет три вещи: раздачу размера частям
|
|
21
|
+
выпадающего блока через контекст, перехват фокуса внутри открытого блока
|
|
22
|
+
(`FocusTrap`) и правило «после открытия мышью фокус на поле не возвращается».
|
|
23
|
+
Состоянием открытости, закрытием по `Escape` и по нажатию вне блока по-прежнему
|
|
24
|
+
владеет `SelectExtendedField`.
|
|
25
|
+
|
|
26
|
+
Используй когда: нужен множественный выбор — чекбоксы со счётчиком, дерево
|
|
27
|
+
категорий, фильтр с явным подтверждением — и выбранное показывается тегами в поле.
|
|
28
|
+
|
|
29
|
+
Не используй когда:
|
|
30
|
+
|
|
31
|
+
- нужен выбор одного значения из списка — используй `SelectField`;
|
|
32
|
+
- нужен поиск по вводу с подсказками — используй `SuggestField`;
|
|
33
|
+
- нужен множественный выбор в виде фильтр-чипа над таблицей — используй `ChipMultiselect` (он построен на этом компоненте);
|
|
34
|
+
- содержимое выпадающего блока стандартное, а перехват фокуса не нужен — используй `SelectExtendedField` напрямую;
|
|
35
|
+
- выпадающий блок не привязан к полю ввода — используй `Dropdown`.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Варианты и props
|
|
40
|
+
|
|
41
|
+
### `MultiselectField`
|
|
42
|
+
|
|
43
|
+
`IMultiselectFieldProps` расширяет `ISelectExtendedFieldProps` единственным
|
|
44
|
+
собственным prop'ом `size`. Всё остальное — обязательные рендер-функции
|
|
45
|
+
`renderTarget` и `children`, колбэки `onOpen` / `onClose`, `closeOnTab` и
|
|
46
|
+
html-атрибуты корневого `<div>` — приходит из `SelectExtendedField`, см.
|
|
47
|
+
`SelectExtendedField-ai.md`.
|
|
48
|
+
|
|
49
|
+
| Prop | Тип | По умолчанию | Описание |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| `size` | `EComponentSize` | `EComponentSize.MD` | Размер. Раздаётся **только частям выпадающего блока** через контекст. Полю выбора размер нужно передать отдельно |
|
|
52
|
+
| `renderTarget` | `(props: ISelectExtendedFieldTargetProvideProps) => React.ReactNode` | — | Обязательный. Рендер-функция поля выбора. Получает `opened` и `setOpened` |
|
|
53
|
+
| `children` | `(props: ISelectExtendedFieldDropdownProvideProps) => React.ReactNode` | — | Обязательный. Рендер-функция выпадающего блока. Получает `opened`, `setOpened`, `targetRef`, `dropdownRef` |
|
|
54
|
+
|
|
55
|
+
### `MultiselectField.Target`
|
|
56
|
+
|
|
57
|
+
Это `SelectExtendedFieldTarget` — тот же компонент, что и у `SelectField` и
|
|
58
|
+
`ChipSelect`. Собственного варианта поля у `MultiselectField` нет. `size` и
|
|
59
|
+
`status` он берёт из своих props, а не из контекста мульти-списка, поэтому
|
|
60
|
+
`size` передаётся в него явно. Выбранные значения кладутся в `label` —
|
|
61
|
+
как правило `TagGroup` с `Tag` на каждое значение.
|
|
62
|
+
|
|
63
|
+
### `MultiselectField.Dropdown` (`MultiselectFieldDropdown`)
|
|
64
|
+
|
|
65
|
+
`IMultiselectFieldDropdownProps` расширяет `IDropdownProps` одним prop'ом
|
|
66
|
+
`focusTrapProps` (`FocusTrapProps` из `focus-trap-react`).
|
|
67
|
+
|
|
68
|
+
| Prop | Тип | По умолчанию | Описание |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| `focusTrapProps` | `FocusTrapProps` | — | Прокидывается в `FocusTrap`. `focusTrapOptions` мержатся поверх умолчаний компонента |
|
|
71
|
+
| `width` | `EDropdownWidth` | `EDropdownWidth.MIN_TARGET` | Умолчание задаёт компонент; потребитель может переопределить |
|
|
72
|
+
| `size` | `EComponentSize` | из контекста | Умолчание — `size` мульти-списка; явный prop имеет приоритет |
|
|
73
|
+
| `opened`, `setOpened`, `targetRef` | — | — | Обязательные, приходят из рендер-функции `children` |
|
|
74
|
+
| `mobileViewProps` | `Omit<IDropdownMobileProps, "opened" \| "setOpened">` | — | Содержимое мобильной версии. Мобильная версия включена **всегда** — см. «Инварианты» |
|
|
75
|
+
|
|
76
|
+
Умолчания `focusTrapOptions`: `clickOutsideDeactivates: true`,
|
|
77
|
+
`preventScroll: true`, `returnFocusOnDeactivate` — по признаку «блок открыт мышью».
|
|
78
|
+
Любое из них переопределяется через `focusTrapProps.focusTrapOptions`.
|
|
79
|
+
|
|
80
|
+
Ссылку на контейнер блока компонент принимает через обычный `ref` (в отличие от
|
|
81
|
+
`SelectExtendedField.Dropdown`, где для этого есть prop `forwardedRef`). В него
|
|
82
|
+
передаётся `dropdownRef` из рендер-функции.
|
|
83
|
+
|
|
84
|
+
### `MultiselectField.Dropdown.Header` / `.Content` / `.Footer`
|
|
85
|
+
|
|
86
|
+
Три слота выпадающего блока. Своих props, кроме перечисленных ниже, нет —
|
|
87
|
+
`className` мержится, остальные атрибуты уходят на корневой `<div>`.
|
|
88
|
+
|
|
89
|
+
| Часть | Своё | Описание |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `Header` | — | Верхняя фиксированная область: поле фильтра |
|
|
92
|
+
| `Content` | `loading?: boolean` | Прокручиваемая область со списком. Максимальную высоту задаёт `size` из контекста (SM 208px / MD 266px / LG 296px). `loading` показывает `LoaderScreen` поверх содержимого |
|
|
93
|
+
| `Footer` | — | Нижняя фиксированная область: кнопки. Отделена разделителем |
|
|
25
94
|
|
|
26
95
|
---
|
|
27
96
|
|
|
28
|
-
##
|
|
97
|
+
## Дизайн-токены
|
|
98
|
+
|
|
99
|
+
Переопределяются через `ThemeProvider` (prop `tokens`) — см. `ThemeProvider-ai.md` →
|
|
100
|
+
«Как переопределять токены». Значения по умолчанию —
|
|
101
|
+
`src/components/DesignTokens/components/MultiselectField.ts`.
|
|
29
102
|
|
|
30
103
|
```text
|
|
31
|
-
|
|
32
|
-
├── MultiselectField.tsx # forwardRef, обёртка + Object.assign
|
|
33
|
-
├── MultiselectFieldContext.ts # { size }
|
|
34
|
-
├── index.ts # Barrel (только MultiselectField)
|
|
35
|
-
├── components/
|
|
36
|
-
│ ├── MultiselectFieldDropdown.tsx # Dropdown + FocusTrap + Object.assign
|
|
37
|
-
│ ├── MultiselectFieldDropdownHeader.tsx # <div>
|
|
38
|
-
│ ├── MultiselectFieldDropdownContent.tsx # <div> + опц. LoaderScreen
|
|
39
|
-
│ ├── MultiselectFieldDropdownFooter.tsx # <div>
|
|
40
|
-
│ └── index.ts
|
|
41
|
-
└── styles/*.module.less
|
|
104
|
+
MultiselectField.Divider_Color
|
|
42
105
|
```
|
|
43
106
|
|
|
107
|
+
Единственный собственный токен — цвет разделителя над `Footer`. Всё остальное
|
|
108
|
+
приходит из компонентов, на которых собран мульти-список: `FormField` (поле
|
|
109
|
+
выбора), `Dropdown` и `DropdownList` (выпадающий блок), `LoaderScreen` (загрузка
|
|
110
|
+
содержимого), `Tag` / `TagGroup` (выбранные значения), `Checkbox` /
|
|
111
|
+
`CheckboxTree` (опции).
|
|
112
|
+
|
|
44
113
|
---
|
|
45
114
|
|
|
46
|
-
##
|
|
115
|
+
## Инварианты
|
|
116
|
+
|
|
117
|
+
- `forwardRef` на `MultiselectField` и `MultiselectFieldDropdown` — не убирать. Ref `MultiselectField` указывает на корневой `<div>` `SelectExtendedField`, ref `MultiselectFieldDropdown` — на контейнер выпадающего блока (в него передаётся `dropdownRef`).
|
|
118
|
+
- Barrel `src/components/MultiselectField/index.ts` экспортирует только `MultiselectField` и `IMultiselectFieldProps`. Части выпадающего блока публичны **через статические свойства** (`MultiselectField.Dropdown.Header` и т.д.), а не через barrel. Их типы (`IMultiselectFieldDropdownProps`, `IMultiselectFieldDropdownContentProps`, ...) в публичный API не входят — выносить их туда без отдельного решения не нужно.
|
|
119
|
+
- Статические свойства `MultiselectField.Target`, `MultiselectField.Dropdown` и `MultiselectFieldDropdown.Header` / `.Content` / `.Footer` — часть публичного API.
|
|
120
|
+
- `MultiselectField.Target` — это `SelectExtendedFieldTarget` по ссылке (`MultiselectField.Target === SelectExtendedFieldTarget`). Правки в нём затрагивают также `SelectField` и `ChipSelect`.
|
|
121
|
+
- **Мобильная версия выпадающего блока включена всегда.** `MultiselectFieldDropdown` передаёт в `Dropdown` объект `mobileViewProps` даже когда потребитель ничего не передал — для этого есть константа-заглушка `ALWAYS_MOBILE_VIEW_PROPS`: `Dropdown` выбирает мобильную версию по truthiness этого prop'а. Убрать объект — значит на мобильной ширине отрендерить десктопный блок вместо полноэкранного. Наблюдаемое поведение, покрыто unit-тестом «Should render the mobile version on mobile width even without mobileViewProps». Visual-стори этот инвариант не держат: `PlaygroundExample` передаёт `mobileViewProps` сам, поэтому скриншоты не упадут, даже если убрать объект из компонента.
|
|
122
|
+
- Признак «открыт мышью» живёт в `mouseUsedRef` в контексте: `MultiselectField` ставит его в `true` на `mousedown`, `MultiselectFieldDropdown` сбрасывает в `false`, когда блок закрылся. Он не state — переключение не должно вызывать перерисовку.
|
|
123
|
+
- `size` из props раздаётся **только через контекст** и до поля выбора не доходит. Потребитель передаёт размер в `MultiselectField.Target` сам. Не «чинить» это без согласования: у поля и у выпадающего блока размеры могут различаться намеренно (в примерах фильтр внутри `Header` — размера SM при MD-поле).
|
|
124
|
+
- `Header` / `Content` / `Footer` объявлены как `React.FC` и `ref` **не пробрасывают** — в отличие от `MultiselectField` и `MultiselectField.Dropdown`. Перевод их на `forwardRef` обратно совместим по props, но меняет тип компонента, поэтому делается отдельной задачей, а не попутно.
|
|
125
|
+
- `Content` имеет `tabIndex={-1}`: он фокусируется программно (в том числе `FocusTrap`), но не попадает в обход по `Tab`. Не убирать.
|
|
126
|
+
- Классы `sm` / `md` / `lg` в `MultiselectFieldDropdownContent.module.less` читаются через `createSizeToClassNameMap` — переименование класса ломает соответствие размеру.
|
|
127
|
+
- Компонент React 17-совместим (ветка `release-0`): не использовать `useId`, `useSyncExternalStore`, `useInsertionEffect` и поведение, зависящее от automatic batching.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Accessibility
|
|
132
|
+
|
|
133
|
+
- Пока выпадающий блок открыт, фокус заперт внутри него (`FocusTrap` из `focus-trap-react`). Содержимое обёрнуто в `<div role="presentation">` — обёртка нужна `FocusTrap` как единственный контейнер и не должна попадать в дерево доступности.
|
|
134
|
+
- `clickOutsideDeactivates: true` — нажатие вне блока снимает перехват фокуса, после чего блок закрывает уже `SelectExtendedField` по своему `mousedown`-слушателю.
|
|
135
|
+
- Возврат фокуса на поле при закрытии зависит от того, как блок открыли: после клавиатуры фокус возвращается, после мыши — нет (иначе клик вне блока повторно подсвечивал бы поле). Признак сбрасывается на каждое закрытие, поэтому следующее открытие оценивается заново.
|
|
136
|
+
- `aria-haspopup="listbox"` и `aria-expanded` выставляет `MultiselectField.Target` (то есть `SelectExtendedFieldTarget`). Клавиши открытия поля — `Space`, `Enter`, `ArrowDown`, `ArrowUp`.
|
|
137
|
+
- `Escape` закрывает блок из любого места страницы — слушатель `SelectExtendedField` висит на `window`.
|
|
138
|
+
- Роли и клавиатурную навигацию **внутри** блока компонент не задаёт: содержимое произвольное (чекбоксы, дерево, кнопки), связь поля со списком (`aria-controls`, `aria-activedescendant`) и `aria-label` на элементах — забота потребителя.
|
|
139
|
+
- Компонент не хардкодит текстовые строки: `fieldLabel`, `placeholder`, подписи кнопок и заголовок мобильной версии передаёт потребитель — библиотека мультиязычная.
|
|
140
|
+
- В состоянии `loading` у `Content` `LoaderScreen` перекрывает содержимое подложкой, но элементы под ней остаются в DOM и в порядке обхода — если содержимое нужно отключить, потребитель делает это сам.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Связанные компоненты
|
|
145
|
+
|
|
146
|
+
Отдельного AI.md нет ни у `MultiselectFieldDropdown`, ни у его частей — они
|
|
147
|
+
описаны здесь. По критериям `docs/ai/CONTEXT.md` → «Когда создавать
|
|
148
|
+
`{ComponentName}-ai.md`» отдельные документы им и не нужны: из barrel
|
|
149
|
+
`src/components/MultiselectField/index.ts` они не экспортируются и доступны
|
|
150
|
+
только как статические свойства.
|
|
151
|
+
|
|
152
|
+
**Внутри папки компонента:**
|
|
153
|
+
|
|
154
|
+
- `MultiselectFieldDropdown` (`MultiselectField.Dropdown`) — выпадающий блок: `Dropdown` внутри `FocusTrap`.
|
|
155
|
+
- `MultiselectFieldDropdownHeader` / `Content` / `Footer` — слоты блока; своя логика есть только у `Content` (размер из контекста, `loading`).
|
|
156
|
+
- `MultiselectFieldContext` — внутренний контекст (`size`, `mouseUsedRef`). Из barrel не экспортируется, в публичный API не выносить.
|
|
157
|
+
|
|
158
|
+
**Основа и альтернативы:**
|
|
159
|
+
|
|
160
|
+
- `SelectExtendedField` — база: состояние открытости, закрытие по `Escape` / нажатию вне блока / `Tab`. `IMultiselectFieldProps` наследует его props.
|
|
161
|
+
- `SelectField` — выбор одного значения из готового списка опций.
|
|
162
|
+
- `SuggestField` — поиск по вводу с подсказками; на `SelectExtendedField` не построен.
|
|
163
|
+
- `ChipMultiselect` — тот же множественный выбор, но в виде фильтр-чипа. Построен на `MultiselectField`: правки здесь затрагивают его.
|
|
164
|
+
|
|
165
|
+
**Инфраструктура:**
|
|
166
|
+
|
|
167
|
+
- `Dropdown` — выпадающее меню через `Portal`, позиционирование и полноэкранная мобильная версия.
|
|
168
|
+
- `LoaderScreen` — лоадер поверх содержимого при `Content loading`.
|
|
169
|
+
- `FormField` — визуальный контейнер поля выбора и поля фильтра в `Header`.
|
|
170
|
+
- `Tag` / `TagGroup`, `Checkbox`, `CheckboxTree` — типовое наполнение поля и выпадающего блока в примерах; частью компонента не являются.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Stories
|
|
175
|
+
|
|
176
|
+
Основные истории: `stories/MultiselectField/MultiselectField.stories.tsx`
|
|
177
|
+
Файлы примеров: `stories/MultiselectField/examples/`
|
|
178
|
+
|
|
179
|
+
| Story | Example file | Что демонстрирует |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `Playground` | `PlaygroundExample.tsx` | Интерактивный контроль: размер, статус, загрузка, префикс/постфикс, поле фильтра, кнопка очистки |
|
|
182
|
+
| `Default` | `DefaultExample.tsx` | Базовый сценарий: теги в поле, фильтр в `Header`, чекбоксы в `Content`, кнопки в `Footer`, мобильная версия |
|
|
183
|
+
| `Sizes` | `SizesExample.tsx` | Размеры SM / MD / LG |
|
|
184
|
+
| `Statuses` | `StatusesExample.tsx` | Состояния поля: DEFAULT / ERROR / WARNING / DISABLED |
|
|
185
|
+
| `Loading` | `LoadingExample.tsx` | Загрузка поля (лоадер вместо каретки) и загрузка содержимого (`Content loading`) |
|
|
186
|
+
| `DropdownWithoutInput` | `DropdownWithoutInputExample.tsx` | Выпадающий блок без поля фильтра — только список и кнопки |
|
|
187
|
+
| `WithClearButton` | `WithClearButtonExample.tsx` | Кнопка очистки значения в поле через `onClear` |
|
|
188
|
+
| `WithPrefixAndPostfix` | `WithPrefixAndPostfixExample.tsx` | Слоты `prefix` и `postfix` поля выбора |
|
|
189
|
+
| `Example` | `ProductionExample.tsx` | Приближенный к продуктовому сценарий (в Storybook — «Example: production») |
|
|
190
|
+
| `WithCheckboxTree` | `WithCheckboxTreeExample.tsx` | Дерево категорий вместо плоского списка чекбоксов (в Storybook — «Example: with CheckboxTree») |
|
|
191
|
+
| `VisualTests` | `PlaygroundExample.tsx` | Скриншот-регрессия: поле с выбранным значением и раскрытый блок |
|
|
192
|
+
| `VisualTestsNotFound` | `PlaygroundExample.tsx` | Скриншот-регрессия: пустой результат фильтра во всех размерах; на мобильной ширине — полноэкранная версия |
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## История изменений
|
|
197
|
+
|
|
198
|
+
| Дата | Изменение |
|
|
199
|
+
|---|---|
|
|
200
|
+
| 2026-09-03 | Создан документ AI-ready для `MultiselectField` (TRI-61). |
|
|
201
|
+
| 2026-09-03 | AI-рефакторинг (TRI-61): JSDoc на props, контексте и всех частях выпадающего блока; значение контекста обёрнуто в `useMemo`, обработчик `onMouseDown` — в `useCallback` (выигрыша в текущей связке нет: части приходят из рендер-функции `children` и пересоздаются каждый рендер независимо от контекста, — это страховка на случай `React.memo` у потребителя); объект-заглушка `mobileViewProps` вынесен в константу, чтобы инвариант «мобильная версия включена всегда» читался из самого выражения; unit-тесты разбиты по субкомпонентам и расширены с 1 до 45 кейсов. Публичный API и наблюдаемое поведение не изменены. |
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Примеры использования
|
|
206
|
+
|
|
207
|
+
### Default
|
|
208
|
+
|
|
209
|
+
`stories/MultiselectField/examples/DefaultExample.tsx`
|
|
47
210
|
|
|
48
211
|
```tsx
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
212
|
+
import React, { useMemo, useRef, useState } from "react";
|
|
213
|
+
import {
|
|
214
|
+
Button,
|
|
215
|
+
Checkbox,
|
|
216
|
+
CheckboxYGroup,
|
|
217
|
+
EButtonTheme,
|
|
218
|
+
EComponentSize,
|
|
219
|
+
FormField,
|
|
220
|
+
FormFieldClear,
|
|
221
|
+
FormFieldInput,
|
|
222
|
+
FormFieldLabel,
|
|
223
|
+
FormFieldPostfix,
|
|
224
|
+
MultiselectField,
|
|
225
|
+
Tag,
|
|
226
|
+
TagGroup,
|
|
227
|
+
Text,
|
|
228
|
+
ETextSize,
|
|
229
|
+
DropdownMobileFooter,
|
|
230
|
+
DropdownMobileBody,
|
|
231
|
+
DropdownMobileHeader,
|
|
232
|
+
DropdownMobileInput,
|
|
233
|
+
DropdownMobileClose,
|
|
234
|
+
} from "@sberbusiness/triplex-next";
|
|
235
|
+
import "../MultiselectField.less";
|
|
236
|
+
|
|
237
|
+
interface IOption {
|
|
238
|
+
id: string;
|
|
239
|
+
label: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Плоский список опций для примера (раньше задавался как дерево). */
|
|
243
|
+
const createOptions = (): IOption[] => {
|
|
244
|
+
const group1 = [...Array(40).keys()].map((item) => ({
|
|
245
|
+
id: `multiselect-option-1-${item}`,
|
|
246
|
+
label: `Значение 1-${item}`,
|
|
247
|
+
}));
|
|
248
|
+
const group2: IOption[] = [
|
|
249
|
+
{ id: "multiselect-option-2-1", label: "Значение 2-1" },
|
|
250
|
+
{ id: "multiselect-option-2-2", label: "Значение 2-2" },
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
return [...group1, ...group2, { id: "multiselect-option-3", label: "Значение 3" }];
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export const DefaultExample = () => {
|
|
257
|
+
/** Ref на target, который нужен для корректного позиционирования dropdown. */
|
|
258
|
+
const targetRef = useRef<HTMLDivElement>(null);
|
|
259
|
+
/** Мемоизированные опции, чтобы не пересоздавать их на каждый рендер. */
|
|
260
|
+
const options = useMemo(() => createOptions(), []);
|
|
261
|
+
/** Массив id выбранных leaf-чекбоксов. */
|
|
262
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
263
|
+
/** Текущее значение фильтра в поле поиска dropdown. */
|
|
264
|
+
const [filter, setFilter] = useState("");
|
|
265
|
+
/** Set для быстрых O(1) проверок выбранности чекбокса по id. */
|
|
266
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
267
|
+
const visibleOptions = useMemo(() => {
|
|
268
|
+
const lower = filter.trim().toLowerCase();
|
|
269
|
+
if (!lower.length) {
|
|
270
|
+
return options;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return options.filter((opt) => opt.label.toLowerCase().includes(lower));
|
|
274
|
+
}, [filter, options]);
|
|
275
|
+
|
|
276
|
+
const selectedOptions = useMemo(() => options.filter((opt) => selectedSet.has(opt.id)), [options, selectedSet]);
|
|
277
|
+
|
|
278
|
+
const handleToggle = (option: IOption, checked: boolean) => {
|
|
279
|
+
setSelectedIds((prev) => {
|
|
280
|
+
if (checked) {
|
|
281
|
+
if (prev.includes(option.id)) {
|
|
282
|
+
return prev;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return [...prev, option.id];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return prev.filter((id) => id !== option.id);
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const handleClearAll = () => {
|
|
293
|
+
setSelectedIds([]);
|
|
294
|
+
setFilter("");
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const renderCheckboxList = () => (
|
|
298
|
+
<CheckboxYGroup aria-label="Options">
|
|
299
|
+
{visibleOptions.map((opt) => (
|
|
300
|
+
<Checkbox
|
|
301
|
+
key={opt.id}
|
|
302
|
+
id={opt.id}
|
|
303
|
+
size={EComponentSize.MD}
|
|
304
|
+
checked={selectedSet.has(opt.id)}
|
|
305
|
+
onChange={(event) => handleToggle(opt, event.target.checked)}
|
|
306
|
+
>
|
|
307
|
+
{opt.label}
|
|
67
308
|
</Checkbox>
|
|
68
309
|
))}
|
|
69
|
-
</
|
|
310
|
+
</CheckboxYGroup>
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
/** Рендер тегов выбранных значений или счетчика, если выбранных значений много. */
|
|
314
|
+
const renderTags = () => {
|
|
315
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
316
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
317
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
318
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
319
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
320
|
+
event.stopPropagation();
|
|
321
|
+
}
|
|
322
|
+
};
|
|
70
323
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
324
|
+
if (!selectedOptions.length) return null;
|
|
325
|
+
if (selectedOptions.length > 3) {
|
|
326
|
+
return (
|
|
327
|
+
<Tag
|
|
328
|
+
id="many"
|
|
329
|
+
size={EComponentSize.SM}
|
|
330
|
+
onFocus={handleTagFocus}
|
|
331
|
+
onBlur={handleTagBlur}
|
|
332
|
+
onClick={handleTagClick}
|
|
333
|
+
onKeyDown={handleTagKeyDown}
|
|
334
|
+
onRemove={handleClearAll}
|
|
335
|
+
>
|
|
336
|
+
{`Выбрано ${selectedOptions.length} значения`}
|
|
337
|
+
</Tag>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<TagGroup size={EComponentSize.SM}>
|
|
343
|
+
{selectedOptions.map((opt) => (
|
|
344
|
+
<Tag
|
|
345
|
+
key={opt.id}
|
|
346
|
+
id={`tag-${opt.id}`}
|
|
347
|
+
size={EComponentSize.SM}
|
|
348
|
+
onFocus={handleTagFocus}
|
|
349
|
+
onBlur={handleTagBlur}
|
|
350
|
+
onClick={handleTagClick}
|
|
351
|
+
onKeyDown={handleTagKeyDown}
|
|
352
|
+
onRemove={() => handleToggle(opt, false)}
|
|
353
|
+
>
|
|
354
|
+
{opt.label}
|
|
355
|
+
</Tag>
|
|
356
|
+
))}
|
|
357
|
+
</TagGroup>
|
|
358
|
+
);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<div style={{ maxWidth: "320px" }}>
|
|
363
|
+
<MultiselectField
|
|
364
|
+
renderTarget={(props) => (
|
|
365
|
+
<MultiselectField.Target
|
|
366
|
+
{...props}
|
|
367
|
+
ref={targetRef}
|
|
368
|
+
size={EComponentSize.MD}
|
|
369
|
+
fieldLabel="Label"
|
|
370
|
+
placeholder="Select to proceed"
|
|
371
|
+
label={renderTags()}
|
|
372
|
+
/>
|
|
373
|
+
)}
|
|
374
|
+
>
|
|
375
|
+
{({ opened, setOpened, targetRef: dropdownTargetRef, dropdownRef }) => (
|
|
376
|
+
<MultiselectField.Dropdown
|
|
377
|
+
opened={opened}
|
|
378
|
+
setOpened={setOpened}
|
|
379
|
+
targetRef={dropdownTargetRef}
|
|
380
|
+
ref={dropdownRef}
|
|
381
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
382
|
+
mobileViewProps={{
|
|
383
|
+
children: (
|
|
384
|
+
<>
|
|
385
|
+
<DropdownMobileHeader
|
|
386
|
+
controlButtons={<DropdownMobileClose onClick={() => setOpened(false)} />}
|
|
387
|
+
>
|
|
388
|
+
<DropdownMobileInput
|
|
389
|
+
autoFocus={true}
|
|
390
|
+
placeholder="Type to proceed"
|
|
391
|
+
value={filter}
|
|
392
|
+
onChange={(event) => setFilter(event.target.value)}
|
|
393
|
+
/>
|
|
394
|
+
</DropdownMobileHeader>
|
|
395
|
+
<DropdownMobileBody>
|
|
396
|
+
{renderCheckboxList()}
|
|
397
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
398
|
+
<Text size={ETextSize.B3} className="not-found">
|
|
399
|
+
Nothing was found.
|
|
400
|
+
</Text>
|
|
401
|
+
)}
|
|
402
|
+
</DropdownMobileBody>
|
|
403
|
+
<DropdownMobileFooter>
|
|
404
|
+
<Button
|
|
405
|
+
theme={EButtonTheme.SECONDARY}
|
|
406
|
+
size={EComponentSize.MD}
|
|
407
|
+
onClick={() => setOpened(false)}
|
|
408
|
+
>
|
|
409
|
+
Button text
|
|
410
|
+
</Button>
|
|
411
|
+
<Button
|
|
412
|
+
theme={EButtonTheme.LINK}
|
|
413
|
+
size={EComponentSize.MD}
|
|
414
|
+
onClick={handleClearAll}
|
|
415
|
+
>
|
|
416
|
+
Button link text
|
|
417
|
+
</Button>
|
|
418
|
+
</DropdownMobileFooter>
|
|
419
|
+
</>
|
|
420
|
+
),
|
|
421
|
+
}}
|
|
422
|
+
>
|
|
423
|
+
<MultiselectField.Dropdown.Header>
|
|
424
|
+
<FormField size={EComponentSize.SM}>
|
|
425
|
+
<FormFieldLabel>Type to proceed</FormFieldLabel>
|
|
426
|
+
<FormFieldInput value={filter} onChange={(event) => setFilter(event.target.value)} />
|
|
427
|
+
<FormFieldPostfix>
|
|
428
|
+
<FormFieldClear onClick={() => setFilter("")} />
|
|
429
|
+
</FormFieldPostfix>
|
|
430
|
+
</FormField>
|
|
431
|
+
</MultiselectField.Dropdown.Header>
|
|
432
|
+
<MultiselectField.Dropdown.Content>
|
|
433
|
+
{renderCheckboxList()}
|
|
434
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
435
|
+
<div className="not-found md">
|
|
436
|
+
<Text size={ETextSize.B3}>Nothing was found.</Text>
|
|
437
|
+
</div>
|
|
438
|
+
)}
|
|
439
|
+
</MultiselectField.Dropdown.Content>
|
|
440
|
+
<MultiselectField.Dropdown.Footer>
|
|
441
|
+
<Button
|
|
442
|
+
theme={EButtonTheme.SECONDARY}
|
|
443
|
+
size={EComponentSize.SM}
|
|
444
|
+
onClick={() => setOpened(false)}
|
|
445
|
+
>
|
|
446
|
+
Button text
|
|
447
|
+
</Button>
|
|
448
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.SM} onClick={handleClearAll}>
|
|
449
|
+
Button link text
|
|
450
|
+
</Button>
|
|
451
|
+
</MultiselectField.Dropdown.Footer>
|
|
452
|
+
</MultiselectField.Dropdown>
|
|
453
|
+
)}
|
|
454
|
+
</MultiselectField>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
};
|
|
76
458
|
```
|
|
77
459
|
|
|
78
|
-
|
|
460
|
+
### Sizes
|
|
79
461
|
|
|
80
|
-
|
|
462
|
+
`stories/MultiselectField/examples/SizesExample.tsx`
|
|
81
463
|
|
|
82
|
-
|
|
464
|
+
```tsx
|
|
465
|
+
import React, { useMemo, useRef, useState } from "react";
|
|
466
|
+
import {
|
|
467
|
+
Button,
|
|
468
|
+
Checkbox,
|
|
469
|
+
CheckboxYGroup,
|
|
470
|
+
EButtonTheme,
|
|
471
|
+
EComponentSize,
|
|
472
|
+
FormField,
|
|
473
|
+
FormFieldClear,
|
|
474
|
+
FormFieldInput,
|
|
475
|
+
FormFieldLabel,
|
|
476
|
+
FormFieldPostfix,
|
|
477
|
+
MultiselectField,
|
|
478
|
+
Text,
|
|
479
|
+
ETextSize,
|
|
480
|
+
Tag,
|
|
481
|
+
TagGroup,
|
|
482
|
+
} from "@sberbusiness/triplex-next";
|
|
483
|
+
import "../MultiselectField.less";
|
|
83
484
|
|
|
84
|
-
|
|
485
|
+
interface IOption {
|
|
486
|
+
id: string;
|
|
487
|
+
label: string;
|
|
488
|
+
}
|
|
85
489
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
| `size` | `EComponentSize` | `MD` | Пишется в `MultiselectFieldContext` и пробрасывается в Dropdown. |
|
|
89
|
-
| `...ISelectExtendedFieldProps` | — | — | Всё поведение outer-контейнера (opened, onOpenedChange, targetRef, …) от SelectExtendedField. |
|
|
490
|
+
/** Набор размеров для демонстрации вариативности компонента. */
|
|
491
|
+
const SIZES = [EComponentSize.SM, EComponentSize.MD, EComponentSize.LG];
|
|
90
492
|
|
|
91
|
-
|
|
493
|
+
export const SizesExample = () => (
|
|
494
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "24px", maxWidth: "320px" }}>
|
|
495
|
+
{SIZES.map((size) => (
|
|
496
|
+
<div key={size}>
|
|
497
|
+
<div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: 700 }}>{size.toUpperCase()}</div>
|
|
498
|
+
<SizeItem size={size} />
|
|
499
|
+
</div>
|
|
500
|
+
))}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
92
503
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
504
|
+
/** Плоский список опций с уникальным префиксом id для каждого размера. */
|
|
505
|
+
const createOptions = (prefix: string): IOption[] => [
|
|
506
|
+
{ id: `${prefix}-option-1`, label: "Значение 1" },
|
|
507
|
+
{ id: `${prefix}-option-2`, label: "Значение 2" },
|
|
508
|
+
{ id: `${prefix}-option-3`, label: "Значение 3" },
|
|
509
|
+
];
|
|
97
510
|
|
|
98
|
-
|
|
99
|
-
|
|
511
|
+
/** Нормализация размера кнопок для footer в зависимости от размера поля. */
|
|
512
|
+
const getButtonSize = (size: EComponentSize) => (size === EComponentSize.LG ? EComponentSize.MD : EComponentSize.SM);
|
|
100
513
|
|
|
101
|
-
|
|
514
|
+
interface ISizeItemProps {
|
|
515
|
+
size: EComponentSize;
|
|
516
|
+
}
|
|
102
517
|
|
|
103
|
-
|
|
104
|
-
|
|
518
|
+
const SizeItem = ({ size }: ISizeItemProps) => {
|
|
519
|
+
/** Ref на target для привязки dropdown. */
|
|
520
|
+
const targetRef = useRef<HTMLDivElement>(null);
|
|
521
|
+
/** Опции для текущего размера. */
|
|
522
|
+
const options = useMemo(() => createOptions(size), [size]);
|
|
523
|
+
/** Id выбранных чекбоксов. */
|
|
524
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
525
|
+
/** Значение фильтра в input dropdown. */
|
|
526
|
+
const [filter, setFilter] = useState("");
|
|
527
|
+
/** Set для быстрых проверок выбранности по id. */
|
|
528
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
529
|
+
const visibleOptions = useMemo(() => {
|
|
530
|
+
const lower = filter.trim().toLowerCase();
|
|
531
|
+
if (!lower.length) {
|
|
532
|
+
return options;
|
|
533
|
+
}
|
|
105
534
|
|
|
106
|
-
|
|
535
|
+
return options.filter((opt) => opt.label.toLowerCase().includes(lower));
|
|
536
|
+
}, [filter, options]);
|
|
107
537
|
|
|
108
|
-
|
|
109
|
-
interface IMultiselectFieldContext { size: EComponentSize; }
|
|
110
|
-
```
|
|
538
|
+
const selectedOptions = useMemo(() => options.filter((opt) => selectedSet.has(opt.id)), [options, selectedSet]);
|
|
111
539
|
|
|
112
|
-
|
|
540
|
+
const handleToggle = (option: IOption, checked: boolean) => {
|
|
541
|
+
setSelectedIds((prev) => {
|
|
542
|
+
if (checked) {
|
|
543
|
+
if (prev.includes(option.id)) {
|
|
544
|
+
return prev;
|
|
545
|
+
}
|
|
113
546
|
|
|
114
|
-
|
|
547
|
+
return [...prev, option.id];
|
|
548
|
+
}
|
|
115
549
|
|
|
116
|
-
|
|
550
|
+
return prev.filter((id) => id !== option.id);
|
|
551
|
+
});
|
|
552
|
+
};
|
|
117
553
|
|
|
118
|
-
|
|
554
|
+
const handleClearAll = () => {
|
|
555
|
+
setSelectedIds([]);
|
|
556
|
+
setFilter("");
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const renderCheckboxList = () => (
|
|
560
|
+
<CheckboxYGroup aria-label="Options">
|
|
561
|
+
{visibleOptions.map((opt) => (
|
|
562
|
+
<Checkbox
|
|
563
|
+
key={opt.id}
|
|
564
|
+
id={opt.id}
|
|
565
|
+
size={size}
|
|
566
|
+
checked={selectedSet.has(opt.id)}
|
|
567
|
+
onChange={(event) => handleToggle(opt, event.target.checked)}
|
|
568
|
+
>
|
|
569
|
+
{opt.label}
|
|
570
|
+
</Checkbox>
|
|
571
|
+
))}
|
|
572
|
+
</CheckboxYGroup>
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
/** Рендер тегов выбранных значений в target. */
|
|
576
|
+
const renderTags = () => {
|
|
577
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
578
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
579
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
580
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
581
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
582
|
+
event.stopPropagation();
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
if (!selectedOptions.length) return null;
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<TagGroup size={EComponentSize.SM}>
|
|
590
|
+
{selectedOptions.map((opt) => (
|
|
591
|
+
<Tag
|
|
592
|
+
key={opt.id}
|
|
593
|
+
id={`tag-${opt.id}`}
|
|
594
|
+
size={EComponentSize.SM}
|
|
595
|
+
onFocus={handleTagFocus}
|
|
596
|
+
onBlur={handleTagBlur}
|
|
597
|
+
onClick={handleTagClick}
|
|
598
|
+
onKeyDown={handleTagKeyDown}
|
|
599
|
+
onRemove={() => handleToggle(opt, false)}
|
|
600
|
+
>
|
|
601
|
+
{opt.label}
|
|
602
|
+
</Tag>
|
|
603
|
+
))}
|
|
604
|
+
</TagGroup>
|
|
605
|
+
);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
<MultiselectField
|
|
610
|
+
size={size}
|
|
611
|
+
renderTarget={(props) => (
|
|
612
|
+
<MultiselectField.Target
|
|
613
|
+
{...props}
|
|
614
|
+
ref={targetRef}
|
|
615
|
+
size={size}
|
|
616
|
+
fieldLabel="Label"
|
|
617
|
+
placeholder="Select to proceed"
|
|
618
|
+
label={renderTags()}
|
|
619
|
+
/>
|
|
620
|
+
)}
|
|
621
|
+
>
|
|
622
|
+
{({ opened, setOpened, targetRef: dropdownTargetRef, dropdownRef }) => (
|
|
623
|
+
<MultiselectField.Dropdown
|
|
624
|
+
opened={opened}
|
|
625
|
+
setOpened={setOpened}
|
|
626
|
+
targetRef={dropdownTargetRef}
|
|
627
|
+
ref={dropdownRef}
|
|
628
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
629
|
+
>
|
|
630
|
+
<MultiselectField.Dropdown.Header>
|
|
631
|
+
<FormField size={getButtonSize(size)}>
|
|
632
|
+
<FormFieldLabel>Type to proceed</FormFieldLabel>
|
|
633
|
+
<FormFieldInput value={filter} onChange={(event) => setFilter(event.target.value)} />
|
|
634
|
+
<FormFieldPostfix>
|
|
635
|
+
<FormFieldClear onClick={() => setFilter("")} />
|
|
636
|
+
</FormFieldPostfix>
|
|
637
|
+
</FormField>
|
|
638
|
+
</MultiselectField.Dropdown.Header>
|
|
639
|
+
<MultiselectField.Dropdown.Content>
|
|
640
|
+
{renderCheckboxList()}
|
|
641
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
642
|
+
<div className={`not-found ${size}`}>
|
|
643
|
+
<Text size={ETextSize.B3}>Nothing was found.</Text>
|
|
644
|
+
</div>
|
|
645
|
+
)}
|
|
646
|
+
</MultiselectField.Dropdown.Content>
|
|
647
|
+
<MultiselectField.Dropdown.Footer>
|
|
648
|
+
<Button
|
|
649
|
+
theme={EButtonTheme.SECONDARY}
|
|
650
|
+
size={getButtonSize(size)}
|
|
651
|
+
onClick={() => setOpened(false)}
|
|
652
|
+
>
|
|
653
|
+
Button text
|
|
654
|
+
</Button>
|
|
655
|
+
<Button theme={EButtonTheme.LINK} size={getButtonSize(size)} onClick={handleClearAll}>
|
|
656
|
+
Button link text
|
|
657
|
+
</Button>
|
|
658
|
+
</MultiselectField.Dropdown.Footer>
|
|
659
|
+
</MultiselectField.Dropdown>
|
|
660
|
+
)}
|
|
661
|
+
</MultiselectField>
|
|
662
|
+
);
|
|
663
|
+
};
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### Statuses
|
|
667
|
+
|
|
668
|
+
`stories/MultiselectField/examples/StatusesExample.tsx`
|
|
119
669
|
|
|
120
670
|
```tsx
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
671
|
+
import React, { useMemo, useRef, useState } from "react";
|
|
672
|
+
import {
|
|
673
|
+
Button,
|
|
674
|
+
Checkbox,
|
|
675
|
+
CheckboxYGroup,
|
|
676
|
+
EButtonTheme,
|
|
677
|
+
EComponentSize,
|
|
678
|
+
EFormFieldStatus,
|
|
679
|
+
FormField,
|
|
680
|
+
FormFieldClear,
|
|
681
|
+
FormFieldInput,
|
|
682
|
+
FormFieldLabel,
|
|
683
|
+
FormFieldPostfix,
|
|
684
|
+
MultiselectField,
|
|
685
|
+
Text,
|
|
686
|
+
ETextSize,
|
|
687
|
+
Tag,
|
|
688
|
+
TagGroup,
|
|
689
|
+
IconWrapper,
|
|
690
|
+
} from "@sberbusiness/triplex-next";
|
|
691
|
+
import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
|
|
692
|
+
|
|
693
|
+
import "../MultiselectField.less";
|
|
694
|
+
|
|
695
|
+
/** Набор статусов для демонстрации состояний поля. */
|
|
696
|
+
const STATUS_ITEMS = [
|
|
697
|
+
{ id: "default", label: "DEFAULT", status: EFormFieldStatus.DEFAULT },
|
|
698
|
+
{ id: "error", label: "ERROR", status: EFormFieldStatus.ERROR },
|
|
699
|
+
{ id: "warning", label: "WARNING", status: EFormFieldStatus.WARNING },
|
|
700
|
+
{ id: "disabled", label: "DISABLED", status: EFormFieldStatus.DISABLED },
|
|
701
|
+
];
|
|
702
|
+
|
|
703
|
+
export const StatusesExample = () => (
|
|
704
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "24px", maxWidth: "320px" }}>
|
|
705
|
+
{STATUS_ITEMS.map((item) => (
|
|
706
|
+
<div key={item.id}>
|
|
707
|
+
<div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: 700 }}>{item.label}</div>
|
|
708
|
+
<StatusItem status={item.status} idPrefix={item.id} />
|
|
709
|
+
</div>
|
|
710
|
+
))}
|
|
711
|
+
</div>
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
interface IOption {
|
|
715
|
+
id: string;
|
|
716
|
+
label: string;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
interface IStatusItemProps {
|
|
720
|
+
idPrefix: string;
|
|
721
|
+
status: EFormFieldStatus;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Плоский список опций с уникальным префиксом id для каждого статуса. */
|
|
725
|
+
const createOptions = (prefix: string): IOption[] => [
|
|
726
|
+
{ id: `${prefix}-option-1`, label: "Значение 1" },
|
|
727
|
+
{ id: `${prefix}-option-2`, label: "Значение 2" },
|
|
728
|
+
{ id: `${prefix}-option-3`, label: "Значение 3" },
|
|
729
|
+
];
|
|
730
|
+
|
|
731
|
+
const StatusItem = ({ idPrefix, status }: IStatusItemProps) => {
|
|
732
|
+
/** Ref на target для корректной привязки dropdown. */
|
|
733
|
+
const targetRef = useRef<HTMLDivElement>(null);
|
|
734
|
+
/** Опции для текущего статуса. */
|
|
735
|
+
const options = useMemo(() => createOptions(idPrefix), [idPrefix]);
|
|
736
|
+
/** Id выбранных чекбоксов. */
|
|
737
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
738
|
+
/** Значение фильтра в input dropdown. */
|
|
739
|
+
const [filter, setFilter] = useState("");
|
|
740
|
+
/** Set для быстрых проверок выбранности. */
|
|
741
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
742
|
+
const visibleOptions = useMemo(() => {
|
|
743
|
+
const lower = filter.trim().toLowerCase();
|
|
744
|
+
if (!lower.length) {
|
|
745
|
+
return options;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return options.filter((opt) => opt.label.toLowerCase().includes(lower));
|
|
749
|
+
}, [filter, options]);
|
|
750
|
+
|
|
751
|
+
const selectedOptions = useMemo(() => options.filter((opt) => selectedSet.has(opt.id)), [options, selectedSet]);
|
|
752
|
+
|
|
753
|
+
const handleToggle = (option: IOption, checked: boolean) => {
|
|
754
|
+
setSelectedIds((prev) => {
|
|
755
|
+
if (checked) {
|
|
756
|
+
if (prev.includes(option.id)) {
|
|
757
|
+
return prev;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return [...prev, option.id];
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return prev.filter((id) => id !== option.id);
|
|
764
|
+
});
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const handleClearAll = () => {
|
|
768
|
+
setSelectedIds([]);
|
|
769
|
+
setFilter("");
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const renderCheckboxList = () => (
|
|
773
|
+
<CheckboxYGroup aria-label="Options">
|
|
774
|
+
{visibleOptions.map((opt) => (
|
|
775
|
+
<Checkbox
|
|
776
|
+
key={opt.id}
|
|
777
|
+
id={opt.id}
|
|
778
|
+
size={EComponentSize.MD}
|
|
779
|
+
checked={selectedSet.has(opt.id)}
|
|
780
|
+
onChange={(event) => handleToggle(opt, event.target.checked)}
|
|
781
|
+
>
|
|
782
|
+
{opt.label}
|
|
783
|
+
</Checkbox>
|
|
784
|
+
))}
|
|
785
|
+
</CheckboxYGroup>
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
/** Рендер тегов выбранных значений в target. */
|
|
789
|
+
const renderTags = () => {
|
|
790
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
791
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
792
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
793
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
794
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
795
|
+
event.stopPropagation();
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
if (!selectedOptions.length) return null;
|
|
800
|
+
|
|
801
|
+
return (
|
|
802
|
+
<TagGroup size={EComponentSize.SM}>
|
|
803
|
+
{selectedOptions.map((opt) => (
|
|
804
|
+
<Tag
|
|
805
|
+
key={opt.id}
|
|
806
|
+
id={`tag-${opt.id}`}
|
|
807
|
+
size={EComponentSize.SM}
|
|
808
|
+
onFocus={handleTagFocus}
|
|
809
|
+
onBlur={handleTagBlur}
|
|
810
|
+
onClick={handleTagClick}
|
|
811
|
+
onKeyDown={handleTagKeyDown}
|
|
812
|
+
onRemove={() => handleToggle(opt, false)}
|
|
813
|
+
>
|
|
814
|
+
{opt.label}
|
|
815
|
+
</Tag>
|
|
816
|
+
))}
|
|
817
|
+
</TagGroup>
|
|
818
|
+
);
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
return (
|
|
822
|
+
<MultiselectField
|
|
823
|
+
renderTarget={(props) => (
|
|
824
|
+
<MultiselectField.Target
|
|
825
|
+
{...props}
|
|
826
|
+
ref={targetRef}
|
|
827
|
+
status={status}
|
|
828
|
+
size={EComponentSize.MD}
|
|
829
|
+
fieldLabel="Label"
|
|
830
|
+
placeholder="Select to proceed"
|
|
831
|
+
label={renderTags()}
|
|
832
|
+
prefix={
|
|
833
|
+
<IconWrapper disableInteraction disabled={status === EFormFieldStatus.DISABLED} displayContents>
|
|
834
|
+
<DefaulticonStrokePrdIcon20 paletteIndex={5} />
|
|
835
|
+
</IconWrapper>
|
|
836
|
+
}
|
|
837
|
+
/>
|
|
838
|
+
)}
|
|
839
|
+
>
|
|
840
|
+
{({ opened, setOpened, targetRef: dropdownTargetRef, dropdownRef }) => (
|
|
841
|
+
<MultiselectField.Dropdown
|
|
842
|
+
opened={opened}
|
|
843
|
+
setOpened={setOpened}
|
|
844
|
+
targetRef={dropdownTargetRef}
|
|
845
|
+
ref={dropdownRef}
|
|
846
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
847
|
+
>
|
|
848
|
+
<MultiselectField.Dropdown.Header>
|
|
849
|
+
<FormField size={EComponentSize.SM}>
|
|
850
|
+
<FormFieldLabel>Type to proceed</FormFieldLabel>
|
|
851
|
+
<FormFieldInput value={filter} onChange={(event) => setFilter(event.target.value)} />
|
|
852
|
+
<FormFieldPostfix>
|
|
853
|
+
<FormFieldClear onClick={() => setFilter("")} />
|
|
854
|
+
</FormFieldPostfix>
|
|
855
|
+
</FormField>
|
|
856
|
+
</MultiselectField.Dropdown.Header>
|
|
857
|
+
<MultiselectField.Dropdown.Content>
|
|
858
|
+
{renderCheckboxList()}
|
|
859
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
860
|
+
<div className="not-found md">
|
|
861
|
+
<Text size={ETextSize.B3}>Nothing was found.</Text>
|
|
862
|
+
</div>
|
|
863
|
+
)}
|
|
864
|
+
</MultiselectField.Dropdown.Content>
|
|
865
|
+
<MultiselectField.Dropdown.Footer>
|
|
866
|
+
<Button
|
|
867
|
+
theme={EButtonTheme.SECONDARY}
|
|
868
|
+
size={EComponentSize.SM}
|
|
869
|
+
onClick={() => setOpened(false)}
|
|
870
|
+
>
|
|
871
|
+
Button text
|
|
872
|
+
</Button>
|
|
873
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.SM} onClick={handleClearAll}>
|
|
874
|
+
Button link text
|
|
875
|
+
</Button>
|
|
876
|
+
</MultiselectField.Dropdown.Footer>
|
|
877
|
+
</MultiselectField.Dropdown>
|
|
878
|
+
)}
|
|
879
|
+
</MultiselectField>
|
|
880
|
+
);
|
|
881
|
+
};
|
|
124
882
|
```
|
|
125
883
|
|
|
126
|
-
|
|
127
|
-
- **Dropdown переопределяет** `size` prop на Dropdown: `<Dropdown ... size={size} />`. То есть потребительский `size` на Dropdown **игнорируется** — всегда выигрывает context.
|
|
884
|
+
### Loading
|
|
128
885
|
|
|
129
|
-
|
|
886
|
+
`stories/MultiselectField/examples/LoadingExample.tsx`
|
|
130
887
|
|
|
131
888
|
```tsx
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
889
|
+
import React, { useMemo, useRef, useState } from "react";
|
|
890
|
+
import {
|
|
891
|
+
Button,
|
|
892
|
+
Checkbox,
|
|
893
|
+
CheckboxYGroup,
|
|
894
|
+
EButtonTheme,
|
|
895
|
+
EComponentSize,
|
|
896
|
+
FormField,
|
|
897
|
+
FormFieldClear,
|
|
898
|
+
FormFieldInput,
|
|
899
|
+
FormFieldLabel,
|
|
900
|
+
FormFieldPostfix,
|
|
901
|
+
MultiselectField,
|
|
902
|
+
Text,
|
|
903
|
+
ETextSize,
|
|
904
|
+
Tag,
|
|
905
|
+
TagGroup,
|
|
906
|
+
DropdownMobileHeader,
|
|
907
|
+
DropdownMobileClose,
|
|
908
|
+
DropdownMobileInput,
|
|
909
|
+
DropdownMobileBody,
|
|
910
|
+
DropdownMobileFooter,
|
|
911
|
+
DropdownMobileLoader,
|
|
912
|
+
} from "@sberbusiness/triplex-next";
|
|
913
|
+
import "../MultiselectField.less";
|
|
914
|
+
|
|
915
|
+
interface IOption {
|
|
916
|
+
id: string;
|
|
917
|
+
label: string;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Фиксированный список опций для демонстрации loading-состояния. */
|
|
921
|
+
const OPTIONS: IOption[] = [
|
|
922
|
+
{ id: "loading-option-1", label: "Значение 1" },
|
|
923
|
+
{ id: "loading-option-2", label: "Значение 2" },
|
|
924
|
+
{ id: "loading-option-3", label: "Значение 3" },
|
|
925
|
+
];
|
|
926
|
+
|
|
927
|
+
export const LoadingExample = () => {
|
|
928
|
+
return (
|
|
929
|
+
<div style={{ maxWidth: "320px", display: "flex", flexDirection: "column", gap: "24px" }}>
|
|
930
|
+
<div>
|
|
931
|
+
<div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: 700 }}>Loading in target</div>
|
|
932
|
+
<LoadingMultiselect targetLoading />
|
|
933
|
+
</div>
|
|
934
|
+
|
|
935
|
+
<div>
|
|
936
|
+
<div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: 700 }}>Loading in dropdown</div>
|
|
937
|
+
<LoadingMultiselect dropdownContentLoading />
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
);
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
interface ILoadingMultiselectProps {
|
|
944
|
+
targetLoading?: boolean;
|
|
945
|
+
dropdownContentLoading?: boolean;
|
|
946
|
+
forceOpened?: boolean;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const LoadingMultiselect = ({
|
|
950
|
+
targetLoading = false,
|
|
951
|
+
dropdownContentLoading = false,
|
|
952
|
+
forceOpened = false,
|
|
953
|
+
}: ILoadingMultiselectProps) => {
|
|
954
|
+
/** Ref на target для привязки dropdown к полю. */
|
|
955
|
+
const targetRef = useRef<HTMLDivElement>(null);
|
|
956
|
+
/** Id выбранных чекбоксов. */
|
|
957
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
958
|
+
/** Значение фильтра в input dropdown. */
|
|
959
|
+
const [filter, setFilter] = useState("");
|
|
960
|
+
/** Быстрый поиск выбранного элемента по id. */
|
|
961
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
962
|
+
const visibleOptions = useMemo(() => {
|
|
963
|
+
const lower = filter.trim().toLowerCase();
|
|
964
|
+
if (!lower.length) {
|
|
965
|
+
return OPTIONS;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower));
|
|
969
|
+
}, [filter]);
|
|
970
|
+
|
|
971
|
+
const selectedOptions = useMemo(() => OPTIONS.filter((opt) => selectedSet.has(opt.id)), [selectedSet]);
|
|
972
|
+
|
|
973
|
+
const handleToggle = (option: IOption, checked: boolean) => {
|
|
974
|
+
setSelectedIds((prev) => {
|
|
975
|
+
if (checked) {
|
|
976
|
+
if (prev.includes(option.id)) {
|
|
977
|
+
return prev;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return [...prev, option.id];
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return prev.filter((id) => id !== option.id);
|
|
984
|
+
});
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const handleClearAll = () => {
|
|
988
|
+
setSelectedIds([]);
|
|
989
|
+
setFilter("");
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
const renderCheckboxList = () => (
|
|
993
|
+
<CheckboxYGroup aria-label="Options">
|
|
994
|
+
{visibleOptions.map((opt) => (
|
|
995
|
+
<Checkbox
|
|
996
|
+
key={opt.id}
|
|
997
|
+
id={opt.id}
|
|
998
|
+
size={EComponentSize.MD}
|
|
999
|
+
checked={selectedSet.has(opt.id)}
|
|
1000
|
+
onChange={(event) => handleToggle(opt, event.target.checked)}
|
|
1001
|
+
>
|
|
1002
|
+
{opt.label}
|
|
1003
|
+
</Checkbox>
|
|
1004
|
+
))}
|
|
1005
|
+
</CheckboxYGroup>
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
/** Рендер тегов выбранных значений в target. */
|
|
1009
|
+
const renderTags = () => {
|
|
1010
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1011
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1012
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1013
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
1014
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
1015
|
+
event.stopPropagation();
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
if (!selectedOptions.length) return null;
|
|
1020
|
+
|
|
1021
|
+
return (
|
|
1022
|
+
<TagGroup size={EComponentSize.SM}>
|
|
1023
|
+
{selectedOptions.map((opt) => (
|
|
1024
|
+
<Tag
|
|
1025
|
+
key={opt.id}
|
|
1026
|
+
id={`tag-${opt.id}`}
|
|
1027
|
+
size={EComponentSize.SM}
|
|
1028
|
+
onFocus={handleTagFocus}
|
|
1029
|
+
onBlur={handleTagBlur}
|
|
1030
|
+
onClick={handleTagClick}
|
|
1031
|
+
onKeyDown={handleTagKeyDown}
|
|
1032
|
+
onRemove={() => handleToggle(opt, false)}
|
|
1033
|
+
>
|
|
1034
|
+
{opt.label}
|
|
1035
|
+
</Tag>
|
|
1036
|
+
))}
|
|
1037
|
+
</TagGroup>
|
|
1038
|
+
);
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
return (
|
|
1042
|
+
<MultiselectField
|
|
1043
|
+
renderTarget={(props) => (
|
|
1044
|
+
<MultiselectField.Target
|
|
1045
|
+
{...props}
|
|
1046
|
+
ref={targetRef}
|
|
1047
|
+
loading={targetLoading}
|
|
1048
|
+
size={EComponentSize.MD}
|
|
1049
|
+
fieldLabel="Label"
|
|
1050
|
+
placeholder="Select to proceed"
|
|
1051
|
+
label={renderTags()}
|
|
1052
|
+
/>
|
|
1053
|
+
)}
|
|
1054
|
+
>
|
|
1055
|
+
{({ opened, setOpened, targetRef: dropdownTargetRef, dropdownRef }) => (
|
|
1056
|
+
<MultiselectField.Dropdown
|
|
1057
|
+
opened={forceOpened ? true : opened}
|
|
1058
|
+
setOpened={setOpened}
|
|
1059
|
+
targetRef={dropdownTargetRef}
|
|
1060
|
+
ref={dropdownRef}
|
|
1061
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
1062
|
+
mobileViewProps={{
|
|
1063
|
+
children: (
|
|
1064
|
+
<>
|
|
1065
|
+
<DropdownMobileHeader
|
|
1066
|
+
controlButtons={
|
|
1067
|
+
<>
|
|
1068
|
+
<DropdownMobileLoader />
|
|
1069
|
+
<DropdownMobileClose onClick={() => setOpened(false)} />
|
|
1070
|
+
</>
|
|
1071
|
+
}
|
|
1072
|
+
>
|
|
1073
|
+
<DropdownMobileInput
|
|
1074
|
+
autoFocus={true}
|
|
1075
|
+
placeholder="Type to proceed"
|
|
1076
|
+
value={filter}
|
|
1077
|
+
onChange={(event) => setFilter(event.target.value)}
|
|
1078
|
+
/>
|
|
1079
|
+
</DropdownMobileHeader>
|
|
1080
|
+
<DropdownMobileBody>
|
|
1081
|
+
{renderCheckboxList()}
|
|
1082
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
1083
|
+
<Text size={ETextSize.B3} className="not-found">
|
|
1084
|
+
Nothing was found.
|
|
1085
|
+
</Text>
|
|
1086
|
+
)}
|
|
1087
|
+
</DropdownMobileBody>
|
|
1088
|
+
<DropdownMobileFooter>
|
|
1089
|
+
<Button
|
|
1090
|
+
theme={EButtonTheme.SECONDARY}
|
|
1091
|
+
size={EComponentSize.MD}
|
|
1092
|
+
onClick={() => setOpened(false)}
|
|
1093
|
+
>
|
|
1094
|
+
Button text
|
|
1095
|
+
</Button>
|
|
1096
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.MD} onClick={handleClearAll}>
|
|
1097
|
+
Button link text
|
|
1098
|
+
</Button>
|
|
1099
|
+
</DropdownMobileFooter>
|
|
1100
|
+
</>
|
|
1101
|
+
),
|
|
1102
|
+
}}
|
|
1103
|
+
>
|
|
1104
|
+
<MultiselectField.Dropdown.Header>
|
|
1105
|
+
<FormField size={EComponentSize.SM}>
|
|
1106
|
+
<FormFieldLabel>Type to proceed</FormFieldLabel>
|
|
1107
|
+
<FormFieldInput value={filter} onChange={(event) => setFilter(event.target.value)} />
|
|
1108
|
+
<FormFieldPostfix>
|
|
1109
|
+
<FormFieldClear onClick={() => setFilter("")} />
|
|
1110
|
+
</FormFieldPostfix>
|
|
1111
|
+
</FormField>
|
|
1112
|
+
</MultiselectField.Dropdown.Header>
|
|
1113
|
+
<MultiselectField.Dropdown.Content loading={dropdownContentLoading}>
|
|
1114
|
+
{renderCheckboxList()}
|
|
1115
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
1116
|
+
<div className="not-found md">
|
|
1117
|
+
<Text size={ETextSize.B3}>Nothing was found.</Text>
|
|
1118
|
+
</div>
|
|
1119
|
+
)}
|
|
1120
|
+
</MultiselectField.Dropdown.Content>
|
|
1121
|
+
<MultiselectField.Dropdown.Footer>
|
|
1122
|
+
<Button
|
|
1123
|
+
theme={EButtonTheme.SECONDARY}
|
|
1124
|
+
size={EComponentSize.SM}
|
|
1125
|
+
onClick={() => setOpened(false)}
|
|
1126
|
+
>
|
|
1127
|
+
Button text
|
|
1128
|
+
</Button>
|
|
1129
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.SM} onClick={handleClearAll}>
|
|
1130
|
+
Button link text
|
|
1131
|
+
</Button>
|
|
1132
|
+
</MultiselectField.Dropdown.Footer>
|
|
1133
|
+
</MultiselectField.Dropdown>
|
|
1134
|
+
)}
|
|
1135
|
+
</MultiselectField>
|
|
1136
|
+
);
|
|
1137
|
+
};
|
|
135
1138
|
```
|
|
136
1139
|
|
|
137
|
-
|
|
138
|
-
- Все потомки оборачиваются в `<div role="presentation">` — требование FocusTrap (нужен single wrapper-element).
|
|
1140
|
+
### DropdownWithoutInput
|
|
139
1141
|
|
|
140
|
-
|
|
1142
|
+
`stories/MultiselectField/examples/DropdownWithoutInputExample.tsx`
|
|
141
1143
|
|
|
142
1144
|
```tsx
|
|
143
|
-
|
|
1145
|
+
import React, { useMemo, useRef, useState } from "react";
|
|
1146
|
+
import {
|
|
1147
|
+
Button,
|
|
1148
|
+
Checkbox,
|
|
1149
|
+
CheckboxYGroup,
|
|
1150
|
+
DropdownMobileBody,
|
|
1151
|
+
DropdownMobileFooter,
|
|
1152
|
+
EButtonTheme,
|
|
1153
|
+
EComponentSize,
|
|
1154
|
+
MultiselectField,
|
|
1155
|
+
Tag,
|
|
1156
|
+
TagGroup,
|
|
1157
|
+
} from "@sberbusiness/triplex-next";
|
|
1158
|
+
|
|
1159
|
+
interface IOption {
|
|
1160
|
+
id: string;
|
|
1161
|
+
label: string;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Фиксированный список опций для версии dropdown без поля фильтра. */
|
|
1165
|
+
const OPTIONS: IOption[] = [
|
|
1166
|
+
{ id: "without-input-option-1", label: "Значение 1" },
|
|
1167
|
+
{ id: "without-input-option-2", label: "Значение 2" },
|
|
1168
|
+
{ id: "without-input-option-3", label: "Значение 3" },
|
|
1169
|
+
];
|
|
1170
|
+
|
|
1171
|
+
export const DropdownWithoutInputExample = () => {
|
|
1172
|
+
/** Ref на target для корректного позиционирования dropdown. */
|
|
1173
|
+
const targetRef = useRef<HTMLDivElement>(null);
|
|
1174
|
+
/** Id выбранных чекбоксов. */
|
|
1175
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
1176
|
+
/** Set для быстрых проверок выбранности. */
|
|
1177
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
1178
|
+
|
|
1179
|
+
const selectedOptions = useMemo(() => OPTIONS.filter((opt) => selectedSet.has(opt.id)), [selectedSet]);
|
|
1180
|
+
|
|
1181
|
+
const handleToggle = (option: IOption, checked: boolean) => {
|
|
1182
|
+
setSelectedIds((prev) => {
|
|
1183
|
+
if (checked) {
|
|
1184
|
+
if (prev.includes(option.id)) {
|
|
1185
|
+
return prev;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
return [...prev, option.id];
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return prev.filter((id) => id !== option.id);
|
|
1192
|
+
});
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
const handleClearAll = () => {
|
|
1196
|
+
setSelectedIds([]);
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
const renderCheckboxList = () => (
|
|
1200
|
+
<CheckboxYGroup aria-label="Options">
|
|
1201
|
+
{OPTIONS.map((opt) => (
|
|
1202
|
+
<Checkbox
|
|
1203
|
+
key={opt.id}
|
|
1204
|
+
id={opt.id}
|
|
1205
|
+
size={EComponentSize.MD}
|
|
1206
|
+
checked={selectedSet.has(opt.id)}
|
|
1207
|
+
onChange={(event) => handleToggle(opt, event.target.checked)}
|
|
1208
|
+
>
|
|
1209
|
+
{opt.label}
|
|
1210
|
+
</Checkbox>
|
|
1211
|
+
))}
|
|
1212
|
+
</CheckboxYGroup>
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
/** Рендер тегов выбранных значений в target. */
|
|
1216
|
+
const renderTags = () => {
|
|
1217
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1218
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1219
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1220
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
1221
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
1222
|
+
event.stopPropagation();
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
if (!selectedOptions.length) return null;
|
|
1227
|
+
|
|
1228
|
+
return (
|
|
1229
|
+
<TagGroup size={EComponentSize.SM}>
|
|
1230
|
+
{selectedOptions.map((opt) => (
|
|
1231
|
+
<Tag
|
|
1232
|
+
key={opt.id}
|
|
1233
|
+
id={`tag-${opt.id}`}
|
|
1234
|
+
size={EComponentSize.SM}
|
|
1235
|
+
onFocus={handleTagFocus}
|
|
1236
|
+
onBlur={handleTagBlur}
|
|
1237
|
+
onClick={handleTagClick}
|
|
1238
|
+
onKeyDown={handleTagKeyDown}
|
|
1239
|
+
onRemove={() => handleToggle(opt, false)}
|
|
1240
|
+
>
|
|
1241
|
+
{opt.label}
|
|
1242
|
+
</Tag>
|
|
1243
|
+
))}
|
|
1244
|
+
</TagGroup>
|
|
1245
|
+
);
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
return (
|
|
1249
|
+
<div style={{ maxWidth: "320px" }}>
|
|
1250
|
+
<MultiselectField
|
|
1251
|
+
renderTarget={(props) => (
|
|
1252
|
+
<MultiselectField.Target
|
|
1253
|
+
{...props}
|
|
1254
|
+
ref={targetRef}
|
|
1255
|
+
size={EComponentSize.MD}
|
|
1256
|
+
fieldLabel="Label"
|
|
1257
|
+
placeholder="Select to proceed"
|
|
1258
|
+
label={renderTags()}
|
|
1259
|
+
/>
|
|
1260
|
+
)}
|
|
1261
|
+
>
|
|
1262
|
+
{({ opened, setOpened, targetRef: dropdownTargetRef, dropdownRef }) => (
|
|
1263
|
+
<MultiselectField.Dropdown
|
|
1264
|
+
opened={opened}
|
|
1265
|
+
setOpened={setOpened}
|
|
1266
|
+
targetRef={dropdownTargetRef}
|
|
1267
|
+
ref={dropdownRef}
|
|
1268
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
1269
|
+
mobileViewProps={{
|
|
1270
|
+
children: (
|
|
1271
|
+
<>
|
|
1272
|
+
<DropdownMobileBody>{renderCheckboxList()}</DropdownMobileBody>
|
|
1273
|
+
<DropdownMobileFooter>
|
|
1274
|
+
<Button
|
|
1275
|
+
theme={EButtonTheme.SECONDARY}
|
|
1276
|
+
size={EComponentSize.MD}
|
|
1277
|
+
onClick={() => setOpened(false)}
|
|
1278
|
+
>
|
|
1279
|
+
Button text
|
|
1280
|
+
</Button>
|
|
1281
|
+
<Button
|
|
1282
|
+
theme={EButtonTheme.LINK}
|
|
1283
|
+
size={EComponentSize.MD}
|
|
1284
|
+
onClick={handleClearAll}
|
|
1285
|
+
>
|
|
1286
|
+
Button link text
|
|
1287
|
+
</Button>
|
|
1288
|
+
</DropdownMobileFooter>
|
|
1289
|
+
</>
|
|
1290
|
+
),
|
|
1291
|
+
}}
|
|
1292
|
+
>
|
|
1293
|
+
<MultiselectField.Dropdown.Content>{renderCheckboxList()}</MultiselectField.Dropdown.Content>
|
|
1294
|
+
<MultiselectField.Dropdown.Footer>
|
|
1295
|
+
<Button
|
|
1296
|
+
theme={EButtonTheme.SECONDARY}
|
|
1297
|
+
size={EComponentSize.SM}
|
|
1298
|
+
onClick={() => setOpened(false)}
|
|
1299
|
+
>
|
|
1300
|
+
Button text
|
|
1301
|
+
</Button>
|
|
1302
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.SM} onClick={handleClearAll}>
|
|
1303
|
+
Button link text
|
|
1304
|
+
</Button>
|
|
1305
|
+
</MultiselectField.Dropdown.Footer>
|
|
1306
|
+
</MultiselectField.Dropdown>
|
|
1307
|
+
)}
|
|
1308
|
+
</MultiselectField>
|
|
1309
|
+
</div>
|
|
1310
|
+
);
|
|
1311
|
+
};
|
|
144
1312
|
```
|
|
145
1313
|
|
|
146
|
-
|
|
1314
|
+
### WithClearButton
|
|
1315
|
+
|
|
1316
|
+
`stories/MultiselectField/examples/WithClearButtonExample.tsx`
|
|
1317
|
+
|
|
1318
|
+
```tsx
|
|
1319
|
+
import React, { useMemo, useRef, useState } from "react";
|
|
1320
|
+
import {
|
|
1321
|
+
Button,
|
|
1322
|
+
Checkbox,
|
|
1323
|
+
CheckboxYGroup,
|
|
1324
|
+
EButtonTheme,
|
|
1325
|
+
EComponentSize,
|
|
1326
|
+
FormField,
|
|
1327
|
+
FormFieldClear,
|
|
1328
|
+
FormFieldInput,
|
|
1329
|
+
FormFieldLabel,
|
|
1330
|
+
FormFieldPostfix,
|
|
1331
|
+
MultiselectField,
|
|
1332
|
+
Text,
|
|
1333
|
+
ETextSize,
|
|
1334
|
+
Tag,
|
|
1335
|
+
TagGroup,
|
|
1336
|
+
DropdownMobileHeader,
|
|
1337
|
+
DropdownMobileClose,
|
|
1338
|
+
DropdownMobileInput,
|
|
1339
|
+
DropdownMobileBody,
|
|
1340
|
+
DropdownMobileFooter,
|
|
1341
|
+
} from "@sberbusiness/triplex-next";
|
|
1342
|
+
import "../MultiselectField.less";
|
|
1343
|
+
|
|
1344
|
+
interface IOption {
|
|
1345
|
+
id: string;
|
|
1346
|
+
label: string;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Фиксированный список опций для демонстрации кнопки очистки. */
|
|
1350
|
+
const OPTIONS: IOption[] = [
|
|
1351
|
+
{ id: "with-clear-option-1", label: "Значение 1" },
|
|
1352
|
+
{ id: "with-clear-option-2", label: "Значение 2" },
|
|
1353
|
+
{ id: "with-clear-option-3", label: "Значение 3" },
|
|
1354
|
+
];
|
|
1355
|
+
|
|
1356
|
+
export const WithClearButtonExample = () => {
|
|
1357
|
+
/** Ref на target для корректной привязки dropdown. */
|
|
1358
|
+
const targetRef = useRef<HTMLDivElement>(null);
|
|
1359
|
+
/** Id выбранных чекбоксов. */
|
|
1360
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
1361
|
+
/** Значение фильтра в input dropdown. */
|
|
1362
|
+
const [filter, setFilter] = useState("");
|
|
1363
|
+
/** Set для быстрых проверок выбранности. */
|
|
1364
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
1365
|
+
const visibleOptions = useMemo(() => {
|
|
1366
|
+
const lower = filter.trim().toLowerCase();
|
|
1367
|
+
if (!lower.length) {
|
|
1368
|
+
return OPTIONS;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
return OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower));
|
|
1372
|
+
}, [filter]);
|
|
1373
|
+
|
|
1374
|
+
const selectedOptions = useMemo(() => OPTIONS.filter((opt) => selectedSet.has(opt.id)), [selectedSet]);
|
|
1375
|
+
|
|
1376
|
+
const handleToggle = (option: IOption, checked: boolean) => {
|
|
1377
|
+
setSelectedIds((prev) => {
|
|
1378
|
+
if (checked) {
|
|
1379
|
+
if (prev.includes(option.id)) {
|
|
1380
|
+
return prev;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
return [...prev, option.id];
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return prev.filter((id) => id !== option.id);
|
|
1387
|
+
});
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
/** Очистка фильтра и всех выбранных значений. */
|
|
1391
|
+
const handleClearAll = () => {
|
|
1392
|
+
setSelectedIds([]);
|
|
1393
|
+
setFilter("");
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
const renderCheckboxList = () => (
|
|
1397
|
+
<CheckboxYGroup aria-label="Options">
|
|
1398
|
+
{visibleOptions.map((opt) => (
|
|
1399
|
+
<Checkbox
|
|
1400
|
+
key={opt.id}
|
|
1401
|
+
id={opt.id}
|
|
1402
|
+
size={EComponentSize.MD}
|
|
1403
|
+
checked={selectedSet.has(opt.id)}
|
|
1404
|
+
onChange={(event) => handleToggle(opt, event.target.checked)}
|
|
1405
|
+
>
|
|
1406
|
+
{opt.label}
|
|
1407
|
+
</Checkbox>
|
|
1408
|
+
))}
|
|
1409
|
+
</CheckboxYGroup>
|
|
1410
|
+
);
|
|
1411
|
+
|
|
1412
|
+
/** Рендер тегов выбранных значений в target. */
|
|
1413
|
+
const renderTags = () => {
|
|
1414
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1415
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1416
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1417
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
1418
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
1419
|
+
event.stopPropagation();
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
if (!selectedOptions.length) return null;
|
|
147
1424
|
|
|
148
|
-
|
|
1425
|
+
return (
|
|
1426
|
+
<TagGroup size={EComponentSize.SM}>
|
|
1427
|
+
{selectedOptions.map((opt) => (
|
|
1428
|
+
<Tag
|
|
1429
|
+
key={opt.id}
|
|
1430
|
+
id={`tag-${opt.id}`}
|
|
1431
|
+
size={EComponentSize.SM}
|
|
1432
|
+
onFocus={handleTagFocus}
|
|
1433
|
+
onBlur={handleTagBlur}
|
|
1434
|
+
onClick={handleTagClick}
|
|
1435
|
+
onKeyDown={handleTagKeyDown}
|
|
1436
|
+
onRemove={() => handleToggle(opt, false)}
|
|
1437
|
+
>
|
|
1438
|
+
{opt.label}
|
|
1439
|
+
</Tag>
|
|
1440
|
+
))}
|
|
1441
|
+
</TagGroup>
|
|
1442
|
+
);
|
|
1443
|
+
};
|
|
149
1444
|
|
|
150
|
-
|
|
1445
|
+
return (
|
|
1446
|
+
<div style={{ maxWidth: "320px" }}>
|
|
1447
|
+
<MultiselectField
|
|
1448
|
+
renderTarget={(props) => (
|
|
1449
|
+
<MultiselectField.Target
|
|
1450
|
+
{...props}
|
|
1451
|
+
ref={targetRef}
|
|
1452
|
+
onClear={handleClearAll}
|
|
1453
|
+
size={EComponentSize.MD}
|
|
1454
|
+
fieldLabel="Label"
|
|
1455
|
+
placeholder="Select to proceed"
|
|
1456
|
+
label={renderTags()}
|
|
1457
|
+
/>
|
|
1458
|
+
)}
|
|
1459
|
+
>
|
|
1460
|
+
{({ opened, setOpened, targetRef: dropdownTargetRef, dropdownRef }) => (
|
|
1461
|
+
<MultiselectField.Dropdown
|
|
1462
|
+
opened={opened}
|
|
1463
|
+
setOpened={setOpened}
|
|
1464
|
+
targetRef={dropdownTargetRef}
|
|
1465
|
+
ref={dropdownRef}
|
|
1466
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
1467
|
+
mobileViewProps={{
|
|
1468
|
+
children: (
|
|
1469
|
+
<>
|
|
1470
|
+
<DropdownMobileHeader
|
|
1471
|
+
controlButtons={<DropdownMobileClose onClick={() => setOpened(false)} />}
|
|
1472
|
+
>
|
|
1473
|
+
<DropdownMobileInput
|
|
1474
|
+
autoFocus={true}
|
|
1475
|
+
placeholder="Type to proceed"
|
|
1476
|
+
value={filter}
|
|
1477
|
+
onChange={(event) => setFilter(event.target.value)}
|
|
1478
|
+
/>
|
|
1479
|
+
</DropdownMobileHeader>
|
|
1480
|
+
<DropdownMobileBody>
|
|
1481
|
+
{renderCheckboxList()}
|
|
1482
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
1483
|
+
<Text size={ETextSize.B3} className="not-found">
|
|
1484
|
+
Nothing was found.
|
|
1485
|
+
</Text>
|
|
1486
|
+
)}
|
|
1487
|
+
</DropdownMobileBody>
|
|
1488
|
+
<DropdownMobileFooter>
|
|
1489
|
+
<Button
|
|
1490
|
+
theme={EButtonTheme.SECONDARY}
|
|
1491
|
+
size={EComponentSize.MD}
|
|
1492
|
+
onClick={() => setOpened(false)}
|
|
1493
|
+
>
|
|
1494
|
+
Button text
|
|
1495
|
+
</Button>
|
|
1496
|
+
<Button
|
|
1497
|
+
theme={EButtonTheme.LINK}
|
|
1498
|
+
size={EComponentSize.MD}
|
|
1499
|
+
onClick={handleClearAll}
|
|
1500
|
+
>
|
|
1501
|
+
Button link text
|
|
1502
|
+
</Button>
|
|
1503
|
+
</DropdownMobileFooter>
|
|
1504
|
+
</>
|
|
1505
|
+
),
|
|
1506
|
+
}}
|
|
1507
|
+
>
|
|
1508
|
+
<MultiselectField.Dropdown.Header>
|
|
1509
|
+
<FormField size={EComponentSize.SM}>
|
|
1510
|
+
<FormFieldLabel>Type to proceed</FormFieldLabel>
|
|
1511
|
+
<FormFieldInput value={filter} onChange={(event) => setFilter(event.target.value)} />
|
|
1512
|
+
<FormFieldPostfix>
|
|
1513
|
+
<FormFieldClear onClick={() => setFilter("")} />
|
|
1514
|
+
</FormFieldPostfix>
|
|
1515
|
+
</FormField>
|
|
1516
|
+
</MultiselectField.Dropdown.Header>
|
|
1517
|
+
<MultiselectField.Dropdown.Content>
|
|
1518
|
+
{renderCheckboxList()}
|
|
1519
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
1520
|
+
<div className="not-found md">
|
|
1521
|
+
<Text size={ETextSize.B3}>Nothing was found.</Text>
|
|
1522
|
+
</div>
|
|
1523
|
+
)}
|
|
1524
|
+
</MultiselectField.Dropdown.Content>
|
|
1525
|
+
<MultiselectField.Dropdown.Footer>
|
|
1526
|
+
<Button
|
|
1527
|
+
theme={EButtonTheme.SECONDARY}
|
|
1528
|
+
size={EComponentSize.SM}
|
|
1529
|
+
onClick={() => setOpened(false)}
|
|
1530
|
+
>
|
|
1531
|
+
Button text
|
|
1532
|
+
</Button>
|
|
1533
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.SM} onClick={handleClearAll}>
|
|
1534
|
+
Button link text
|
|
1535
|
+
</Button>
|
|
1536
|
+
</MultiselectField.Dropdown.Footer>
|
|
1537
|
+
</MultiselectField.Dropdown>
|
|
1538
|
+
)}
|
|
1539
|
+
</MultiselectField>
|
|
1540
|
+
</div>
|
|
1541
|
+
);
|
|
1542
|
+
};
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
### WithPrefixAndPostfix
|
|
151
1546
|
|
|
152
|
-
|
|
1547
|
+
`stories/MultiselectField/examples/WithPrefixAndPostfixExample.tsx`
|
|
153
1548
|
|
|
154
1549
|
```tsx
|
|
155
|
-
{
|
|
1550
|
+
import React, { useMemo, useRef, useState } from "react";
|
|
1551
|
+
import {
|
|
1552
|
+
Button,
|
|
1553
|
+
Checkbox,
|
|
1554
|
+
CheckboxYGroup,
|
|
1555
|
+
EButtonTheme,
|
|
1556
|
+
EComponentSize,
|
|
1557
|
+
ETooltipSize,
|
|
1558
|
+
FormField,
|
|
1559
|
+
FormFieldClear,
|
|
1560
|
+
FormFieldInput,
|
|
1561
|
+
FormFieldLabel,
|
|
1562
|
+
FormFieldPostfix,
|
|
1563
|
+
HelpBox,
|
|
1564
|
+
MultiselectField,
|
|
1565
|
+
Text,
|
|
1566
|
+
ETextSize,
|
|
1567
|
+
Tag,
|
|
1568
|
+
TagGroup,
|
|
1569
|
+
DropdownMobileHeader,
|
|
1570
|
+
DropdownMobileClose,
|
|
1571
|
+
DropdownMobileInput,
|
|
1572
|
+
DropdownMobileBody,
|
|
1573
|
+
DropdownMobileFooter,
|
|
1574
|
+
IconWrapper,
|
|
1575
|
+
} from "@sberbusiness/triplex-next";
|
|
1576
|
+
import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
|
|
1577
|
+
import "../MultiselectField.less";
|
|
1578
|
+
|
|
1579
|
+
interface IOption {
|
|
1580
|
+
id: string;
|
|
1581
|
+
label: string;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/** Фиксированный список опций для демонстрации prefix/postfix. */
|
|
1585
|
+
const OPTIONS: IOption[] = [
|
|
1586
|
+
{ id: "prefix-postfix-option-1", label: "Значение 1" },
|
|
1587
|
+
{ id: "prefix-postfix-option-2", label: "Значение 2" },
|
|
1588
|
+
{ id: "prefix-postfix-option-3", label: "Значение 3" },
|
|
1589
|
+
];
|
|
1590
|
+
|
|
1591
|
+
export const WithPrefixAndPostfixExample = () => {
|
|
1592
|
+
/** Ref на target для корректной привязки dropdown. */
|
|
1593
|
+
const targetRef = useRef<HTMLDivElement>(null);
|
|
1594
|
+
/** Id выбранных чекбоксов. */
|
|
1595
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
1596
|
+
/** Значение фильтра в input dropdown. */
|
|
1597
|
+
const [filter, setFilter] = useState("");
|
|
1598
|
+
/** Set для быстрых проверок выбранности. */
|
|
1599
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
1600
|
+
const visibleOptions = useMemo(() => {
|
|
1601
|
+
const lower = filter.trim().toLowerCase();
|
|
1602
|
+
if (!lower.length) {
|
|
1603
|
+
return OPTIONS;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
return OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower));
|
|
1607
|
+
}, [filter]);
|
|
1608
|
+
|
|
1609
|
+
const selectedOptions = useMemo(() => OPTIONS.filter((opt) => selectedSet.has(opt.id)), [selectedSet]);
|
|
1610
|
+
|
|
1611
|
+
const handleToggle = (option: IOption, checked: boolean) => {
|
|
1612
|
+
setSelectedIds((prev) => {
|
|
1613
|
+
if (checked) {
|
|
1614
|
+
if (prev.includes(option.id)) {
|
|
1615
|
+
return prev;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
return [...prev, option.id];
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
return prev.filter((id) => id !== option.id);
|
|
1622
|
+
});
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
const handleClearAll = () => {
|
|
1626
|
+
setSelectedIds([]);
|
|
1627
|
+
setFilter("");
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
const renderCheckboxList = () => (
|
|
1631
|
+
<CheckboxYGroup aria-label="Options">
|
|
1632
|
+
{visibleOptions.map((opt) => (
|
|
1633
|
+
<Checkbox
|
|
1634
|
+
key={opt.id}
|
|
1635
|
+
id={opt.id}
|
|
1636
|
+
size={EComponentSize.MD}
|
|
1637
|
+
checked={selectedSet.has(opt.id)}
|
|
1638
|
+
onChange={(event) => handleToggle(opt, event.target.checked)}
|
|
1639
|
+
>
|
|
1640
|
+
{opt.label}
|
|
1641
|
+
</Checkbox>
|
|
1642
|
+
))}
|
|
1643
|
+
</CheckboxYGroup>
|
|
1644
|
+
);
|
|
1645
|
+
|
|
1646
|
+
/** Рендер тегов выбранных значений в target. */
|
|
1647
|
+
const renderTags = () => {
|
|
1648
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1649
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1650
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1651
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
1652
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
1653
|
+
event.stopPropagation();
|
|
1654
|
+
}
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
if (!selectedOptions.length) return null;
|
|
1658
|
+
|
|
1659
|
+
return (
|
|
1660
|
+
<TagGroup size={EComponentSize.SM}>
|
|
1661
|
+
{selectedOptions.map((opt) => (
|
|
1662
|
+
<Tag
|
|
1663
|
+
key={opt.id}
|
|
1664
|
+
id={`tag-${opt.id}`}
|
|
1665
|
+
size={EComponentSize.SM}
|
|
1666
|
+
onFocus={handleTagFocus}
|
|
1667
|
+
onBlur={handleTagBlur}
|
|
1668
|
+
onClick={handleTagClick}
|
|
1669
|
+
onKeyDown={handleTagKeyDown}
|
|
1670
|
+
onRemove={() => handleToggle(opt, false)}
|
|
1671
|
+
>
|
|
1672
|
+
{opt.label}
|
|
1673
|
+
</Tag>
|
|
1674
|
+
))}
|
|
1675
|
+
</TagGroup>
|
|
1676
|
+
);
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1679
|
+
return (
|
|
1680
|
+
<div style={{ maxWidth: "320px" }}>
|
|
1681
|
+
<MultiselectField
|
|
1682
|
+
renderTarget={(props) => (
|
|
1683
|
+
<MultiselectField.Target
|
|
1684
|
+
{...props}
|
|
1685
|
+
ref={targetRef}
|
|
1686
|
+
size={EComponentSize.MD}
|
|
1687
|
+
fieldLabel="Label"
|
|
1688
|
+
placeholder="Select to proceed"
|
|
1689
|
+
label={renderTags()}
|
|
1690
|
+
prefix={
|
|
1691
|
+
<IconWrapper disableInteraction displayContents>
|
|
1692
|
+
<DefaulticonStrokePrdIcon20 paletteIndex={5} />
|
|
1693
|
+
</IconWrapper>
|
|
1694
|
+
}
|
|
1695
|
+
postfix={<HelpBox tooltipSize={ETooltipSize.SM}>HelpBox text</HelpBox>}
|
|
1696
|
+
/>
|
|
1697
|
+
)}
|
|
1698
|
+
>
|
|
1699
|
+
{({ opened, setOpened, targetRef: dropdownTargetRef, dropdownRef }) => (
|
|
1700
|
+
<MultiselectField.Dropdown
|
|
1701
|
+
opened={opened}
|
|
1702
|
+
setOpened={setOpened}
|
|
1703
|
+
targetRef={dropdownTargetRef}
|
|
1704
|
+
ref={dropdownRef}
|
|
1705
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
1706
|
+
mobileViewProps={{
|
|
1707
|
+
children: (
|
|
1708
|
+
<>
|
|
1709
|
+
<DropdownMobileHeader
|
|
1710
|
+
controlButtons={<DropdownMobileClose onClick={() => setOpened(false)} />}
|
|
1711
|
+
>
|
|
1712
|
+
<DropdownMobileInput
|
|
1713
|
+
autoFocus={true}
|
|
1714
|
+
placeholder="Type to proceed"
|
|
1715
|
+
value={filter}
|
|
1716
|
+
onChange={(event) => setFilter(event.target.value)}
|
|
1717
|
+
/>
|
|
1718
|
+
</DropdownMobileHeader>
|
|
1719
|
+
<DropdownMobileBody>
|
|
1720
|
+
{renderCheckboxList()}
|
|
1721
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
1722
|
+
<Text size={ETextSize.B3} className="not-found">
|
|
1723
|
+
Nothing was found.
|
|
1724
|
+
</Text>
|
|
1725
|
+
)}
|
|
1726
|
+
</DropdownMobileBody>
|
|
1727
|
+
<DropdownMobileFooter>
|
|
1728
|
+
<Button
|
|
1729
|
+
theme={EButtonTheme.SECONDARY}
|
|
1730
|
+
size={EComponentSize.MD}
|
|
1731
|
+
onClick={() => setOpened(false)}
|
|
1732
|
+
>
|
|
1733
|
+
Button text
|
|
1734
|
+
</Button>
|
|
1735
|
+
<Button
|
|
1736
|
+
theme={EButtonTheme.LINK}
|
|
1737
|
+
size={EComponentSize.MD}
|
|
1738
|
+
onClick={handleClearAll}
|
|
1739
|
+
>
|
|
1740
|
+
Button link text
|
|
1741
|
+
</Button>
|
|
1742
|
+
</DropdownMobileFooter>
|
|
1743
|
+
</>
|
|
1744
|
+
),
|
|
1745
|
+
}}
|
|
1746
|
+
>
|
|
1747
|
+
<MultiselectField.Dropdown.Header>
|
|
1748
|
+
<FormField size={EComponentSize.SM}>
|
|
1749
|
+
<FormFieldLabel>Type to proceed</FormFieldLabel>
|
|
1750
|
+
<FormFieldInput value={filter} onChange={(event) => setFilter(event.target.value)} />
|
|
1751
|
+
<FormFieldPostfix>
|
|
1752
|
+
<FormFieldClear onClick={() => setFilter("")} />
|
|
1753
|
+
</FormFieldPostfix>
|
|
1754
|
+
</FormField>
|
|
1755
|
+
</MultiselectField.Dropdown.Header>
|
|
1756
|
+
<MultiselectField.Dropdown.Content>
|
|
1757
|
+
{renderCheckboxList()}
|
|
1758
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
1759
|
+
<div className="not-found md">
|
|
1760
|
+
<Text size={ETextSize.B3}>Nothing was found.</Text>
|
|
1761
|
+
</div>
|
|
1762
|
+
)}
|
|
1763
|
+
</MultiselectField.Dropdown.Content>
|
|
1764
|
+
<MultiselectField.Dropdown.Footer>
|
|
1765
|
+
<Button
|
|
1766
|
+
theme={EButtonTheme.SECONDARY}
|
|
1767
|
+
size={EComponentSize.SM}
|
|
1768
|
+
onClick={() => setOpened(false)}
|
|
1769
|
+
>
|
|
1770
|
+
Button text
|
|
1771
|
+
</Button>
|
|
1772
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.SM} onClick={handleClearAll}>
|
|
1773
|
+
Button link text
|
|
1774
|
+
</Button>
|
|
1775
|
+
</MultiselectField.Dropdown.Footer>
|
|
1776
|
+
</MultiselectField.Dropdown>
|
|
1777
|
+
)}
|
|
1778
|
+
</MultiselectField>
|
|
1779
|
+
</div>
|
|
1780
|
+
);
|
|
1781
|
+
};
|
|
156
1782
|
```
|
|
157
1783
|
|
|
158
|
-
|
|
159
|
-
- Visibility — потребитель контролирует `loading` prop.
|
|
1784
|
+
### Example
|
|
160
1785
|
|
|
161
|
-
|
|
1786
|
+
`stories/MultiselectField/examples/ProductionExample.tsx`
|
|
162
1787
|
|
|
163
|
-
|
|
1788
|
+
```tsx
|
|
1789
|
+
import React, { useMemo, useRef, useState } from "react";
|
|
1790
|
+
import {
|
|
1791
|
+
Button,
|
|
1792
|
+
Checkbox,
|
|
1793
|
+
CheckboxYGroup,
|
|
1794
|
+
DropdownMobileBody,
|
|
1795
|
+
DropdownMobileClose,
|
|
1796
|
+
DropdownMobileFooter,
|
|
1797
|
+
DropdownMobileHeader,
|
|
1798
|
+
DropdownMobileInput,
|
|
1799
|
+
EButtonTheme,
|
|
1800
|
+
EComponentSize,
|
|
1801
|
+
EFontType,
|
|
1802
|
+
ETextSize,
|
|
1803
|
+
ETooltipSize,
|
|
1804
|
+
FormField,
|
|
1805
|
+
FormFieldClear,
|
|
1806
|
+
FormFieldDescription,
|
|
1807
|
+
FormFieldInput,
|
|
1808
|
+
FormFieldLabel,
|
|
1809
|
+
FormFieldPostfix,
|
|
1810
|
+
HelpBox,
|
|
1811
|
+
Link,
|
|
1812
|
+
MultiselectField,
|
|
1813
|
+
Tag,
|
|
1814
|
+
TagGroup,
|
|
1815
|
+
Text,
|
|
1816
|
+
} from "@sberbusiness/triplex-next";
|
|
1817
|
+
import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
|
|
1818
|
+
import "../MultiselectField.less";
|
|
164
1819
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
- **`MultiselectFieldContext.size` default `MD`** — не менять без breaking-проверки.
|
|
170
|
-
- **`Content.loading`** — контроль loader'а, типичное API.
|
|
171
|
-
- **ForwardRef** на корне `MultiselectField` и на `MultiselectFieldDropdown`.
|
|
172
|
-
- **Header/Footer/Content — `React.FC`** (без forwardRef).
|
|
1820
|
+
interface IOption {
|
|
1821
|
+
id: string;
|
|
1822
|
+
label: string;
|
|
1823
|
+
}
|
|
173
1824
|
|
|
174
|
-
|
|
1825
|
+
/** Фиксированный список опций для production-like примера. */
|
|
1826
|
+
const OPTIONS: IOption[] = [
|
|
1827
|
+
{ id: "production-option-1", label: "Значение 1" },
|
|
1828
|
+
{ id: "production-option-2", label: "Значение 2" },
|
|
1829
|
+
{ id: "production-option-3", label: "Значение 3" },
|
|
1830
|
+
];
|
|
175
1831
|
|
|
176
|
-
|
|
1832
|
+
export const ProductionExample = () => (
|
|
1833
|
+
<div style={{ maxWidth: "320px" }}>
|
|
1834
|
+
<ProductionMultiselect />
|
|
1835
|
+
<FormFieldDescription>
|
|
1836
|
+
<Text tag="div" size={ETextSize.B4} type={EFontType.SECONDARY}>
|
|
1837
|
+
(21) Description{" "}
|
|
1838
|
+
<Link href="#" onClick={(event) => event.preventDefault()}>
|
|
1839
|
+
Link text
|
|
1840
|
+
</Link>
|
|
1841
|
+
</Text>
|
|
1842
|
+
</FormFieldDescription>
|
|
1843
|
+
</div>
|
|
1844
|
+
);
|
|
177
1845
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
1846
|
+
const ProductionMultiselect = () => {
|
|
1847
|
+
/** Ref на target для корректного позиционирования dropdown. */
|
|
1848
|
+
const targetRef = useRef<HTMLDivElement>(null);
|
|
1849
|
+
/** Id выбранных чекбоксов. */
|
|
1850
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
1851
|
+
/** Значение фильтра в input dropdown. */
|
|
1852
|
+
const [filter, setFilter] = useState("");
|
|
1853
|
+
/** Set для быстрых проверок выбранности. */
|
|
1854
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
1855
|
+
const visibleOptions = useMemo(() => {
|
|
1856
|
+
const lower = filter.trim().toLowerCase();
|
|
1857
|
+
if (!lower.length) {
|
|
1858
|
+
return OPTIONS;
|
|
1859
|
+
}
|
|
183
1860
|
|
|
184
|
-
|
|
1861
|
+
return OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower));
|
|
1862
|
+
}, [filter]);
|
|
185
1863
|
|
|
186
|
-
|
|
1864
|
+
const selectedOptions = useMemo(() => OPTIONS.filter((opt) => selectedSet.has(opt.id)), [selectedSet]);
|
|
187
1865
|
|
|
188
|
-
|
|
1866
|
+
const handleToggle = (option: IOption, checked: boolean) => {
|
|
1867
|
+
setSelectedIds((prev) => {
|
|
1868
|
+
if (checked) {
|
|
1869
|
+
if (prev.includes(option.id)) {
|
|
1870
|
+
return prev;
|
|
1871
|
+
}
|
|
189
1872
|
|
|
190
|
-
|
|
1873
|
+
return [...prev, option.id];
|
|
1874
|
+
}
|
|
191
1875
|
|
|
192
|
-
|
|
1876
|
+
return prev.filter((id) => id !== option.id);
|
|
1877
|
+
});
|
|
1878
|
+
};
|
|
193
1879
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
1880
|
+
/** Очистка фильтра и всех выбранных значений. */
|
|
1881
|
+
const handleClearAll = () => {
|
|
1882
|
+
setSelectedIds([]);
|
|
1883
|
+
setFilter("");
|
|
1884
|
+
};
|
|
199
1885
|
|
|
200
|
-
|
|
1886
|
+
const renderCheckboxList = () => (
|
|
1887
|
+
<CheckboxYGroup aria-label="Options">
|
|
1888
|
+
{visibleOptions.map((opt) => (
|
|
1889
|
+
<Checkbox
|
|
1890
|
+
key={opt.id}
|
|
1891
|
+
id={opt.id}
|
|
1892
|
+
size={EComponentSize.MD}
|
|
1893
|
+
checked={selectedSet.has(opt.id)}
|
|
1894
|
+
onChange={(event) => handleToggle(opt, event.target.checked)}
|
|
1895
|
+
>
|
|
1896
|
+
{opt.label}
|
|
1897
|
+
</Checkbox>
|
|
1898
|
+
))}
|
|
1899
|
+
</CheckboxYGroup>
|
|
1900
|
+
);
|
|
201
1901
|
|
|
202
|
-
|
|
1902
|
+
/** Рендер тегов выбранных значений в target. */
|
|
1903
|
+
const renderTags = () => {
|
|
1904
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1905
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1906
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
1907
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
1908
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
1909
|
+
event.stopPropagation();
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
203
1912
|
|
|
204
|
-
|
|
205
|
-
Примеры: `stories/MultiselectField/examples/`
|
|
1913
|
+
if (!selectedOptions.length) return null;
|
|
206
1914
|
|
|
207
|
-
|
|
1915
|
+
return (
|
|
1916
|
+
<TagGroup size={EComponentSize.SM}>
|
|
1917
|
+
{selectedOptions.map((opt) => (
|
|
1918
|
+
<Tag
|
|
1919
|
+
key={opt.id}
|
|
1920
|
+
id={`tag-${opt.id}`}
|
|
1921
|
+
size={EComponentSize.SM}
|
|
1922
|
+
onFocus={handleTagFocus}
|
|
1923
|
+
onBlur={handleTagBlur}
|
|
1924
|
+
onClick={handleTagClick}
|
|
1925
|
+
onKeyDown={handleTagKeyDown}
|
|
1926
|
+
onRemove={() => handleToggle(opt, false)}
|
|
1927
|
+
>
|
|
1928
|
+
{opt.label}
|
|
1929
|
+
</Tag>
|
|
1930
|
+
))}
|
|
1931
|
+
</TagGroup>
|
|
1932
|
+
);
|
|
1933
|
+
};
|
|
208
1934
|
|
|
209
|
-
|
|
1935
|
+
return (
|
|
1936
|
+
<MultiselectField
|
|
1937
|
+
renderTarget={(props) => (
|
|
1938
|
+
<MultiselectField.Target
|
|
1939
|
+
{...props}
|
|
1940
|
+
ref={targetRef}
|
|
1941
|
+
size={EComponentSize.MD}
|
|
1942
|
+
onClear={handleClearAll}
|
|
1943
|
+
fieldLabel="Label"
|
|
1944
|
+
placeholder="Select to proceed"
|
|
1945
|
+
label={renderTags()}
|
|
1946
|
+
prefix={<DefaulticonStrokePrdIcon20 paletteIndex={5} />}
|
|
1947
|
+
postfix={<HelpBox tooltipSize={ETooltipSize.SM}>HelpBox text</HelpBox>}
|
|
1948
|
+
/>
|
|
1949
|
+
)}
|
|
1950
|
+
>
|
|
1951
|
+
{({ opened, setOpened, targetRef: dropdownTargetRef, dropdownRef }) => (
|
|
1952
|
+
<MultiselectField.Dropdown
|
|
1953
|
+
opened={opened}
|
|
1954
|
+
setOpened={setOpened}
|
|
1955
|
+
targetRef={dropdownTargetRef}
|
|
1956
|
+
ref={dropdownRef}
|
|
1957
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
1958
|
+
mobileViewProps={{
|
|
1959
|
+
children: (
|
|
1960
|
+
<>
|
|
1961
|
+
<DropdownMobileHeader
|
|
1962
|
+
controlButtons={<DropdownMobileClose onClick={() => setOpened(false)} />}
|
|
1963
|
+
>
|
|
1964
|
+
<DropdownMobileInput
|
|
1965
|
+
autoFocus={true}
|
|
1966
|
+
placeholder="Type to proceed"
|
|
1967
|
+
value={filter}
|
|
1968
|
+
onChange={(event) => setFilter(event.target.value)}
|
|
1969
|
+
/>
|
|
1970
|
+
</DropdownMobileHeader>
|
|
1971
|
+
<DropdownMobileBody>
|
|
1972
|
+
{renderCheckboxList()}
|
|
1973
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
1974
|
+
<Text size={ETextSize.B3} className="not-found">
|
|
1975
|
+
Nothing was found.
|
|
1976
|
+
</Text>
|
|
1977
|
+
)}
|
|
1978
|
+
</DropdownMobileBody>
|
|
1979
|
+
<DropdownMobileFooter>
|
|
1980
|
+
<Button
|
|
1981
|
+
theme={EButtonTheme.SECONDARY}
|
|
1982
|
+
size={EComponentSize.MD}
|
|
1983
|
+
onClick={() => setOpened(false)}
|
|
1984
|
+
>
|
|
1985
|
+
Button text
|
|
1986
|
+
</Button>
|
|
1987
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.MD} onClick={handleClearAll}>
|
|
1988
|
+
Button link text
|
|
1989
|
+
</Button>
|
|
1990
|
+
</DropdownMobileFooter>
|
|
1991
|
+
</>
|
|
1992
|
+
),
|
|
1993
|
+
}}
|
|
1994
|
+
>
|
|
1995
|
+
<MultiselectField.Dropdown.Header>
|
|
1996
|
+
<FormField size={EComponentSize.SM}>
|
|
1997
|
+
<FormFieldLabel>Type to proceed</FormFieldLabel>
|
|
1998
|
+
<FormFieldInput value={filter} onChange={(event) => setFilter(event.target.value)} />
|
|
1999
|
+
<FormFieldPostfix>
|
|
2000
|
+
<FormFieldClear onClick={() => setFilter("")} />
|
|
2001
|
+
</FormFieldPostfix>
|
|
2002
|
+
</FormField>
|
|
2003
|
+
</MultiselectField.Dropdown.Header>
|
|
2004
|
+
<MultiselectField.Dropdown.Content>
|
|
2005
|
+
{renderCheckboxList()}
|
|
2006
|
+
{!!filter.trim().length && !visibleOptions.length && (
|
|
2007
|
+
<div className="not-found md">
|
|
2008
|
+
<Text size={ETextSize.B3}>Nothing was found.</Text>
|
|
2009
|
+
</div>
|
|
2010
|
+
)}
|
|
2011
|
+
</MultiselectField.Dropdown.Content>
|
|
2012
|
+
<MultiselectField.Dropdown.Footer>
|
|
2013
|
+
<Button
|
|
2014
|
+
theme={EButtonTheme.SECONDARY}
|
|
2015
|
+
size={EComponentSize.SM}
|
|
2016
|
+
onClick={() => setOpened(false)}
|
|
2017
|
+
>
|
|
2018
|
+
Button text
|
|
2019
|
+
</Button>
|
|
2020
|
+
<Button theme={EButtonTheme.LINK} size={EComponentSize.SM} onClick={handleClearAll}>
|
|
2021
|
+
Button link text
|
|
2022
|
+
</Button>
|
|
2023
|
+
</MultiselectField.Dropdown.Footer>
|
|
2024
|
+
</MultiselectField.Dropdown>
|
|
2025
|
+
)}
|
|
2026
|
+
</MultiselectField>
|
|
2027
|
+
);
|
|
2028
|
+
};
|
|
2029
|
+
```
|
|
2030
|
+
|
|
2031
|
+
### WithCheckboxTree
|
|
2032
|
+
|
|
2033
|
+
`stories/MultiselectField/examples/WithCheckboxTreeExample.tsx`
|
|
2034
|
+
|
|
2035
|
+
```tsx
|
|
2036
|
+
import React, { useMemo, useState } from "react";
|
|
2037
|
+
import {
|
|
2038
|
+
Button,
|
|
2039
|
+
CheckboxTreeExtended,
|
|
2040
|
+
EButtonTheme,
|
|
2041
|
+
EComponentSize,
|
|
2042
|
+
FormField,
|
|
2043
|
+
FormFieldClear,
|
|
2044
|
+
FormFieldInput,
|
|
2045
|
+
FormFieldLabel,
|
|
2046
|
+
FormFieldPostfix,
|
|
2047
|
+
MultiselectField,
|
|
2048
|
+
Tag,
|
|
2049
|
+
TagGroup,
|
|
2050
|
+
Text,
|
|
2051
|
+
ETextSize,
|
|
2052
|
+
} from "@sberbusiness/triplex-next";
|
|
2053
|
+
import "../MultiselectField.less";
|
|
2054
|
+
|
|
2055
|
+
interface INode {
|
|
2056
|
+
id: string;
|
|
2057
|
+
label: string;
|
|
2058
|
+
children?: INode[];
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/** Генерация исходного дерева чекбоксов для примера. */
|
|
2062
|
+
const createNodes = (): INode[] => [
|
|
2063
|
+
{
|
|
2064
|
+
id: "multiselect-option-0",
|
|
2065
|
+
label: "Все",
|
|
2066
|
+
children: [
|
|
2067
|
+
{
|
|
2068
|
+
id: "multiselect-option-1",
|
|
2069
|
+
label: "Группа 1",
|
|
2070
|
+
children: [...Array(5).keys()].map((item) => ({
|
|
2071
|
+
id: `multiselect-option-1-${item}`,
|
|
2072
|
+
label: `Значение 1-${item}`,
|
|
2073
|
+
})),
|
|
2074
|
+
},
|
|
2075
|
+
{
|
|
2076
|
+
id: "multiselect-option-2",
|
|
2077
|
+
label: "Группа 2",
|
|
2078
|
+
children: [
|
|
2079
|
+
{ id: "multiselect-option-2-1", label: "Значение 2-1" },
|
|
2080
|
+
{ id: "multiselect-option-2-2", label: "Значение 2-2" },
|
|
2081
|
+
],
|
|
2082
|
+
},
|
|
2083
|
+
{ id: "multiselect-option-3", label: "Значение 3" },
|
|
2084
|
+
],
|
|
2085
|
+
},
|
|
2086
|
+
];
|
|
2087
|
+
|
|
2088
|
+
/** Получение всех leaf-id для ноды и ее потомков. */
|
|
2089
|
+
const getLeafIds = (node: INode): string[] => (node.children ? node.children.flatMap(getLeafIds) : [node.id]);
|
|
2090
|
+
|
|
2091
|
+
export const WithCheckboxTreeExample = () => {
|
|
2092
|
+
/** Мемоизированные исходные ноды дерева, чтобы не пересоздавать их на каждый рендер. */
|
|
2093
|
+
const nodes = useMemo(() => createNodes(), []);
|
|
2094
|
+
/** Массив id выбранных leaf-чекбоксов. */
|
|
2095
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
2096
|
+
/** Текущее значение фильтра в поле поиска dropdown. */
|
|
2097
|
+
const [filter, setFilter] = useState("");
|
|
2098
|
+
/** Set для быстрых O(1) проверок выбранности чекбокса по id. */
|
|
2099
|
+
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
2100
|
+
/** Переключение состояния ноды: выбирает/снимает все leaf-потомки. */
|
|
2101
|
+
const handleToggle = (node: INode, checked: boolean) => {
|
|
2102
|
+
const leafIds = getLeafIds(node);
|
|
2103
|
+
const leafSet = new Set(leafIds);
|
|
2104
|
+
|
|
2105
|
+
setSelectedIds((prev) => {
|
|
2106
|
+
if (checked) {
|
|
2107
|
+
return Array.from(new Set([...prev, ...leafIds]));
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
return prev.filter((id) => !leafSet.has(id));
|
|
2111
|
+
});
|
|
2112
|
+
};
|
|
2113
|
+
/** Проверка, подходит ли нода под фильтр сама или через дочерние элементы. */
|
|
2114
|
+
const doesNodeMatchFilter = (node: INode): boolean => {
|
|
2115
|
+
const lowerFilter = filter.trim().toLowerCase();
|
|
2116
|
+
if (!lowerFilter.length) return true;
|
|
2117
|
+
if (node.label.toLowerCase().includes(lowerFilter)) return true;
|
|
2118
|
+
return Boolean(node.children?.some(doesNodeMatchFilter));
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
/** Рекурсивный рендер ноды дерева с вычислением checked/bulk состояния. */
|
|
2122
|
+
const renderNode = (node: INode): React.ReactNode => {
|
|
2123
|
+
if (!doesNodeMatchFilter(node)) return null;
|
|
2124
|
+
|
|
2125
|
+
const leafIds = getLeafIds(node);
|
|
2126
|
+
const checkedCount = leafIds.filter((id) => selectedSet.has(id)).length;
|
|
2127
|
+
const checked = checkedCount > 0;
|
|
2128
|
+
const bulk = checked && checkedCount < leafIds.length;
|
|
2129
|
+
|
|
2130
|
+
return (
|
|
2131
|
+
<CheckboxTreeExtended.Node
|
|
2132
|
+
key={node.id}
|
|
2133
|
+
id={node.id}
|
|
2134
|
+
opened={Boolean(node.children)}
|
|
2135
|
+
checkbox={(props) => (
|
|
2136
|
+
<CheckboxTreeExtended.Checkbox
|
|
2137
|
+
{...props}
|
|
2138
|
+
checked={checked}
|
|
2139
|
+
bulk={bulk}
|
|
2140
|
+
onChange={(event) => handleToggle(node, event.target.checked)}
|
|
2141
|
+
>
|
|
2142
|
+
{" "}
|
|
2143
|
+
{node.label}
|
|
2144
|
+
</CheckboxTreeExtended.Checkbox>
|
|
2145
|
+
)}
|
|
2146
|
+
>
|
|
2147
|
+
{node.children?.map(renderNode)}
|
|
2148
|
+
</CheckboxTreeExtended.Node>
|
|
2149
|
+
);
|
|
2150
|
+
};
|
|
2151
|
+
|
|
2152
|
+
/** Плоский список выбранных leaf-нод для рендера тегов в target. */
|
|
2153
|
+
const selectedLeafNodes = nodes
|
|
2154
|
+
.flatMap(getLeafIds)
|
|
2155
|
+
.filter((id) => selectedSet.has(id))
|
|
2156
|
+
.map((id) => ({ id, label: id.replace("multiselect-option-", "Значение ") }));
|
|
2157
|
+
/** Рендер тегов выбранных значений или счетчика, если выбранных значений много. */
|
|
2158
|
+
const renderTags = () => {
|
|
2159
|
+
const handleTagFocus = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
2160
|
+
const handleTagBlur = (event: React.FocusEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
2161
|
+
const handleTagClick = (event: React.MouseEvent<HTMLSpanElement>) => event.stopPropagation();
|
|
2162
|
+
const handleTagKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
2163
|
+
if (event.code === "Enter" || event.code === "Space") {
|
|
2164
|
+
event.stopPropagation();
|
|
2165
|
+
}
|
|
2166
|
+
};
|
|
2167
|
+
|
|
2168
|
+
if (!selectedLeafNodes.length) return null;
|
|
2169
|
+
if (selectedLeafNodes.length > 3) {
|
|
2170
|
+
return (
|
|
2171
|
+
<Tag
|
|
2172
|
+
id="many"
|
|
2173
|
+
size={EComponentSize.SM}
|
|
2174
|
+
onFocus={handleTagFocus}
|
|
2175
|
+
onBlur={handleTagBlur}
|
|
2176
|
+
onClick={handleTagClick}
|
|
2177
|
+
onKeyDown={handleTagKeyDown}
|
|
2178
|
+
onRemove={() => setSelectedIds([])}
|
|
2179
|
+
>
|
|
2180
|
+
{`Выбрано ${selectedLeafNodes.length} значения`}
|
|
2181
|
+
</Tag>
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
2184
|
+
return (
|
|
2185
|
+
<TagGroup size={EComponentSize.SM}>
|
|
2186
|
+
{selectedLeafNodes.map((node) => (
|
|
2187
|
+
<Tag
|
|
2188
|
+
key={node.id}
|
|
2189
|
+
id={`tag-${node.id}`}
|
|
2190
|
+
size={EComponentSize.SM}
|
|
2191
|
+
onFocus={handleTagFocus}
|
|
2192
|
+
onBlur={handleTagBlur}
|
|
2193
|
+
onClick={handleTagClick}
|
|
2194
|
+
onKeyDown={handleTagKeyDown}
|
|
2195
|
+
onRemove={() => setSelectedIds((prev) => prev.filter((id) => id !== node.id))}
|
|
2196
|
+
>
|
|
2197
|
+
{node.label}
|
|
2198
|
+
</Tag>
|
|
2199
|
+
))}
|
|
2200
|
+
</TagGroup>
|
|
2201
|
+
);
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
return (
|
|
2205
|
+
<div style={{ maxWidth: "320px" }}>
|
|
2206
|
+
<MultiselectField
|
|
2207
|
+
renderTarget={(props) => (
|
|
2208
|
+
<MultiselectField.Target
|
|
2209
|
+
{...props}
|
|
2210
|
+
size={EComponentSize.MD}
|
|
2211
|
+
fieldLabel="Label"
|
|
2212
|
+
placeholder="Select to proceed"
|
|
2213
|
+
label={renderTags()}
|
|
2214
|
+
/>
|
|
2215
|
+
)}
|
|
2216
|
+
>
|
|
2217
|
+
{({ opened, setOpened, dropdownRef, targetRef }) => (
|
|
2218
|
+
<MultiselectField.Dropdown
|
|
2219
|
+
opened={opened}
|
|
2220
|
+
setOpened={setOpened}
|
|
2221
|
+
targetRef={targetRef}
|
|
2222
|
+
ref={dropdownRef}
|
|
2223
|
+
focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
|
|
2224
|
+
>
|
|
2225
|
+
<MultiselectField.Dropdown.Header>
|
|
2226
|
+
<FormField size={EComponentSize.SM}>
|
|
2227
|
+
<FormFieldLabel>Type to proceed</FormFieldLabel>
|
|
2228
|
+
<FormFieldInput value={filter} onChange={(event) => setFilter(event.target.value)} />
|
|
2229
|
+
<FormFieldPostfix>
|
|
2230
|
+
<FormFieldClear onClick={() => setFilter("")} />
|
|
2231
|
+
</FormFieldPostfix>
|
|
2232
|
+
</FormField>
|
|
2233
|
+
</MultiselectField.Dropdown.Header>
|
|
2234
|
+
<MultiselectField.Dropdown.Content>
|
|
2235
|
+
<CheckboxTreeExtended>{nodes.map(renderNode)}</CheckboxTreeExtended>
|
|
2236
|
+
{!!filter.length && !nodes.some(doesNodeMatchFilter) && (
|
|
2237
|
+
<Text size={ETextSize.B3}>Nothing was found.</Text>
|
|
2238
|
+
)}
|
|
2239
|
+
</MultiselectField.Dropdown.Content>
|
|
2240
|
+
|
|
2241
|
+
<MultiselectField.Dropdown.Footer>
|
|
2242
|
+
<Button
|
|
2243
|
+
theme={EButtonTheme.SECONDARY}
|
|
2244
|
+
size={EComponentSize.SM}
|
|
2245
|
+
onClick={() => setOpened(false)}
|
|
2246
|
+
>
|
|
2247
|
+
Button text
|
|
2248
|
+
</Button>
|
|
2249
|
+
<Button
|
|
2250
|
+
theme={EButtonTheme.LINK}
|
|
2251
|
+
size={EComponentSize.SM}
|
|
2252
|
+
onClick={() => setSelectedIds([])}
|
|
2253
|
+
>
|
|
2254
|
+
Button link text
|
|
2255
|
+
</Button>
|
|
2256
|
+
</MultiselectField.Dropdown.Footer>
|
|
2257
|
+
</MultiselectField.Dropdown>
|
|
2258
|
+
)}
|
|
2259
|
+
</MultiselectField>
|
|
2260
|
+
</div>
|
|
2261
|
+
);
|
|
2262
|
+
};
|
|
2263
|
+
```
|
|
210
2264
|
|
|
211
|
-
| Дата | Изменение |
|
|
212
|
-
|---|---|
|
|
213
|
-
| 2026-04-18 | Создан документ |
|