@sberbusiness/triplex-next-mcp-server 1.46.0-beta → 1.48.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/bundle/components/AmountField.md +222 -323
- package/bundle/components/Avatar.md +138 -146
- package/bundle/components/CardStatic.md +402 -0
- package/bundle/components/CheckboxTree.md +157 -215
- package/bundle/components/CheckboxTreeExtended.md +7 -7
- package/bundle/components/ChipGroup.md +1 -1
- package/bundle/components/ChipMonthYearPicker.md +4 -4
- package/bundle/components/ChipSuggest.md +7 -6
- package/bundle/components/CollapsibleTree.md +8 -4
- package/bundle/components/CollapsibleTreeExtended.md +8 -6
- package/bundle/components/Confirm.md +346 -281
- package/bundle/components/DateRange.md +351 -210
- package/bundle/components/FooterPage.md +1 -0
- package/bundle/components/FormField.md +11 -4
- package/bundle/components/FormGroup.md +3 -2
- package/bundle/components/LightBox.md +8 -0
- package/bundle/components/ListMaster.md +2 -2
- package/bundle/components/ListTableItem.md +6 -6
- package/bundle/components/MonthYearField.md +15 -4
- package/bundle/components/Slider.md +389 -97
- package/bundle/components/SliderRange.md +486 -105
- package/bundle/components/StatusTracker.md +386 -94
- package/bundle/components/Stepper.md +355 -102
- package/bundle/components/StepperExtended.md +633 -0
- package/bundle/components/SuggestField.md +1049 -96
- package/bundle/components/SwipeableArea.md +7 -7
- package/bundle/components/Tabs.md +265 -92
- package/bundle/components/TabsExtended.md +18 -18
- package/bundle/components/TabsLine.md +301 -127
- package/bundle/components/TagColor.md +187 -66
- package/bundle/components/TagGroup.md +184 -48
- package/bundle/components/TextareaField.md +278 -70
- package/bundle/components/TopOverlay.md +17 -1
- package/bundle/components/TreeView.md +16 -7
- package/bundle/components/TriggerClickOnKeyDownEvent.md +277 -0
- package/bundle/components/UploadZone.md +318 -0
- package/bundle/guides/codestyle.md +16 -1
- package/bundle/guides/commits.md +47 -1
- package/bundle/guides/tests.md +23 -2
- package/bundle/manifest.json +48 -21
- package/bundle/release-notes/1.47.0.mdx +115 -0
- package/bundle/release-notes/1.48.0.mdx +68 -0
- package/bundle/release-notes/1.49.0.mdx +7 -0
- package/bundle/tokens.json +3 -3
- package/dist/index.js +220 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
---
|
|
2
|
+
component: TriggerClickOnKeyDownEvent
|
|
3
|
+
category: Triggers
|
|
4
|
+
related: [KeyDownListener, ComposedKeyDownListener, LightBox]
|
|
5
|
+
tokens: []
|
|
6
|
+
stories: stories/TriggerClickOnKeyDownEvent/TriggerClickOnKeyDownEvent.stories.tsx
|
|
7
|
+
version: "1.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# TriggerClickOnKeyDownEvent
|
|
11
|
+
|
|
12
|
+
## Назначение
|
|
13
|
+
|
|
14
|
+
Сервисный headless-компонент. Пока смонтирован, слушает `keydown` на `window`
|
|
15
|
+
(через `KeyDownListener`) и при совпадении кода нажатой клавиши с `eventKeyCode`
|
|
16
|
+
вызывает нативный `click()` на кнопке из `targetRef`. Собственной разметки не
|
|
17
|
+
рендерит: `children` выводятся как есть.
|
|
18
|
+
|
|
19
|
+
Используй когда: действие уже реализовано кнопкой, и его нужно продублировать
|
|
20
|
+
глобальной горячей клавишей — закрытие оверлея по `Esc`, листание галереи
|
|
21
|
+
стрелками. Клик по настоящей кнопке даёт потребителю один обработчик вместо двух
|
|
22
|
+
и сохраняет поведение кнопки (`disabled`, `onClick`, аналитика).
|
|
23
|
+
|
|
24
|
+
Не используй когда:
|
|
25
|
+
- Нужно выполнить произвольный обработчик, а не клик по кнопке — бери
|
|
26
|
+
`KeyDownListener` напрямую.
|
|
27
|
+
- Нужно несколько разных клавиш с разными действиями — `ComposedKeyDownListener`.
|
|
28
|
+
- Клавиша должна работать только внутри конкретного элемента — навесь `onKeyDown`
|
|
29
|
+
на сам элемент: слушатель здесь глобальный и сработает при вводе в любое поле
|
|
30
|
+
на странице.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Варианты и props
|
|
35
|
+
|
|
36
|
+
### Обязательные props
|
|
37
|
+
|
|
38
|
+
| Prop | Тип | Описание |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `eventKeyCode` | `number \| number[]` | Код клавиши из `EVENT_KEY_CODES` или массив кодов. Тип переиспользуется из `IKeyDownListenerProps` через `Pick` |
|
|
41
|
+
| `targetRef` | `React.RefObject<HTMLButtonElement>` | Ссылка на кнопку, по которой выполняется клик. Кнопка не обязана находиться внутри `children` |
|
|
42
|
+
| `children` | `React.ReactElement` | Рендерится как есть. Обязателен по типу (в отличие от `KeyDownListener`), обёртки над ним компонент не создаёт |
|
|
43
|
+
|
|
44
|
+
### Особенности поведения
|
|
45
|
+
|
|
46
|
+
- **Клик выполняется только по видимой кнопке.** Перед `click()` проверяется
|
|
47
|
+
`buttonEl.offsetParent !== null` — у элемента, скрытого через `display: none`
|
|
48
|
+
(своим стилем или стилем предка), `offsetParent` равен `null`. На этом
|
|
49
|
+
построены `LightBoxClose` и `LightBoxArrow`: десктопная и мобильная копии
|
|
50
|
+
кнопки переключаются через `display: none`, оба триггера смонтированы, но
|
|
51
|
+
срабатывает только видимый.
|
|
52
|
+
- **Проверка не покрывает другие способы скрытия.** `visibility: hidden`,
|
|
53
|
+
`opacity: 0`, нулевой размер и вынос за пределы вьюпорта дают ненулевой
|
|
54
|
+
`offsetParent`, и клик пройдёт. Отдельный случай — **собственный**
|
|
55
|
+
`position: fixed` у самой кнопки: у такого элемента `offsetParent` тоже
|
|
56
|
+
`null`, поэтому клавишей он не кликается. `fixed` на предке на проверку не
|
|
57
|
+
влияет — тогда `offsetParent` кнопки равен ближайшему позиционированному
|
|
58
|
+
предку, то есть самому `fixed`-контейнеру. Поэтому `ModalWindowClose` и
|
|
59
|
+
`LightBoxClose`, живущие внутри `fixed`-оверлеев, по Esc работают.
|
|
60
|
+
- **Пустой `targetRef` безопасен.** Если `targetRef.current === null` (кнопка не
|
|
61
|
+
смонтирована), обработчик молча ничего не делает.
|
|
62
|
+
- **Клик синтезируется через нативный `HTMLElement.click()`**, а не через
|
|
63
|
+
`dispatchEvent` с кастомным событием: `onClick` кнопки получает обычное
|
|
64
|
+
`click`-событие с `event.detail === 0`. `disabled`-кнопка такой клик нативно
|
|
65
|
+
игнорирует.
|
|
66
|
+
- Триггеры **вкладываются друг в друга**, когда одна клавиша должна обслуживать
|
|
67
|
+
несколько кнопок (`LightBoxClose`, `LightBoxArrow`): каждый уровень добавляет
|
|
68
|
+
свой `KeyDownListener` и не влияет на `children`.
|
|
69
|
+
- Компонент не вызывает `preventDefault` и не останавливает всплытие — нажатие
|
|
70
|
+
клавиши дойдёт и до других слушателей.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Дизайн-токены
|
|
75
|
+
|
|
76
|
+
Собственных стилей и CSS-переменных нет — компонент не рендерит разметку.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Инварианты
|
|
81
|
+
|
|
82
|
+
- **`forwardRef` осознанно отсутствует** — то же исключение, что у
|
|
83
|
+
`KeyDownListener`: компонент не рендерит собственный host-элемент, форвардить
|
|
84
|
+
`ref` некуда. Добавление `forwardRef` изменило бы публичный тип компонента с
|
|
85
|
+
`React.FC` на `React.ForwardRefExoticComponent` — не делать.
|
|
86
|
+
- **`ITriggerClickOnKeyDownEventProps` экспортируется** — из файла и через
|
|
87
|
+
`export *` в barrel, как у остальных компонентов библиотеки. Интерфейс —
|
|
88
|
+
часть публичного API: имена и типы полей не менять.
|
|
89
|
+
- **Имена props `children`, `eventKeyCode`, `targetRef`** и barrel-экспорт
|
|
90
|
+
`TriggerClickOnKeyDownEvent` из `src/components/Triggers/index.ts` — публичное
|
|
91
|
+
API, не менять.
|
|
92
|
+
- **Тип `targetRef` — `RefObject<HTMLButtonElement>`**, а не `Ref`: компоненту
|
|
93
|
+
нужен доступ к `.current`, callback-ref сюда не подходит.
|
|
94
|
+
- **Проверка `offsetParent !== null`** — часть контракта, а не оптимизация. Её
|
|
95
|
+
снятие сломает `LightBoxClose`/`LightBoxArrow`: клавиша начнёт кликать скрытые
|
|
96
|
+
копии кнопок. Тест `does not click the target button when it is hidden`
|
|
97
|
+
фиксирует это поведение.
|
|
98
|
+
- Реализация остаётся React 17-совместимой (без хуков-новинок React 18): код
|
|
99
|
+
синхронизируется в ветку `release-0`.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Accessibility
|
|
104
|
+
|
|
105
|
+
- Собственной ARIA-семантики нет — компонент прозрачен для accessibility-дерева.
|
|
106
|
+
- Горячая клавиша глобальная и не анонсируется screen reader'ом, но действие
|
|
107
|
+
всегда доступно и обычным путём — через саму кнопку. Это и есть причина
|
|
108
|
+
кликать по кнопке, а не вызывать обработчик напрямую.
|
|
109
|
+
- `Esc` для закрытия оверлеев — ожидаемое поведение. Возврат фокуса на триггер
|
|
110
|
+
после закрытия компонент не делает: это ответственность потребителя
|
|
111
|
+
(`ModalWindow`, `LightBox`).
|
|
112
|
+
- Если кнопка иконочная, доступное имя (`title` / `aria-label`) передаёт
|
|
113
|
+
потребитель — библиотека мультиязычная и текст внутри не хардкодит.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Связанные компоненты
|
|
118
|
+
|
|
119
|
+
- `KeyDownListener` (`src/components/KeyDownListener/`) — слушатель клавиш, на
|
|
120
|
+
котором построен триггер. Нужен, когда по клавише выполняется произвольный
|
|
121
|
+
обработчик, а не клик по кнопке.
|
|
122
|
+
- `ComposedKeyDownListener` — несколько клавиш с разными обработчиками; альтернатива,
|
|
123
|
+
когда действий по клавишам больше одного и они не сводятся к клику по кнопке.
|
|
124
|
+
- `LightBox` — потребитель, который опирается на проверку видимости: `LightBoxClose`
|
|
125
|
+
и `LightBoxArrow` держат смонтированными по два триггера на одну клавишу
|
|
126
|
+
(десктопная и мобильная копии кнопки) и рассчитывают, что сработает только
|
|
127
|
+
видимая.
|
|
128
|
+
|
|
129
|
+
### Остальные потребители внутри библиотеки
|
|
130
|
+
|
|
131
|
+
- `ModalWindowClose` — закрытие модального окна по `Esc`.
|
|
132
|
+
- `Confirm.Close` — закрытие подтверждения по `Esc` при `clickByEsc`.
|
|
133
|
+
- `LightBoxSideOverlayCloseDesktop` — закрытие бокового оверлея лайтбокса по `Esc`.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Stories
|
|
138
|
+
|
|
139
|
+
Основные истории: `stories/TriggerClickOnKeyDownEvent/TriggerClickOnKeyDownEvent.stories.tsx`
|
|
140
|
+
Файлы примеров: `stories/TriggerClickOnKeyDownEvent/examples/`
|
|
141
|
+
|
|
142
|
+
| Story | Example file | Что демонстрирует |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `Playground` | `Playground.tsx` | Интерактивный выбор `eventKeyCode` и переключатель видимости кнопки |
|
|
145
|
+
| `Default` | `Default.tsx` | Кнопка закрытия панели кликается по `Esc` |
|
|
146
|
+
| `WithMultipleKeys` | `WithMultipleKeys.tsx` | Массив кодов в `eventKeyCode`: кнопка кликается по `Enter` и по стрелке вправо |
|
|
147
|
+
| `WithHiddenTarget` | `WithHiddenTarget.tsx` | Вложенные триггеры на одну клавишу; скрытая через `display: none` кнопка пропускается |
|
|
148
|
+
|
|
149
|
+
Набор целиком исключён из скриншот-тестов (`testRunner: { skip: true }` в `meta`):
|
|
150
|
+
собственной разметки компонент не рендерит, на скриншот попадала бы только
|
|
151
|
+
обвязка примеров. Поведение покрыто unit-тестами
|
|
152
|
+
(`src/components/Triggers/__tests__/TriggerClickOnKeyDownEvent.test.tsx`).
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## История изменений
|
|
157
|
+
|
|
158
|
+
| Дата | Изменение |
|
|
159
|
+
|---|---|
|
|
160
|
+
| 2026-09-17 | Создан документ. AI-рефакторинг (TRI-101): JSDoc на props и на обработчик клавиши, ветвление в обработчике сведено к одной проверке, импорт React поднят в начало файла; unit-тесты (9 кейсов), stories по modern pattern. `forwardRef` осознанно отсутствует. По итогам ревью PR #628 `ITriggerClickOnKeyDownEventProps` стал экспортируемым — аддитивное расширение публичного API, приведение к общей конвенции репозитория. |
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Примеры использования
|
|
165
|
+
|
|
166
|
+
### Default
|
|
167
|
+
|
|
168
|
+
`stories/TriggerClickOnKeyDownEvent/examples/Default.tsx`
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
import React, { useRef, useState } from "react";
|
|
172
|
+
import { Button, EButtonTheme, EVENT_KEY_CODES, TriggerClickOnKeyDownEvent } from "@sberbusiness/triplex-next";
|
|
173
|
+
|
|
174
|
+
export const Default = () => {
|
|
175
|
+
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
176
|
+
const [visible, setVisible] = useState(true);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: "400px" }}>
|
|
180
|
+
<div>Нажмите Esc или кнопку «Закрыть» — панель скроется.</div>
|
|
181
|
+
{visible && (
|
|
182
|
+
<div style={{ border: "1px dashed rgb(125, 131, 138)", borderRadius: "4px", padding: "16px" }}>
|
|
183
|
+
<TriggerClickOnKeyDownEvent eventKeyCode={EVENT_KEY_CODES.ESCAPE} targetRef={closeButtonRef}>
|
|
184
|
+
<Button ref={closeButtonRef} onClick={() => setVisible(false)} theme={EButtonTheme.SECONDARY}>
|
|
185
|
+
Закрыть
|
|
186
|
+
</Button>
|
|
187
|
+
</TriggerClickOnKeyDownEvent>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### WithMultipleKeys
|
|
196
|
+
|
|
197
|
+
`stories/TriggerClickOnKeyDownEvent/examples/WithMultipleKeys.tsx`
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
import React, { useRef, useState } from "react";
|
|
201
|
+
import { Button, EButtonTheme, EVENT_KEY_CODES, TriggerClickOnKeyDownEvent } from "@sberbusiness/triplex-next";
|
|
202
|
+
|
|
203
|
+
export const WithMultipleKeys = () => {
|
|
204
|
+
const nextButtonRef = useRef<HTMLButtonElement>(null);
|
|
205
|
+
const [step, setStep] = useState(1);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: "400px" }}>
|
|
209
|
+
<div>Кнопка кликается и по Enter, и по стрелке вправо — в eventKeyCode передан массив кодов.</div>
|
|
210
|
+
<TriggerClickOnKeyDownEvent
|
|
211
|
+
eventKeyCode={[EVENT_KEY_CODES.ENTER, EVENT_KEY_CODES.ARROW_RIGHT]}
|
|
212
|
+
targetRef={nextButtonRef}
|
|
213
|
+
>
|
|
214
|
+
<Button
|
|
215
|
+
ref={nextButtonRef}
|
|
216
|
+
onClick={() => setStep((prevStep) => prevStep + 1)}
|
|
217
|
+
theme={EButtonTheme.GENERAL}
|
|
218
|
+
>
|
|
219
|
+
Дальше
|
|
220
|
+
</Button>
|
|
221
|
+
</TriggerClickOnKeyDownEvent>
|
|
222
|
+
<div style={{ border: "1px dashed rgb(125, 131, 138)", borderRadius: "4px", padding: "16px" }}>
|
|
223
|
+
Шаг: {step}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### WithHiddenTarget
|
|
231
|
+
|
|
232
|
+
`stories/TriggerClickOnKeyDownEvent/examples/WithHiddenTarget.tsx`
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
import React, { useRef, useState } from "react";
|
|
236
|
+
import { Button, EButtonTheme, EVENT_KEY_CODES, TriggerClickOnKeyDownEvent } from "@sberbusiness/triplex-next";
|
|
237
|
+
|
|
238
|
+
export const WithHiddenTarget = () => {
|
|
239
|
+
const visibleButtonRef = useRef<HTMLButtonElement>(null);
|
|
240
|
+
const hiddenButtonRef = useRef<HTMLButtonElement>(null);
|
|
241
|
+
const [lastClicked, setLastClicked] = useState("—");
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "16px", maxWidth: "400px" }}>
|
|
245
|
+
<div>
|
|
246
|
+
Два триггера на одну клавишу: по Esc кликается только видимая кнопка, скрытая через display: none
|
|
247
|
+
пропускается.
|
|
248
|
+
</div>
|
|
249
|
+
<TriggerClickOnKeyDownEvent eventKeyCode={EVENT_KEY_CODES.ESCAPE} targetRef={visibleButtonRef}>
|
|
250
|
+
<TriggerClickOnKeyDownEvent eventKeyCode={EVENT_KEY_CODES.ESCAPE} targetRef={hiddenButtonRef}>
|
|
251
|
+
<>
|
|
252
|
+
<Button
|
|
253
|
+
ref={visibleButtonRef}
|
|
254
|
+
onClick={() => setLastClicked("видимая кнопка")}
|
|
255
|
+
theme={EButtonTheme.SECONDARY}
|
|
256
|
+
>
|
|
257
|
+
Видимая кнопка
|
|
258
|
+
</Button>
|
|
259
|
+
<Button
|
|
260
|
+
ref={hiddenButtonRef}
|
|
261
|
+
onClick={() => setLastClicked("скрытая кнопка")}
|
|
262
|
+
theme={EButtonTheme.SECONDARY}
|
|
263
|
+
style={{ display: "none" }}
|
|
264
|
+
>
|
|
265
|
+
Скрытая кнопка
|
|
266
|
+
</Button>
|
|
267
|
+
</>
|
|
268
|
+
</TriggerClickOnKeyDownEvent>
|
|
269
|
+
</TriggerClickOnKeyDownEvent>
|
|
270
|
+
<div style={{ border: "1px dashed rgb(125, 131, 138)", borderRadius: "4px", padding: "16px" }}>
|
|
271
|
+
Последний клик: {lastClicked}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
```
|
|
277
|
+
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
---
|
|
2
|
+
component: UploadZone
|
|
3
|
+
category: UploadZone
|
|
4
|
+
related: []
|
|
5
|
+
tokens:
|
|
6
|
+
- UploadZone.Background_Default
|
|
7
|
+
- UploadZone.Background_Hover
|
|
8
|
+
- UploadZone.BorderColor_Default
|
|
9
|
+
- UploadZone.DragArea_Background
|
|
10
|
+
- UploadZone.DragArea_BorderColor
|
|
11
|
+
stories: stories/UploadZone/UploadZone.stories.tsx
|
|
12
|
+
version: "1.0"
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# UploadZone
|
|
16
|
+
|
|
17
|
+
## Назначение
|
|
18
|
+
|
|
19
|
+
Зона загрузки файлов: кликабельная область с пунктирной рамкой, открывающая
|
|
20
|
+
системный диалог выбора файлов, и опциональная дроп-зона, которая раскрывается
|
|
21
|
+
поверх заданного контейнера, пока пользователь перетаскивает файлы.
|
|
22
|
+
|
|
23
|
+
Собственного содержимого компонент не рисует — разметка передаётся
|
|
24
|
+
render-функцией в `children`, а результат выбора приходит одним обработчиком
|
|
25
|
+
`onChange` и для диалога, и для drag-and-drop.
|
|
26
|
+
|
|
27
|
+
Используй когда: нужен блок «перетащите файл или выберите на компьютере»,
|
|
28
|
+
в том числе с подсветкой всей области формы/карточки при перетаскивании.
|
|
29
|
+
Не используй когда: нужен готовый список загруженных файлов со статусами —
|
|
30
|
+
`UploadZone` отвечает только за приём файлов, таблицу/список строит потребитель
|
|
31
|
+
(см. story `Example: production`); нужна кнопка загрузки без зоны
|
|
32
|
+
перетаскивания — достаточно обычного `<input type="file">` рядом с `Button`.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Варианты и props
|
|
37
|
+
|
|
38
|
+
### Обязательные props
|
|
39
|
+
|
|
40
|
+
| Prop | Тип | Описание |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `children` | `(props: IUploadZoneChildrenProvideProps) => React.ReactNode` | Render-функция содержимого зоны. Получает `openUploadDialog` |
|
|
43
|
+
| `onChange` | `UploadZoneOnChangeType` | Единый обработчик выбора файлов: `(files: FileList \| null, e: React.SyntheticEvent) => void`. Вызывается и при выборе через диалог, и при drop |
|
|
44
|
+
|
|
45
|
+
### Опциональные props
|
|
46
|
+
|
|
47
|
+
| Prop | Тип | По умолчанию | Описание |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `dropZoneContainer` | `HTMLElement \| null` | `undefined` | Контейнер, на площади которого перехватывается перетаскивание. Без него drag-and-drop не работает вовсе |
|
|
50
|
+
| `renderContainerContent` | `() => JSX.Element` | `undefined` | Контент внутри дроп-зоны (иконка + подпись «Положите файлы сюда») |
|
|
51
|
+
|
|
52
|
+
Остальные props — `Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "children">`.
|
|
53
|
+
|
|
54
|
+
### Куда попадают `className`, `ref` и `...rest`
|
|
55
|
+
|
|
56
|
+
Это главная неочевидность компонента:
|
|
57
|
+
|
|
58
|
+
- **`ref`** указывает на корневой `<div class="uploadZone">` — обёртку,
|
|
59
|
+
которая задаёт фон и `position: relative`.
|
|
60
|
+
- **`className` и все остальные HTML-атрибуты** уходят **не** на корневой
|
|
61
|
+
элемент, а на абсолютно позиционированный слой-перехватчик кликов
|
|
62
|
+
(`uploadZoneDragArea`) внутри него. Те же `className` и `...rest`
|
|
63
|
+
дублируются на слой дроп-зоны (`uploadZoneContainerDragArea`), когда он
|
|
64
|
+
смонтирован.
|
|
65
|
+
- **`onDrop` и `onDragOver`** вынимаются из `...rest` отдельно и вешаются
|
|
66
|
+
только на дроп-зону. `onDragOver` вызывается после `preventDefault()`,
|
|
67
|
+
`onDrop` — до `onChange`.
|
|
68
|
+
- `onClick` из `...rest` раскладывается **после** внутреннего обработчика, поэтому
|
|
69
|
+
переданный `onClick` полностью заменяет открытие диалога по клику на зону.
|
|
70
|
+
|
|
71
|
+
### `UploadZone.Input` — поле выбора файлов
|
|
72
|
+
|
|
73
|
+
Составная часть: `UploadZone.Input` (он же экспортируемый `UploadZoneInput`).
|
|
74
|
+
|
|
75
|
+
- Рендерится внутри `children` и обязателен, если нужен диалог выбора файлов:
|
|
76
|
+
`openUploadDialog()` вызывает `click()` именно на нём. Без `UploadZone.Input`
|
|
77
|
+
и клик по зоне, и `openUploadDialog()` ничего не делают.
|
|
78
|
+
- Поле скрыто (`display: none`) и потому не фокусируется — доступную точку
|
|
79
|
+
входа даёт потребитель (см. Accessibility).
|
|
80
|
+
- Свой `onChange` полем не поддерживается (`Omit<..., "onChange">`): изменение
|
|
81
|
+
всегда уходит в `onChange` родительского `UploadZone` через контекст.
|
|
82
|
+
Остальные атрибуты (`multiple`, `accept`, `disabled`, `data-*`) проходят как есть.
|
|
83
|
+
- При клике поле сбрасывает `value`, иначе повторный выбор того же файла не
|
|
84
|
+
вызовет `change`.
|
|
85
|
+
- `ref` пробрасывается на `<input>`; параллельно ссылка кладётся во внутренний
|
|
86
|
+
контекст — переданный потребителем ref её не отменяет.
|
|
87
|
+
|
|
88
|
+
### Как работает дроп-зона
|
|
89
|
+
|
|
90
|
+
- Слушатели `dragenter` / `dragleave` вешаются на `dropZoneContainer`.
|
|
91
|
+
Счётчик вложенных `dragenter` (события всплывают от дочерних элементов)
|
|
92
|
+
не даёт зоне мигать при движении курсора над содержимым.
|
|
93
|
+
- Пока идёт перетаскивание, в `dropZoneContainer` добавляется отдельный
|
|
94
|
+
`<div>` с собственным React-root (`createRoot`), в нём рендерится дроп-зона
|
|
95
|
+
на всю площадь контейнера. После `drop` или ухода курсора узел удаляется,
|
|
96
|
+
а root размонтируется на следующем микротаске.
|
|
97
|
+
- Контент дроп-зоны формируется **один раз** в момент появления. Изменение
|
|
98
|
+
`renderContainerContent` или `className` во время перетаскивания на уже
|
|
99
|
+
открытую зону не влияет.
|
|
100
|
+
- Контейнеру нужен `position: relative` (и `display: flow-root`, если зона
|
|
101
|
+
должна накрывать блок целиком) — слой позиционируется абсолютно по всем
|
|
102
|
+
четырём сторонам.
|
|
103
|
+
|
|
104
|
+
### Мобильная вёрстка
|
|
105
|
+
|
|
106
|
+
На ширине `≤ @screen-sm-max` (767px) фон корневого элемента и слой с пунктирной
|
|
107
|
+
рамкой скрываются: перетаскивание на мобильных недоступно. Разметку под мобильный
|
|
108
|
+
сценарий (заголовок + кнопка «Загрузить», вызывающая `openUploadDialog`) даёт
|
|
109
|
+
потребитель — типовой приём с `MobileView` показан в stories `Default`
|
|
110
|
+
и `Example: production`. На узком экране перетаскивания нет, поэтому по дизайну
|
|
111
|
+
вместо области сброса показывается обычная кнопка загрузки.
|
|
112
|
+
|
|
113
|
+
Список уже загруженных файлов потребитель переключает тем же `MobileView`
|
|
114
|
+
отдельно от зоны: на широком экране — таблица, на узком — `List` с
|
|
115
|
+
`ListTableItem` (имя, размер, статус), а действия над файлом уходят в
|
|
116
|
+
`controlButtons` под свайпом. Пример — story `Example: production`.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Дизайн-токены
|
|
121
|
+
|
|
122
|
+
Переопределяются через `ThemeProvider` (prop `tokens`) — см. `ThemeProvider-ai.md` →
|
|
123
|
+
«Как переопределять токены». Значения по умолчанию — `src/components/DesignTokens/components/UploadZone.ts`.
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
UploadZone.Background_Default
|
|
127
|
+
UploadZone.Background_Hover
|
|
128
|
+
UploadZone.BorderColor_Default
|
|
129
|
+
UploadZone.DragArea_Background
|
|
130
|
+
UploadZone.DragArea_BorderColor
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`Background_*` — фон корневого блока в покое и при наведении (на мобильной
|
|
134
|
+
ширине фон отключён). `BorderColor_Default` — пунктирная рамка зоны в покое.
|
|
135
|
+
`DragArea_*` — фон и рамка дроп-зоны, видимой во время перетаскивания.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Инварианты
|
|
140
|
+
|
|
141
|
+
- `forwardRef` на `UploadZone` — не убирать; ref всегда указывает на корневой
|
|
142
|
+
`<div class="uploadZone">`, а не на слой-перехватчик.
|
|
143
|
+
- Раскладка `className` / `...rest` на слой `uploadZoneDragArea` (а не на
|
|
144
|
+
корневой элемент) — наблюдаемое поведение публичного API. Перенос их на
|
|
145
|
+
корневой `<div>` сломает существующую вёрстку потребителей.
|
|
146
|
+
- `onChange` — единственный канал результата и для диалога, и для drop.
|
|
147
|
+
Добавлять отдельный `onChange` в `UploadZone.Input` нельзя: тип поля намеренно
|
|
148
|
+
исключает этот prop.
|
|
149
|
+
- `UploadZoneOnChangeType` экспортируется из barrel без префикса `T`
|
|
150
|
+
(нарушение текущего naming convention) — переименование будет ломающим
|
|
151
|
+
изменением, оставлено как есть.
|
|
152
|
+
- Barrel `src/components/UploadZone/index.ts` экспортирует `UploadZone`,
|
|
153
|
+
`IUploadZoneProps`, `IUploadZoneChildrenProvideProps`, `UploadZoneInput`,
|
|
154
|
+
`IUploadZoneInputProps`, `UploadZoneOnChangeType`.
|
|
155
|
+
- `UploadZoneContext` — внутренняя деталь, в barrel его нет; связь
|
|
156
|
+
`UploadZone` ↔ `UploadZone.Input` можно менять свободно.
|
|
157
|
+
- Класс `uploadZoneContainerDragArea` используется unit-тестами для поиска
|
|
158
|
+
смонтированной дроп-зоны — переименование требует правки тестов.
|
|
159
|
+
- Дроп-зона монтируется через отдельный `createRoot` (React 18 API) — это
|
|
160
|
+
единственное место компонента, зависящее от версии React.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Accessibility
|
|
165
|
+
|
|
166
|
+
- Слой-перехватчик кликов — `<div role="none">` с `onClick`: он намеренно
|
|
167
|
+
скрыт от дерева доступности и **не управляется с клавиатуры**.
|
|
168
|
+
- Поле `UploadZone.Input` скрыто через `display: none`, поэтому в таб-порядок
|
|
169
|
+
тоже не попадает.
|
|
170
|
+
- Отсюда требование к потребителю: чтобы загрузка была доступна с клавиатуры
|
|
171
|
+
и скринридеру, в `children` нужен фокусируемый элемент, вызывающий
|
|
172
|
+
`openUploadDialog`.
|
|
173
|
+
- Этому требованию отвечает `Button` (в том числе `theme={EButtonTheme.LINK}`,
|
|
174
|
+
если нужен вид ссылки): он рендерит `<button>` и попадает в таб-порядок.
|
|
175
|
+
**`Link` без `href` — не отвечает**: он рендерит `<a role="link">`, а такой
|
|
176
|
+
элемент браузер в таб-порядок не включает.
|
|
177
|
+
- В десктопном контенте примеров stories используется `Link`, поэтому точка
|
|
178
|
+
входа в диалог там с клавиатуры недоступна. Мобильные ветки `Default`
|
|
179
|
+
и `Example: production` вызывают `openUploadDialog` из `Button` и доступны.
|
|
180
|
+
В продуктовом коде предпочитай `Button`.
|
|
181
|
+
- Текстовые подписи и `aria-label` задаёт потребитель: компонент не хардкодит
|
|
182
|
+
строки на конкретном языке.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Связанные компоненты
|
|
187
|
+
|
|
188
|
+
Отдельного семейства и близких альтернатив у компонента нет — `related` пуст.
|
|
189
|
+
|
|
190
|
+
- `UploadZone.Input` — составная часть, описана выше; собственного AI.md не имеет.
|
|
191
|
+
- Типовые соседи в разметке (связь композиционная, не структурная): `MobileView`
|
|
192
|
+
для мобильного варианта содержимого, `TableBasic` со списком загруженных
|
|
193
|
+
файлов на широком экране и `List` / `ListTableItem` /
|
|
194
|
+
`ListItemControlsButton` — на узком, `Link` / `Button` как точка вызова
|
|
195
|
+
`openUploadDialog`, `HelpBox` с подсказкой о допустимых форматах.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Stories
|
|
200
|
+
|
|
201
|
+
Основные истории: `stories/UploadZone/UploadZone.stories.tsx`
|
|
202
|
+
Файлы примеров: `stories/UploadZone/examples/`
|
|
203
|
+
|
|
204
|
+
| Story | Example file | Что демонстрирует |
|
|
205
|
+
|---|---|---|
|
|
206
|
+
| `Playground` | `Playground.tsx` | Переключение `dropZoneContainer`, `renderContainerContent` и `multiple` |
|
|
207
|
+
| `Default` | `Default.tsx` | Зона с дроп-зоной на площади внешнего контейнера; на узком экране через `MobileView` вместо неё кнопка |
|
|
208
|
+
| `Production` (Example: production) | `Production.tsx` | Композиция со списком загруженных файлов: таблица на широком экране, `ListTableItem` на узком; оба варианта через `MobileView` |
|
|
209
|
+
| `VisualTests` | `VisualTests.tsx` | Состояния для скриншот-тестов: покой и раскрытая дроп-зона (через `play`) |
|
|
210
|
+
|
|
211
|
+
`Playground` исключён из скриншот-тестов: кадр зависит от значений controls.
|
|
212
|
+
`Production` тоже исключён — пример композиционный, его кадр ловит изменения
|
|
213
|
+
`TableBasic`, `List` и `MobileView`, а не самой зоны загрузки.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## История изменений
|
|
218
|
+
|
|
219
|
+
| Дата | Изменение |
|
|
220
|
+
|---|---|
|
|
221
|
+
| 2026-09-16 | Создан документ. AI-рефакторинг: `UploadZone.Input` получил `forwardRef` и корректный `displayName`, stories переписаны на modern pattern |
|
|
222
|
+
| 2026-09-18 | Правки по ревью: в примерах точка входа в диалог вернулась на `Link`, уточнён раздел Accessibility |
|
|
223
|
+
| 2026-09-21 | Правки по ревью: в `Example: production` список загруженных файлов на узком экране переведён на `ListTableItem` |
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Примеры использования
|
|
228
|
+
|
|
229
|
+
### Default
|
|
230
|
+
|
|
231
|
+
`stories/UploadZone/examples/Default.tsx`
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
import React, { useState } from "react";
|
|
235
|
+
import {
|
|
236
|
+
UploadZone,
|
|
237
|
+
MobileView,
|
|
238
|
+
Gap,
|
|
239
|
+
Text,
|
|
240
|
+
Link,
|
|
241
|
+
Button,
|
|
242
|
+
EButtonTheme,
|
|
243
|
+
EComponentSize,
|
|
244
|
+
EFontType,
|
|
245
|
+
ETextSize,
|
|
246
|
+
} from "@sberbusiness/triplex-next";
|
|
247
|
+
import { ClouddraguploadStrokeSrvIcon32 } from "@sberbusiness/icons-next";
|
|
248
|
+
|
|
249
|
+
export const Default = () => {
|
|
250
|
+
/** Контейнер, на площади которого перехватывается перетаскивание файлов. */
|
|
251
|
+
const [dropZoneContainer, setDropZoneContainer] = useState<HTMLDivElement | null>(null);
|
|
252
|
+
|
|
253
|
+
const handleChange = () => {};
|
|
254
|
+
|
|
255
|
+
const renderContainerContent = () => (
|
|
256
|
+
<div
|
|
257
|
+
style={{
|
|
258
|
+
display: "flex",
|
|
259
|
+
flexDirection: "column",
|
|
260
|
+
alignItems: "center",
|
|
261
|
+
justifyContent: "center",
|
|
262
|
+
height: "100%",
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<ClouddraguploadStrokeSrvIcon32 paletteIndex={5} />
|
|
266
|
+
<Gap size={4} />
|
|
267
|
+
<Text type={EFontType.PRIMARY} size={ETextSize.B3} tag="div">
|
|
268
|
+
Положите файлы сюда
|
|
269
|
+
</Text>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
/** Широкий экран: перетаскивание плюс ссылка выбора файлов. */
|
|
274
|
+
const renderDesktopContent = (openUploadDialog: () => void) => (
|
|
275
|
+
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "16px 0" }}>
|
|
276
|
+
<UploadZone.Input multiple />
|
|
277
|
+
<ClouddraguploadStrokeSrvIcon32 paletteIndex={5} />
|
|
278
|
+
<Gap size={4} />
|
|
279
|
+
<Text type={EFontType.PRIMARY} size={ETextSize.B3} tag="div">
|
|
280
|
+
Label text
|
|
281
|
+
{"\u00A0"}
|
|
282
|
+
<Link onClick={openUploadDialog}>Link text</Link>
|
|
283
|
+
</Text>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
/** Узкий экран: перетаскивания нет, поэтому вместо области сброса — обычная кнопка. */
|
|
288
|
+
const renderMobileContent = (openUploadDialog: () => void) => (
|
|
289
|
+
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", padding: "16px 0" }}>
|
|
290
|
+
<UploadZone.Input multiple />
|
|
291
|
+
<Text type={EFontType.PRIMARY} size={ETextSize.B3} tag="div">
|
|
292
|
+
Файлы для импорта
|
|
293
|
+
</Text>
|
|
294
|
+
<Gap size={8} />
|
|
295
|
+
<Button theme={EButtonTheme.SECONDARY} size={EComponentSize.SM} onClick={openUploadDialog}>
|
|
296
|
+
Загрузить
|
|
297
|
+
</Button>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div ref={setDropZoneContainer} style={{ position: "relative", display: "flow-root", maxWidth: "480px" }}>
|
|
303
|
+
<UploadZone
|
|
304
|
+
onChange={handleChange}
|
|
305
|
+
dropZoneContainer={dropZoneContainer}
|
|
306
|
+
renderContainerContent={renderContainerContent}
|
|
307
|
+
>
|
|
308
|
+
{({ openUploadDialog }) => (
|
|
309
|
+
<MobileView fallback={renderDesktopContent(openUploadDialog)}>
|
|
310
|
+
{renderMobileContent(openUploadDialog)}
|
|
311
|
+
</MobileView>
|
|
312
|
+
)}
|
|
313
|
+
</UploadZone>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
```
|
|
318
|
+
|
|
@@ -108,6 +108,9 @@ import { Button, EButtonTheme } from "@sberbusiness/triplex-next";
|
|
|
108
108
|
// ✅ Внутри src/components
|
|
109
109
|
import { EComponentSize } from "@sberbusiness/triplex-next/enums";
|
|
110
110
|
|
|
111
|
+
// ✅ Внутренний модуль вне публичных subentry — только относительный путь
|
|
112
|
+
import { setForwardedRef } from "../../helpers/setForwardedRef";
|
|
113
|
+
|
|
111
114
|
// ✅ В unit-тестах можно импортировать локально, следуя паттерну файла
|
|
112
115
|
import { Button } from "../Button";
|
|
113
116
|
```
|
|
@@ -116,6 +119,16 @@ import { Button } from "../Button";
|
|
|
116
119
|
|
|
117
120
|
- В новых stories/examples, которые показывают копируемый код, импортируй публичный API из `@sberbusiness/triplex-next` или публичных subentry.
|
|
118
121
|
- В unit-тестах и внутреннем коде следуй локальному паттерну файла и предпочитай ближайшие импорты для тестируемого модуля и внутренних зависимостей.
|
|
122
|
+
- Алиас `@sberbusiness/triplex-next/...` внутри `src` допустим только для путей,
|
|
123
|
+
которые являются публичными subentry: `components`, `enums`, `utils`, `consts`,
|
|
124
|
+
`types`. Всё остальное — `src/helpers/`, внутренние `components/*/utils.ts`,
|
|
125
|
+
`components/*/consts.ts` и прочие непубличные модули — импортируй относительным
|
|
126
|
+
путём.
|
|
127
|
+
|
|
128
|
+
Почему так: алиас настроен только на время сборки (`vite.config.ts`, `resolve.alias`),
|
|
129
|
+
а `package.json` не объявляет `exports`, поэтому алиасный путь выглядит как публичный
|
|
130
|
+
subentry, хотя модуль из barrel не экспортируется. Ориентир — существующий
|
|
131
|
+
`src/helpers/breakpoints.ts`: он везде импортируется как `../../helpers/breakpoints`.
|
|
119
132
|
|
|
120
133
|
Порядок импортов (prettier-plugin-organize-imports не используется, но придерживайся):
|
|
121
134
|
|
|
@@ -133,7 +146,9 @@ import { Button } from "../Button";
|
|
|
133
146
|
|
|
134
147
|
- Только `.module.less` файлы — никаких глобальных стилей.
|
|
135
148
|
- Имена классов — **camelCase**: `styles.secondaryLight`, `styles.iconOnly`.
|
|
136
|
-
- Никаких **inline styles** в компонентах
|
|
149
|
+
- Никаких **inline styles** в компонентах за исключением:
|
|
150
|
+
- передачи CSS-переменных для динамических стилей;
|
|
151
|
+
- использования в stories для лейаута примеров.
|
|
137
152
|
- **Цвета** — только через CSS-переменные токенов (`var(--triplex-next-...)`). Никаких hex/rgb.
|
|
138
153
|
В стилях это правильная запись: CSS-переменные — внутренний слой токенов. А в документации
|
|
139
154
|
(`*-ai.md`, тексты Storybook) токен указывается путём `{Группа}.{Токен}` — см.
|