@sberbusiness/triplex-next-mcp-server 0.24.0-beta2 → 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.
- package/README.md +10 -0
- package/bundle/components/AlertContext.md +268 -0
- package/bundle/components/BodyPage.md +186 -0
- package/bundle/components/Button.md +93 -85
- package/bundle/components/ButtonDropdown.md +36 -70
- package/bundle/components/ButtonDropdownExtended.md +422 -0
- package/bundle/components/ButtonIcon.md +41 -48
- package/bundle/components/Caption.md +291 -0
- package/bundle/components/Checkbox.md +43 -122
- package/bundle/components/CheckboxXGroup.md +109 -46
- package/bundle/components/CheckboxYGroup.md +77 -49
- package/bundle/components/CodeText.md +197 -0
- package/bundle/components/Col.md +311 -162
- package/bundle/components/CollapsibleTree.md +584 -0
- package/bundle/components/CollapsibleTreeExtended.md +422 -0
- package/bundle/components/Dropdown.md +748 -346
- package/bundle/components/EmptyView.md +14 -15
- package/bundle/components/FooterPage.md +203 -0
- package/bundle/components/Gap.md +115 -57
- package/bundle/components/HeaderPage.md +226 -0
- package/bundle/components/HelpBox.md +45 -84
- package/bundle/components/ImageGallery.md +423 -0
- package/bundle/components/ImageGalleryExtended.md +606 -0
- package/bundle/components/LightBox.md +2252 -233
- package/bundle/components/List.md +216 -183
- package/bundle/components/ListActionItem.md +157 -0
- package/bundle/components/ListItem.md +276 -0
- package/bundle/components/ListItemControlsButton.md +103 -0
- package/bundle/components/ListItemControlsButtonDropdown.md +107 -0
- package/bundle/components/ListItemSelectable.md +99 -0
- package/bundle/components/ListItemTable.md +390 -0
- package/bundle/components/ListMaster.md +876 -156
- package/bundle/components/ListMasterFooter.md +80 -0
- package/bundle/components/ListMasterHeader.md +86 -0
- package/bundle/components/ListSortable.md +153 -0
- package/bundle/components/ListSortableItem.md +175 -0
- package/bundle/components/ListTableItem.md +391 -0
- package/bundle/components/ModalWindow.md +427 -237
- package/bundle/components/MonthYearField.md +145 -109
- package/bundle/components/Notification.md +625 -155
- package/bundle/components/Overlay.md +271 -170
- package/bundle/components/Page.md +284 -186
- package/bundle/components/Pagination.md +206 -229
- package/bundle/components/Portal.md +131 -67
- package/bundle/components/Row.md +171 -69
- package/bundle/components/Text.md +320 -0
- package/bundle/components/TextField.md +364 -97
- package/bundle/components/Title.md +296 -0
- package/bundle/guides/codestyle.md +38 -11
- package/bundle/guides/commits.md +119 -26
- package/bundle/guides/context.md +34 -30
- package/bundle/guides/stories.md +110 -40
- package/bundle/guides/template.md +3 -7
- package/bundle/guides/tests.md +48 -2
- package/bundle/manifest.json +275 -30
- package/bundle/release-notes/1.26.0.mdx +33 -2
- package/bundle/release-notes/1.27.0.mdx +43 -0
- package/bundle/release-notes/1.28.0.mdx +25 -0
- package/bundle/release-notes/1.29.0.mdx +47 -0
- package/bundle/release-notes/1.30.0.mdx +56 -0
- package/bundle/release-notes/1.31.0.mdx +52 -0
- package/bundle/release-notes/1.32.0.mdx +35 -0
- package/bundle/release-notes/1.33.0.mdx +61 -0
- package/bundle/release-notes/1.34.0.mdx +35 -0
- package/bundle/release-notes/1.35.0.mdx +21 -0
- package/bundle/release-notes/1.35.1.mdx +11 -0
- package/bundle/release-notes/1.36.0.mdx +21 -0
- package/bundle/release-notes/1.37.0.mdx +71 -0
- package/bundle/release-notes/1.38.0.mdx +36 -0
- package/bundle/release-notes/1.39.0.mdx +66 -0
- package/bundle/release-notes/1.40.0.mdx +27 -0
- package/bundle/release-notes/1.41.0.mdx +7 -0
- package/package.json +1 -1
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
component: Dropdown
|
|
3
|
-
category:
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
82
|
-
-
|
|
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
|
-
|
|
40
|
+
`Dropdown` расширяет `IDropdownDesktopProps`, а тот — `React.HTMLAttributes<HTMLDivElement>`. Все неизвестные компоненту атрибуты попадают на корневой `<div>` десктопного меню.
|
|
41
|
+
|
|
42
|
+
### Обязательные props
|
|
93
43
|
|
|
94
44
|
| Prop | Тип | Описание |
|
|
95
45
|
|---|---|---|
|
|
96
|
-
| `opened` | `boolean` |
|
|
97
|
-
| `setOpened` | `(opened: boolean) => void` |
|
|
98
|
-
| `targetRef` | `RefObject<HTMLElement>` |
|
|
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
|
-
|
|
50
|
+
### Опциональные props
|
|
111
51
|
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
- `className`.
|
|
116
|
-
- `...HTMLDivAttributes`.
|
|
117
|
-
- (opened, setOpened форвардятся из Dropdown.)
|
|
64
|
+
### Список опций — `DropdownList` и `DropdownList.Item`
|
|
118
65
|
|
|
119
|
-
|
|
66
|
+
`Dropdown` не знает про списки: клавиатурную навигацию и роли `listbox` / `option` добавляет `DropdownList`.
|
|
120
67
|
|
|
121
|
-
| Prop | Тип |
|
|
68
|
+
| Prop `DropdownList` | Тип | По умолчанию | Описание |
|
|
122
69
|
|---|---|---|---|
|
|
123
|
-
| `dropdownOpened` | `boolean` | — |
|
|
124
|
-
| `
|
|
125
|
-
| `loading` | `boolean` |
|
|
126
|
-
| `
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
- `LEFT = "left"` — левая граница dropdown'а = левая target'а.
|
|
140
|
-
- `RIGHT = "right"` — правая граница dropdown'а = правая target'а.
|
|
84
|
+
### Ограничения
|
|
141
85
|
|
|
142
|
-
|
|
143
|
-
- `
|
|
144
|
-
-
|
|
145
|
-
- `
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
→ top, если тоже не влезает → bottom (скролл). 4px offset между
|
|
190
|
-
target и dropdown (`offset = 4`).
|
|
115
|
+
## Инварианты
|
|
191
116
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
129
|
+
---
|
|
197
130
|
|
|
198
|
-
|
|
199
|
-
document.addEventListener("wheel", wheelHandler, { passive: false });
|
|
200
|
-
document.addEventListener("keydown", keyDownHandler);
|
|
201
|
-
document.body.classList.add(overflowHiddenClassName);
|
|
202
|
-
```
|
|
131
|
+
## Accessibility
|
|
203
132
|
|
|
204
|
-
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
-
|
|
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
|
-
|
|
235
|
-
кадре, чтобы дать браузеру layout'нуть.
|
|
145
|
+
## Связанные компоненты
|
|
236
146
|
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
После первого layout — setStyleState с рассчитанными top/left/width.
|
|
241
|
-
Это **анти-flicker трюк**: если dropdown-ом монтируется и **рендерится
|
|
242
|
-
первым кадром в (0, 0)**, юзер увидит скачок. `opacity: 0` скрывает
|
|
243
|
-
эту микросекунду.
|
|
155
|
+
**Десктоп (`src/components/Dropdown/desktop/`):**
|
|
244
156
|
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
const { scopeClassName } = useToken();
|
|
256
|
-
const classNames = clsx(styles.dropdownDesktop, sizeToClassNameMap[size], scopeClassName, className);
|
|
257
|
-
```
|
|
173
|
+
**Общее:**
|
|
258
174
|
|
|
259
|
-
|
|
260
|
-
`
|
|
261
|
-
|
|
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
|
-
|
|
283
|
-
срабатывали только на **реальное** переключение, а не на initial
|
|
284
|
-
`opened=false`. Если удалить `mountedRef` — `onClose` вызовется сразу
|
|
285
|
-
после mount.
|
|
179
|
+
---
|
|
286
180
|
|
|
287
|
-
|
|
181
|
+
## Stories
|
|
288
182
|
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
чтобы активный элемент был в видимой области, с 4px offset.
|
|
199
|
+
---
|
|
300
200
|
|
|
301
|
-
|
|
302
|
-
target'а (input/button), который использует `aria-activedescendant=id`
|
|
303
|
-
для screen reader'ов. Target вне `DropdownList`, но в родительском
|
|
304
|
-
контексте.
|
|
201
|
+
## История изменений
|
|
305
202
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
209
|
+
---
|
|
311
210
|
|
|
312
|
-
|
|
313
|
-
`onMouseOver/Out` переопределены — move mouse на item тоже меняет
|
|
314
|
-
активный.
|
|
211
|
+
## Примеры использования
|
|
315
212
|
|
|
316
|
-
|
|
317
|
-
мыши "отбрасывает" клавиатурный активный индекс и синхронизирует с
|
|
318
|
-
местом курсора.
|
|
213
|
+
### Default
|
|
319
214
|
|
|
320
|
-
|
|
215
|
+
`stories/Dropdown/examples/Default.tsx`
|
|
321
216
|
|
|
322
217
|
```tsx
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
swipe-to-close и overlay-backdrop. DropdownMobile — лишь стайлинг
|
|
330
|
-
поверх.
|
|
331
|
-
|
|
332
|
-
**`document.body.classList.add(dropdownMobileBodyOverflowClassName)`**
|
|
333
|
-
— блокирует скролл body, пока открыт мобильный dropdown.
|
|
281
|
+
### Sizes
|
|
334
282
|
|
|
335
|
-
|
|
283
|
+
`stories/Dropdown/examples/Sizes.tsx`
|
|
336
284
|
|
|
337
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
440
|
+
`stories/Dropdown/examples/Alignments.tsx`
|
|
369
441
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
656
|
+
`stories/Dropdown/examples/WithSelectedOption.tsx`
|
|
414
657
|
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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 | Создан документ |
|