@sberbusiness/triplex-next-mcp-server 0.36.0-beta0 → 1.40.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 (35) hide show
  1. package/bundle/components/AlertContext.md +268 -0
  2. package/bundle/components/Button.md +5 -2
  3. package/bundle/components/ButtonDropdown.md +9 -1
  4. package/bundle/components/ButtonDropdownExtended.md +422 -0
  5. package/bundle/components/ButtonIcon.md +7 -1
  6. package/bundle/components/Caption.md +291 -0
  7. package/bundle/components/CodeText.md +197 -0
  8. package/bundle/components/Col.md +311 -162
  9. package/bundle/components/CollapsibleTree.md +6 -3
  10. package/bundle/components/CollapsibleTreeExtended.md +6 -2
  11. package/bundle/components/Dropdown.md +748 -346
  12. package/bundle/components/LightBox.md +2252 -233
  13. package/bundle/components/List.md +26 -1
  14. package/bundle/components/ListActionItem.md +157 -0
  15. package/bundle/components/ListItem.md +34 -9
  16. package/bundle/components/ListItemSelectable.md +2 -2
  17. package/bundle/components/ListMaster.md +4 -5
  18. package/bundle/components/ListTableItem.md +391 -0
  19. package/bundle/components/Notification.md +21 -21
  20. package/bundle/components/Page.md +1 -0
  21. package/bundle/components/Portal.md +131 -67
  22. package/bundle/components/Row.md +171 -69
  23. package/bundle/components/Text.md +320 -0
  24. package/bundle/components/TextField.md +364 -97
  25. package/bundle/components/Title.md +296 -0
  26. package/bundle/guides/commits.md +118 -24
  27. package/bundle/guides/context.md +1 -1
  28. package/bundle/guides/tests.md +42 -0
  29. package/bundle/manifest.json +89 -14
  30. package/bundle/release-notes/1.37.0.mdx +42 -1
  31. package/bundle/release-notes/1.38.0.mdx +36 -0
  32. package/bundle/release-notes/1.39.0.mdx +66 -0
  33. package/bundle/release-notes/1.40.0.mdx +27 -0
  34. package/bundle/release-notes/1.41.0.mdx +7 -0
  35. package/package.json +1 -1
@@ -1,10 +1,21 @@
1
1
  ---
2
2
  component: Dropdown
3
- category: Overlays
4
- figma-node: ""
5
- figma-file: ""
6
- related: [DropdownList, DropdownListItem, Portal, OverlayBase, MobileView, SelectField, Suggest, DatePickerExtended]
7
- tokens: [Dropdown_Background, DropdownList_*, DropdownMobile_*, DropdownMobileList_*]
3
+ category: Dropdown
4
+ related: [ButtonDropdown, ButtonDropdownExtended, Portal, MobileView, OverlayBase, SelectField, SelectExtendedField, SuggestField, MultiselectField, TabsLine]
5
+ tokens:
6
+ - --triplex-next-Dropdown-Background
7
+ - --triplex-next-Dropdown-Shadow
8
+ - --triplex-next-DropdownList-Color
9
+ - --triplex-next-DropdownList-Background_Default
10
+ - --triplex-next-DropdownList-Background_Active
11
+ - --triplex-next-DropdownList-Background_Selected
12
+ - --triplex-next-DropdownMobile-Backdrop
13
+ - --triplex-next-DropdownMobile-Header_Background
14
+ - --triplex-next-DropdownMobile-Content_Background
15
+ - --triplex-next-DropdownMobile-Footer_Background
16
+ - --triplex-next-DropdownMobile-Border_Color
17
+ - --triplex-next-DropdownMobileList-Active_Background
18
+ - --triplex-next-DropdownMobileList-Selected_Background
8
19
  stories: stories/Dropdown/Dropdown.stories.tsx
9
20
  version: "1.0"
10
21
  ---
@@ -13,421 +24,812 @@ version: "1.0"
13
24
 
14
25
  ## Назначение
15
26
 
16
- Основной overlay-компонент библиотеки для выпадающих меню. Renders в
17
- `Portal` на `document.body`, **автоматически разветвляется**:
18
- - **Desktop (`DropdownDesktop`)** — абсолютно позиционированный блок
19
- рядом с `targetRef` (top/bottom auto-flip, left/right alignment,
20
- ширина content/target/min-target), слушает window resize, document
21
- scroll и ResizeObserver для reposition; блокирует внешний скролл
22
- и клавиатурные события при открытии.
23
- - **Mobile (`DropdownMobile`)** — полноэкранный bottom-overlay
24
- (`OverlayBase direction=BOTTOM`), с композицией
25
- `Header`/`Body`/`Footer`/`Close`.
26
-
27
- Имеет внутренние compound-структуры:
28
- - **`DropdownList`** + `DropdownList.Item` — список с клавиатурной
29
- навигацией (↑/↓, scroll-into-view, active-descendant через
30
- `DropdownListContext`).
31
- - **`DropdownMobileList`** + `DropdownMobileListItem` — аналог для
32
- мобильной ветки.
33
- - **`DropdownMobileInput` / `DropdownMobileMaskedInput`** —
34
- специальные input'ы в header'е мобильного Dropdown'а.
35
-
36
- Используй когда: строишь любой выпадающий UI (Select, Suggest, Menu,
37
- DatePicker, custom dropdown). **В большинстве случаев** бери готовую
38
- обёртку (`SelectField`, `Suggest`, `DatePickerExtended`) — Dropdown
39
- прямо в UI встречается реже.
40
- Не используй как: абсолютный "tooltip" — бери `Tooltip`; bottom sheet
41
- без target'а — `OverlayBase`/`SideOverlay`; модалку — `LightBox`/
42
- `ModalWindow`.
27
+ Выпадающее меню: рендерится через `Portal` в `document.body` и фиксированно позиционируется относительно управляющего элемента (`targetRef`). Содержимое произвольное — список опций, форма фильтра, любой блок. Если задан `mobileViewProps`, на мобильной ширине экрана (<768px) вместо десктопного меню открывается полноэкранная мобильная версия.
43
28
 
44
- ---
45
-
46
- ## Файловая структура
47
-
48
- ```text
49
- src/components/Dropdown/
50
- ├── Dropdown.tsx # forwardRef, разветвление MobileView
51
- ├── DropdownListContext.ts # Context { activeDescendant, setActiveDescendant }
52
- ├── index.ts # Barrel
53
- ├── desktop/
54
- │ ├── DropdownDesktop.tsx # forwardRef, positioning + scroll-block
55
- │ ├── DropdownList.tsx # FC, клавиатурная навигация, role="listbox"
56
- │ ├── DropdownListItem.tsx # ListItem (role="option", active, selected)
57
- │ ├── enums.ts # EDropdownDirection/Alignment/Width
58
- │ └── index.ts
59
- ├── mobile/
60
- │ ├── DropdownMobile.tsx # forwardRef, OverlayBase wrapper
61
- │ ├── DropdownMobileInner.tsx # Внутренняя разметка (animation, overflow)
62
- │ ├── DropdownMobileHeader.tsx # Header с title/controlButtons
63
- │ ├── DropdownMobileBody.tsx # Scrollable body
64
- │ ├── DropdownMobileFooter.tsx # Footer (sticky)
65
- │ ├── DropdownMobileClose.tsx # Кнопка закрытия
66
- │ ├── DropdownMobileList.tsx # List для мобильного
67
- │ ├── DropdownMobileListItem.tsx # Item для мобильного
68
- │ ├── DropdownMobileInput.tsx # Input в header
69
- │ ├── DropdownMobileMaskedInput.tsx # MaskedInput в header
70
- │ ├── DropdownMobileLoader.tsx # Loader
71
- │ └── index.ts
72
- └── styles/
73
- ├── DropdownDesktop.module.less
74
- ├── DropdownDesktopList.module.less
75
- ├── DropdownMobile.module.less
76
- ├── DropdownMobileInput.module.less
77
- └── DropdownMobileMaskedInput.module.less
78
- ```
29
+ Используй когда: нужно показать всплывающий блок, привязанный к элементу-триггеру, и при этом выйти за пределы контейнеров с `overflow: hidden`.
30
+ Не используй когда:
79
31
 
