@sberbusiness/triplex-next-mcp-server 1.40.0-beta → 1.41.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/bundle/components/Calendar.md +183 -368
- package/bundle/components/Chip.md +310 -314
- package/bundle/components/FormField.md +468 -232
- package/bundle/components/IconWrapper.md +142 -89
- package/bundle/components/Island.md +221 -129
- package/bundle/components/KeyDownListener.md +215 -102
- package/bundle/components/LoaderMiddle.md +265 -0
- package/bundle/components/LoaderScreen.md +178 -81
- package/bundle/components/ThemeProvider.md +305 -117
- package/bundle/components/Tooltip.md +421 -181
- package/bundle/guides/commits.md +14 -6
- package/bundle/manifest.json +22 -11
- package/bundle/release-notes/1.41.0.mdx +76 -0
- package/bundle/release-notes/1.42.0.mdx +7 -0
- package/package.json +1 -1
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
component: KeyDownListener
|
|
3
|
-
category:
|
|
4
|
-
|
|
5
|
-
figma-file: ""
|
|
6
|
-
related: [ComposedKeyDownListener, WindowResizeListener]
|
|
3
|
+
category: KeyDownListener
|
|
4
|
+
related: [ComposedKeyDownListener, TriggerClickOnKeyDownEvent, SelectExtendedField, SliderExtended]
|
|
7
5
|
tokens: []
|
|
8
|
-
stories:
|
|
6
|
+
stories: stories/KeyDownListener/KeyDownListener.stories.tsx
|
|
9
7
|
version: "1.0"
|
|
10
8
|
---
|
|
11
9
|
|
|
@@ -13,145 +11,260 @@ version: "1.0"
|
|
|
13
11
|
|
|
14
12
|
## Назначение
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`
|
|
19
|
-
|
|
14
|
+
Сервисный headless-компонент. Пока смонтирован, слушает `keydown` на `window` и
|
|
15
|
+
вызывает `onMatch`, если числовой код нажатой клавиши (`event.keyCode`) совпал с
|
|
16
|
+
`eventKeyCode`. Собственной разметки не рендерит: `children` выводятся как есть,
|
|
17
|
+
без обёртки; без `children` не рендерит ничего.
|
|
20
18
|
|
|
21
|
-
Используй когда:
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
Используй когда: нужна глобальная (документная) горячая клавиша, работающая
|
|
20
|
+
независимо от того, где находится фокус — закрытие оверлея по `Esc`, стрелки для
|
|
21
|
+
управления слайдером/ползунком, подтверждение по `Enter`.
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
src/components/KeyDownListener/
|
|
34
|
-
├── KeyDownListener.tsx # React.Component (class) с window-keydown
|
|
35
|
-
├── ComposedKeyDownListener.tsx # FC, сворачивает массив listener'ов в tree
|
|
36
|
-
└── index.ts # Barrel: оба компонента
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
Нет styles, stories, tests.
|
|
23
|
+
Не используй когда:
|
|
24
|
+
- Клавиша должна обрабатываться только внутри конкретного элемента — навесь
|
|
25
|
+
`onKeyDown` на сам элемент, иначе обработчик сработает при вводе в другое поле
|
|
26
|
+
на странице.
|
|
27
|
+
- Нужно кликнуть по конкретной кнопке по нажатию клавиши — есть готовый
|
|
28
|
+
`TriggerClickOnKeyDownEvent`.
|
|
29
|
+
- Нужно несколько разных клавиш с разными обработчиками — используй
|
|
30
|
+
`ComposedKeyDownListener` (см. «Связанные компоненты»).
|
|
40
31
|
|
|
41
32
|
---
|
|
42
33
|
|
|
43
34
|
## Варианты и props
|
|
44
35
|
|
|
45
|
-
###
|
|
36
|
+
### Обязательные props
|
|
46
37
|
|
|
47
|
-
| Prop | Тип |
|
|
48
|
-
|
|
49
|
-
| `eventKeyCode` | `number \| number[]` |
|
|
50
|
-
| `onMatch` | `(event: KeyboardEvent) => void` |
|
|
51
|
-
| `children` | `React.ReactNode` | Нет | Рендерятся "прозрачно" (компонент не оборачивает в DOM). |
|
|
52
|
-
|
|
53
|
-
Тип кода: `keyCode = (typeof EVENT_KEY_CODES)[keyof typeof EVENT_KEY_CODES]`
|
|
54
|
-
(числа из `@sberbusiness/triplex-next/utils/keyboard`): `ENTER=13`, `ESCAPE=27`,
|
|
55
|
-
`TAB=9`, `ARROW_*=37-40`, и т.д.
|
|
38
|
+
| Prop | Тип | Описание |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `eventKeyCode` | `number \| number[]` | Код клавиши из `EVENT_KEY_CODES` или массив кодов. Массив — «любая из перечисленных клавиш»; различить нажатую можно по `event.keyCode` внутри `onMatch` |
|
|
41
|
+
| `onMatch` | `(event: KeyboardEvent) => void` | Вызывается при совпадении. Получает нативный `KeyboardEvent` (не React SyntheticEvent) |
|
|
56
42
|
|
|
57
|
-
###
|
|
43
|
+
### Опциональные props
|
|
58
44
|
|
|
59
|
-
| Prop | Тип |
|
|
45
|
+
| Prop | Тип | По умолчанию | Описание |
|
|
60
46
|
|---|---|---|---|
|
|
61
|
-
| `
|
|
62
|
-
|
|
47
|
+
| `children` | `React.ReactNode` | — | Рендерится как есть. Компонент не оборачивает содержимое и никак на него не влияет — вложение нужно только для читаемости кода |
|
|
48
|
+
|
|
49
|
+
### Особенности поведения
|
|
50
|
+
|
|
51
|
+
- Слушатель навешивается на `window` в `componentDidMount` и снимается в
|
|
52
|
+
`componentWillUnmount`. Компонент не вызывает `preventDefault` и не
|
|
53
|
+
останавливает всплытие — это ответственность `onMatch`.
|
|
54
|
+
- Сравнение идёт по устаревшему `event.keyCode`, а не по `event.key`. Коды
|
|
55
|
+
берутся из `EVENT_KEY_CODES` (`@sberbusiness/triplex-next`); не хардкодь числа.
|
|
56
|
+
- `onMatch` и `eventKeyCode` читаются из `this.props` в момент события, поэтому
|
|
57
|
+
свежие значения после rerender применяются без переподписки.
|
|
58
|
+
- Несколько смонтированных `KeyDownListener` на одну клавишу срабатывают все —
|
|
59
|
+
порядок соответствует порядку монтирования.
|
|
60
|
+
- Компонент не проверяет, где находится фокус: `Esc` сработает и во время ввода
|
|
61
|
+
в поле. Если это нежелательно, фильтруй в `onMatch` по `event.target`.
|
|
63
62
|
|
|
64
63
|
---
|
|
65
64
|
|
|
66
|
-
##
|
|
67
|
-
|
|
68
|
-
### `KeyDownListener` — class component
|
|
65
|
+
## Дизайн-токены
|
|
69
66
|
|
|
70
|
-
|
|
71
|
-
export class KeyDownListener extends React.Component<IKeyDownListenerProps> {
|
|
72
|
-
public componentDidMount() { window.addEventListener("keydown", this.handleKeyDown); }
|
|
73
|
-
public componentWillUnmount() { window.removeEventListener("keydown", this.handleKeyDown); }
|
|
74
|
-
public render() { return this.props.children || null; }
|
|
75
|
-
}
|
|
76
|
-
```
|
|
67
|
+
Собственных стилей и CSS-переменных нет — компонент не рендерит разметку.
|
|
77
68
|
|
|
78
|
-
|
|
79
|
-
- Не `forwardRef` — компонент не имеет своего DOM.
|
|
69
|
+
---
|
|
80
70
|
|
|
81
|
-
|
|
71
|
+
## Инварианты
|
|
82
72
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
73
|
+
- **`KeyDownListener` — class-компонент**, и это часть публичного контракта:
|
|
74
|
+
`ref` указывает на инстанс класса, метод `handleKeyDown` публичный. Не
|
|
75
|
+
переписывать на функциональный компонент.
|
|
76
|
+
- **Без `forwardRef` — осознанное исключение** из общего правила библиотеки:
|
|
77
|
+
компонент не рендерит собственный host-элемент, форвардить ref некуда.
|
|
78
|
+
- **Имена props `children`, `eventKeyCode`, `onMatch`** и интерфейс
|
|
79
|
+
`IKeyDownListenerProps` (экспортируется из barrel) — публичное API, не менять.
|
|
80
|
+
`IKeyDownListenerProps` переиспользуется в `TriggerClickOnKeyDownEvent`
|
|
81
|
+
(`Pick<IKeyDownListenerProps, "eventKeyCode">`) и в props
|
|
82
|
+
`ComposedKeyDownListener`.
|
|
83
|
+
- **Сравнение по `event.keyCode`** — не мигрировать на `event.key` без отдельной
|
|
84
|
+
задачи: у `EVENT_KEYS.SPACE` значения `["Spacebar", "Space"]` не совпадают с
|
|
85
|
+
современным `event.key === " "`, и миграция изменит наблюдаемое поведение у
|
|
86
|
+
потребителей.
|
|
87
|
+
- **`render` возвращает `this.props.children || null`** — falsy-`children`
|
|
88
|
+
(`0`, `""`) осознанно дают `null`. Не заменять на `??`.
|
|
89
|
+
- Реализация остаётся React 17-совместимой (класс, без хуков и React 18-only
|
|
90
|
+
API): код синхронизируется в `release-0`.
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
- Числовые константы (13, 27, 40, ...) должны использоваться через `EVENT_KEY_CODES.ENTER` / `.ESCAPE` и т.д., не литерально.
|
|
93
|
-
- Для новых компонентов предпочитать `isKey(event.key, "ENTER")` из `utils/keyboard` — она поддерживает `key`-strings и legacy-fallback.
|
|
92
|
+
---
|
|
94
93
|
|
|
95
|
-
|
|
94
|
+
## Accessibility
|
|
96
95
|
|
|
97
|
-
|
|
96
|
+
- Собственной ARIA-семантики нет — компонент прозрачен для accessibility-дерева
|
|
97
|
+
и не рендерит фокусируемых элементов.
|
|
98
|
+
- Горячая клавиша глобальная: она перехватывается независимо от позиции фокуса и
|
|
99
|
+
не анонсируется screen reader'ом. Если клавиша дублирует видимое действие,
|
|
100
|
+
оставь пользователю и обычный путь (кнопку) — клавиатурный шорткат не должен
|
|
101
|
+
быть единственным способом выполнить действие.
|
|
102
|
+
- `Esc` для закрытия оверлеев — ожидаемое поведение; возврат фокуса на триггер
|
|
103
|
+
после закрытия компонент не делает, это ответственность потребителя.
|
|
98
104
|
|
|
99
|
-
|
|
105
|
+
---
|
|
100
106
|
|
|
101
|
-
|
|
102
|
-
let accumulatedElement = children as JSX.Element;
|
|
103
|
-
for (let i = 0; i < keyDownListeners.length; i++) {
|
|
104
|
-
accumulatedElement = <KeyDownListener {...}>{accumulatedElement}</KeyDownListener>;
|
|
105
|
-
}
|
|
106
|
-
```
|
|
107
|
+
## Связанные компоненты
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
### `ComposedKeyDownListener` (`src/components/KeyDownListener/ComposedKeyDownListener.tsx`)
|
|
110
|
+
|
|
111
|
+
Композитор: оборачивает `children` в `KeyDownListener` по одному на каждый
|
|
112
|
+
элемент `keyDownListeners`. Экспортируется из того же barrel вместе со своим
|
|
113
|
+
интерфейсом props `IComposedKeyDownListenerProps` — публичное API, не менять.
|
|
114
|
+
|
|
115
|
+
Собственного AI.md пока не имеет — вся его логика описана здесь. По правилу
|
|
116
|
+
`docs/ai/CONTEXT.md` § «Когда создавать `{ComponentName}-ai.md`» он его
|
|
117
|
+
заслуживает (оба условия выполнены: barrel-экспорт + собственный prop
|
|
118
|
+
`keyDownListeners`), но по scope задачи TRI-48 соседние экспортируемые
|
|
119
|
+
компоненты выносятся в отдельные задачи. Компонент добавлен отдельной строкой в
|
|
120
|
+
`docs/ai/ROADMAP.md`, документ будет заведён своей задачей. Следствие, о котором
|
|
121
|
+
нужно знать до тех пор: `scripts/generateMcpData.ts` собирает записи по файлам
|
|
122
|
+
`src/components/*/*-ai.md`, поэтому отдельной записи в `mcp-data.json` у
|
|
123
|
+
`ComposedKeyDownListener` нет — агент найдёт его только через этот документ.
|
|
124
|
+
|
|
125
|
+
| Prop | Тип | Описание |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `keyDownListeners` | `IKeyDownListenerProps[]` | Конфигурация слушателей. Используются только `eventKeyCode` и `onMatch`, поле `children` каждого элемента игнорируется |
|
|
128
|
+
| `children` | `React.ReactNode` | Содержимое. Рендерится как есть; при пустом `keyDownListeners` — просто `children` |
|
|
129
|
+
|
|
130
|
+
Первый элемент массива оказывается самым внутренним слушателем. На порядок
|
|
131
|
+
срабатывания это влияет слабо — все слушатели подписаны на `window` независимо
|
|
132
|
+
друг от друга, а порядок вызова совпадает с порядком элементов массива.
|
|
133
|
+
|
|
134
|
+
**Ограничение: длина `keyDownListeners` должна быть стабильной между рендерами.**
|
|
135
|
+
Глубина вложенности равна длине массива, поэтому при её изменении на позиции,
|
|
136
|
+
где раньше были `children`, React монтирует новый `KeyDownListener`, а сами
|
|
137
|
+
`children` уезжают на уровень ниже и перемонтируются: DOM-узлы пересоздаются,
|
|
138
|
+
введённые значения инпутов, фокус и текущие анимации теряются. Если набор
|
|
139
|
+
слушателей динамический — держи массив постоянной длины (например, с
|
|
140
|
+
`onMatch`-заглушками) или используй `KeyDownListener` напрямую.
|
|
141
|
+
|
|
142
|
+
### Потребители внутри библиотеки
|
|
143
|
+
|
|
144
|
+
- `TriggerClickOnKeyDownEvent` (`src/components/Triggers/`) — по нажатию клавиши
|
|
145
|
+
вызывает `click` на элементе из `targetRef`.
|
|
146
|
+
- `SelectExtendedField` — закрывает выпадающий список по `Esc`.
|
|
147
|
+
- `SliderExtendedDot`, `SliderExtendedTrack` — стрелки для перемещения ползунка,
|
|
148
|
+
слушатели монтируются только когда элемент в фокусе.
|
|
111
149
|
|
|
112
150
|
---
|
|
113
151
|
|
|
114
|
-
##
|
|
152
|
+
## Stories
|
|
153
|
+
|
|
154
|
+
Основные истории: `stories/KeyDownListener/KeyDownListener.stories.tsx`
|
|
155
|
+
Файлы примеров: `stories/KeyDownListener/examples/`
|
|
115
156
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
157
|
+
| Story | Example file | Что демонстрирует |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `Playground` | `Playground.tsx` | Интерактивный выбор `eventKeyCode` и счётчик срабатываний `onMatch` |
|
|
160
|
+
| `Default` | `Default.tsx` | Слушатель одной клавиши: панель скрывается по `Esc` |
|
|
161
|
+
| `WithMultipleKeys` | `WithMultipleKeys.tsx` | Массив кодов в `eventKeyCode`; нажатая клавиша различается по `event.keyCode` |
|
|
162
|
+
| `Example` | `Example.tsx` | `ComposedKeyDownListener`: `Enter` подтверждает, `Esc` отменяет |
|
|
163
|
+
| `VisualTests` | — | Состояние после срабатывания слушателя: `play` диспатчит на `window` `keydown` с `keyCode` клавиши `Esc` |
|
|
122
164
|
|
|
123
165
|
---
|
|
124
166
|
|
|
125
|
-
##
|
|
167
|
+
## История изменений
|
|
126
168
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
-
|
|
169
|
+
| Дата | Изменение |
|
|
170
|
+
|---|---|
|
|
171
|
+
| 2026-08-04 | По ревью PR #502: интерфейс props `IComposedKeyDownListenerProps` стал экспортируемым — публичная поверхность пакета расширена аддитивно. `ComposedKeyDownListener` добавлен отдельной строкой в `docs/ai/ROADMAP.md`, собственный AI.md вынесен в отдельную задачу. |
|
|
172
|
+
| 2026-08-04 | Создан документ. AI-рефакторинг (TRI-48): JSDoc на props `IKeyDownListenerProps` и `ComposedKeyDownListener`, ветвление в `handleKeyDown` сведено к одной проверке (снят `eslint-disable`), в `ComposedKeyDownListener` убран `as JSX.Element` и добавлен `displayName`, unit-тесты (16 кейсов на оба компонента), stories по modern pattern. Публичный API не менялся; компонент остаётся class-компонентом, `forwardRef` осознанно отсутствует. |
|
|
130
173
|
|
|
131
174
|
---
|
|
132
175
|
|
|
133
|
-
##
|
|
176
|
+
## Примеры использования
|
|
134
177
|
|
|
135
|
-
|
|
178
|
+
### Default
|
|
136
179
|
|
|
137
|
-
|
|
180
|
+
`stories/KeyDownListener/examples/Default.tsx`
|
|
138
181
|
|
|
139
|
-
|
|
182
|
+
```tsx
|
|
183
|
+
import React, { useState } from "react";
|
|
184
|
+
import { KeyDownListener, EVENT_KEY_CODES } from "@sberbusiness/triplex-next";
|
|
185
|
+
|
|
186
|
+
export const Default = () => {
|
|
187
|
+
const [visible, setVisible] = useState(true);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: "400px" }}>
|
|
191
|
+
<KeyDownListener eventKeyCode={EVENT_KEY_CODES.ESCAPE} onMatch={() => setVisible(false)} />
|
|
192
|
+
<div>Нажмите Esc, чтобы скрыть панель.</div>
|
|
193
|
+
{visible && (
|
|
194
|
+
<div style={{ border: "1px dashed rgb(125, 131, 138)", borderRadius: "4px", padding: "16px" }}>
|
|
195
|
+
Панель скрывается по нажатию Esc в любом месте страницы.
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
```
|
|
140
202
|
|
|
141
|
-
|
|
142
|
-
- `WindowResizeListener` — аналогичный паттерн для resize.
|
|
143
|
-
- `@sberbusiness/triplex-next/utils/keyboard` — `EVENT_KEY_CODES`, `EVENT_KEYS`, `isKey`.
|
|
203
|
+
### WithMultipleKeys
|
|
144
204
|
|
|
145
|
-
|
|
205
|
+
`stories/KeyDownListener/examples/WithMultipleKeys.tsx`
|
|
146
206
|
|
|
147
|
-
|
|
207
|
+
```tsx
|
|
208
|
+
import React, { useState } from "react";
|
|
209
|
+
import { KeyDownListener, EVENT_KEY_CODES } from "@sberbusiness/triplex-next";
|
|
210
|
+
|
|
211
|
+
export const WithMultipleKeys = () => {
|
|
212
|
+
const [value, setValue] = useState(0);
|
|
213
|
+
|
|
214
|
+
const handleMatch = (event: KeyboardEvent) => {
|
|
215
|
+
setValue((prevValue) => (event.keyCode === EVENT_KEY_CODES.ARROW_LEFT ? prevValue - 1 : prevValue + 1));
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<KeyDownListener eventKeyCode={[EVENT_KEY_CODES.ARROW_LEFT, EVENT_KEY_CODES.ARROW_RIGHT]} onMatch={handleMatch}>
|
|
220
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: "400px" }}>
|
|
221
|
+
<div>Один слушатель реагирует на несколько клавиш: стрелка влево и стрелка вправо.</div>
|
|
222
|
+
<div style={{ border: "1px dashed rgb(125, 131, 138)", borderRadius: "4px", padding: "16px" }}>
|
|
223
|
+
Значение: {value}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</KeyDownListener>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
```
|
|
148
230
|
|
|
149
|
-
|
|
231
|
+
### Example
|
|
150
232
|
|
|
151
|
-
|
|
233
|
+
`stories/KeyDownListener/examples/Example.tsx`
|
|
152
234
|
|
|
153
|
-
|
|
235
|
+
```tsx
|
|
236
|
+
import React, { useState } from "react";
|
|
237
|
+
import { ComposedKeyDownListener, EVENT_KEY_CODES } from "@sberbusiness/triplex-next";
|
|
238
|
+
|
|
239
|
+
export const Example = () => {
|
|
240
|
+
const [result, setResult] = useState<string | null>(null);
|
|
241
|
+
|
|
242
|
+
const keyDownListeners = [
|
|
243
|
+
{ eventKeyCode: EVENT_KEY_CODES.ENTER, onMatch: () => setResult("Подтверждено по Enter") },
|
|
244
|
+
{ eventKeyCode: EVENT_KEY_CODES.ESCAPE, onMatch: () => setResult("Отменено по Esc") },
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
if (result !== null) {
|
|
248
|
+
return (
|
|
249
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: "400px" }}>
|
|
250
|
+
<div>{result}</div>
|
|
251
|
+
<button type="button" onClick={() => setResult(null)}>
|
|
252
|
+
Показать панель снова
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<ComposedKeyDownListener keyDownListeners={keyDownListeners}>
|
|
260
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: "400px" }}>
|
|
261
|
+
<div>Панель с горячими клавишами: Enter подтверждает, Esc отменяет.</div>
|
|
262
|
+
<div style={{ border: "1px dashed rgb(125, 131, 138)", borderRadius: "4px", padding: "16px" }}>
|
|
263
|
+
Ожидание нажатия клавиши.
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</ComposedKeyDownListener>
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
```
|
|
154
270
|
|
|
155
|
-
| Дата | Изменение |
|
|
156
|
-
|---|---|
|
|
157
|
-
| 2026-04-18 | Создан документ |
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
---
|
|
2
|
+
component: LoaderMiddle
|
|
3
|
+
category: Loaders
|
|
4
|
+
related: [LoaderSmall, LoaderScreen, TableBasic]
|
|
5
|
+
tokens:
|
|
6
|
+
- --triplex-next-Loader-Middle_Background_Default
|
|
7
|
+
- --triplex-next-Loader-Element_Background_Brand
|
|
8
|
+
stories: stories/Loaders/LoaderMiddle/LoaderMiddle.stories.tsx
|
|
9
|
+
version: "1.0"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# LoaderMiddle
|
|
13
|
+
|
|
14
|
+
## Назначение
|
|
15
|
+
|
|
16
|
+
Квадратный индикатор загрузки фиксированного размера 64×64 px: четыре точки по углам
|
|
17
|
+
внутренней области и линия, обегающая их по периметру. Все состояния анимации задаются CSS —
|
|
18
|
+
у компонента нет ни props, ни внутреннего состояния.
|
|
19
|
+
|
|
20
|
+
Используй когда: нужно показать, что грузится целая область или страница, а место под
|
|
21
|
+
индикатор заведомо есть (плитка, пустая таблица, оверлей поверх контента).
|
|
22
|
+
|
|
23
|
+
Не используй когда:
|
|
24
|
+
- Индикатор должен встраиваться в строку текста, кнопку или узкий элемент управления —
|
|
25
|
+
возьми `LoaderSmall` (горизонтальный, с темами и размерами `SM` / `MD` / `LG`).
|
|
26
|
+
- Нужен индикатор с подложкой, подписью и кнопками отмены — возьми `LoaderScreen`
|
|
27
|
+
(`type="middle"` рендерит внутри себя именно `LoaderMiddle`).
|
|
28
|
+
- Нужно управлять размером, цветом или темой — LoaderMiddle не настраивается, см. «Инварианты».
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Варианты и props
|
|
33
|
+
|
|
34
|
+
У компонента **нет props**. Публичный тип — `React.FC` без параметров: `<LoaderMiddle />` —
|
|
35
|
+
единственная допустимая форма вызова.
|
|
36
|
+
|
|
37
|
+
Следствия, важные при интеграции:
|
|
38
|
+
|
|
39
|
+
- `className` и произвольные HTML-атрибуты **не принимаются** — обернуть в свой контейнер и
|
|
40
|
+
стилизовать его. Так поступает `WithAnimationExample` в stories: сбрасывает фон через
|
|
41
|
+
внешний класс-обёртку, а не через prop.
|
|
42
|
+
- `ref` на корневой `<div>` **не пробрасывается** — компонент не обёрнут в `forwardRef`.
|
|
43
|
+
Если нужен доступ к DOM, измеряй элемент-обёртку.
|
|
44
|
+
- Размер (64×64), геометрия точек (12×12) и тайминги анимаций (цикл 3 s) заданы в
|
|
45
|
+
`styles/LoaderMiddle.module.less` и снаружи не настраиваются.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Дизайн-токены
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
--triplex-next-Loader-Middle_Background_Default
|
|
53
|
+
--triplex-next-Loader-Element_Background_Brand
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `Middle_Background_Default` — фон квадратной подложки (`ColorNeutral.80` в светлой теме,
|
|
57
|
+
`ColorDarkNeutral.50` в тёмной).
|
|
58
|
+
- `Element_Background_Brand` — цвет точек и обегающей линии.
|
|
59
|
+
|
|
60
|
+
Оба токена объявлены в `src/components/DesignTokens/components/Loader.ts` и общие для
|
|
61
|
+
семейства Loader (`Element_Background_Brand` используется также в `LoaderSmall`).
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Инварианты
|
|
66
|
+
|
|
67
|
+
- **Публичный контракт — компонент без props.** Добавление `className`, `...rest` или обёртка
|
|
68
|
+
в `forwardRef` меняет публичный тип компонента и требует отдельной задачи и записи в release
|
|
69
|
+
notes. По той же схеме объявлен сосед `LoaderSmall` (`React.FC`, без `forwardRef`).
|
|
70
|
+
- **Корневой элемент — `<div role="status">`.** На эту связку опираются unit-тесты компонента и
|
|
71
|
+
тесты потребителей (`LoaderScreen.test.tsx`, `TableBasic.test.tsx`), которые ищут лоадер через
|
|
72
|
+
`getByRole("status", { name: "loading" })`. Имена CSS-классов снаружи не используются —
|
|
73
|
+
тесты потребителей намеренно опираются только на роль и доступное имя.
|
|
74
|
+
- **Разметка внутренностей фиксирована:** контейнер `.loaderMiddleDots`, один `.line` и ровно
|
|
75
|
+
четыре точки `.dot` с модификаторами `.dot1`–`.dot4`. Порядок и количество завязаны на
|
|
76
|
+
keyframes (`lineCycle`, `dotCycleMiddle`) и на задержки анимации; удаление или перестановка
|
|
77
|
+
ломает визуальную последовательность.
|
|
78
|
+
- **`displayName = "LoaderMiddle"`** — используется в React DevTools.
|
|
79
|
+
- **Токены `Loader-*` общие для семейства** — переименование ломает темизацию у потребителей и
|
|
80
|
+
затрагивает `LoaderSmall`.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Accessibility
|
|
85
|
+
|
|
86
|
+
- Корневой элемент имеет `role="status"` — это неявная `aria-live="polite"` область: скринридер
|
|
87
|
+
сообщит о появлении лоадера, не прерывая текущее чтение.
|
|
88
|
+
- `aria-label="loading"` захардкожен в компоненте. Это отступление от общего правила библиотеки
|
|
89
|
+
«не хардкодить язык в aria-атрибутах», унаследованное от `LoaderSmall`; менять его нельзя без
|
|
90
|
+
согласования — строка входит в доступное имя, по которому лоадер находят тесты потребителей.
|
|
91
|
+
Локализация доступного имени — предмет отдельной задачи по всему семейству Loader.
|
|
92
|
+
- Компонент неинтерактивен: не получает фокус, не обрабатывает клавиатуру и не содержит текста.
|
|
93
|
+
Если загрузку нужно подписать для пользователя — используй `LoaderScreen` с `description`.
|
|
94
|
+
- Анимация непрерывная и не отключается по `prefers-reduced-motion` — известное ограничение
|
|
95
|
+
семейства Loader.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Связанные компоненты
|
|
100
|
+
|
|
101
|
+
- `LoaderSmall` (`src/components/Loader/LoaderSmall/LoaderSmall.tsx`) — горизонтальный вариант из
|
|
102
|
+
трёх точек с props `theme` (`ELoaderSmallTheme`) и `size` (`EComponentSize`). Экспортируется из
|
|
103
|
+
того же barrel `src/components/Loader/index.ts`.
|
|
104
|
+
- `LoaderScreen` (`src/components/LoaderScreen/LoaderScreen.tsx`) — оверлей-подложка с лоадером по
|
|
105
|
+
центру, подписью и кнопками; при `type="middle"` рендерит `LoaderMiddle` без обёрток.
|
|
106
|
+
- `TableBasic` (`src/components/Table/TableBasic/TableBasic.tsx`) — показывает `LoaderMiddle` в
|
|
107
|
+
футере таблицы при догрузке данных на пустом состоянии.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Stories
|
|
112
|
+
|
|
113
|
+
Основные истории: `stories/Loaders/LoaderMiddle/LoaderMiddle.stories.tsx`
|
|
114
|
+
Файлы примеров: `stories/Loaders/LoaderMiddle/examples/`
|
|
115
|
+
|
|
116
|
+
| Story | Example file | Что демонстрирует |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `Default` | `DefaultExample.tsx` | Единственная форма вызова — `<LoaderMiddle />` без props |
|
|
119
|
+
| `WithAnimation` | `WithAnimationExample.tsx` | Замена лоадера на Lottie-анимацию результата (success / fail / warning / waiting) с подгонкой размера точек |
|
|
120
|
+
| `VisualTests` | — | Скриншот-регрессия, анимации заглушены декоратором |
|
|
121
|
+
|
|
122
|
+
Стори `Default` и `WithAnimation` исключены из скриншот-тестов (`testRunner: { skip: true }`):
|
|
123
|
+
первая визуально идентична `VisualTests`, вторая завязана на проигрывание Lottie и давала бы
|
|
124
|
+
нестабильный снимок. Playground не создаётся — у компонента нет настраиваемых props
|
|
125
|
+
(`docs/ai/stories-guide.md` → «Когда не создавать Playground»).
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## История изменений
|
|
130
|
+
|
|
131
|
+
| Дата | Изменение |
|
|
132
|
+
|---|---|
|
|
133
|
+
| 2026-08-05 | Создан документ. AI-рефакторинг: JSDoc на компоненте, порядок импортов приведён к codestyle, в LESS объединены дублирующиеся базовые стили `.dot` и `.line`, unit-тесты расширены с 1 до 2 кейсов (роль корневого элемента, количество точек и линии). В stories `controls: { disable: true }` перенесён на верхний уровень `parameters` (внутри `docs` не применялся). Публичный API, DOM и визуальное поведение не изменены. |
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Примеры использования
|
|
138
|
+
|
|
139
|
+
### Default
|
|
140
|
+
|
|
141
|
+
`stories/Loaders/LoaderMiddle/examples/DefaultExample.tsx`
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
import React from "react";
|
|
145
|
+
import { LoaderMiddle } from "@sberbusiness/triplex-next";
|
|
146
|
+
|
|
147
|
+
export const DefaultExample = () => <LoaderMiddle />;
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### WithAnimation
|
|
151
|
+
|
|
152
|
+
`stories/Loaders/LoaderMiddle/examples/WithAnimationExample.tsx`
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import React, { useState } from "react";
|
|
156
|
+
import { Player } from "@lottiefiles/react-lottie-player";
|
|
157
|
+
import { Button, EButtonTheme, EComponentSize, LoaderMiddle } from "@sberbusiness/triplex-next";
|
|
158
|
+
import "./WithAnimationExample.less";
|
|
159
|
+
|
|
160
|
+
const successSignAnimation = new URL("../lottie_animations/successSign.json", import.meta.url).href;
|
|
161
|
+
const failSignAnimation = new URL("../lottie_animations/failSign.json", import.meta.url).href;
|
|
162
|
+
const warningSignAnimation = new URL("../lottie_animations/warningSign.json", import.meta.url).href;
|
|
163
|
+
const waitingSignAnimation = new URL("../lottie_animations/waitingSign.json", import.meta.url).href;
|
|
164
|
+
|
|
165
|
+
/** Размер стороны LoaderMiddle */
|
|
166
|
+
const loaderMiddleSize = 64;
|
|
167
|
+
/** Диаметр dots в LoaderMiddle */
|
|
168
|
+
const loaderMiddleDotSize = 12;
|
|
169
|
+
|
|
170
|
+
/** Размер стороны Lottie */
|
|
171
|
+
const lottieSize = 85;
|
|
172
|
+
/** Диаметр dots в Lottie - изначальный диаметр 12×12, под transform scale [80,80] */
|
|
173
|
+
const lottieDotSize = 12 * 0.8;
|
|
174
|
+
|
|
175
|
+
/** Диаметр dots в Lottie после вписывания Lottie в LoaderMiddle */
|
|
176
|
+
const lottieDotSizeWhenFitted = (lottieDotSize / lottieSize) * loaderMiddleSize;
|
|
177
|
+
/** Масштаб dots в Lottie после вписывания Lottie в LoaderMiddle */
|
|
178
|
+
const lottieDotSizeScale = loaderMiddleDotSize / lottieDotSizeWhenFitted;
|
|
179
|
+
|
|
180
|
+
/** Целевой размер стороны Lottie, при котором dots в Lottie совпадают с dots в LoaderMiddle. */
|
|
181
|
+
const lottieViewportSize = loaderMiddleSize * lottieDotSizeScale;
|
|
182
|
+
|
|
183
|
+
type TStatus = "success" | "fail" | "warning" | "waiting";
|
|
184
|
+
|
|
185
|
+
const STATUS_TO_ANIMATION_MAP: Record<TStatus, string> = {
|
|
186
|
+
success: successSignAnimation,
|
|
187
|
+
fail: failSignAnimation,
|
|
188
|
+
warning: warningSignAnimation,
|
|
189
|
+
waiting: waitingSignAnimation,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const WithAnimationExample = () => {
|
|
193
|
+
const [lottieStarted, setLottieStarted] = useState(false);
|
|
194
|
+
const [isLottiePlay, setIsLottiePlay] = useState(false);
|
|
195
|
+
|
|
196
|
+
const handleChange = () => {
|
|
197
|
+
if (lottieStarted) {
|
|
198
|
+
setLottieStarted(false);
|
|
199
|
+
setIsLottiePlay(false);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
setLottieStarted(true);
|
|
204
|
+
setIsLottiePlay(true);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const renderContent = (status: TStatus) => {
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
style={{
|
|
211
|
+
width: loaderMiddleSize,
|
|
212
|
+
height: loaderMiddleSize,
|
|
213
|
+
position: "relative",
|
|
214
|
+
margin: "32px 0 0 32px",
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
{!lottieStarted ? (
|
|
218
|
+
<div className="withAnimationExample">
|
|
219
|
+
<LoaderMiddle />
|
|
220
|
+
</div>
|
|
221
|
+
) : (
|
|
222
|
+
<div
|
|
223
|
+
style={{
|
|
224
|
+
position: "absolute",
|
|
225
|
+
top: "50%",
|
|
226
|
+
left: "50%",
|
|
227
|
+
width: lottieViewportSize,
|
|
228
|
+
height: lottieViewportSize,
|
|
229
|
+
transform: "translate(-50%, -50%)",
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
<Player
|
|
233
|
+
autoplay={isLottiePlay}
|
|
234
|
+
loop={false}
|
|
235
|
+
keepLastFrame
|
|
236
|
+
src={STATUS_TO_ANIMATION_MAP[status]}
|
|
237
|
+
onEvent={(event) => {
|
|
238
|
+
if (event === "complete") {
|
|
239
|
+
setIsLottiePlay(false);
|
|
240
|
+
}
|
|
241
|
+
}}
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 16, alignItems: "flex-start" }}>
|
|
251
|
+
<Button theme={EButtonTheme.GENERAL} size={EComponentSize.SM} onClick={handleChange}>
|
|
252
|
+
{lottieStarted ? "Start Loading" : "Stop Loading"}
|
|
253
|
+
</Button>
|
|
254
|
+
|
|
255
|
+
<div style={{ display: "flex", gap: 24 }}>
|
|
256
|
+
{renderContent("success")}
|
|
257
|
+
{renderContent("fail")}
|
|
258
|
+
{renderContent("warning")}
|
|
259
|
+
{renderContent("waiting")}
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
```
|
|
265
|
+
|