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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +7 -6
  2. package/bundle/components/AlertContext.md +11 -8
  3. package/bundle/components/AlertProcess.md +13 -10
  4. package/bundle/components/Badge.md +6 -3
  5. package/bundle/components/Button.md +46 -43
  6. package/bundle/components/ButtonDropdownExtended.md +3 -2
  7. package/bundle/components/ButtonIcon.md +7 -4
  8. package/bundle/components/Calendar.md +62 -59
  9. package/bundle/components/Caption.md +26 -23
  10. package/bundle/components/CardAction.md +615 -0
  11. package/bundle/components/Checkbox.md +32 -29
  12. package/bundle/components/CheckboxTreeExtended.md +9 -5
  13. package/bundle/components/Chip.md +51 -47
  14. package/bundle/components/ChipMonthYearPicker.md +344 -0
  15. package/bundle/components/ChipMultiselect.md +1251 -0
  16. package/bundle/components/ChipOptions.md +329 -0
  17. package/bundle/components/ChipSort.md +333 -0
  18. package/bundle/components/ChipSuggest.md +587 -0
  19. package/bundle/components/CodeText.md +26 -23
  20. package/bundle/components/Col.md +1 -2
  21. package/bundle/components/CollapsibleTree.md +8 -5
  22. package/bundle/components/DateField.md +222 -303
  23. package/bundle/components/DatePickerExtended.md +3 -1
  24. package/bundle/components/Dropdown.md +33 -28
  25. package/bundle/components/EmptyView.md +12 -5
  26. package/bundle/components/FooterPage.md +6 -2
  27. package/bundle/components/FormField.md +47 -42
  28. package/bundle/components/FormGroup.md +192 -52
  29. package/bundle/components/Header.md +491 -211
  30. package/bundle/components/HeaderPage.md +6 -2
  31. package/bundle/components/HelpBox.md +7 -1
  32. package/bundle/components/ImageGallery.md +33 -34
  33. package/bundle/components/ImageGalleryExtended.md +33 -34
  34. package/bundle/components/Island.md +16 -13
  35. package/bundle/components/IslandAccordion.md +443 -152
  36. package/bundle/components/IslandWidget.md +622 -157
  37. package/bundle/components/LightBox.md +14 -12
  38. package/bundle/components/Link.md +211 -96
  39. package/bundle/components/ListActionItem.md +7 -4
  40. package/bundle/components/ListItem.md +11 -8
  41. package/bundle/components/ListItemControlsButton.md +16 -13
  42. package/bundle/components/ListItemControlsButtonDropdown.md +9 -6
  43. package/bundle/components/ListItemSelectable.md +7 -4
  44. package/bundle/components/ListMasterFooter.md +8 -5
  45. package/bundle/components/ListMasterHeader.md +8 -5
  46. package/bundle/components/ListSortableItem.md +11 -8
  47. package/bundle/components/ListTableItem.md +7 -4
  48. package/bundle/components/LoaderMiddle.md +7 -4
  49. package/bundle/components/LoaderScreen.md +11 -8
  50. package/bundle/components/LoaderSmall.md +7 -4
  51. package/bundle/components/Marker.md +184 -69
  52. package/bundle/components/MarkerStatus.md +206 -88
  53. package/bundle/components/MaskedField.md +4 -5
  54. package/bundle/components/MasterTable.md +602 -0
  55. package/bundle/components/ModalWindow.md +7 -4
  56. package/bundle/components/MonthYearField.md +3 -1
  57. package/bundle/components/MultiselectField.md +2183 -132
  58. package/bundle/components/Notification.md +12 -9
  59. package/bundle/components/Overlay.md +9 -4
  60. package/bundle/components/Page.md +2 -2
  61. package/bundle/components/Pagination.md +15 -8
  62. package/bundle/components/Row.md +1 -1
  63. package/bundle/components/SelectExtendedField.md +3 -3
  64. package/bundle/components/SelectField.md +466 -148
  65. package/bundle/components/SliderExtended.md +39 -36
  66. package/bundle/components/SmallInput.md +194 -51
  67. package/bundle/components/Suggest.md +535 -99
  68. package/bundle/components/SwipeableArea.md +428 -79
  69. package/bundle/components/TableBasic.md +19 -11
  70. package/bundle/components/TabsExtended.md +24 -21
  71. package/bundle/components/Tag.md +6 -2
  72. package/bundle/components/Text.md +28 -25
  73. package/bundle/components/TextField.md +3 -4
  74. package/bundle/components/ThemeProvider.md +50 -1
  75. package/bundle/components/Title.md +26 -23
  76. package/bundle/components/Tooltip.md +24 -21
  77. package/bundle/components/TopOverlay.md +7 -4
  78. package/bundle/guides/codestyle.md +3 -0
  79. package/bundle/guides/context.md +84 -12
  80. package/bundle/guides/guidelines.md +321 -0
  81. package/bundle/guides/template.md +22 -12
  82. package/bundle/manifest.json +75 -17
  83. package/bundle/release-notes/1.45.0.mdx +117 -0
  84. package/bundle/release-notes/1.46.0.mdx +66 -0
  85. package/bundle/release-notes/1.47.0.mdx +7 -0
  86. package/bundle/tokens.json +6362 -0
  87. package/dist/index.js +177 -28
  88. package/dist/index.js.map +1 -1
  89. package/package.json +1 -1