80
- Особенности:
81
- - **Portal → `document.body`** — всегда, независимо от desktop/mobile.
82
- - **Два параллельных дерева подкомпонентов** (desktop + mobile) с
83
- похожими именами, но разными props и поведением.
84
- - **`data-tx`** на desktop и mobile root.
85
- - **`useToken().scopeClassName`** на DropdownDesktop — получает
86
- namespace для CSS-переменных из ThemeProvider.
32
+ - нужна готовая кнопка со списком действий — используй `ButtonDropdown`;
33
+ - нужна кнопка с произвольным выпадающим блоком и закрытием по Escape / Tab / клику вне — используй `ButtonDropdownExtended` (он оборачивает `Dropdown` и добавляет эту логику);
34
+ - нужно поле выбора значения — используй `SelectField`, `SelectExtendedField`, `SuggestField`, `MultiselectField`.
87
35
 
88
36
  ---
89
37
 
90
38
  ## Варианты и props
91
39
 
92
- ### Root: `IDropdownProps extends IDropdownDesktopProps`
40
+ `Dropdown` расширяет `IDropdownDesktopProps`, а тот — `React.HTMLAttributes<HTMLDivElement>`. Все неизвестные компоненту атрибуты попадают на корневой `<div>` десктопного меню.
41
+
42
+ ### Обязательные props
93
43
 
94
44
  | Prop | Тип | Описание |
95
45
  |---|---|---|
96
- | `opened` | `boolean` | Открыт/закрыт. Controlled. |
97
- | `setOpened` | `(opened: boolean) => void` | Сеттер, вызываемый из outside-click / mobile close. |
98
- | `targetRef` | `RefObject<HTMLElement>` | Target для позиционирования (desktop) / точка привязки жеста. |
99
- | `size` | `EComponentSize` | `MD` default. |
100
- | `direction` | `EDropdownDirection` | `AUTO` \| `BOTTOM` \| `TOP` — desktop. |
101
- | `alignment` | `EDropdownAlignment` | `LEFT` \| `RIGHT` — desktop. |
102
- | `width` | `EDropdownWidth` | `CONTENT` \| `TARGET` \| `MIN_TARGET` — desktop. |
103
- | `onOpen` | `() => void` | Callback при открытии (после mount). |
104
- | `onClose` | `() => void` | Callback при закрытии. |
105
- | `mobileViewProps` | `Omit<IDropdownMobileProps, "opened" \| "setOpened">` | **Если задан** — на мобильном открывается полноэкранный DropdownMobile. Если не задан — на мобильном тот же desktop-дропдаун. |
106
- | `...HTMLDivAttributes` | — | — |
107
-
108
- ### `DropdownDesktop` props (также все → root Dropdown)
46
+ | `opened` | `boolean` | Состояние открытости. Компонент полностью управляемый: собственного state открытости нет |
47
+ | `setOpened` | `(opened: boolean) => void` | Изменение состояния открытости. Используется мобильной версией (закрытие по тапу на подложку); в десктопной версии не вызывается |
48
+ | `targetRef` | `React.RefObject<HTMLElement>` | Ссылка на управляющий элемент. От его `getBoundingClientRect()` считается положение меню |
109
49
 
110
- Все выше + `style`, `className`, `...HTMLDivAttributes`.
50
+ ### Опциональные props
111
51
 
112
- ### `DropdownMobile` props (через `mobileViewProps`)
52
+ | Prop | Тип | По умолчанию | Описание |
53
+ |---|---|---|---|
54
+ | `size` | `EComponentSize` | `EComponentSize.MD` | Размер меню (влияет на радиус скругления). Размер списка задаётся отдельно — через `size` у `DropdownList` |
55
+ | `direction` | `EDropdownDirection` | `AUTO` | Направление раскрытия. `AUTO` — вниз, а если снизу не хватает места и сверху места больше, то вверх |
56
+ | `alignment` | `EDropdownAlignment` | `LEFT` | Выравнивание относительно управляющего элемента. Если меню выходит за границу экрана, оно смещается внутрь области просмотра |
57
+ | `width` | `EDropdownWidth` | `CONTENT` | Расчёт ширины: по содержимому, равна ширине управляющего элемента (`TARGET`) или не уже неё (`MIN_TARGET`) |
58
+ | `onOpen` | `() => void` | — | Вызывается при переходе `opened` в `true`. На первом рендере не вызывается |
59
+ | `onClose` | `() => void` | — | Вызывается при переходе `opened` в `false`. На первом рендере не вызывается |
60
+ | `mobileViewProps` | `Omit<IDropdownMobileProps, "opened" \| "setOpened">` | — | Включает адаптивный режим. `mobileViewProps.children` заменяет `children` в мобильной версии; если не задан, используется `children` |
61
+ | `className` | `string` | — | Дополнительный класс. В десктопной версии — на корневом `<div>` меню, в мобильной — на wrapper'е оверлея |
62
+ | `style` | `React.CSSProperties` | — | Мержится **поверх** вычисленного положения, поэтому им можно перебить `top` / `left` / `width` |
113
63
 
114
- - `children` (может быть разный от desktop).
115
- - `className`.
116
- - `...HTMLDivAttributes`.
117
- - (opened, setOpened форвардятся из Dropdown.)
64
+ ### Список опций — `DropdownList` и `DropdownList.Item`
118
65
 
119
- ### `DropdownList` compound
66
+ `Dropdown` не знает про списки: клавиатурную навигацию и роли `listbox` / `option` добавляет `DropdownList`.
120
67
 
121
- | Prop | Тип | Default | Описание |
68
+ | Prop `DropdownList` | Тип | По умолчанию | Описание |
122
69
  |---|---|---|---|
123
- | `dropdownOpened` | `boolean` | — | Необходим для подписки на keydown только когда открыто. |
124
- | `listRef` | `RefObject<HTMLDivElement>` | — | Ссылка на скролл-контейнер. |
125
- | `loading` | `boolean` | — | Добавляет loader-item в конец списка. |
126
- | `size` | `EComponentSize` | `MD` | Влияет на классы. |
127
- | `children` | `DropdownList.Item` | — | Только Item'ы. |
128
-
129
- Статик: `DropdownList.Item = DropdownListItem`.
70
+ | `dropdownOpened` | `boolean` | — | Обязательный. Пока `true`, список слушает `ArrowUp` / `ArrowDown` на документе |
71
+ | `size` | `EComponentSize` | `MD` | Размер списка. Пробрасывается в каждый `DropdownList.Item` |
72
+ | `loading` | `boolean` | `false` | Добавляет в конец списка служебный элемент с лоадером |
73
+ | `listRef` | `React.RefObject<HTMLDivElement>` | — | Внешняя ссылка на контейнер списка. Если не передана, используется внутренняя |
130
74
 
131
- ### Enums
132
-
133
- **`EDropdownDirection`:**
134
- - `AUTO = "auto"` — сначала пытается снизу, если не влезает — сверху, иначе снизу.
135
- - `BOTTOM = "bottom"` — принудительно снизу.
136
- - `TOP = "top"` — принудительно сверху.
75
+ | Prop `DropdownList.Item` | Тип | По умолчанию | Описание |
76
+ |---|---|---|---|
77
+ | `id` | `string` | — | Обязательный и уникальный: используется как значение `aria-activedescendant` |
78
+ | `onSelect` | `() => void` | — | Выбор элемента: по клику и по клавишам из `keyCodesForSelection`, когда элемент активен |
79
+ | `selected` | `boolean` | `false` | Элемент выбран: подсветка + `aria-selected`. При открытии меню активным становится именно он |
80
+ | `active` | `boolean` | — | Активен при навигации с клавиатуры. **Проставляется самим `DropdownList`** — снаружи задавать не нужно |
81
+ | `keyCodesForSelection` | `number[]` | `[SPACE, ENTER]` | Числовые `keyCode` клавиш выбора |
82
+ | `showNotificationIcon` | `boolean` | `false` | Значок новых уведомлений (`Badge.Dot`) в углу элемента |
137
83
 
138
- **`EDropdownAlignment`:**
139
- - `LEFT = "left"` — левая граница dropdown'а = левая target'а.
140
- - `RIGHT = "right"` — правая граница dropdown'а = правая target'а.
84
+ ### Ограничения
141
85
 
142
- **`EDropdownWidth`:**
143
- - `CONTENT = "content"` — по ширине контента.
144
- - `TARGET = "target"` — `width = targetRect.width`.
145
- - `MIN_TARGET = "min-target"` — `minWidth = targetRect.width`.
86
+ - `DropdownList` в качестве `children` принимает только `DropdownList.Item` — остальные узлы рендерятся как есть, но не участвуют в навигации и нумерации.
87
+ - `DropdownList` — не `forwardRef`-компонент: ссылка на контейнер списка передаётся через prop `listRef`.
88
+ - Закрытие по `Escape`, `Tab` и клику вне меню `Dropdown` **не реализует**. Это ответственность потребителя либо `ButtonDropdownExtended`.
89
+ - Мобильная версия строится на `OverlayBase` и имеет собственный набор субкомпонентов (`DropdownMobileHeader`, `DropdownMobileBody`, `DropdownMobileList` и т.д.), которые передаются через `mobileViewProps.children`.
146
90
 
147
91
  ---
148
92
 
149
- ## Ключевые особенности реализации
150
-
151
- ### Portal → document.body
93
+ ## Дизайн-токены
152
94
 
153
- ```tsx
154
- <Portal container={document.body}>...</Portal>
155
95
  ```
156
-
157
- Dropdown всегда рендерится в `body`, чтобы не зависеть от `overflow:hidden`
158
- родителей и z-index-стеков. Минус: вычисление позиции в window-координатах,
159
- а не относительно родителя.
160
-
161
- ### MobileView разветвление — только при `mobileViewProps`
162
-
163
- ```tsx
164
- {mobileViewProps ? (
165
- <MobileView fallback={<DropdownDesktop .../>}>
166
- <DropdownMobile>{mobileViewProps?.children || children}</DropdownMobile>
167
- </MobileView>
168
- ) : (
169
- <DropdownDesktop>{children}</DropdownDesktop>
170
- )}
96
+ --triplex-next-Dropdown-Background
97
+ --triplex-next-Dropdown-Shadow
98
+ --triplex-next-DropdownList-Color
99
+ --triplex-next-DropdownList-Background_Default
100
+ --triplex-next-DropdownList-Background_Active
101
+ --triplex-next-DropdownList-Background_Selected
102
+ --triplex-next-DropdownMobile-Backdrop
103
+ --triplex-next-DropdownMobile-Header_Background
104
+ --triplex-next-DropdownMobile-Content_Background
105
+ --triplex-next-DropdownMobile-Footer_Background
106
+ --triplex-next-DropdownMobile-Border_Color
107
+ --triplex-next-DropdownMobileList-Active_Background
108
+ --triplex-next-DropdownMobileList-Selected_Background
171
109
  ```
172
110
 
173
- **Если `mobileViewProps` не передан — даже на мобильном будет
174
- DropdownDesktop.** Это разумно для меню (просто маленький dropdown),
175
- но для полноэкранных селектов требуется явный `mobileViewProps`.
176
-
177
- ### Desktop: позиционирование через `getBoundingClientRect`
111
+ `DropdownMobileInput` дополнительно использует токены поля ввода: `--triplex-next-FormField-Input_Color_Default`, `--triplex-next-FormField-Placeholder_Color`.
178
112
 
179
- ```ts
180
- const setPosition = useCallback(() => {
181
- const dropdownRect = dropdownRef.current.getBoundingClientRect();
182
- const targetRect = targetRef.current.getBoundingClientRect();
183
- // ...
184
- setStyleState(css); // { top/bottom, left, width?, minWidth? }
185
- }, [...]);
186
- ```
113
+ ---
187
114
 
188
- **`direction=AUTO`:** сначала пробует low (bottom), если не влезает
189
- → top, если тоже не влезает → bottom (скролл). 4px offset между
190
- target и dropdown (`offset = 4`).
115
+ ## Инварианты
191
116
 
192
- **`alignment=LEFT`:** dropdown.left = target.left, если влезает вправо;
193
- иначе target.right - dropdown.width (с `Math.max(0, ...)`).
194
- **`alignment=RIGHT`:** зеркально.
117
+ - `forwardRef` на `Dropdown`, `DropdownDesktop`, `DropdownListItem` и всех мобильных субкомпонентах — не убирать. Ref `Dropdown` указывает на корневой `<div>` десктопного меню, а в мобильном режиме — на контентный `<div>` (`.dropdownMobile`).
118
+ - Меню в закрытом состоянии не рендерит ничего (`DropdownDesktop` возвращает `null`) — на это опираются потребители, монтирующие `Dropdown` безусловно.
119
+ - Компонент управляемый: не добавлять внутренний state открытости, иначе сломается контракт `opened` / `setOpened`.
120
+ - `Dropdown` рендерится в `document.body` через `Portal`. Смена контейнера портала — breaking change для потребителей, которые ищут меню в DOM.
121
+ - Публичные barrel-экспорты `src/components/Dropdown/index.ts`, `desktop/index.ts`, `mobile/index.ts` должны сохраняться: компонент используется внутри `ButtonDropdown`, `SelectField`, `SelectExtendedField`, `SuggestField`, `MultiselectField`, `TabsLine`, `DatePickerExtended`, `Chip`, `List`.
122
+ - `DropdownList` проставляет дочерним элементам `active`, `size`, `ref`, `onMouseOver` и `onMouseOut` через `cloneElement` — эти props у `DropdownList.Item` нельзя считать «пользовательскими».
123
+ - Пока десктопное меню открыто, на `document.body` висит класс блокировки скролла, а на документе — обработчики `wheel` и `keydown`. Снятие обработчиков в cleanup эффекта обязательно.
124
+ - Числовой формат `keyCodesForSelection` (`number[]`, а не `KeyboardEvent.code`) — часть публичного API.
125
+ - Хелперы из `desktop/utils.ts` внутренние: не экспортировать их через barrel.
126
+ - У мобильного списка (`DropdownMobileList`) нет собственного класса-зацепки — в отличие от десктопного `.dropdownDesktopList`. Это осознанно: в `styles/DropdownMobile.module.less` такого класса никогда не было, стилизуются только элементы списка и лоадер. Корневой `<div>` получает только `className` снаружи; не добавлять пустое правило в LESS ради зацепки без задачи.
127
+ - `listRef` у `DropdownList` и `setActiveDescendant` из `DropdownListContext` должны быть стабильными между рендерами (ref из `useRef`, сеттер из `useState`). Нестабильные значения переподписывают клавиатурный обработчик и сбрасывают `aria-activedescendant` на каждый рендер.
195
128
 
196
- ### Desktop: блокировка скролла и клавиш при открытии
129
+ ---
197
130
 
198
- ```ts
199
- document.addEventListener("wheel", wheelHandler, { passive: false });
200
- document.addEventListener("keydown", keyDownHandler);
201
- document.body.classList.add(overflowHiddenClassName);
202
- ```
131
+ ## Accessibility
203
132
 