@@ -1,10 +1,9 @@
1
1
  ---
2
2
  component: MultiselectField
3
- category: Form Controls
4
- figma-node: ""
5
- figma-file: ""
6
- related: [SelectExtendedField, SelectField, Dropdown, Checkbox, FormField]
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
- Контейнер для **мульти-выбора** из списка: `<SelectExtendedField>` с
17
- `<MultiselectField.Dropdown>` внутри. Сам компонент — **тонкая обёртка**,
18
- прокидывающая `size` через `MultiselectFieldContext` к дочерним слотам
19
- (Header/Content/Footer). Список опций (чекбоксы), кнопки применения,
20
- заголовок с поиском — реализуются потребителем в Header/Content/Footer.
15
+ Поле выбора нескольких значений: поле с тегами выбранного и выпадающий блок с
16
+ произвольным содержимым — списком чекбоксов, деревом, фильтром и кнопками
17
+ подтверждения.
21
18
 
22
- Используй когда: нужен выбор нескольких элементов с собственным dropdown'ом
23
- и UI-контролами (поиск, "выбрать все", apply). Для одиночного выбора —
24
- `SelectField`. Для более гибкого API — `SelectExtendedField` напрямую.
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
- src/components/MultiselectField/
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
- ## Compound-структура
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
- <MultiselectField
50
- size={EComponentSize.MD}
51
- opened={opened}
52
- onOpenedChange={setOpened}
53
- >
54
- <MultiselectField.Target>
55
- {/* Target — реэкспорт SelectExtendedFieldTarget */}
56
- </MultiselectField.Target>
57
-
58
- <MultiselectField.Dropdown>
59
- <MultiselectField.Dropdown.Header>
60
- {/* Поиск, "Выбрать все" */}
61
- </MultiselectField.Dropdown.Header>
62
-
63
- <MultiselectField.Dropdown.Content loading={loading}>
64
- {items.map((it) => (
65
- <Checkbox key={it.id} checked={...} onChange={...}>
66
- {it.label}
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
- </MultiselectField.Dropdown.Content>
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
- <MultiselectField.Dropdown.Footer>
72
- <Button>Применить</Button>
73
- </MultiselectField.Dropdown.Footer>
74
- </MultiselectField.Dropdown>
75
- </MultiselectField>
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
- `MultiselectField.Target` **= реэкспорт** `SelectExtendedFieldTarget` (не собственный компонент). Используй как псевдоним.
460
+ ### Sizes
79
461
 
80
- ---
462
+ `stories/MultiselectField/examples/SizesExample.tsx`
81
463
 
82
- ## Варианты и props
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
- ### MultiselectField — `IMultiselectFieldProps extends ISelectExtendedFieldProps`
485
+ interface IOption {
486
+ id: string;
487
+ label: string;
488
+ }
85
489
 
86
- | Prop | Тип | Default | Описание |
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
- ### MultiselectField.Dropdown — `IMultiselectFieldDropdownProps extends IDropdownProps`
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
- | Prop | Тип | Default | Описание |
94
- |---|---|---|---|
95
- | `focusTrapProps` | `FocusTrapProps` | — | Переопределение `focus-trap-react`. |
96
- | `opened`, `targetRef`, `mobileViewProps`, `...IDropdownProps` | — | — | От `Dropdown`. |
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
- - **`width` захардкожен `EDropdownWidth.MIN_TARGET`** — dropdown всегда минимум как target. Не переопределяется.
99
- - **`size` берётся из `MultiselectFieldContext`**, не от prop'а.
511
+ /** Нормализация размера кнопок для footer в зависимости от размера поля. */
512
+ const getButtonSize = (size: EComponentSize) => (size === EComponentSize.LG ? EComponentSize.MD : EComponentSize.SM);
100
513
 
101
- ### MultiselectField.Dropdown.Header / Footer / Content — `HTMLAttributes<HTMLDivElement>`
514
+ interface ISizeItemProps {
515
+ size: EComponentSize;
516
+ }
102
517
 
103
- - Header / Footer — простые `<div>`-слоты с собственной стилизацией (padding, border).
104
- - Content — `<div tabIndex={-1}>` с `sizeToClassNameMap[size]` для подстройки (scroll height, padding). Имеет prop **`loading: boolean`** — при true показывает `LoaderScreen type="small"` поверх контента.
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
- ### MultiselectFieldContext
535
+ return options.filter((opt) => opt.label.toLowerCase().includes(lower));
536
+ }, [filter, options]);
107
537
 
108
- ```ts
109
- interface IMultiselectFieldContext { size: EComponentSize; }
110
- ```
538
+ const selectedOptions = useMemo(() => options.filter((opt) => selectedSet.has(opt.id)), [options, selectedSet]);
111
539
 
112
- Публичная точка расширения: при создании кастомных элементов в Content можно читать `size` и адаптировать под него.
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
- ### `size` — через Context, не через prop на Dropdown
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
- <MultiselectFieldContext.Provider value={{ size }}>
122
- <SelectExtendedField {...props}>{children}</SelectExtendedField>
123
- </MultiselectFieldContext.Provider>
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
- - Dropdown и Content читают `size` из контекста.
127
- - **Dropdown переопределяет** `size` prop на Dropdown: `<Dropdown ... size={size} />`. То есть потребительский `size` на Dropdown **игнорируется** — всегда выигрывает context.
884
+ ### Loading
128
885
 
129
- ### `FocusTrap` внутри Dropdown
886
+ `stories/MultiselectField/examples/LoadingExample.tsx`
130
887
 
131
888
  ```tsx
132
- <FocusTrap focusTrapOptions={{ clickOutsideDeactivates: true, preventScroll: true, ... }}>
133
- <div role="presentation">{children}</div>
134
- </FocusTrap>
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
- - `clickOutsideDeactivates: true` — клик вне dropdown'а деактивирует trap и закрывает (стандарт для popover/selects).
138
- - Все потомки оборачиваются в `<div role="presentation">` — требование FocusTrap (нужен single wrapper-element).
1140
+ ### DropdownWithoutInput
139
1141
 
140
- ### `MultiselectField.Target = SelectExtendedFieldTarget`
1142
+ `stories/MultiselectField/examples/DropdownWithoutInputExample.tsx`
141
1143
 
142
1144
  ```tsx
143
- Object.assign(..., { Dropdown, Target: SelectExtendedFieldTarget })
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
- - **Это не собственная реализация**, а пересвет. Потребитель видит `.Target` в типах, но фактически это SelectExtendedField's target.
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
- ### Content `tabIndex={-1}`
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
- Чтобы скролл внутри Content был фокусируемым мышью/колесиком (и при этом не участвовал в Tab-последовательности). Для переключения между чекбоксами — TAB идёт по интерактивным детям.
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
- ### Loader — `LoaderScreen type="small"`
1547
+ `stories/MultiselectField/examples/WithPrefixAndPostfixExample.tsx`
153
1548
 
154
1549
  ```tsx
155
- {loading && <LoaderScreen type="small" className={styles.loaderScreen} />}
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
- - Маленький оверлей (см. LoaderScreen-AI).
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
- - **`size` через `MultiselectFieldContext`** — публичная точка расширения; не заменять на prop-drilling без решения по обратной совместимости.
166
- - **`MultiselectField.Target = SelectExtendedFieldTarget`** — псевдоним, не своя реализация. При рефакторинге SelectExtendedField — проверять, что Target совместим.
167
- - **`EDropdownWidth.MIN_TARGET`** захардкожен — ширина dropdown'а всегда ≥ ширины target'а.
168
- - **`FocusTrap clickOutsideDeactivates: true`** — контракт: клик вне → закрытие. При смене — потребуется явное `onOpenedChange`.
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
- ## Accessibility
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
- - FocusTrap удерживает фокус внутри dropdown'а → клавиатурная навигация между чекбоксами/кнопками корректна.
179
- - `role="presentation"` на wrapper'е FocusTrap — правильно: обёртка не несёт семантики.
180
- - **Нет `aria-multiselectable="true"` на dropdown'е** — потребителям для скринридеров имеет смысл добавить через `...rest` на Content / Dropdown.
181
- - Чекбоксы внутри Content — стандартный `<Checkbox>` с нативной keyboard-семантикой.
182
- - Target — `SelectExtendedFieldTarget` обеспечивает `aria-expanded` / `aria-haspopup="listbox"` (проверь в SelectExtendedField, если не — гэп).
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
- Нет собственных. Размеры/padding/цвет от `Dropdown`, `SelectExtendedField`, `Checkbox`.
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
- - `SelectExtendedField` / `SelectExtendedFieldTarget` — основа.
195
- - `Dropdown` — popover-container.
196
- - `Checkbox` — типичный элемент в Content.
197
- - `LoaderScreen` — loader в Content.
198
- - `focus-trap-react` — FocusTrap.
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
- ## Stories
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
- `stories/MultiselectField/MultiselectField.stories.tsx`
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 | Создан документ |