204
- - **`wheelHandler`:** preventDefault колеса, если target не внутри
205
- dropdown'а. Эффект: страница не скроллится, но dropdown (если в нём
206
- есть scroll) — скроллится.
207
- - **`keyDownHandler`:** preventDefault на Space/PageUp/PageDown/End/Home/
208
- Arrows, **но только если `event.target === document.body`**. Это
209
- защита от "прыжка страницы", когда ни одно поле не сфокусировано.
210
- Если фокус в input'е — клавиши работают нормально.
211
- - **`body.classList.add(overflowHiddenClassName)`:** визуально
212
- "замораживает" страницу.
213
-
214
- **Эффект отписки:** `removeEventListener` + `classList.remove` —
215
- обязательны. Memory leak и залипший overflow без них.
216
-
217
- ### Desktop: ResizeObserver + scroll/resize listeners
218
-
219
- ```ts
220
- const observer = new ResizeObserver(setPosition);
221
- observer.observe(dropdownRef.current);
222
- observer.observe(targetRef.current);
223
- // + document.addEventListener("scroll", updatePosition, true);
224
- // + window.addEventListener("resize", updatePosition);
225
- ```
133
+ - `DropdownList` рендерит контейнер с `role="listbox"`, `DropdownList.Item` — `role="option"` и `aria-selected`. Мобильные `DropdownMobileList` / `DropdownMobileListItem` — те же роли.
134
+ - Клавиатурная навигация по списку: `ArrowDown` / `ArrowUp` перемещают активный элемент по кругу и прокручивают контейнер к нему, `Enter` / `Space` (или клавиши из `keyCodesForSelection`) вызывают `onSelect` активного элемента. Обработчики висят на `document`, пока `dropdownOpened === true`.
135
+ - При открытии активным становится элемент с `selected`, иначе первый; список прокручивается к нему.
136
+ - Пока меню открыто, `DropdownDesktop` гасит клавиши прокрутки страницы (`Space`, `PageUp`, `PageDown`, `Home`, `End`, стрелки), но только если фокус на `document.body`.
137
+ - `aria-activedescendant` компонент сам на триггер **не выставляет**: `DropdownList` публикует id активного элемента через `DropdownListContext.setActiveDescendant`, а связать его с триггером должен потребитель (см. `SelectField`, `ButtonDropdown`). Контекст сбрасывается при закрытии и размонтировании списка.
138
+ - Потребитель отвечает за атрибуты триггера: `aria-haspopup`, `aria-expanded`, `aria-controls`, `aria-activedescendant`.
139
+ - Компонент не хардкодит текстовые строки: `aria-label` для `DropdownMobileClose` и заголовки мобильной версии передаёт потребитель (библиотека мультиязычная).
140
+ - **Известное ограничение:** при уходе курсора с элемента списка (`onMouseOut`) подсветка снимается, но `activeDescendant` в контексте не сбрасывается — `aria-activedescendant` на триггере продолжает указывать на последний наведённый пункт до следующей смены активного элемента или закрытия меню. Поведение унаследованное; его исправление меняет наблюдаемый контракт для `SelectField` / `ButtonDropdown` / `SuggestField` и требует отдельного решения.
141
+ - Фокус компонент не перехватывает и не возвращает. Если нужен focus trap, его добавляет потребитель (так делает `MultiselectField.Dropdown`).
226
142
 
227
- **Три источника изменений:**
228
- 1. **ResizeObserver** — dropdown или target изменили размер (загрузка
229
- данных).
230
- 2. **`scroll` с `capture=true`** — любой предок target'а скроллится
231
- → dropdown должен следовать.
232
- 3. **`resize`** — окно поменяло размер.
143
+ ---
233
144
 
234
- `updatePosition = () => setTimeout(setPosition)` — пересчёт в следующем
235
- кадре, чтобы дать браузеру layout'нуть.
145
+ ## Связанные компоненты
236
146
 
237
- ### Desktop: `{opacity: 0}` при закрытии
147
+ Отдельного AI.md пока нет ни у одного субкомпонента семейства — они описаны здесь.
148
+ Это область задачи TRI-35 («Scope: только Dropdown; соседние экспортируемые компоненты
149
+ из `src/components/Dropdown/` — отдельные задачи»), а не вывод по критериям
150
+ `docs/ai/CONTEXT.md` → «Когда создавать `{ComponentName}-ai.md`». По этим критериям
151
+ `DropdownList`, `DropdownListItem`, `DropdownMobile` и `DropdownMobileListItem`
152
+ заслуживают собственных AI.md и отдельных строк в `docs/ai/ROADMAP.md` — как это
153
+ сделано для семейства `List`. Решение о том, заводить ли их, за владельцем репозитория.
238
154
 
239
- Первый рендер — `{opacity: 0}`, пока `setPosition` не отработал.
240
- После первого layout — setStyleState с рассчитанными top/left/width.
241
- Это **анти-flicker трюк**: если dropdown-ом монтируется и **рендерится
242
- первым кадром в (0, 0)**, юзер увидит скачок. `opacity: 0` скрывает
243
- эту микросекунду.
155
+ **Десктоп (`src/components/Dropdown/desktop/`):**
244
156
 
245
- ### Desktop: `if (!opened) return null`
157
+ - `DropdownDesktop` — десктопное меню: позиционирование, блокировка скролла страницы. `Dropdown` рендерит его напрямую или как fallback `MobileView`.
158
+ - `DropdownList` — контейнер списка с клавиатурной навигацией, `role="listbox"`. Статическое свойство `DropdownList.Item`.
159
+ - `DropdownListItem` — элемент списка, `role="option"`, выбор мышью и клавиатурой.
246
160
 
247
- При закрытии компонент **размонтируется**. Это важно:
248
- - Не платим за event listeners при закрытом dropdown'е.
249
- - Каждое открытие — новый цикл позиционирования.
250
- - **Потеря внутреннего state** (scroll position, focus) при закрытии.
161
+ **Мобильная версия (`src/components/Dropdown/mobile/`):**
251
162
 
252
- ### Desktop: `useToken().scopeClassName`
163
+ - `DropdownMobile` — полноэкранный оверлей снизу на базе `OverlayBase`, блокирует скролл body.
164
+ - `DropdownMobileInner` — внутренняя разметка оверлея: подложка и контент с анимацией открытия/закрытия.
165
+ - `DropdownMobileHeader` — шапка со слотом `controlButtons` (тип `IDropdownMobileHeaderProps` объявлен в `src/components/Tooltip/types.ts`).
166
+ - `DropdownMobileBody` — скроллируемое тело.
167
+ - `DropdownMobileFooter` — футер для кнопок.
168
+ - `DropdownMobileList` / `DropdownMobileListItem` — список и элемент списка мобильной версии.
169
+ - `DropdownMobileClose` — кнопка закрытия (обёртка над `ButtonIcon`).
170
+ - `DropdownMobileLoader` — лоадер для шапки (обёртка над `LoaderSmall`).
171
+ - `DropdownMobileInput` / `DropdownMobileMaskedInput` — поля ввода для шапки (второе — обёртка над `FormFieldMaskedInput` с `presets`).
253
172
 
254
- ```ts
255
- const { scopeClassName } = useToken();
256
- const classNames = clsx(styles.dropdownDesktop, sizeToClassNameMap[size], scopeClassName, className);
257
- ```
173
+ **Общее:**
258
174
 
259
- **`scopeClassName` нужен**, потому что `Portal` выносит dropdown в
260
- `body`, и ThemeProvider-scope родителя пропадает. `scopeClassName`
261
- возвращает класс, под которым активна текущая тема, — dropdown его
262
- тянет явно.
263
-
264
- **Без этого** dropdown получит дефолтную (светлую) тему,
265
- независимо от того, что родитель в `<ThemeProvider theme="dark">`.
266
-
267
- ### Dropdown.onOpen/onClose — effect с `mountedRef`
268
-
269
- ```ts
270
- const mountedRef = useRef(false);
271
- useEffect(() => {
272
- if (!mountedRef.current) {
273
- mountedRef.current = true;
274
- } else if (opened) {
275
- onOpen?.();
276
- } else {
277
- onClose?.();
278
- }
279
- }, [opened]);
280
- ```
175
+ - `DropdownListContext` — контекст `activeDescendant` / `setActiveDescendant` между списком и триггером.
176
+ - `Portal`, `MobileView`, `OverlayBase` — инфраструктура рендера.
177
+ - `ButtonDropdown`, `ButtonDropdownExtended` — готовые кнопки с выпадающим блоком поверх `Dropdown`.
281
178
 
282
- **Пропуск первого вызова** (initial mount) — чтобы `onOpen`/`onClose`
283
- срабатывали только на **реальное** переключение, а не на initial
284
- `opened=false`. Если удалить `mountedRef` — `onClose` вызовется сразу
285
- после mount.
179
+ ---
286
180
 
287
- ### DropdownList: keyboard navigation + activeDescendant
181
+ ## Stories
288
182
 
289
- ```ts
290
- // Esc/Arrows на document, preventDefault:
291
- if (keyCode === ARROW_DOWN) nextIndex = (currentIndex + 1) % childrenLength;
292
- if (keyCode === ARROW_UP) nextIndex = (currentIndex - 1 + childrenLength) % childrenLength;
293
- ```
183
+ Основные истории: `stories/Dropdown/Dropdown.stories.tsx`
184
+ Файлы примеров: `stories/Dropdown/examples/`
294
185
 
295
- **Wrap-around** — стрелки зацикливают навигацию (вниз с последнего →
296
- на первый, вверх с первого → на последний).
186
+ | Story | Example file | Что демонстрирует |
187
+ |---|---|---|
188
+ | `Playground` | `Playground.tsx` | Интерактивный контроль `size`, `direction`, `alignment`, `width` и загрузки списка |
189
+ | `Default` | `Default.tsx` | Базовый сценарий: кнопка-триггер, список опций, выбор значения |
190
+ | `Sizes` | `Sizes.tsx` | Размеры SM / MD / LG |
191
+ | `Directions` | `Directions.tsx` | Раскрытие вниз (`BOTTOM`) и вверх (`TOP`) |
192
+ | `Alignments` | `Alignments.tsx` | Выравнивание по левому и правому краю управляющего элемента |
193
+ | `Widths` | `Widths.tsx` | `CONTENT` / `TARGET` / `MIN_TARGET` |
194
+ | `Loading` | `Loading.tsx` | Подгрузка опций: элемент с лоадером в конце списка |
195
+ | `WithSelectedOption` | `WithSelectedOption.tsx` | Выбранная опция подсвечена и активна при открытии |
196
+ | `MobileView` | `MobileView.tsx` | Адаптивный режим: `mobileViewProps` и мобильные субкомпоненты |
197
+ | `VisualTests` | `VisualTests.tsx` | Длинный список со скроллом, значок уведомления, произвольный контент вместо списка |
297
198
 
298
- **`scrollContainerToItem(nextIndex)`** — виртуально-скроллит контейнер,
299
- чтобы активный элемент был в видимой области, с 4px offset.
199
+ ---
300
200
 
301
- **`DropdownListContext.setActiveDescendant(id)`** — контекст для
302
- target'а (input/button), который использует `aria-activedescendant=id`
303
- для screen reader'ов. Target вне `DropdownList`, но в родительском
304
- контексте.
201
+ ## История изменений
305
202
 
306
- **Initial active:** при открытии — ищет первый `selected` child
307
- (`child.props.selected === true`), если не найдено — ставит active=0.
308
- Скроллит в начало при отсутствии selected.
203
+ | Дата | Изменение |
204
+ |---|---|
205
+ | 2026-07-31 | Создан документ AI-ready для `Dropdown` (TRI-35). |
206
+ | 2026-07-31 | AI-рефакторинг: `DropdownList` больше не создаёт ref через `React.createRef()` в рендере (из-за этого автопрокрутка к активному элементу не работала), сброс активного элемента при открытии переведён на корректировку state в рендере вместо `setState` в эффекте, прокрутка вынесена в `desktop/utils.ts`, в `DropdownListItem` починен устаревший обработчик клавиатуры, в `Dropdown` актуальные `onOpen` / `onClose` хранятся в ref. Публичный API не изменён. Добавлены unit-тесты (109 кейсов) и stories. |
207
+ | 2026-07-31 | Правки по ревью PR #491: эффект `onOpen` / `onClose` в `Dropdown` сравнивает предыдущее значение `opened` вместо флага «смонтирован» — колбэки больше не срабатывают ложно при двойном маунте в React 18 StrictMode (регрессионный тест `__tests__/Dropdown.strictMode.test.tsx`). Документационные stories стартуют закрытыми и раскрываются через `play`. Сброс активного элемента `DropdownList` сделан симметричным: при закрытии подсветка снимается вместе с `activeDescendant`. |
309
208
 
310
- ### DropdownList: cloneElement + MouseOver/MouseOut
209
+ ---
311
210
 
312
- Каждый child клонируется с `active={activeListItemIndex === index}` +
313
- `onMouseOver/Out` переопределены — move mouse на item тоже меняет
314
- активный.
211
+ ## Примеры использования
315
212
 
316
- **Dual navigation:** клавиатура и мышь вместе работают. Движение
317
- мыши "отбрасывает" клавиатурный активный индекс и синхронизирует с
318
- местом курсора.
213
+ ### Default
319
214
 
320
- ### Mobile: OverlayBase + body-overflow block
215
+ `stories/Dropdown/examples/Default.tsx`
321
216
 
322
217
  ```tsx
323
- <OverlayBase setOpened={setOpened} opened={opened} direction={EOverlayDirection.BOTTOM}>
324
- {(props) => <DropdownMobileInner {...props} ... />}
325
- </OverlayBase>
218
+ import React, { useRef, useState } from "react";
219
+ import {
220
+ Button,
221
+ Dropdown,
222
+ DropdownList,
223
+ EButtonTheme,
224
+ EComponentSize,
225
+ EDropdownWidth,
226
+ } from "@sberbusiness/triplex-next";
227
+
228
+ const OPTIONS = [
229
+ { id: "default-option-1", label: "Значение 1" },
230
+ { id: "default-option-2", label: "Значение 2" },
231
+ { id: "default-option-3", label: "Значение 3" },
232
+ ];
233
+
234
+ export const Default = () => {
235
+ const targetRef = useRef<HTMLButtonElement>(null);
236
+ const [opened, setOpened] = useState(false);
237
+ const [selectedId, setSelectedId] = useState<string>();
238
+
239
+ const handleSelect = (id: string) => {
240
+ setSelectedId(id);
241
+ setOpened(false);
242
+ };
243
+
244
+ return (
245
+ <div>
246
+ <Button
247
+ theme={EButtonTheme.SECONDARY}
248
+ size={EComponentSize.MD}
249
+ aria-haspopup="listbox"
250
+ aria-expanded={opened}
251
+ onClick={() => setOpened(!opened)}
252
+ ref={targetRef}
253
+ >
254
+ Button text
255
+ </Button>
256
+ <Dropdown
257
+ opened={opened}
258
+ setOpened={setOpened}
259
+ targetRef={targetRef}
260
+ size={EComponentSize.MD}
261
+ width={EDropdownWidth.MIN_TARGET}
262
+ >
263
+ <DropdownList dropdownOpened={opened} size={EComponentSize.MD}>
264
+ {OPTIONS.map((option) => (
265
+ <DropdownList.Item
266
+ key={option.id}
267
+ id={option.id}
268
+ selected={option.id === selectedId}
269
+ onSelect={() => handleSelect(option.id)}
270
+ >
271
+ {option.label}
272
+ </DropdownList.Item>
273
+ ))}
274
+ </DropdownList>
275
+ </Dropdown>
276
+ </div>
277
+ );
278
+ };
326
279
  ```
327
280
 
328
- **OverlayBase** — базовый "шторка" оверлей с анимацией bottom-up,
329
- swipe-to-close и overlay-backdrop. DropdownMobile — лишь стайлинг
330
- поверх.
331
-
332
- **`document.body.classList.add(dropdownMobileBodyOverflowClassName)`**
333
- — блокирует скролл body, пока открыт мобильный dropdown.
281
+ ### Sizes
334
282
 
335
- ### `data-tx={process.env.npm_package_version}` — на desktop и mobile root
283
+ `stories/Dropdown/examples/Sizes.tsx`
336
284
 
337
- Стандарт. Оба разветвления ставят `data-tx`. Не удалять.
285
+ ```tsx
286
+ import React, { useRef, useState } from "react";
287
+ import {
288
+ Button,
289
+ Dropdown,
290
+ DropdownList,
291
+ EButtonTheme,
292
+ EComponentSize,
293
+ EDropdownDirection,
294
+ EDropdownWidth,
295
+ } from "@sberbusiness/triplex-next";
296
+
297
+ const OPTIONS = [
298
+ { id: "option-1", label: "Значение 1" },
299
+ { id: "option-2", label: "Значение 2" },
300
+ { id: "option-3", label: "Значение 3" },
301
+ ];
302
+
303
+ const SIZES = Object.values(EComponentSize);
304
+
305
+ interface ISizeItemProps {
306
+ size: EComponentSize;
307
+ }
308
+
309
+ const SizeItem = ({ size }: ISizeItemProps) => {
310
+ const targetRef = useRef<HTMLButtonElement>(null);
311
+ const [opened, setOpened] = useState(false);
312
+
313
+ return (
314
+ <div>
315
+ <div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: "700" }}>{size.toUpperCase()}</div>
316
+ <Button
317
+ theme={EButtonTheme.SECONDARY}
318
+ size={size}
319
+ aria-haspopup="listbox"
320
+ aria-expanded={opened}
321
+ onClick={() => setOpened(!opened)}
322
+ ref={targetRef}
323
+ >
324
+ Button text
325
+ </Button>
326
+ <Dropdown
327
+ opened={opened}
328
+ setOpened={setOpened}
329
+ targetRef={targetRef}
330
+ size={size}
331
+ direction={EDropdownDirection.BOTTOM}
332
+ width={EDropdownWidth.MIN_TARGET}
333
+ >
334
+ <DropdownList dropdownOpened={opened} size={size}>
335
+ {OPTIONS.map((option) => (
336
+ <DropdownList.Item
337
+ key={option.id}
338
+ id={`${size}-${option.id}`}
339
+ onSelect={() => setOpened(false)}
340
+ >
341
+ {option.label}
342
+ </DropdownList.Item>
343
+ ))}
344
+ </DropdownList>
345
+ </Dropdown>
346
+ </div>
347
+ );
348
+ };
349
+
350
+ export const Sizes = () => (
351
+ <div style={{ display: "flex", flexDirection: "column", gap: "180px" }}>
352
+ {SIZES.map((size) => (
353
+ <SizeItem key={size} size={size} />
354
+ ))}
355
+ </div>
356
+ );
357
+ ```
338
358
 
339
- ---
359
+ ### Directions
340
360
 
341
- ## Инварианты
361
+ `stories/Dropdown/examples/Directions.tsx`
342
362
 
343
- - **`opened` + `setOpened`** — обязательная пара (controlled).
344
- - **`targetRef` обязателен** — без него desktop не может позиционироваться.
345
- Если target недоступен — dropdown не откроется корректно.
346
- - **Portal → document.body всегда** — не менять на inline.
347
- - **`scopeClassName` из `useToken()`** — обязателен на DropdownDesktop.
348
- Без него Portal-теряет контекст темы.
349
- - **Desktop `if (!opened) return null`** — компонент размонтируется,
350
- не скрывается через CSS. Не менять.
351
- - **`data-tx`** на desktop и mobile root.
352
- - **`mobileViewProps` — необязательный**. Если не задан — мобильная
353
- версия использует DropdownDesktop.
354
- - **`DropdownList.Item`** — единственный допустимый child (технически
355
- клонирование игнорирует не-ReactElement'ы, но API об этом).
356
- - **Wrap-around navigation в DropdownList** — поведение, не менять.
357
- - **`mountedRef` в Dropdown.tsx** — skip first effect fire, не удалять.
358
- - **`wheelHandler` с `passive: false`** — обязательно для preventDefault.
359
- - **`keyDownHandler` фильтрует по `event.target === document.body`** —
360
- не preventDefault'ить клавиши в input'ах.
361
- - **`overflowHiddenClassName` add/remove на body** — аккуратно
362
- отписываться.
363
- - **Selected→active логика при открытии DropdownList** — работает
364
- как "scroll to selected". Не сломать.
363
+ ```tsx
364
+ import React, { useRef, useState } from "react";
365
+ import {
366
+ Button,
367
+ Dropdown,
368
+ DropdownList,
369
+ EButtonTheme,
370
+ EComponentSize,
371
+ EDropdownDirection,
372
+ EDropdownWidth,
373
+ } from "@sberbusiness/triplex-next";
374
+
375
+ const OPTIONS = [
376
+ { id: "option-1", label: "Значение 1" },
377
+ { id: "option-2", label: "Значение 2" },
378
+ { id: "option-3", label: "Значение 3" },
379
+ ];
380
+
381
+ const DIRECTIONS = [EDropdownDirection.BOTTOM, EDropdownDirection.TOP];
382
+
383
+ interface IDirectionItemProps {
384
+ direction: EDropdownDirection;
385
+ }
386
+
387
+ const DirectionItem = ({ direction }: IDirectionItemProps) => {
388
+ const targetRef = useRef<HTMLButtonElement>(null);
389
+ const [opened, setOpened] = useState(false);
390
+
391
+ return (
392
+ <div>
393
+ <div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: "700" }}>{direction.toUpperCase()}</div>
394
+ <Button
395
+ theme={EButtonTheme.SECONDARY}
396
+ size={EComponentSize.MD}
397
+ aria-haspopup="listbox"
398
+ aria-expanded={opened}
399
+ onClick={() => setOpened(!opened)}
400
+ ref={targetRef}
401
+ >
402
+ Button text
403
+ </Button>
404
+ <Dropdown
405
+ opened={opened}
406
+ setOpened={setOpened}
407
+ targetRef={targetRef}
408
+ size={EComponentSize.MD}
409
+ direction={direction}
410
+ width={EDropdownWidth.MIN_TARGET}
411
+ >
412
+ <DropdownList dropdownOpened={opened} size={EComponentSize.MD}>
413
+ {OPTIONS.map((option) => (
414
+ <DropdownList.Item
415
+ key={option.id}
416
+ id={`${direction}-${option.id}`}
417
+ onSelect={() => setOpened(false)}
418
+ >
419
+ {option.label}
420
+ </DropdownList.Item>
421
+ ))}
422
+ </DropdownList>
423
+ </Dropdown>
424
+ </div>
425
+ );
426
+ };
427
+
428
+ export const Directions = () => (
429
+ // Отступы резервируют место для выпадающего меню сверху и снизу от управляющего элемента.
430
+ <div style={{ display: "flex", gap: "40px", padding: "200px 0" }}>
431
+ {DIRECTIONS.map((direction) => (
432
+ <DirectionItem key={direction} direction={direction} />
433
+ ))}
434
+ </div>
435
+ );
436
+ ```
365
437
 
366
- ---
438
+ ### Alignments
367
439
 
368
- ## Accessibility
440
+ `stories/Dropdown/examples/Alignments.tsx`
369
441
 
370
- - **`role="listbox"`** на DropdownList; `role="option"` на DropdownListItem
371
- (последнее в соответствующем компоненте).
372
- - **`aria-activedescendant`** — target (input/button) должен
373
- выставлять его из `DropdownListContext.activeDescendant`. Screen
374
- reader объявляет активный option.
375
- - **Клавиатура:**
376
- - Esc — закрыть (обрабатывается потребителем, Dropdown сам не ловит).
377
- - ↑/↓ — навигация по DropdownList.
378
- - Enter — выбор (DropdownListItem сам обрабатывает).
379
- - Tab — не работает внутри (DropdownList не в tab-order).
380
- - **Mobile:** OverlayBase + swipe-to-close. Focus-trap через OverlayBase.
381
- - **`role="dialog" aria-modal="true"`** — не проставляется автоматически,
382
- потребитель может передать через `...rest` (как делает
383
- `DatePickerExtendedDropdown`).
384
- - **Body-scroll lock** — предотвращает "прыжки" при скролле на
385
- мобильных.
442
+ ```tsx
443
+ import React, { useRef, useState } from "react";
444
+ import {
445
+ Button,
446
+ Dropdown,
447
+ DropdownList,
448
+ EButtonTheme,
449
+ EComponentSize,
450
+ EDropdownAlignment,
451
+ EDropdownDirection,
452
+ } from "@sberbusiness/triplex-next";
453
+
454
+ const OPTIONS = [
455
+ { id: "option-1", label: "Значение с длинным названием" },
456
+ { id: "option-2", label: "Значение 2" },
457
+ { id: "option-3", label: "Значение 3" },
458
+ ];
459
+
460
+ const ALIGNMENTS = Object.values(EDropdownAlignment);
461
+
462
+ interface IAlignmentItemProps {
463
+ alignment: EDropdownAlignment;
464
+ }
465
+
466
+ const AlignmentItem = ({ alignment }: IAlignmentItemProps) => {
467
+ const targetRef = useRef<HTMLButtonElement>(null);
468
+ const [opened, setOpened] = useState(false);
469
+
470
+ return (
471
+ <div>
472
+ <div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: "700" }}>{alignment.toUpperCase()}</div>
473
+ <Button
474
+ theme={EButtonTheme.SECONDARY}
475
+ size={EComponentSize.MD}
476
+ aria-haspopup="listbox"
477
+ aria-expanded={opened}
478
+ onClick={() => setOpened(!opened)}
479
+ ref={targetRef}
480
+ >
481
+ Button text
482
+ </Button>
483
+ <Dropdown
484
+ opened={opened}
485
+ setOpened={setOpened}
486
+ targetRef={targetRef}
487
+ size={EComponentSize.MD}
488
+ alignment={alignment}
489
+ direction={EDropdownDirection.BOTTOM}
490
+ >
491
+ <DropdownList dropdownOpened={opened} size={EComponentSize.MD}>
492
+ {OPTIONS.map((option) => (
493
+ <DropdownList.Item
494
+ key={option.id}
495
+ id={`${alignment}-${option.id}`}
496
+ onSelect={() => setOpened(false)}
497
+ >
498
+ {option.label}
499
+ </DropdownList.Item>
500
+ ))}
501
+ </DropdownList>
502
+ </Dropdown>
503
+ </div>
504
+ );
505
+ };
506
+
507
+ export const Alignments = () => (
508
+ // Отступ слева даёт место выпадающему меню при выравнивании по правому краю управляющего элемента.
509
+ <div style={{ display: "flex", flexDirection: "column", gap: "180px", paddingLeft: "120px" }}>
510
+ {ALIGNMENTS.map((alignment) => (
511
+ <AlignmentItem key={alignment} alignment={alignment} />
512
+ ))}
513
+ </div>
514
+ );
515
+ ```
386
516
 
387
- ---
517
+ ### Widths
388
518
 
389
- ## Дизайн-токены
519
+ `stories/Dropdown/examples/Widths.tsx`
390
520
 
391
- Группы в `src/components/DesignTokens/components/`:
392
- - **`Dropdown.ts`** — `--triplex-next-Dropdown-Background` + border/shadow
393
- - **`DropdownList.ts`** — hover/active/selected для item'ов
394
- - **`DropdownMobile.ts`** — фон, border-radius, header/footer
395
- - **`DropdownMobileList.ts`** — item'ы в мобильной ветке
521
+ ```tsx
522
+ import React, { useRef, useState } from "react";
523
+ import {
524
+ Button,
525
+ Dropdown,
526
+ DropdownList,
527
+ EButtonTheme,
528
+ EComponentSize,
529
+ EDropdownDirection,
530
+ EDropdownWidth,
531
+ } from "@sberbusiness/triplex-next";
532
+
533
+ const OPTIONS = [
534
+ { id: "option-1", label: "Значение 1" },
535
+ { id: "option-2", label: "Значение 2" },
536
+ { id: "option-3", label: "Значение 3" },
537
+ ];
538
+
539
+ const WIDTHS = Object.values(EDropdownWidth);
540
+
541
+ interface IWidthItemProps {
542
+ width: EDropdownWidth;
543
+ }
544
+
545
+ const WidthItem = ({ width }: IWidthItemProps) => {
546
+ const targetRef = useRef<HTMLButtonElement>(null);
547
+ const [opened, setOpened] = useState(false);
548
+
549
+ return (
550
+ <div style={{ width: "280px" }}>
551
+ <div style={{ marginBottom: "8px", fontSize: "16px", fontWeight: "700" }}>{width.toUpperCase()}</div>
552
+ <Button
553
+ block
554
+ theme={EButtonTheme.SECONDARY}
555
+ size={EComponentSize.MD}
556
+ aria-haspopup="listbox"
557
+ aria-expanded={opened}
558
+ onClick={() => setOpened(!opened)}
559
+ ref={targetRef}
560
+ >
561
+ Button text
562
+ </Button>
563
+ <Dropdown
564
+ opened={opened}
565
+ setOpened={setOpened}
566
+ targetRef={targetRef}
567
+ size={EComponentSize.MD}
568
+ width={width}
569
+ direction={EDropdownDirection.BOTTOM}
570
+ >
571
+ <DropdownList dropdownOpened={opened} size={EComponentSize.MD}>
572
+ {OPTIONS.map((option) => (
573
+ <DropdownList.Item
574
+ key={option.id}
575
+ id={`${width}-${option.id}`}
576
+ onSelect={() => setOpened(false)}
577
+ >
578
+ {option.label}
579
+ </DropdownList.Item>
580
+ ))}
581
+ </DropdownList>
582
+ </Dropdown>
583
+ </div>
584
+ );
585
+ };
586
+
587
+ export const Widths = () => (
588
+ <div style={{ display: "flex", flexDirection: "column", gap: "180px" }}>
589
+ {WIDTHS.map((width) => (
590
+ <WidthItem key={width} width={width} />
591
+ ))}
592
+ </div>
593
+ );
594
+ ```
396
595
 
397
- ---
596
+ ### Loading
398
597
 
399
- ## Связанные компоненты
598
+ `stories/Dropdown/examples/Loading.tsx`
400
599
 
401
- - `Portal` (`src/components/Portal`) — рендер в body.
402
- - `MobileView` (`src/components/MobileView`) — разветвление.
403
- - `OverlayBase` (`src/components/Overlay`) — база для мобильного.
404
- - `useToken` / `ThemeProvider` — scopeClassName.
405
- - `DropdownList` / `DropdownListItem` — compound-список (desktop).
406
- - `DropdownMobile*` — mobile compound-семейство.
407
- - Основные обёртки: `SelectField`, `SelectExtendedField`, `Suggest`,
408
- `SuggestField`, `DatePickerExtended`, `MultiselectField`.
409
- - `createSizeToClassNameMap` — утилита для size-класса.
600
+ ```tsx
601
+ import React, { useRef, useState } from "react";
602
+ import {
603
+ Button,
604
+ Dropdown,
605
+ DropdownList,
606
+ EButtonTheme,
607
+ EComponentSize,
608
+ EDropdownDirection,
609
+ EDropdownWidth,
610
+ } from "@sberbusiness/triplex-next";
611
+
612
+ const OPTIONS = [
613
+ { id: "loading-option-1", label: "Значение 1" },
614
+ { id: "loading-option-2", label: "Значение 2" },
615
+ ];
616
+
617
+ export const Loading = () => {
618
+ const targetRef = useRef<HTMLButtonElement>(null);
619
+ const [opened, setOpened] = useState(false);
620
+
621
+ return (
622
+ <div>
623
+ <Button
624
+ theme={EButtonTheme.SECONDARY}
625
+ size={EComponentSize.MD}
626
+ aria-haspopup="listbox"
627
+ aria-expanded={opened}
628
+ onClick={() => setOpened(!opened)}
629
+ ref={targetRef}
630
+ >
631
+ Button text
632
+ </Button>
633
+ <Dropdown
634
+ opened={opened}
635
+ setOpened={setOpened}
636
+ targetRef={targetRef}
637
+ size={EComponentSize.MD}
638
+ direction={EDropdownDirection.BOTTOM}
639
+ width={EDropdownWidth.MIN_TARGET}
640
+ >
641
+ <DropdownList loading dropdownOpened={opened} size={EComponentSize.MD}>
642
+ {OPTIONS.map((option) => (
643
+ <DropdownList.Item key={option.id} id={option.id} onSelect={() => setOpened(false)}>
644
+ {option.label}
645
+ </DropdownList.Item>
646
+ ))}
647
+ </DropdownList>
648
+ </Dropdown>
649
+ </div>
650
+ );
651
+ };
652
+ ```
410
653
 
411
- ---
654
+ ### WithSelectedOption
412
655
 
413
- ## Stories
656
+ `stories/Dropdown/examples/WithSelectedOption.tsx`
414
657
 
415
- `stories/Dropdown/Dropdown.stories.tsx`
416
- Примеры: `stories/Dropdown/examples/`
658
+ ```tsx
659
+ import React, { useRef, useState } from "react";
660
+ import {
661
+ Button,
662
+ Dropdown,
663
+ DropdownList,
664
+ EButtonTheme,
665
+ EComponentSize,
666
+ EDropdownDirection,
667
+ EDropdownWidth,
668
+ } from "@sberbusiness/triplex-next";
669
+
670
+ const OPTIONS = [
671
+ { id: "selected-option-1", label: "Значение 1" },
672
+ { id: "selected-option-2", label: "Значение 2" },
673
+ { id: "selected-option-3", label: "Значение 3" },
674
+ ];
675
+
676
+ export const WithSelectedOption = () => {
677
+ const targetRef = useRef<HTMLButtonElement>(null);
678
+ const [opened, setOpened] = useState(false);
679
+ const [selectedId, setSelectedId] = useState("selected-option-2");
680
+
681
+ const handleSelect = (id: string) => {
682
+ setSelectedId(id);
683
+ setOpened(false);
684
+ };
685
+
686
+ return (
687
+ <div>
688
+ <Button
689
+ theme={EButtonTheme.SECONDARY}
690
+ size={EComponentSize.MD}
691
+ aria-haspopup="listbox"
692
+ aria-expanded={opened}
693
+ onClick={() => setOpened(!opened)}
694
+ ref={targetRef}
695
+ >
696
+ Button text
697
+ </Button>
698
+ <Dropdown
699
+ opened={opened}
700
+ setOpened={setOpened}
701
+ targetRef={targetRef}
702
+ size={EComponentSize.MD}
703
+ direction={EDropdownDirection.BOTTOM}
704
+ width={EDropdownWidth.MIN_TARGET}
705
+ >
706
+ <DropdownList dropdownOpened={opened} size={EComponentSize.MD}>
707
+ {OPTIONS.map((option) => (
708
+ <DropdownList.Item
709
+ key={option.id}
710
+ id={option.id}
711
+ selected={option.id === selectedId}
712
+ onSelect={() => handleSelect(option.id)}
713
+ >
714
+ {option.label}
715
+ </DropdownList.Item>
716
+ ))}
717
+ </DropdownList>
718
+ </Dropdown>
719
+ </div>
720
+ );
721
+ };
722
+ ```
417
723
 
418
- Типовые stories:
419
- - `Default` — базовый открывающийся dropdown
420
- - `Directions` — auto/bottom/top
421
- - `Alignments` — left/right
422
- - `Widths` — content/target/min-target
423
- - `MobileView` — с `mobileViewProps`
424
- - `DropdownList` — список с item'ами
425
- - `Playground` — интерактив
724
+ ### MobileView
426
725
 
427
- ---
726
+ `stories/Dropdown/examples/MobileView.tsx`
428
727
 
429
- ## История изменений
728
+ ```tsx
729
+ import React, { useRef, useState } from "react";
730
+ import {
731
+ Button,
732
+ Dropdown,
733
+ DropdownList,
734
+ DropdownMobileBody,
735
+ DropdownMobileClose,
736
+ DropdownMobileFooter,
737
+ DropdownMobileHeader,
738
+ DropdownMobileList,
739
+ DropdownMobileListItem,
740
+ EButtonTheme,
741
+ EComponentSize,
742
+ EDropdownWidth,
743
+ ETextSize,
744
+ Text,
745
+ } from "@sberbusiness/triplex-next";
746
+
747
+ const OPTIONS = [
748
+ { id: "mobile-option-1", label: "Значение 1" },
749
+ { id: "mobile-option-2", label: "Значение 2" },
750
+ { id: "mobile-option-3", label: "Значение 3" },
751
+ ];
752
+
753
+ export const MobileView = () => {
754
+ const targetRef = useRef<HTMLButtonElement>(null);
755
+ const [opened, setOpened] = useState(false);
756
+ const [selectedId, setSelectedId] = useState<string>();
757
+
758
+ const handleSelect = (id: string) => {
759
+ setSelectedId(id);
760
+ setOpened(false);
761
+ };
762
+
763
+ return (
764
+ <div>
765
+ <Button
766
+ theme={EButtonTheme.SECONDARY}
767
+ size={EComponentSize.MD}
768
+ aria-haspopup="listbox"
769
+ aria-expanded={opened}
770
+ onClick={() => setOpened(!opened)}
771
+ ref={targetRef}
772
+ >
773
+ Button text
774
+ </Button>
775
+ <Dropdown
776
+ opened={opened}
777
+ setOpened={setOpened}
778
+ targetRef={targetRef}
779
+ size={EComponentSize.MD}
780
+ width={EDropdownWidth.MIN_TARGET}
781
+ mobileViewProps={{
782
+ children: (
783
+ <>
784
+ <DropdownMobileHeader
785
+ controlButtons={
786
+ <DropdownMobileClose aria-label="Закрыть" onClick={() => setOpened(false)} />
787
+ }
788
+ >
789
+ <Text size={ETextSize.B1}>Заголовок</Text>
790
+ </DropdownMobileHeader>
791
+ <DropdownMobileBody>
792
+ <DropdownMobileList>
793
+ {OPTIONS.map((option) => (
794
+ <DropdownMobileListItem
795
+ key={option.id}
796
+ id={`mobile-${option.id}`}
797
+ selected={option.id === selectedId}
798
+ onSelect={() => handleSelect(option.id)}
799
+ >
800
+ {option.label}
801
+ </DropdownMobileListItem>
802
+ ))}
803
+ </DropdownMobileList>
804
+ </DropdownMobileBody>
805
+ <DropdownMobileFooter>
806
+ <Button
807
+ theme={EButtonTheme.SECONDARY}
808
+ size={EComponentSize.MD}
809
+ onClick={() => setOpened(false)}
810
+ >
811
+ Button text
812
+ </Button>
813
+ </DropdownMobileFooter>
814
+ </>
815
+ ),
816
+ }}
817
+ >
818
+ <DropdownList dropdownOpened={opened} size={EComponentSize.MD}>
819
+ {OPTIONS.map((option) => (
820
+ <DropdownList.Item
821
+ key={option.id}
822
+ id={option.id}
823
+ selected={option.id === selectedId}
824
+ onSelect={() => handleSelect(option.id)}
825
+ >
826
+ {option.label}
827
+ </DropdownList.Item>
828
+ ))}
829
+ </DropdownList>
830
+ </Dropdown>
831
+ </div>
832
+ );
833
+ };
834
+ ```
430
835
 
431
- | Дата | Изменение |
432
- |---|---|
433
- | 2026-04-18 | Создан документ |