@sberbusiness/triplex-next-mcp-server 0.36.0-beta0 → 1.40.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/bundle/components/AlertContext.md +268 -0
  2. package/bundle/components/Button.md +5 -2
  3. package/bundle/components/ButtonDropdown.md +9 -1
  4. package/bundle/components/ButtonDropdownExtended.md +422 -0
  5. package/bundle/components/ButtonIcon.md +7 -1
  6. package/bundle/components/Caption.md +291 -0
  7. package/bundle/components/CodeText.md +197 -0
  8. package/bundle/components/Col.md +311 -162
  9. package/bundle/components/CollapsibleTree.md +6 -3
  10. package/bundle/components/CollapsibleTreeExtended.md +6 -2
  11. package/bundle/components/Dropdown.md +748 -346
  12. package/bundle/components/LightBox.md +2252 -233
  13. package/bundle/components/List.md +26 -1
  14. package/bundle/components/ListActionItem.md +157 -0
  15. package/bundle/components/ListItem.md +34 -9
  16. package/bundle/components/ListItemSelectable.md +2 -2
  17. package/bundle/components/ListMaster.md +4 -5
  18. package/bundle/components/ListTableItem.md +391 -0
  19. package/bundle/components/Notification.md +21 -21
  20. package/bundle/components/Page.md +1 -0
  21. package/bundle/components/Portal.md +131 -67
  22. package/bundle/components/Row.md +171 -69
  23. package/bundle/components/Text.md +320 -0
  24. package/bundle/components/TextField.md +364 -97
  25. package/bundle/components/Title.md +296 -0
  26. package/bundle/guides/commits.md +118 -24
  27. package/bundle/guides/context.md +1 -1
  28. package/bundle/guides/tests.md +42 -0
  29. package/bundle/manifest.json +89 -14
  30. package/bundle/release-notes/1.37.0.mdx +42 -1
  31. package/bundle/release-notes/1.38.0.mdx +36 -0
  32. package/bundle/release-notes/1.39.0.mdx +66 -0
  33. package/bundle/release-notes/1.40.0.mdx +27 -0
  34. package/bundle/release-notes/1.41.0.mdx +7 -0
  35. package/package.json +1 -1
@@ -1,13 +1,12 @@
1
1
  ---
2
2
  component: LightBox
3
- category: Overlays
4
- figma-node: ""
5
- figma-file: ""
6
- related: [ModalWindow, Overlay, TopOverlay, Portal, MobileView, FocusTrap]
3
+ category: LightBox
4
+ related: [Page, TopOverlay, Portal, FocusTrapExtended, LoaderScreen, Button, ModalWindow]
7
5
  tokens:
8
- - --lightBox-screen-left
9
- - --lightBox-screen-width
10
- - --lightBox-screen-top
6
+ - --triplex-next-LightBox-Backdrop_Background
7
+ - --triplex-next-LightBox-Content_Background
8
+ - --triplex-next-LightBox-SideOverlay_Background
9
+ - --triplex-next-scroll-width
11
10
  stories: stories/LightBox/LightBox.stories.tsx
12
11
  version: "1.0"
13
12
  ---
@@ -16,307 +15,2327 @@ version: "1.0"
16
15
 
17
16
  ## Назначение
18
17
 
19
- Полноэкранный модальный контейнер (лайтбокс) — занимает всю область окна
20
- с backdrop'ом и focus-trap'ом. Composite: Header-controls + Content +
21
- левая/правая sidebar-панели + выезжающий справа SideOverlay + TopOverlay.
22
- Рассчитан на "иммерсивные" сценарии (просмотр документа, мастер настройки,
23
- workflow-редактор).
18
+ Полноэкранный диалог поверх страницы для отображения крупного контента: просмотр документа,
19
+ пошаговый сценарий, галерея. Рендерится через портал в отдельную DOM-ноду, блокирует скролл
20
+ страницы, держит фокус внутри (focus trap). Составной компонент: контентная область
21
+ (`LightBox.Content`), кнопки управления (`LightBox.Controls` — закрыть/назад/вперёд),
22
+ боковые панели-сайдбары (`LightBox.LeftSidebar` / `LightBox.RightSidebar`), выезжающая справа
23
+ панель (`LightBox.SideOverlay`) и верхняя панель (`LightBox.TopOverlay`).
24
24
 
25
- Используй когда: нужен полноэкранный режим с собственным scroll-container'ом,
26
- focus management и возможностью иметь вложенные overlay'и (sidebar,
27
- top-bar, боковая панель). Для стандартного диалога — `ModalWindow`.
25
+ Используй когда: контенту нужен весь экран и собственный сценарий навигации (листание,
26
+ вложенные панели).
27
+ Не используй когда: достаточно компактного диалога с кнопками действий — возьми `ModalWindow`;
28
+ нужна нотификация — возьми соответствующий компонент уведомлений.
28
29
 
29
- ---
30
-
31
- ## Файловая структура
32
-
33
- ```text
34
- src/components/LightBox/
35
- ├── LightBox.tsx # Основной компонент + Object.assign
36
- ├── LightBoxContent.tsx # Скроллируемый контент, paddingTop = высота controls
37
- ├── enums.ts # ELightBoxSize (SM/MD/LG/XL)
38
- ├── index.ts # Barrel (+ re-export SideOverlay, enums)
39
- ├── LightBoxControls/
40
- │ ├── LightBoxControls.tsx # Container с data-lightbox-component="controls"
41
- │ ├── LightBoxClose.tsx
42
- │ ├── LightBoxNext.tsx
43
- │ └── LightBoxPrev.tsx
44
- ├── LightBoxSidebars/
45
- │ ├── LightBoxLeftSidebar.tsx # forwardRef, ResizeObserver, show/hide по minVisibleWidth
46
- │ └── LightBoxRightSidebar.tsx
47
- ├── LightBoxSideOverlay/
48
- │ ├── LightBoxSideOverlay.tsx # Выезжающая справа панель (Overlay direction=RIGHT)
49
- │ ├── LightBoxSideOverlayCloseDesktop.tsx
50
- │ ├── LightBoxSideOverlayCloseMobile.tsx
51
- │ ├── LightBoxSideOverlayLoader.tsx
52
- │ └── index.ts # Re-export SideOverlay
53
- ├── LightBoxViewManager/
54
- │ ├── LightBoxViewManager.tsx # Вычисляет CSS-переменные --lightBox-screen-*
55
- │ └── LightBoxViewManagerConsts.ts
56
- └── styles/
57
- ├── LightBox.module.less
58
- ├── LightBoxControls.module.less
59
- └── LightBoxScroll.module.less
60
- ```
30
+ Внутрь `LightBox.Content` кладётся `Page` (Header/Body/Footer). Если контента мало —
31
+ `Page.Body` типа `FIRST`; если много — тип `SECOND` с несколькими `Island` внутри.
61
32
 
62
33
  ---
63
34
 
64
- ## Compound-структура
35
+ ## Варианты и props
65
36
 
66
- ```tsx
67
- <LightBox size={ELightBoxSize.LG} isLoading={loading}>
68
- <LightBox.Controls>
69
- <LightBox.Controls.Prev onClick={...} />
70
- <LightBox.Controls.Next onClick={...} />
71
- <LightBox.Controls.Close onClick={onClose} />
72
- </LightBox.Controls>
37
+ ### Размеры (`ELightBoxSize`)
73
38
 
74
- <LightBox.LeftSidebar fixed onShow={...}>
75
- {/* Левая панель — список разделов */}
76
- </LightBox.LeftSidebar>
39
+ Ограничивают максимальную ширину контентной области:
77
40
 
78
- <LightBox.Content isLoading={contentLoading} loadingTitle="Загрузка…">
79
- {/* Основной контент */}
80
- </LightBox.Content>
41
+ | Значение | Max-width контента |
42
+ |---|---|
43
+ | `SM` | 664px |
44
+ | `MD` (по умолчанию) | 864px |
45
+ | `LG` | 1064px |
46
+ | `XL` | 1264px |
81
47
 
82
- <LightBox.RightSidebar>{/* Правая панель */}</LightBox.RightSidebar>
48
+ ### Обязательные props (`LightBox`)
83
49
 
84
- <LightBox.SideOverlay opened={sideOpen} onClose={...}>
85
- {/* Выезжает справа */}
86
- </LightBox.SideOverlay>
50
+ | Prop | Тип | Описание |
51
+ |---|---|---|
52
+ | `children` | `React.ReactElement[]` | Массив элементов: `LightBox.Content` и опционально `LightBox.Controls`, сайдбары, оверлеи |
87
53
 
88
- <LightBox.TopOverlay>{/* TopOverlay из components/TopOverlay */}</LightBox.TopOverlay>
89
- </LightBox>
90
- ```
54
+ ### Опциональные props (`LightBox`)
91
55
 
92
- `LightBox.TopOverlay` — **реэкспорт внешнего** компонента `TopOverlay`,
93
- а не локальный. Это нужно учитывать при типизации.
56
+ | Prop | Тип | По умолчанию | Описание |
57
+ |---|---|---|---|
58
+ | `size` | `ELightBoxSize` | `MD` | Максимальная ширина контента |
59
+ | `isLoading` | `boolean` | — | Состояние загрузки: деактивирует focus trap; `LightBox.Content` показывает `LoaderScreen` |
60
+ | `isSideOverlayOpened` | `boolean` | — | Флаг открытой боковой панели — добавляет модификатор на корневой элемент (блокирует скролл контента) |
61
+ | `isTopOverlayOpened` | `boolean` | — | Флаг открытой верхней панели — аналогичный модификатор |
62
+ | `focusTrapProps` | `IFocusTrapExtendedProps` | — | Переопределение настроек `FocusTrapExtended` |
63
+ | `forwardRef` | `React.MutableRefObject<HTMLElement \| null>` | — | Ref на корневой контейнер (исторически — prop, а не `React.forwardRef`) |
64
+ | `mountNode` | `HTMLDivElement` | нода `#LightBox-next-mount-node` (создаётся автоматически) | DOM-нода для портала |
65
+ | `lightBoxViewManagerNodeId` | `string` | `LightBox-next-view-manager-node` | Id DOM-элемента, задающего левую/правую визуальные границы лайтбокса (split-mode). Известная особенность: если элемента с кастомным id нет в DOM, fallback-нода создаётся с дефолтным id |
66
+
67
+ Открытие/закрытие управляется снаружи условным рендером: `{isOpen && <LightBox>...</LightBox>}` —
68
+ собственного prop `opened` нет.
69
+
70
+ ### Ключевые props субкомпонентов
71
+
72
+ - `LightBox.Content`: `isLoading`, `loaderScreenProps`; автоматически добавляет `padding-top`
73
+ на высоту `LightBox.Controls`.
74
+ - `LightBox.Controls.Close`: `onClick` (обязателен), `title` (по умолчанию «Закрыть»);
75
+ срабатывает по Esc.
76
+ - `LightBox.Controls.Prev` / `Next`: `onClick` (обязателен), `clickByArrowLeft` /
77
+ `clickByArrowRight` — клик по стрелкам клавиатуры, `dataTutorialId`.
78
+ - `LightBox.LeftSidebar` / `RightSidebar`: `fixed` (фиксация панели), `minVisibleWidth`
79
+ (порог ширины в px, ниже которого содержимое скрывается; по умолчанию 100), `onShow`/`onHide`.
80
+ Отслеживают собственную ширину через `ResizeObserver`.
81
+ - `LightBox.SideOverlay`: `opened` (управляется снаружи), `onOpen`/`onClose` (вызываются по
82
+ окончании CSS-анимации), `isLoading`, `isTopLevelSideOverlayOpened` (открыт другой SideOverlay
83
+ поверх), `isTopOverlayOpened`, `size` (`EComponentSize` SM/MD/LG),
84
+ субкомпоненты `SideOverlay.CloseDesktop` / `SideOverlay.CloseMobile`.
85
+ - `LightBox.TopOverlay` — реэкспорт компонента `TopOverlay`.
94
86
 
95
87
  ---
96
88
 
97
- ## Варианты и props
98
-
99
- ### LightBox — `ILightBoxProps extends HTMLAttributes<HTMLDivElement>`
89
+ ## Дизайн-токены
100
90
 
101
- | Prop | Тип | Default | Описание |
102
- |---|---|---|---|
103
- | `children` | `ReactElement[]` | — | Обязателен как массив (не `ReactNode`). Компонент рендерит всех без перебора — дети статичные слоты. |
104
- | `size` | `ELightBoxSize` | `MD` | `SM`/`MD`/`LG`/`XL` — влияет на ширину контентной зоны. |
105
- | `focusTrapProps` | `FocusTrapProps` | — | Переопределение параметров `focus-trap-react`. |
106
- | `forwardRef` | `MutableRefObject<HTMLElement \| null>` | — | **Не `ref`**, а отдельный prop. Корневой компонент — не `forwardRef`. |
107
- | `mountNode` | `HTMLDivElement` | auto | Куда рендерить через Portal. По умолчанию — DOM-элемент `#LightBox-next-mount-node` (создаётся при первом монтировании). |
108
- | `lightBoxViewManagerNodeId` | `string` | `"LightBox-next-view-manager-node"` | DOM-элемент, по границам которого позиционируется LightBox (для кейса "LightBox не на весь экран"). |
109
- | `isLoading` | `boolean` | — | Показывает loader, блокирует FocusTrap. |
110
- | `isSideOverlayOpened` | `boolean` | — | Родитель контролирует — включает классы `.lightBoxSideOverlayActive` на корне. |
111
- | `isTopOverlayOpened` | `boolean` | — | Аналогично для TopOverlay. |
112
-
113
- ### ELightBoxSize
114
-
115
- ```ts
116
- enum ELightBoxSize { SM = "sm", MD = "md", LG = "lg", XL = "xl" }
91
+ ```css
92
+ --triplex-next-LightBox-Backdrop_Background
93
+ --triplex-next-LightBox-Content_Background
94
+ --triplex-next-LightBox-SideOverlay_Background
95
+ --triplex-next-scroll-width
117
96
  ```
118
97
 
119
- **Не `EComponentSize`** — собственный enum с дополнительным `XL`.
98
+ `--triplex-next-scroll-width` — глобальная переменная ширины скроллбара, проставляется утилитой
99
+ `addClassNameWithScrollbarWidth` при открытии лайтбокса. Ширины размеров (`664/864/1064/1264`)
100
+ заданы LESS-константами `@lightBox-content-max-width-*`.
120
101
 
121
- ### LightBox.Content — `ILightBoxContentProps`
122
-
123
- | Prop | Тип | Описание |
124
- |---|---|---|
125
- | `isLoading` | `boolean` | Накладывает `LoaderScreen` поверх контента. |
126
- | `loadingTitle` | `ReactNode` | Подпись под спиннером. |
102
+ ---
127
103
 
128
- - Внутри — свой scroll-контейнер. `paddingTop` **вычисляется рантайм** по высоте `data-lightbox-component="controls"` (querySelector — **не по ref**).
129
- - Слушает resize через `react-resize-detector` (debounce 100ms) и `WindowResizeListener`.
104
+ ## Инварианты
130
105
 
131
- ### LightBox.LeftSidebar / RightSidebar — `forwardRef<HTMLDivElement>`
106
+ - **Публичный API**: имена props `ILightBoxProps`, значения `ELightBoxSize`, компаундные ключи
107
+ `LightBox.Content` / `SideOverlay` / `TopOverlay` / `Controls` / `LeftSidebar` / `RightSidebar`
108
+ и `Controls.Close` / `Prev` / `Next` — не переименовывать.
109
+ - **`forwardRef`-паттерн**: `LightBox` принимает ref через prop `forwardRef`
110
+ (`MutableRefObject`) — не менять на `React.forwardRef` (breaking change);
111
+ `LightBoxSideOverlay`, `LightBoxLeftSidebar`, `LightBoxRightSidebar` обёрнуты в
112
+ `React.forwardRef` — не убирать.
113
+ - **Экспортируемые константы** `lightBoxMountNodeIdDefault` (`LightBox-next-mount-node`) и
114
+ `lightBoxViewManagerNodeIdDefault` (`LightBox-next-view-manager-node`) — часть публичного API.
115
+ - **`data-lightbox-component="controls"`** на контейнере `LightBox.Controls` — по нему
116
+ `LightBox.Content` вычисляет `padding-top`. Не удалять.
117
+ - **`data-test-id`** `lightBox-prev` / `lightBox-next` на кнопках стрелок — используются
118
+ автотестами потребителей.
119
+ - **Глобальные классы** `global-LB-less-or-equal-media-point-0` / `global-LB-more-media-point-0`
120
+ (`LightBoxViewManagerConsts`) заменяют media query по ширине контейнера; связаны со стилями в
121
+ `src/styles/components/lightbox.less`.
122
+ - Константы `LightBoxViewManagerConsts` (ширина стрелки 64, отступы 16/16, breakpoint 1024 +
123
+ ширина скроллбара) синхронизированы с LESS — менять только парой.
124
+ - Пока смонтирован хотя бы один лайтбокс, на `document.documentElement` висит класс блокировки
125
+ скролла; ведётся счётчик смонтированных лайтбоксов — класс снимается при размонтировании
126
+ последнего (при переключении роутов второй лайтбокс может смонтироваться раньше, чем
127
+ размонтируется первый). Аналогичный счётчик в `LightBoxViewManager` управляет классами
128
+ mount-ноды (`LightBoxMountNodeViewManager`, `LB-*`).
129
+ - Баррел `index.ts` реэкспортирует `LightBox`, `enums`, `LightBoxSideOverlay` — сохранять.
130
+ - React 17 совместимость (синхронизация в release-0): React 18-only API не использовать.
132
131
 
133
- | Prop | Тип | Default | Описание |
134
- |---|---|---|---|
135
- | `fixed` | `boolean` | — | Закрепляет панель (не скроллится вместе с контентом). |
136
- | `minVisibleWidth` | `number` | `100` | Ширина (px), ниже которой контент панели скрывается классом `.hidden`. |
137
- | `onShow` / `onHide` | `() => void` | — | Callback'и при пересечении порога ширины. Хранятся в ref — замыкание на актуальное значение. |
132
+ ---
138
133
 
139
- Использует `ResizeObserver` на outer-элементе.
134
+ ## Accessibility
140
135
 
141
- ### LightBox.SideOverlay — `ILightBoxSideOverlayProps`
136
+ - Корневой элемент и `SideOverlay` — `role="dialog"` + `aria-modal="true"`.
137
+ - **Focus trap** (`FocusTrapExtended`): активен, пока `isLoading` не установлен; у `SideOverlay` —
138
+ пока панель открыта и не анимируется. Начальный фокус — элемент с data-атрибутом
139
+ `FocusTrapUtils.firstInteractionElementDataAttr` (потребитель помечает его сам, например
140
+ заголовок с `tabIndex={-1}`).
141
+ - **Клавиатура**: Esc — клик по `Controls.Close`; ArrowLeft / ArrowRight — клик по
142
+ `Controls.Prev` / `Controls.Next` при включённых `clickByArrowLeft` / `clickByArrowRight`.
143
+ При открытом `SideOverlay` клавиатурные триггеры контролов лайтбокса не активны
144
+ (рендерится вариант кнопок без триггера).
145
+ - Кнопки `Controls.Close` имеют `data-exclude-modal-focus` — исключаются из начального фокуса
146
+ (у `Prev`/`Next` атрибута нет).
147
+ - `title` у `Controls.Close` по умолчанию «Закрыть» — для других языков потребитель передаёт
148
+ свой `title`.
142
149
 
143
- | Prop | Тип | Default | Описание |
144
- |---|---|---|---|
145
- | `opened` | `boolean` | — | Управляемое состояние. |
146
- | `onClose` / `onOpen` | `() => void` | — | Срабатывают после завершения `transitionend` (не сразу при смене `opened`). |
147
- | `focusTrapProps` | `FocusTrapProps` | — | Собственный FocusTrap внутри LightBox'овского (вложенные trap'ы). |
148
- | `isLoading` | `boolean` | — | Показывает внутренний loader. |
149
- | `isTopLevelSideOverlayOpened` | `boolean` | — | Если сверху открыт ещё один overlay — отключить scroll в текущем. |
150
- | `isTopOverlayOpened` | `boolean` | — | Аналогично. |
151
- | `size` | `EComponentSize` | `MD` | Ширина панели. |
150
+ ---
152
151
 
153
- - Имеет подкомпоненты `.CloseDesktop` и `.CloseMobile` (две разных кнопки закрытия по вьюпорту).
154
- - Внутри — `OverlayBase direction={RIGHT}` + `OverlayMask` + `FocusTrap`.
155
- - Отслеживает `opening`/`closing` state'ы по `prevOpened` (useRef) — для анимированных переходов.
152
+ ## Связанные компоненты
156
153
 
157
- ### LightBox.Controls — `ILightBoxControlsFC` (Object.assign)
154
+ - `LightBoxContent` (`LightBoxContent.tsx`) — контентная область; следит за высотой контролов и шириной окна
155
+ - `LightBoxControls` + `LightBoxClose` / `LightBoxPrev` / `LightBoxNext` (`LightBoxControls/`) — кнопки управления; стрелки построены на общем внутреннем `LightBoxArrow` (не экспортируется)
156
+ - `LightBoxLeftSidebar` / `LightBoxRightSidebar` (`LightBoxSidebars/`) — сайдбары с автоскрытием содержимого по ширине (общий хук `useLightBoxSidebarVisibility`)
157
+ - `LightBoxSideOverlay` (`LightBoxSideOverlay/`) — выезжающая справа панель с собственным focus trap, лоадером и кнопками закрытия CloseDesktop/CloseMobile
158
+ - `LightBoxViewManager` (`LightBoxViewManager/`) — внутренний менеджер позиционирования: следит за границами view-manager-ноды, проставляет breakpoint-классы и CSS-переменные
159
+ - `TopOverlay` (`src/components/TopOverlay`) — верхняя панель, реэкспортируется как `LightBox.TopOverlay`
160
+ - `Page` (`src/components/Page`) — рекомендуемая структура содержимого `LightBox.Content`
161
+ - `ModalWindow` — альтернатива для компактных диалогов
158
162
 
159
- Container с `data-lightbox-component="controls"` — **используется для вычисления paddingTop контента** (querySelector в LightBoxContent). Не менять атрибут.
163
+ ---
160
164
 
161
- Подкомпоненты: `.Close`, `.Next`, `.Prev`.
165
+ ## Stories
162
166
 
163
- ### LightBox.TopOverlay
167
+ Основные истории: `stories/LightBox/LightBox.stories.tsx`
168
+ Файлы примеров: `stories/LightBox/examples/`
169
+ Визуальная регрессия: `stories/LightBox/LightBoxInitialOpened.stories.tsx` (лайтбокс, открытый
170
+ сразу при рендере — для скриншот-тестов; baseline в `__screenshots__/`).
164
171
 
165
- Реэкспорт `TopOverlay` из `../TopOverlay/TopOverlay`. Используй как `<LightBox.TopOverlay>` или `<TopOverlay>` — идентично.
172
+ | Story | Example file | Что демонстрирует |
173
+ |---|---|---|
174
+ | `Playground` | — | Интерактивный контроль size / isLoading / контролы / sticky header и footer |
175
+ | `Default` | `DefaultExample.tsx` | Базовая конфигурация с контролами и sticky-шапкой/футером |
176
+ | `Sizes` | `SizesExample.tsx` | Размеры SM / MD / LG / XL |
177
+ | `SmallContent` | `SmallContentExample.tsx` | Малый контент с `Page.Body` типа FIRST |
178
+ | `SplitMode` | `SplitModeExample.tsx` | Позиционирование по границам кастомной view-manager-ноды |
179
+ | `WithSidebars` | `WithSidebarsExample.tsx` | Левый и правый сайдбары |
180
+ | `WithFixedSidebars` | `WithFixedSidebarsExample.tsx` | Фиксированные сайдбары (`fixed`) |
181
+ | `WithOneSidebar` | `WithOneSidebarExample.tsx` | Только один сайдбар |
182
+ | `WithSideOverlay` | `WithSideOverlayExample.tsx` | Боковая выезжающая панель |
183
+ | `WithSideOverlayLoading` | `WithSideOverlayLoadingExample.tsx` | SideOverlay в состоянии загрузки |
184
+ | `WithTopOverlay` | `WithTopOverlayExample.tsx` | Верхняя панель |
185
+ | `WithTopOverlayInSideOverlay` | `WithTopOverlayInSideOverlayExample.tsx` | TopOverlay внутри SideOverlay |
186
+ | `LightBoxInitialOpened/Default` | — | Visual regression: лайтбокс, открытый сразу при рендере (`LightBoxInitialOpened.stories.tsx`, baseline в `__screenshots__/`) |
166
187
 
167
188
  ---
168
189
 
169
- ## Ключевые особенности реализации
190
+ ## История изменений
191
+
192
+ | Дата | Изменение |
193
+ |---|---|
194
+ | 2026-07-22 | Создан документ (Phase 1 AI-Ready). В том же изменении — AI-рефакторинг: JSDoc на props, общий `LightBoxArrow` для Prev/Next, хук `useLightBoxSidebarVisibility` для сайдбаров, устранение eslint-подавлений; публичный API не менялся |
195
+ | 2026-07-22 | Добавлен `export` к props-интерфейсам `ILightBoxControlsProps`, `ILightBoxCloseProps`, `ILightBoxPrevProps`, `ILightBoxNextProps`, `ILightBoxSideOverlayLoaderProps` (аддитивно, для консистентности с остальными интерфейсами компонента) |
196
+ | 2026-07-22 | Багфиксы по ревью PR #474: клавиатурные триггеры контролов целятся в видимую кнопку (стрелки не работали на desktop, Esc — на mobile); таймер-хак блокировки скролла заменён счётчиком смонтированных лайтбоксов; `LightBoxViewManager` снимает классы mount-ноды при размонтировании последнего менеджера |
170
197
 
171
- ### Два portal'а + ViewManager
198
+ ---
172
199
 
173
- ```tsx
174
- <>
175
- <Portal container={lightBoxMountNode.current}>{renderLightBox()}</Portal>
176
- <LightBoxViewManager lightBoxViewManagerNode={...} lightBoxMountNode={...} />
177
- </>
178
- ```
200
+ ## Примеры использования
179
201
 
180
- - **LightBox** рендерится в `#LightBox-next-mount-node` (обычно в body).
181
- - **ViewManager** рендерится в `#LightBox-next-view-manager-node` (по умолчанию в body, но потребитель может создать его в нужном контейнере) и **вычисляет CSS-переменные** `--lightBox-screen-left/width/top` через `<style>`-тег на mountNode.
182
- - Это позволяет LightBox'у "уменьшаться" до размеров конкретной области (например, если есть глобальный sidebar — LightBox не должен его перекрывать).
202
+ ### Default
183
203
 
184
- ### `FocusTrap` + `MobileView` fallback
204
+ `stories/LightBox/examples/DefaultExample.tsx`
185
205
 
186
206
  ```tsx
187
- <MobileView fallback={<FocusTrap ...>{renderLightBox()}</FocusTrap>}>
188
- {renderLightBox()}
189
- </MobileView>
207
+ import React, { useState } from "react";
208
+ import {
209
+ LightBox,
210
+ Page,
211
+ Button,
212
+ EButtonTheme,
213
+ Gap,
214
+ Title,
215
+ Text,
216
+ EFontType,
217
+ ETextSize,
218
+ ETitleSize,
219
+ EHeaderPageType,
220
+ EFooterPageType,
221
+ EBodyPageType,
222
+ Island,
223
+ IslandBody,
224
+ EIslandType,
225
+ FocusTrapUtils,
226
+ EComponentSize,
227
+ } from "@sberbusiness/triplex-next";
228
+ import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
229
+
230
+ const POEM_LINES: string[] = [
231
+ "Мой дядя самых честных правил,",
232
+ "Когда не в шутку занемог,",
233
+ "Он уважать себя заставил",
234
+ "И лучше выдумать не мог.",
235
+ "Его пример другим наука;",
236
+ "Но, боже мой, какая скука",
237
+ "С больным сидеть и день и ночь,",
238
+ "Не отходя ни шагу прочь!",
239
+ "Какое низкое коварство",
240
+ "Полуживого забавлять,",
241
+ "Ему подушки поправлять,",
242
+ "Печально подносить лекарство,",
243
+ "Вздыхать и думать про себя:",
244
+ "Когда же черт возьмёт тебя…",
245
+ ];
246
+
247
+ const PoemBlock: React.FC = () => (
248
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
249
+ <IslandBody>
250
+ {POEM_LINES.map((line) => (
251
+ <React.Fragment key={line}>
252
+ {line}
253
+ <br />
254
+ </React.Fragment>
255
+ ))}
256
+ </IslandBody>
257
+ </Island>
258
+ );
259
+
260
+ const DefaultExample = () => {
261
+ const [isOpen, setIsOpen] = useState(false);
262
+
263
+ const handleOpen = () => setIsOpen(true);
264
+ const handleClose = () => setIsOpen(false);
265
+
266
+ return (
267
+ <div>
268
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={handleOpen}>
269
+ Открыть LightBox
270
+ </Button>
271
+
272
+ {isOpen ? (
273
+ <LightBox isLoading={false} isSideOverlayOpened={false} isTopOverlayOpened={false}>
274
+ <LightBox.Content key="content" isLoading={false}>
275
+ <Page>
276
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
277
+ <Page.Header.Title>
278
+ <Page.Header.Title.Content>
279
+ <Title
280
+ tag="h1"
281
+ size={ETitleSize.H1}
282
+ tabIndex={-1}
283
+ // Устанавливает фокус на первый элемент при открытии LightBox.
284
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
285
+ >
286
+ Евгений Онегин
287
+ </Title>
288
+ <Gap size={8} />
289
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
290
+ Русский поэт, драматург и прозаик, заложивший основы русского
291
+ реалистического направления.
292
+ </Text>
293
+ </Page.Header.Title.Content>
294
+ <Page.Header.Title.Controls>
295
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
296
+ Button text
297
+ </Button>
298
+ <Button
299
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
300
+ theme={EButtonTheme.SECONDARY}
301
+ size={EComponentSize.MD}
302
+ />
303
+ </Page.Header.Title.Controls>
304
+ </Page.Header.Title>
305
+ </Page.Header>
306
+
307
+ <Page.Body type={EBodyPageType.SECOND}>
308
+ {[0, 1, 2].map((index) => (
309
+ <React.Fragment key={index}>
310
+ <PoemBlock />
311
+ {index < 2 && <Gap size={16} />}
312
+ </React.Fragment>
313
+ ))}
314
+ </Page.Body>
315
+
316
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
317
+ <Page.Footer.Description>
318
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
319
+ <Page.Footer.Description.Controls>
320
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
321
+ Button text
322
+ </Button>
323
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
324
+ Button text
325
+ </Button>
326
+ </Page.Footer.Description.Controls>
327
+ </Page.Footer.Description>
328
+ </Page.Footer>
329
+ </Page>
330
+ </LightBox.Content>
331
+
332
+ <LightBox.Controls>
333
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={handleClose} />
334
+ <LightBox.Controls.Prev
335
+ title="Назад"
336
+ clickByArrowLeft
337
+ onClick={() => console.log("Prev clicked")}
338
+ />
339
+ <LightBox.Controls.Next
340
+ title="Вперёд"
341
+ clickByArrowRight
342
+ onClick={() => console.log("Next clicked")}
343
+ />
344
+ </LightBox.Controls>
345
+ </LightBox>
346
+ ) : null}
347
+ </div>
348
+ );
349
+ };
350
+ export default DefaultExample;
190
351
  ```
191
352
 
192
- - На mobile (определяется `MobileView`) — **без focus trap'а** (нативный scroll/focus лучше работает без trap'а на мобильных).
193
- - На desktop — `FocusTrap` с `initialFocus = getFirstInteractionElementByDataAttr` из `FocusTrapUtils`.
194
- - `active={!isLoading}` — trap неактивен во время загрузки.
353
+ ### Sizes
195
354
 
196
- ### Safari z-index hack
355
+ `stories/LightBox/examples/SizesExample.tsx`
197
356
 
198
357
  ```tsx
199
- <span ref={tempButtonRef} className={styles.tempElSafariBug} />
200
-
201
- useEffect(() => {
202
- if (!isSideOverlayOpened && tempButtonRef.current) {
203
- const nextZIndex = Math.round(Math.random() * 100);
204
- tempButtonRef.current.style.zIndex = nextZIndex.toString();
205
- }
206
- }, [isSideOverlayOpened]);
358
+ import React, { useState } from "react";
359
+ import {
360
+ LightBox,
361
+ ELightBoxSize,
362
+ Page,
363
+ Button,
364
+ EButtonTheme,
365
+ Gap,
366
+ Title,
367
+ Text,
368
+ EFontType,
369
+ ETextSize,
370
+ ETitleSize,
371
+ EHeaderPageType,
372
+ EFooterPageType,
373
+ EBodyPageType,
374
+ Island,
375
+ IslandBody,
376
+ EIslandType,
377
+ FocusTrapUtils,
378
+ EComponentSize,
379
+ } from "@sberbusiness/triplex-next";
380
+
381
+ const POEM_LINES: string[] = [
382
+ "Мой дядя самых честных правил,",
383
+ "Когда не в шутку занемог,",
384
+ "Он уважать себя заставил",
385
+ "И лучше выдумать не мог.",
386
+ "Его пример другим наука;",
387
+ "Но, боже мой, какая скука",
388
+ "С больным сидеть и день и ночь,",
389
+ "Не отходя ни шагу прочь!",
390
+ ];
391
+
392
+ const PoemBlock: React.FC = () => (
393
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
394
+ <IslandBody>
395
+ {POEM_LINES.map((line) => (
396
+ <React.Fragment key={line}>
397
+ {line}
398
+ <br />
399
+ </React.Fragment>
400
+ ))}
401
+ </IslandBody>
402
+ </Island>
403
+ );
404
+
405
+ const SIZES = Object.values(ELightBoxSize);
406
+
407
+ export const SizesExample = () => {
408
+ const [openedSize, setOpenedSize] = useState<ELightBoxSize | null>(null);
409
+
410
+ const handleClose = () => setOpenedSize(null);
411
+
412
+ return (
413
+ <div>
414
+ <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
415
+ {SIZES.map((size) => (
416
+ <Button
417
+ key={size}
418
+ theme={EButtonTheme.SECONDARY}
419
+ size={EComponentSize.MD}
420
+ onClick={() => setOpenedSize(size)}
421
+ >
422
+ {size.toUpperCase()}
423
+ </Button>
424
+ ))}
425
+ </div>
426
+
427
+ {openedSize !== null ? (
428
+ <LightBox isLoading={false} isSideOverlayOpened={false} isTopOverlayOpened={false} size={openedSize}>
429
+ <LightBox.Content key="content" isLoading={false}>
430
+ <Page>
431
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
432
+ <Page.Header.Title>
433
+ <Page.Header.Title.Content>
434
+ <Title
435
+ tag="h1"
436
+ size={ETitleSize.H1}
437
+ tabIndex={-1}
438
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
439
+ >
440
+ LightBox {openedSize.toUpperCase()}
441
+ </Title>
442
+ <Gap size={8} />
443
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
444
+ Размер контента: {openedSize.toUpperCase()}
445
+ </Text>
446
+ </Page.Header.Title.Content>
447
+ </Page.Header.Title>
448
+ </Page.Header>
449
+
450
+ <Page.Body type={EBodyPageType.SECOND}>
451
+ <PoemBlock />
452
+ <Gap size={16} />
453
+ <PoemBlock />
454
+ </Page.Body>
455
+
456
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
457
+ <Page.Footer.Description>
458
+ <Page.Footer.Description.Controls>
459
+ <Button
460
+ theme={EButtonTheme.GENERAL}
461
+ size={EComponentSize.MD}
462
+ onClick={handleClose}
463
+ >
464
+ Закрыть
465
+ </Button>
466
+ </Page.Footer.Description.Controls>
467
+ </Page.Footer.Description>
468
+ </Page.Footer>
469
+ </Page>
470
+ </LightBox.Content>
471
+
472
+ <LightBox.Controls>
473
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={handleClose} />
474
+ </LightBox.Controls>
475
+ </LightBox>
476
+ ) : null}
477
+ </div>
478
+ );
479
+ };
207
480
  ```
208
481
 
209
- - Невидимый `<span>`, у которого меняется `z-index` при закрытии SideOverlay — **принудительно вызывает repaint** в Safari. Баг-тикет `DCBSWT-2866`. Не трогать.
482
+ ### SmallContent
210
483
 
211
- ### Scrollbar-width и body lock
484
+ `stories/LightBox/examples/SmallContentExample.tsx`
212
485
 
213
- ```ts
214
- useEffect(() => {
215
- addClassNameWithScrollbarWidth(scrollStyles); // вычисляет ширину скроллбара, прописывает в CSS
216
- addClassNamesToDocumentElement(); // .bodyOverflowHidden → documentElement
217
- setTimeout(addClassNamesToDocumentElement, 100); // fix: при переключении двух LightBox
218
- return () => bodyClassNamesIsLightBoxOpen.forEach(c => document.documentElement.classList.remove(c));
219
- }, []);
486
+ ```tsx
487
+ import React, { useState } from "react";
488
+ import {
489
+ LightBox,
490
+ Page,
491
+ Button,
492
+ EButtonTheme,
493
+ Gap,
494
+ Title,
495
+ Text,
496
+ EFontType,
497
+ ETextSize,
498
+ ETitleSize,
499
+ EHeaderPageType,
500
+ EFooterPageType,
501
+ EBodyPageType,
502
+ FocusTrapUtils,
503
+ EComponentSize,
504
+ } from "@sberbusiness/triplex-next";
505
+ import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
506
+
507
+ export const SmallContentExample = () => {
508
+ const [isOpen, setIsOpen] = useState(false);
509
+
510
+ const handleOpen = () => setIsOpen(true);
511
+ const handleClose = () => setIsOpen(false);
512
+
513
+ return (
514
+ <div>
515
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={handleOpen}>
516
+ Открыть LightBox
517
+ </Button>
518
+
519
+ {isOpen ? (
520
+ <LightBox isLoading={false} isSideOverlayOpened={false} isTopOverlayOpened={false}>
521
+ <LightBox.Content key="content" isLoading={false}>
522
+ <Page>
523
+ <Page.Header type={EHeaderPageType.FIRST}>
524
+ <Page.Header.Title>
525
+ <Page.Header.Title.Content>
526
+ <Title
527
+ tag="h1"
528
+ size={ETitleSize.H1}
529
+ tabIndex={-1}
530
+ // Устанавливает фокус на первый элемент при открытии LightBox.
531
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
532
+ >
533
+ Евгений Онегин
534
+ </Title>
535
+ <Gap size={8} />
536
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
537
+ Русский поэт, драматург и прозаик, заложивший основы русского
538
+ реалистического направления.
539
+ </Text>
540
+ </Page.Header.Title.Content>
541
+ <Page.Header.Title.Controls>
542
+ <Button
543
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
544
+ theme={EButtonTheme.SECONDARY}
545
+ size={EComponentSize.MD}
546
+ />
547
+ <Button
548
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
549
+ theme={EButtonTheme.SECONDARY}
550
+ size={EComponentSize.MD}
551
+ />
552
+ </Page.Header.Title.Controls>
553
+ </Page.Header.Title>
554
+ </Page.Header>
555
+
556
+ <Page.Body type={EBodyPageType.FIRST}>
557
+ Если контента мало, рекомендуется использовать Page.Body типа FIRST, чтобы не было
558
+ пустого пространства.
559
+ </Page.Body>
560
+
561
+ <Page.Footer type={EFooterPageType.FIRST}>
562
+ <Page.Footer.Description>
563
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
564
+ <Page.Footer.Description.Controls>
565
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
566
+ Button text
567
+ </Button>
568
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
569
+ Button text
570
+ </Button>
571
+ </Page.Footer.Description.Controls>
572
+ </Page.Footer.Description>
573
+ </Page.Footer>
574
+ </Page>
575
+ </LightBox.Content>
576
+
577
+ <LightBox.Controls>
578
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={handleClose} />
579
+ <LightBox.Controls.Prev
580
+ title="Назад"
581
+ clickByArrowLeft
582
+ onClick={() => console.log("Prev clicked")}
583
+ />
584
+ <LightBox.Controls.Next
585
+ title="Вперёд"
586
+ clickByArrowRight
587
+ onClick={() => console.log("Next clicked")}
588
+ />
589
+ </LightBox.Controls>
590
+ </LightBox>
591
+ ) : null}
592
+ </div>
593
+ );
594
+ };
220
595
  ```
221
596
 
222
- - Блокировка скролла — на `documentElement`, не `body`.
223
- - `setTimeout(100)` — фикс роутерного бага: новый LightBox монтируется **до** размонтирования старого, классы удаляются старым — повторно добавляем через 100ms.
597
+ ### SplitMode
224
598
 
225
- ### `LightBoxContent.paddingTop` — вычисление по querySelector
599
+ `stories/LightBox/examples/SplitModeExample.tsx`
226
600
 
227
- ```ts
228
- const controlsNode = document.querySelector(`[data-lightbox-component="controls"]`);
229
- if (controlsNode) nextPaddingTop += controlsNode.offsetHeight;
601
+ ```tsx
602
+ import React, { useState } from "react";
603
+ import {
604
+ LightBox,
605
+ Page,
606
+ Button,
607
+ EButtonTheme,
608
+ Gap,
609
+ Title,
610
+ Text,
611
+ EFontType,
612
+ ETextSize,
613
+ ETitleSize,
614
+ EHeaderPageType,
615
+ EFooterPageType,
616
+ EBodyPageType,
617
+ Island,
618
+ IslandBody,
619
+ EIslandType,
620
+ FocusTrapUtils,
621
+ EComponentSize,
622
+ } from "@sberbusiness/triplex-next";
623
+ import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
624
+ import "../styles.less";
625
+
626
+ const POEM_LINES: string[] = [
627
+ "Мой дядя самых честных правил,",
628
+ "Когда не в шутку занемог,",
629
+ "Он уважать себя заставил",
630
+ "И лучше выдумать не мог.",
631
+ "Его пример другим наука;",
632
+ "Но, боже мой, какая скука",
633
+ "С больным сидеть и день и ночь,",
634
+ "Не отходя ни шагу прочь!",
635
+ "Какое низкое коварство",
636
+ "Полуживого забавлять,",
637
+ "Ему подушки поправлять,",
638
+ "Печально подносить лекарство,",
639
+ "Вздыхать и думать про себя:",
640
+ "Когда же черт возьмёт тебя…",
641
+ ];
642
+
643
+ const PoemBlock: React.FC = () => (
644
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
645
+ <IslandBody>
646
+ {POEM_LINES.map((line) => (
647
+ <React.Fragment key={line}>
648
+ {line}
649
+ <br />
650
+ </React.Fragment>
651
+ ))}
652
+ </IslandBody>
653
+ </Island>
654
+ );
655
+
656
+ export const SplitModeExample = () => {
657
+ const [isOpen, setIsOpen] = useState(false);
658
+
659
+ const handleOpen = () => setIsOpen(true);
660
+ const handleClose = () => setIsOpen(false);
661
+
662
+ return (
663
+ <div>
664
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={handleOpen}>
665
+ Открыть LightBox
666
+ </Button>
667
+
668
+ <div id="LightBox-next-view-manager-node-example" />
669
+
670
+ {isOpen ? (
671
+ <LightBox
672
+ isLoading={false}
673
+ isSideOverlayOpened={false}
674
+ isTopOverlayOpened={false}
675
+ lightBoxViewManagerNodeId="LightBox-next-view-manager-node-example"
676
+ >
677
+ <LightBox.Content key="content" isLoading={false}>
678
+ <Page>
679
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
680
+ <Page.Header.Title>
681
+ <Page.Header.Title.Content>
682
+ <Title
683
+ tag="h1"
684
+ size={ETitleSize.H1}
685
+ tabIndex={-1}
686
+ // Устанавливает фокус на первый элемент при открытии LightBox.
687
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
688
+ >
689
+ Евгений Онегин
690
+ </Title>
691
+ <Gap size={8} />
692
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
693
+ Русский поэт, драматург и прозаик, заложивший основы русского
694
+ реалистического направления.
695
+ </Text>
696
+ </Page.Header.Title.Content>
697
+ <Page.Header.Title.Controls>
698
+ <Button
699
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
700
+ theme={EButtonTheme.SECONDARY}
701
+ size={EComponentSize.MD}
702
+ />
703
+ <Button
704
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
705
+ theme={EButtonTheme.SECONDARY}
706
+ size={EComponentSize.MD}
707
+ />
708
+ </Page.Header.Title.Controls>
709
+ </Page.Header.Title>
710
+ </Page.Header>
711
+
712
+ <Page.Body type={EBodyPageType.SECOND}>
713
+ {[0, 1, 2].map((index) => (
714
+ <React.Fragment key={index}>
715
+ <PoemBlock />
716
+ {index < 2 && <Gap size={16} />}
717
+ </React.Fragment>
718
+ ))}
719
+ </Page.Body>
720
+
721
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
722
+ <Page.Footer.Description>
723
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
724
+ <Page.Footer.Description.Controls>
725
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
726
+ Button text
727
+ </Button>
728
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
729
+ Button text
730
+ </Button>
731
+ </Page.Footer.Description.Controls>
732
+ </Page.Footer.Description>
733
+ </Page.Footer>
734
+ </Page>
735
+ </LightBox.Content>
736
+
737
+ <LightBox.Controls>
738
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={handleClose} />
739
+ <LightBox.Controls.Prev
740
+ title="Назад"
741
+ clickByArrowLeft
742
+ onClick={() => console.log("Prev clicked")}
743
+ />
744
+ <LightBox.Controls.Next
745
+ title="Вперёд"
746
+ clickByArrowRight
747
+ onClick={() => console.log("Next clicked")}
748
+ />
749
+ </LightBox.Controls>
750
+ </LightBox>
751
+ ) : null}
752
+ </div>
753
+ );
754
+ };
230
755
  ```
231
756
 
232
- - Контент добавляет сверху padding равный высоте Controls-бара, чтобы они не перекрывали начало контента.
233
- - Debounce 100ms, изменение применяется при `deltaTop > 1` — антидрожание.
757
+ ### WithSidebars
234
758
 
235
- ### `LightBoxSideOverlay` — разделение `opening`/`closing`/`opened`
759
+ `stories/LightBox/examples/WithSidebarsExample.tsx`
236
760
 
237
- ```ts
238
- useEffect(() => {
239
- if (prevOpened.current && !opened) { setOpening(false); setClosing(true); }
240
- else if (!prevOpened.current && opened) { setClosing(false); setOpening(true); }
241
- prevOpened.current = opened;
242
- }, [opened]);
761
+ ```tsx
762
+ import React, { useState } from "react";
763
+ import {
764
+ LightBox,
765
+ Page,
766
+ Button,
767
+ EButtonTheme,
768
+ Gap,
769
+ Title,
770
+ Text,
771
+ EFontType,
772
+ ETextSize,
773
+ ETitleSize,
774
+ EHeaderPageType,
775
+ EFooterPageType,
776
+ EBodyPageType,
777
+ Island,
778
+ IslandBody,
779
+ EIslandType,
780
+ FocusTrapUtils,
781
+ EComponentSize,
782
+ MobileView,
783
+ } from "@sberbusiness/triplex-next";
784
+ import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
785
+
786
+ const POEM_LINES: string[] = [
787
+ "Мой дядя самых честных правил,",
788
+ "Когда не в шутку занемог,",
789
+ "Он уважать себя заставил",
790
+ "И лучше выдумать не мог.",
791
+ "Его пример другим наука;",
792
+ "Но, боже мой, какая скука",
793
+ "С больным сидеть и день и ночь,",
794
+ "Не отходя ни шагу прочь!",
795
+ "Какое низкое коварство",
796
+ "Полуживого забавлять,",
797
+ "Ему подушки поправлять,",
798
+ "Печально подносить лекарство,",
799
+ "Вздыхать и думать про себя:",
800
+ "Когда же черт возьмёт тебя…",
801
+ ];
802
+
803
+ const PoemBlock: React.FC = () => (
804
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
805
+ <IslandBody>
806
+ {POEM_LINES.map((line) => (
807
+ <React.Fragment key={line}>
808
+ {line}
809
+ <br />
810
+ </React.Fragment>
811
+ ))}
812
+ </IslandBody>
813
+ </Island>
814
+ );
815
+
816
+ export const WithSidebarsExample = () => {
817
+ const [isOpen, setIsOpen] = useState(false);
818
+ const [isLeftSidebarVisible, setIsLeftSidebarVisible] = useState(true);
819
+ const [isRightSidebarVisible, setIsRightSidebarVisible] = useState(true);
820
+
821
+ const renderLightBoxControls = () => (
822
+ <LightBox.Controls key="controls">
823
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={() => setIsOpen(false)} />
824
+ <LightBox.Controls.Prev title="Назад" clickByArrowLeft onClick={() => console.log("Prev clicked")} />
825
+ <LightBox.Controls.Next title="Вперёд" clickByArrowRight onClick={() => console.log("Next clicked")} />
826
+ </LightBox.Controls>
827
+ );
828
+
829
+ return (
830
+ <div>
831
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={() => setIsOpen(true)}>
832
+ Открыть LightBox
833
+ </Button>
834
+
835
+ {isOpen ? (
836
+ <LightBox isLoading={false}>
837
+ <LightBox.Content key="content" isLoading={false}>
838
+ <Page>
839
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
840
+ <Page.Header.Title>
841
+ <Page.Header.Title.Content>
842
+ <MobileView
843
+ fallback={
844
+ <Title
845
+ tag="h1"
846
+ size={ETitleSize.H1}
847
+ tabIndex={-1}
848
+ // Устанавливает фокус на первый элемент при открытии LightBox.
849
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
850
+ >
851
+ Евгений Онегин
852
+ </Title>
853
+ }
854
+ >
855
+ <Title tag="h2" size={ETitleSize.H2}>
856
+ Евгений Онегин
857
+ </Title>
858
+ </MobileView>
859
+
860
+ <Gap size={8} />
861
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
862
+ Русский поэт, драматург и прозаик, заложивший основы русского
863
+ реалистического направления.
864
+ </Text>
865
+ </Page.Header.Title.Content>
866
+ <Page.Header.Title.Controls>
867
+ <Button
868
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
869
+ theme={EButtonTheme.SECONDARY}
870
+ size={EComponentSize.MD}
871
+ />
872
+ <Button
873
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
874
+ theme={EButtonTheme.SECONDARY}
875
+ size={EComponentSize.MD}
876
+ />
877
+ </Page.Header.Title.Controls>
878
+ </Page.Header.Title>
879
+ </Page.Header>
880
+
881
+ <Page.Body type={EBodyPageType.SECOND}>
882
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
883
+ <IslandBody>
884
+ Left sidebar: {isLeftSidebarVisible ? "visible" : "hidden"}
885
+ <br />
886
+ Right sidebar: {isRightSidebarVisible ? "visible" : "hidden"}
887
+ </IslandBody>
888
+ </Island>
889
+ <Gap size={16} />
890
+ {[0, 1, 2].map((index) => (
891
+ <React.Fragment key={index}>
892
+ <PoemBlock />
893
+ {index < 2 && <Gap size={16} />}
894
+ </React.Fragment>
895
+ ))}
896
+ </Page.Body>
897
+
898
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
899
+ <Page.Footer.Description>
900
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
901
+ <Page.Footer.Description.Controls>
902
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
903
+ Button text
904
+ </Button>
905
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
906
+ Button text
907
+ </Button>
908
+ </Page.Footer.Description.Controls>
909
+ </Page.Footer.Description>
910
+ </Page.Footer>
911
+ </Page>
912
+ </LightBox.Content>
913
+
914
+ <LightBox.LeftSidebar
915
+ key="left-sidebar"
916
+ minVisibleWidth={140}
917
+ onShow={() => setIsLeftSidebarVisible(true)}
918
+ onHide={() => setIsLeftSidebarVisible(false)}
919
+ >
920
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
921
+ <IslandBody>Left Sidebar</IslandBody>
922
+ </Island>
923
+ </LightBox.LeftSidebar>
924
+
925
+ <LightBox.RightSidebar
926
+ key="right-sidebar"
927
+ onShow={() => setIsRightSidebarVisible(true)}
928
+ onHide={() => setIsRightSidebarVisible(false)}
929
+ >
930
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
931
+ <IslandBody>Right Sidebar</IslandBody>
932
+ </Island>
933
+ </LightBox.RightSidebar>
934
+
935
+ {renderLightBoxControls()}
936
+ </LightBox>
937
+ ) : null}
938
+ </div>
939
+ );
940
+ };
243
941
  ```
244
942
 
245
- - Tri-state: `opened` + `opening` (анимация раскрытия) + `closing` (анимация закрытия). `onOpen`/`onClose` срабатывают **после `transitionEnd`**, не сразу.
246
- - FocusTrap активен только когда `opened && !opening && !closing`.
247
-
248
- ### `LeftSidebar.onShow/onHide` через ref
249
-
250
- Чтобы `ResizeObserver`-коллбэк не пересоздавался при смене обработчиков, они пишутся в `onShowRef`/`onHideRef` через `useEffect`. Паттерн "stable callback".
251
-
252
- ---
253
-
254
- ## Инварианты
943
+ ### WithFixedSidebars
255
944
 
256
- - **`children: ReactElement[]`** — типом заявлен массив. Потребители фактически пишут слоты подряд — TypeScript нестрого проверяет. Не менять без переосмысления.
257
- - **`data-lightbox-component="controls"`** атрибут на Controls-контейнере — **необходим** для работы `LightBoxContent` (querySelector). При замене на ref нужно править `LightBoxContent.tsx`.
258
- - **IDs `LightBox-next-mount-node` и `LightBox-next-view-manager-node`** — константы в модуле, переименование — breaking.
259
- - **`forwardRef` prop (а не `ref`)** — публичный API. LightBoxBase — не `forwardRef`; `Object.assign` поверх. Не переименовывать в `ref`.
260
- - **`ELightBoxSize`** — собственный enum, не `EComponentSize`; есть значение `XL`.
261
- - **Safari span `tempElSafariBug`** — не удалять без регрессии (см. DCBSWT-2866).
262
- - **`setTimeout(100)` на body-lock** — костыль против роутерных гонок, не убирать.
263
- - **`LightBox.SideOverlay.CloseDesktop`/`.CloseMobile`** — две отдельные кнопки, не одна. Mobile/desktop визуально разные.
264
- - **`Portal` + `lightBoxMountNode`** — LightBox никогда не рендерится inline. При SSR потребителю нужен ClientOnly-wrapper.
265
- - **`MobileView` fallback wraps FocusTrap, children — нет**: на mobile focus trap не активен.
266
- - **`onOpen`/`onClose` SideOverlay** вызываются по `transitionend`, не по смене `opened`. Важно для консьюмеров, ждущих завершения анимации.
945
+ `stories/LightBox/examples/WithFixedSidebarsExample.tsx`
267
946
 
268
- ---
947
+ ```tsx
948
+ import React, { useState } from "react";
949
+ import {
950
+ LightBox,
951
+ Page,
952
+ Button,
953
+ EButtonTheme,
954
+ Gap,
955
+ Title,
956
+ Text,
957
+ EFontType,
958
+ ETextSize,
959
+ ETitleSize,
960
+ EHeaderPageType,
961
+ EFooterPageType,
962
+ EBodyPageType,
963
+ Island,
964
+ IslandBody,
965
+ EIslandType,
966
+ FocusTrapUtils,
967
+ EComponentSize,
968
+ MobileView,
969
+ } from "@sberbusiness/triplex-next";
970
+ import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
971
+
972
+ const POEM_LINES: string[] = [
973
+ "Мой дядя самых честных правил,",
974
+ "Когда не в шутку занемог,",
975
+ "Он уважать себя заставил",
976
+ "И лучше выдумать не мог.",
977
+ "Его пример другим наука;",
978
+ "Но, боже мой, какая скука",
979
+ "С больным сидеть и день и ночь,",
980
+ "Не отходя ни шагу прочь!",
981
+ "Какое низкое коварство",
982
+ "Полуживого забавлять,",
983
+ "Ему подушки поправлять,",
984
+ "Печально подносить лекарство,",
985
+ "Вздыхать и думать про себя:",
986
+ "Когда же черт возьмёт тебя…",
987
+ ];
988
+
989
+ const PoemBlock: React.FC = () => (
990
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
991
+ <IslandBody>
992
+ {POEM_LINES.map((line) => (
993
+ <React.Fragment key={line}>
994
+ {line}
995
+ <br />
996
+ </React.Fragment>
997
+ ))}
998
+ </IslandBody>
999
+ </Island>
1000
+ );
1001
+
1002
+ export const WithFixedSidebarsExample = () => {
1003
+ const [isOpen, setIsOpen] = useState(false);
1004
+
1005
+ const renderLightBoxControls = () => (
1006
+ <LightBox.Controls key="controls">
1007
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={() => setIsOpen(false)} />
1008
+ <LightBox.Controls.Prev title="Назад" clickByArrowLeft onClick={() => console.log("Prev clicked")} />
1009
+ <LightBox.Controls.Next title="Вперёд" clickByArrowRight onClick={() => console.log("Next clicked")} />
1010
+ </LightBox.Controls>
1011
+ );
1012
+
1013
+ return (
1014
+ <div>
1015
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={() => setIsOpen(true)}>
1016
+ Открыть LightBox
1017
+ </Button>
1018
+
1019
+ {isOpen ? (
1020
+ <LightBox isLoading={false}>
1021
+ <LightBox.Content key="content" isLoading={false}>
1022
+ <Page>
1023
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
1024
+ <Page.Header.Title>
1025
+ <Page.Header.Title.Content>
1026
+ <MobileView
1027
+ fallback={
1028
+ <Title
1029
+ tag="h1"
1030
+ size={ETitleSize.H1}
1031
+ tabIndex={-1}
1032
+ // Устанавливает фокус на первый элемент при открытии LightBox.
1033
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
1034
+ >
1035
+ Евгений Онегин
1036
+ </Title>
1037
+ }
1038
+ >
1039
+ <Title tag="h2" size={ETitleSize.H2}>
1040
+ Евгений Онегин
1041
+ </Title>
1042
+ </MobileView>
1043
+
1044
+ <Gap size={8} />
1045
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
1046
+ Русский поэт, драматург и прозаик, заложивший основы русского
1047
+ реалистического направления.
1048
+ </Text>
1049
+ </Page.Header.Title.Content>
1050
+ <Page.Header.Title.Controls>
1051
+ <Button
1052
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
1053
+ theme={EButtonTheme.SECONDARY}
1054
+ size={EComponentSize.MD}
1055
+ />
1056
+ <Button
1057
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
1058
+ theme={EButtonTheme.SECONDARY}
1059
+ size={EComponentSize.MD}
1060
+ />
1061
+ </Page.Header.Title.Controls>
1062
+ </Page.Header.Title>
1063
+ </Page.Header>
1064
+
1065
+ <Page.Body type={EBodyPageType.SECOND}>
1066
+ {[0, 1, 2].map((index) => (
1067
+ <React.Fragment key={index}>
1068
+ <PoemBlock />
1069
+ {index < 2 && <Gap size={16} />}
1070
+ </React.Fragment>
1071
+ ))}
1072
+ </Page.Body>
1073
+
1074
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
1075
+ <Page.Footer.Description>
1076
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
1077
+ <Page.Footer.Description.Controls>
1078
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
1079
+ Button text
1080
+ </Button>
1081
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
1082
+ Button text
1083
+ </Button>
1084
+ </Page.Footer.Description.Controls>
1085
+ </Page.Footer.Description>
1086
+ </Page.Footer>
1087
+ </Page>
1088
+ </LightBox.Content>
1089
+
1090
+ <LightBox.LeftSidebar fixed key="left-sidebar">
1091
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
1092
+ <IslandBody>Left Sidebar</IslandBody>
1093
+ </Island>
1094
+ </LightBox.LeftSidebar>
1095
+
1096
+ <LightBox.RightSidebar fixed key="right-sidebar">
1097
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
1098
+ <IslandBody>Right Sidebar</IslandBody>
1099
+ </Island>
1100
+ </LightBox.RightSidebar>
1101
+
1102
+ {renderLightBoxControls()}
1103
+ </LightBox>
1104
+ ) : null}
1105
+ </div>
1106
+ );
1107
+ };
1108
+ ```
269
1109
 
270
- ## Accessibility
1110
+ ### WithOneSidebar
271
1111
 
272
- - `role="dialog"` + `aria-modal="true"` — на корне LightBox и на SideOverlay.
273
- - FocusTrap удерживает фокус внутри (desktop). Потребитель должен проставить tabbable-элементы и осмысленные labels.
274
- - Initial focus — первый элемент с `data-focus-initial` (см. `FocusTrapUtils.getFirstInteractionElementByDataAttr`) или первый tabbable.
275
- - `allowOutsideClick: true` — клик вне модалки не ломает trap (но и не закрывает — консьюмер сам вешает onClose).
276
- - **Mobile — без focus trap'а**. Семантика диалога остаётся через `role="dialog"`, но скринридеры могут не удерживать фокус — закладывайте это для мобильных UX-тестов.
277
- - Контент не анонсируется при открытии (нет `aria-labelledby`/`aria-describedby` по умолчанию) — потребитель должен добавить через `...rest`.
1112
+ `stories/LightBox/examples/WithOneSidebarExample.tsx`
278
1113
 
279
- ---
1114
+ ```tsx
1115
+ import React, { useState } from "react";
1116
+ import {
1117
+ LightBox,
1118
+ Page,
1119
+ Button,
1120
+ EButtonTheme,
1121
+ Gap,
1122
+ Title,
1123
+ Text,
1124
+ EFontType,
1125
+ ETextSize,
1126
+ ETitleSize,
1127
+ EHeaderPageType,
1128
+ EFooterPageType,
1129
+ EBodyPageType,
1130
+ Island,
1131
+ IslandBody,
1132
+ EIslandType,
1133
+ FocusTrapUtils,
1134
+ EComponentSize,
1135
+ MobileView,
1136
+ } from "@sberbusiness/triplex-next";
1137
+ import { DefaulticonStrokePrdIcon20 } from "@sberbusiness/icons-next";
1138
+
1139
+ const POEM_LINES: string[] = [
1140
+ "Мой дядя самых честных правил,",
1141
+ "Когда не в шутку занемог,",
1142
+ "Он уважать себя заставил",
1143
+ "И лучше выдумать не мог.",
1144
+ "Его пример другим наука;",
1145
+ "Но, боже мой, какая скука",
1146
+ "С больным сидеть и день и ночь,",
1147
+ "Не отходя ни шагу прочь!",
1148
+ "Какое низкое коварство",
1149
+ "Полуживого забавлять,",
1150
+ "Ему подушки поправлять,",
1151
+ "Печально подносить лекарство,",
1152
+ "Вздыхать и думать про себя:",
1153
+ "Когда же черт возьмёт тебя…",
1154
+ ];
1155
+
1156
+ const PoemBlock: React.FC = () => (
1157
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
1158
+ <IslandBody>
1159
+ {POEM_LINES.map((line) => (
1160
+ <React.Fragment key={line}>
1161
+ {line}
1162
+ <br />
1163
+ </React.Fragment>
1164
+ ))}
1165
+ </IslandBody>
1166
+ </Island>
1167
+ );
1168
+
1169
+ export const WithOneSidebarExample = () => {
1170
+ const [isOpen, setIsOpen] = useState(false);
1171
+
1172
+ const renderLightBoxControls = () => (
1173
+ <LightBox.Controls key="controls">
1174
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={() => setIsOpen(false)} />
1175
+ <LightBox.Controls.Prev title="Назад" clickByArrowLeft onClick={() => console.log("Prev clicked")} />
1176
+ <LightBox.Controls.Next title="Вперёд" clickByArrowRight onClick={() => console.log("Next clicked")} />
1177
+ </LightBox.Controls>
1178
+ );
1179
+
1180
+ return (
1181
+ <div>
1182
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={() => setIsOpen(true)}>
1183
+ Открыть LightBox
1184
+ </Button>
1185
+
1186
+ {isOpen ? (
1187
+ <LightBox isLoading={false}>
1188
+ <LightBox.Content key="content" isLoading={false}>
1189
+ <Page>
1190
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
1191
+ <Page.Header.Title>
1192
+ <Page.Header.Title.Content>
1193
+ <MobileView
1194
+ fallback={
1195
+ <Title
1196
+ tag="h1"
1197
+ size={ETitleSize.H1}
1198
+ tabIndex={-1}
1199
+ // Устанавливает фокус на первый элемент при открытии LightBox.
1200
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
1201
+ >
1202
+ Евгений Онегин
1203
+ </Title>
1204
+ }
1205
+ >
1206
+ <Title tag="h2" size={ETitleSize.H2}>
1207
+ Евгений Онегин
1208
+ </Title>
1209
+ </MobileView>
1210
+
1211
+ <Gap size={8} />
1212
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
1213
+ Русский поэт, драматург и прозаик, заложивший основы русского
1214
+ реалистического направления.
1215
+ </Text>
1216
+ </Page.Header.Title.Content>
1217
+ <Page.Header.Title.Controls>
1218
+ <Button
1219
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
1220
+ theme={EButtonTheme.SECONDARY}
1221
+ size={EComponentSize.MD}
1222
+ />
1223
+ <Button
1224
+ icon={<DefaulticonStrokePrdIcon20 paletteIndex={0} />}
1225
+ theme={EButtonTheme.SECONDARY}
1226
+ size={EComponentSize.MD}
1227
+ />
1228
+ </Page.Header.Title.Controls>
1229
+ </Page.Header.Title>
1230
+ </Page.Header>
1231
+
1232
+ <Page.Body type={EBodyPageType.SECOND}>
1233
+ {[0, 1, 2].map((index) => (
1234
+ <React.Fragment key={index}>
1235
+ <PoemBlock />
1236
+ {index < 2 && <Gap size={16} />}
1237
+ </React.Fragment>
1238
+ ))}
1239
+ </Page.Body>
1240
+
1241
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
1242
+ <Page.Footer.Description>
1243
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
1244
+ <Page.Footer.Description.Controls>
1245
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
1246
+ Button text
1247
+ </Button>
1248
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
1249
+ Button text
1250
+ </Button>
1251
+ </Page.Footer.Description.Controls>
1252
+ </Page.Footer.Description>
1253
+ </Page.Footer>
1254
+ </Page>
1255
+ </LightBox.Content>
1256
+
1257
+ <LightBox.RightSidebar key="right-sidebar">
1258
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
1259
+ <IslandBody>Right Sidebar</IslandBody>
1260
+ </Island>
1261
+ </LightBox.RightSidebar>
1262
+
1263
+ {renderLightBoxControls()}
1264
+ </LightBox>
1265
+ ) : null}
1266
+ </div>
1267
+ );
1268
+ };
1269
+ ```
280
1270
 
281
- ## Дизайн-токены
1271
+ ### WithSideOverlay
282
1272
 
283
- LightBoxViewManager прописывает **runtime CSS-переменные**:
1273
+ `stories/LightBox/examples/WithSideOverlayExample.tsx`
284
1274
 
285
- ```
286
- --lightBox-screen-left
287
- --lightBox-screen-width
288
- --lightBox-screen-top
1275
+ ```tsx
1276
+ import React, { useState } from "react";
1277
+ import {
1278
+ LightBox,
1279
+ Page,
1280
+ Button,
1281
+ EButtonTheme,
1282
+ Gap,
1283
+ Title,
1284
+ Text,
1285
+ EFontType,
1286
+ ETextSize,
1287
+ ETitleSize,
1288
+ EHeaderPageType,
1289
+ EFooterPageType,
1290
+ EBodyPageType,
1291
+ Island,
1292
+ IslandBody,
1293
+ EIslandType,
1294
+ FocusTrapUtils,
1295
+ EComponentSize,
1296
+ MobileView,
1297
+ } from "@sberbusiness/triplex-next";
1298
+
1299
+ const POEM_LINES: string[] = [
1300
+ "Мой дядя самых честных правил,",
1301
+ "Когда не в шутку занемог,",
1302
+ "Он уважать себя заставил",
1303
+ "И лучше выдумать не мог.",
1304
+ "Его пример другим наука;",
1305
+ "Но, боже мой, какая скука",
1306
+ "С больным сидеть и день и ночь,",
1307
+ "Не отходя ни шагу прочь!",
1308
+ "Какое низкое коварство",
1309
+ "Полуживого забавлять,",
1310
+ "Ему подушки поправлять,",
1311
+ "Печально подносить лекарство,",
1312
+ "Вздыхать и думать про себя:",
1313
+ "Когда же черт возьмёт тебя…",
1314
+ ];
1315
+
1316
+ const PoemBlock: React.FC = () => (
1317
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
1318
+ <IslandBody>
1319
+ {POEM_LINES.map((line) => (
1320
+ <React.Fragment key={line}>
1321
+ {line}
1322
+ <br />
1323
+ </React.Fragment>
1324
+ ))}
1325
+ </IslandBody>
1326
+ </Island>
1327
+ );
1328
+
1329
+ export const WithSideOverlayExample = () => {
1330
+ const [isOpen, setIsOpen] = useState(false);
1331
+ const [openedSideOverlayLG, setOpenedSideOverlayLG] = useState(false);
1332
+ const [openedSideOverlayMD, setOpenedSideOverlayMD] = useState(false);
1333
+ const [openedSideOverlaySM, setOpenedSideOverlaySM] = useState(false);
1334
+
1335
+ const handleOpenSideOverlayLG = () => setOpenedSideOverlayLG(true);
1336
+ const handleCloseSideOverlayLG = () => setOpenedSideOverlayLG(false);
1337
+ const handleOpenSideOverlayMD = () => setOpenedSideOverlayMD(true);
1338
+ const handleCloseSideOverlayMD = () => setOpenedSideOverlayMD(false);
1339
+ const handleOpenSideOverlaySM = () => setOpenedSideOverlaySM(true);
1340
+ const handleCloseSideOverlaySM = () => setOpenedSideOverlaySM(false);
1341
+
1342
+ const handleOpen = () => setIsOpen(true);
1343
+ const handleClose = () => setIsOpen(false);
1344
+
1345
+ const renderLightBoxControls = () => (
1346
+ <LightBox.Controls key="controls">
1347
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={handleClose} />
1348
+ </LightBox.Controls>
1349
+ );
1350
+
1351
+ const renderLightBoxSideOverlayLG = () => (
1352
+ <LightBox.SideOverlay
1353
+ key="sideOverlayLG"
1354
+ opened={openedSideOverlayLG}
1355
+ size={EComponentSize.LG}
1356
+ isTopLevelSideOverlayOpened={openedSideOverlayMD || openedSideOverlaySM}
1357
+ >
1358
+ <Page>
1359
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
1360
+ <Page.Header.Title>
1361
+ <Page.Header.Title.Content>
1362
+ <MobileView
1363
+ fallback={
1364
+ <Title
1365
+ tag="h1"
1366
+ size={ETitleSize.H1}
1367
+ tabIndex={-1}
1368
+ // Устанавливает фокус на первый элемент при открытии LightBox.
1369
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
1370
+ >
1371
+ Евгений Онегин
1372
+ </Title>
1373
+ }
1374
+ >
1375
+ <Title tag="h2" size={ETitleSize.H2}>
1376
+ Евгений Онегин
1377
+ </Title>
1378
+ </MobileView>
1379
+ </Page.Header.Title.Content>
1380
+ <Page.Header.Title.Controls>
1381
+ {/* Кнопка закрытия SideOverlay для мобильного устройства. Отображается только на мобильном устройстве, внутри заголовка SideOverlay. */}
1382
+ <LightBox.SideOverlay.CloseMobile
1383
+ data-test-id="lightbox-side-overlay-close"
1384
+ onClick={handleCloseSideOverlayLG}
1385
+ />
1386
+ </Page.Header.Title.Controls>
1387
+ </Page.Header.Title>
1388
+ </Page.Header>
1389
+
1390
+ <Page.Body type={EBodyPageType.SECOND}>
1391
+ {[0, 1, 2].map((index) => (
1392
+ <React.Fragment key={index}>
1393
+ <PoemBlock />
1394
+ {index < 2 && <Gap size={16} />}
1395
+ </React.Fragment>
1396
+ ))}
1397
+ </Page.Body>
1398
+
1399
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
1400
+ <Page.Footer.Description>
1401
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
1402
+ <Page.Footer.Description.Controls>
1403
+ <Button
1404
+ theme={EButtonTheme.GENERAL}
1405
+ size={EComponentSize.MD}
1406
+ onClick={handleOpenSideOverlayMD}
1407
+ >
1408
+ Открыть SideOverlay MD
1409
+ </Button>
1410
+ </Page.Footer.Description.Controls>
1411
+ </Page.Footer.Description>
1412
+ </Page.Footer>
1413
+ </Page>
1414
+
1415
+ {/* Кнопка закрытия SideOverlay для десктопа. Отображается только на десктопе, справа от заголовка SideOverlay. */}
1416
+ <LightBox.SideOverlay.CloseDesktop
1417
+ data-test-id="lightbox-side-overlay-close"
1418
+ clickByEsc={!openedSideOverlayMD && !openedSideOverlaySM}
1419
+ onClick={handleCloseSideOverlayLG}
1420
+ />
1421
+ </LightBox.SideOverlay>
1422
+ );
1423
+
1424
+ const renderLightBoxSideOverlayMD = () => (
1425
+ <LightBox.SideOverlay
1426
+ key="sideOverlayMD"
1427
+ opened={openedSideOverlayMD}
1428
+ size={EComponentSize.MD}
1429
+ isTopLevelSideOverlayOpened={openedSideOverlaySM}
1430
+ >
1431
+ <Page>
1432
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
1433
+ <Page.Header.Title>
1434
+ <Page.Header.Title.Content>
1435
+ <MobileView
1436
+ fallback={
1437
+ <Title
1438
+ tag="h1"
1439
+ size={ETitleSize.H1}
1440
+ tabIndex={-1}
1441
+ // Устанавливает фокус на первый элемент при открытии LightBox.
1442
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
1443
+ >
1444
+ Евгений Онегин
1445
+ </Title>
1446
+ }
1447
+ >
1448
+ <Title tag="h2" size={ETitleSize.H2}>
1449
+ Евгений Онегин
1450
+ </Title>
1451
+ </MobileView>
1452
+ </Page.Header.Title.Content>
1453
+ <Page.Header.Title.Controls>
1454
+ <LightBox.SideOverlay.CloseMobile
1455
+ data-test-id="lightbox-side-overlay-close"
1456
+ onClick={handleCloseSideOverlayMD}
1457
+ />
1458
+ </Page.Header.Title.Controls>
1459
+ </Page.Header.Title>
1460
+ </Page.Header>
1461
+
1462
+ <Page.Body type={EBodyPageType.SECOND}>
1463
+ {[0, 1, 2].map((index) => (
1464
+ <React.Fragment key={index}>
1465
+ <PoemBlock />
1466
+ {index < 2 && <Gap size={16} />}
1467
+ </React.Fragment>
1468
+ ))}
1469
+ </Page.Body>
1470
+
1471
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
1472
+ <Page.Footer.Description>
1473
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
1474
+ <Page.Footer.Description.Controls>
1475
+ <Button
1476
+ theme={EButtonTheme.GENERAL}
1477
+ size={EComponentSize.MD}
1478
+ onClick={handleOpenSideOverlaySM}
1479
+ >
1480
+ Открыть SideOverlay SM
1481
+ </Button>
1482
+ </Page.Footer.Description.Controls>
1483
+ </Page.Footer.Description>
1484
+ </Page.Footer>
1485
+ </Page>
1486
+
1487
+ <LightBox.SideOverlay.CloseDesktop
1488
+ data-test-id="lightbox-side-overlay-close"
1489
+ clickByEsc={!openedSideOverlaySM}
1490
+ onClick={handleCloseSideOverlayMD}
1491
+ />
1492
+ </LightBox.SideOverlay>
1493
+ );
1494
+
1495
+ const renderLightBoxSideOverlaySM = () => (
1496
+ <LightBox.SideOverlay key="sideOverlaySM" opened={openedSideOverlaySM} size={EComponentSize.SM}>
1497
+ <Page>
1498
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
1499
+ <Page.Header.Title>
1500
+ <Page.Header.Title.Content>
1501
+ <MobileView
1502
+ fallback={
1503
+ <Title
1504
+ tag="h1"
1505
+ size={ETitleSize.H1}
1506
+ tabIndex={-1}
1507
+ // Устанавливает фокус на первый элемент при открытии LightBox.
1508
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
1509
+ >
1510
+ Евгений Онегин
1511
+ </Title>
1512
+ }
1513
+ >
1514
+ <Title tag="h2" size={ETitleSize.H2}>
1515
+ Евгений Онегин
1516
+ </Title>
1517
+ </MobileView>
1518
+ </Page.Header.Title.Content>
1519
+ <Page.Header.Title.Controls>
1520
+ <LightBox.SideOverlay.CloseMobile
1521
+ data-test-id="lightbox-side-overlay-close"
1522
+ onClick={handleCloseSideOverlaySM}
1523
+ />
1524
+ </Page.Header.Title.Controls>
1525
+ </Page.Header.Title>
1526
+ </Page.Header>
1527
+
1528
+ <Page.Body type={EBodyPageType.FIRST}>
1529
+ Если контента мало, рекомендуется использовать Page.Body типа FIRST, чтобы не было пустого
1530
+ пространства.
1531
+ </Page.Body>
1532
+
1533
+ <Page.Footer type={EFooterPageType.FIRST} sticky={openedSideOverlaySM}>
1534
+ <Page.Footer.Description>
1535
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
1536
+ <Page.Footer.Description.Controls>
1537
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
1538
+ Button text
1539
+ </Button>
1540
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
1541
+ Button text
1542
+ </Button>
1543
+ </Page.Footer.Description.Controls>
1544
+ </Page.Footer.Description>
1545
+ </Page.Footer>
1546
+ </Page>
1547
+
1548
+ <LightBox.SideOverlay.CloseDesktop
1549
+ data-test-id="lightbox-side-overlay-close"
1550
+ clickByEsc
1551
+ onClick={handleCloseSideOverlaySM}
1552
+ />
1553
+ </LightBox.SideOverlay>
1554
+ );
1555
+
1556
+ const renderLightBoxSideOverlays = () => (
1557
+ <>
1558
+ {renderLightBoxSideOverlayLG()}
1559
+ {renderLightBoxSideOverlayMD()}
1560
+ {renderLightBoxSideOverlaySM()}
1561
+ </>
1562
+ );
1563
+
1564
+ return (
1565
+ <div>
1566
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={handleOpen}>
1567
+ Открыть LightBox
1568
+ </Button>
1569
+
1570
+ {isOpen ? (
1571
+ <LightBox isLoading={false} isSideOverlayOpened={openedSideOverlayLG} isTopOverlayOpened={false}>
1572
+ <LightBox.Content key="content" isLoading={false}>
1573
+ <Page>
1574
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
1575
+ <Page.Header.Title>
1576
+ <Page.Header.Title.Content>
1577
+ <MobileView
1578
+ fallback={
1579
+ <Title
1580
+ tag="h1"
1581
+ size={ETitleSize.H1}
1582
+ tabIndex={-1}
1583
+ // Устанавливает фокус на первый элемент при открытии LightBox.
1584
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
1585
+ >
1586
+ Евгений Онегин
1587
+ </Title>
1588
+ }
1589
+ >
1590
+ <Title tag="h2" size={ETitleSize.H2}>
1591
+ Евгений Онегин
1592
+ </Title>
1593
+ </MobileView>
1594
+
1595
+ <Gap size={8} />
1596
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
1597
+ Русский поэт, драматург и прозаик, заложивший основы русского
1598
+ реалистического направления.
1599
+ </Text>
1600
+ </Page.Header.Title.Content>
1601
+ <Page.Header.Title.Controls>
1602
+ <Button
1603
+ theme={EButtonTheme.GENERAL}
1604
+ size={EComponentSize.MD}
1605
+ onClick={handleOpenSideOverlayLG}
1606
+ >
1607
+ SideOverlay LG
1608
+ </Button>
1609
+ </Page.Header.Title.Controls>
1610
+ </Page.Header.Title>
1611
+ </Page.Header>
1612
+
1613
+ <Page.Body type={EBodyPageType.SECOND}>
1614
+ {[0, 1, 2].map((index) => (
1615
+ <React.Fragment key={index}>
1616
+ <PoemBlock />
1617
+ {index < 2 && <Gap size={16} />}
1618
+ </React.Fragment>
1619
+ ))}
1620
+ </Page.Body>
1621
+
1622
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
1623
+ <Page.Footer.Description>
1624
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
1625
+ <Page.Footer.Description.Controls>
1626
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
1627
+ Button text
1628
+ </Button>
1629
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
1630
+ Button text
1631
+ </Button>
1632
+ </Page.Footer.Description.Controls>
1633
+ </Page.Footer.Description>
1634
+ </Page.Footer>
1635
+ </Page>
1636
+ </LightBox.Content>
1637
+
1638
+ {renderLightBoxControls()}
1639
+
1640
+ {renderLightBoxSideOverlays()}
1641
+ </LightBox>
1642
+ ) : null}
1643
+ </div>
1644
+ );
1645
+ };
289
1646
  ```
290
1647
 
291
- Их значения вычисляются по `getBoundingClientRect` у `lightBoxViewManagerNode`. Эти переменные используются LESS-стилями LightBox для позиционирования.
1648
+ ### WithSideOverlayLoading
292
1649
 
293
- Прочие визуальные токены — от ThemeProvider (`scopeClassName` подставляется на корень).
1650
+ `stories/LightBox/examples/WithSideOverlayLoadingExample.tsx`
294
1651
 
295
- ---
296
-
297
- ## Связанные компоненты
1652
+ ```tsx
1653
+ import React, { useState } from "react";
1654
+ import {
1655
+ LightBox,
1656
+ Page,
1657
+ Button,
1658
+ EButtonTheme,
1659
+ Gap,
1660
+ Title,
1661
+ Text,
1662
+ EFontType,
1663
+ ETextSize,
1664
+ ETitleSize,
1665
+ EHeaderPageType,
1666
+ EFooterPageType,
1667
+ EBodyPageType,
1668
+ Island,
1669
+ IslandBody,
1670
+ EIslandType,
1671
+ FocusTrapUtils,
1672
+ EComponentSize,
1673
+ } from "@sberbusiness/triplex-next";
1674
+
1675
+ const POEM_LINES: string[] = [
1676
+ "Мой дядя самых честных правил,",
1677
+ "Когда не в шутку занемог,",
1678
+ "Он уважать себя заставил",
1679
+ "И лучше выдумать не мог.",
1680
+ "Его пример другим наука;",
1681
+ "Но, боже мой, какая скука",
1682
+ "С больным сидеть и день и ночь,",
1683
+ "Не отходя ни шагу прочь!",
1684
+ "Какое низкое коварство",
1685
+ "Полуживого забавлять,",
1686
+ "Ему подушки поправлять,",
1687
+ "Печально подносить лекарство,",
1688
+ "Вздыхать и думать про себя:",
1689
+ "Когда же черт возьмёт тебя…",
1690
+ ];
1691
+
1692
+ const PoemBlock: React.FC = () => (
1693
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
1694
+ <IslandBody>
1695
+ {POEM_LINES.map((line) => (
1696
+ <React.Fragment key={line}>
1697
+ {line}
1698
+ <br />
1699
+ </React.Fragment>
1700
+ ))}
1701
+ </IslandBody>
1702
+ </Island>
1703
+ );
1704
+
1705
+ export const WithSideOverlayLoadingExample = () => {
1706
+ const [isOpen, setIsOpen] = useState(false);
1707
+ const [openedSideOverlayLG, setOpenedSideOverlayLG] = useState(false);
1708
+ const [isLoading, setIsLoading] = useState(false);
1709
+
1710
+ const handleOpenSideOverlayLG = () => setOpenedSideOverlayLG(true);
1711
+ const handleCloseSideOverlayLG = () => {
1712
+ setIsLoading(false);
1713
+ setOpenedSideOverlayLG(false);
1714
+ };
1715
+
1716
+ const handleOpen = () => setIsOpen(true);
1717
+ const handleClose = () => setIsOpen(false);
1718
+
1719
+ const renderLightBoxControls = () => (
1720
+ <LightBox.Controls key="controls">
1721
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={handleClose} />
1722
+ </LightBox.Controls>
1723
+ );
1724
+
1725
+ const renderLightBoxSideOverlayLG = () => (
1726
+ <LightBox.SideOverlay
1727
+ key="sideOverlayLG"
1728
+ opened={openedSideOverlayLG}
1729
+ size={EComponentSize.LG}
1730
+ isTopLevelSideOverlayOpened={false}
1731
+ isLoading={isLoading}
1732
+ >
1733
+ <Page>
1734
+ <Page.Header type={EHeaderPageType.FIRST} sticky={openedSideOverlayLG}>
1735
+ <Page.Header.Title>
1736
+ <Page.Header.Title.Content>
1737
+ <Title
1738
+ tag="h1"
1739
+ size={ETitleSize.H1}
1740
+ tabIndex={-1}
1741
+ // Устанавливает фокус на первый элемент при открытии LightBox.
1742
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
1743
+ >
1744
+ Евгений Онегин
1745
+ </Title>
1746
+ </Page.Header.Title.Content>
1747
+ <Page.Header.Title.Controls>
1748
+ {/* Кнопка закрытия SideOverlay для мобильного устройства. Отображается только на мобильном устройстве, внутри заголовка SideOverlay. */}
1749
+ <LightBox.SideOverlay.CloseMobile
1750
+ data-test-id="lightbox-side-overlay-close"
1751
+ onClick={handleCloseSideOverlayLG}
1752
+ />
1753
+ </Page.Header.Title.Controls>
1754
+ </Page.Header.Title>
1755
+ </Page.Header>
1756
+
1757
+ <Page.Body type={EBodyPageType.SECOND}>
1758
+ {[0, 1, 2].map((index) => (
1759
+ <React.Fragment key={index}>
1760
+ <PoemBlock />
1761
+ {index < 2 && <Gap size={16} />}
1762
+ </React.Fragment>
1763
+ ))}
1764
+ </Page.Body>
1765
+
1766
+ <Page.Footer type={EFooterPageType.FIRST} sticky={openedSideOverlayLG}>
1767
+ <Page.Footer.Description>
1768
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
1769
+ <Page.Footer.Description.Controls>
1770
+ <Button
1771
+ theme={EButtonTheme.GENERAL}
1772
+ size={EComponentSize.MD}
1773
+ onClick={() => setIsLoading(true)}
1774
+ >
1775
+ Loading on
1776
+ </Button>
1777
+ </Page.Footer.Description.Controls>
1778
+ </Page.Footer.Description>
1779
+ </Page.Footer>
1780
+ </Page>
1781
+
1782
+ {/* Кнопка закрытия SideOverlay для десктопа. Отображается только на десктопе, справа от заголовка SideOverlay. */}
1783
+ <LightBox.SideOverlay.CloseDesktop
1784
+ data-test-id="lightbox-side-overlay-close"
1785
+ clickByEsc
1786
+ onClick={handleCloseSideOverlayLG}
1787
+ />
1788
+ </LightBox.SideOverlay>
1789
+ );
1790
+
1791
+ return (
1792
+ <div>
1793
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={handleOpen}>
1794
+ Открыть LightBox
1795
+ </Button>
1796
+
1797
+ {isOpen ? (
1798
+ <LightBox isLoading={false} isSideOverlayOpened={openedSideOverlayLG} isTopOverlayOpened={false}>
1799
+ <LightBox.Content key="content" isLoading={false}>
1800
+ <Page>
1801
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
1802
+ <Page.Header.Title>
1803
+ <Page.Header.Title.Content>
1804
+ <Title
1805
+ tag="h1"
1806
+ size={ETitleSize.H1}
1807
+ tabIndex={-1}
1808
+ // Устанавливает фокус на первый элемент при открытии LightBox.
1809
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
1810
+ >
1811
+ Евгений Онегин
1812
+ </Title>
1813
+ <Gap size={8} />
1814
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
1815
+ Русский поэт, драматург и прозаик, заложивший основы русского
1816
+ реалистического направления.
1817
+ </Text>
1818
+ </Page.Header.Title.Content>
1819
+ <Page.Header.Title.Controls>
1820
+ <Button
1821
+ theme={EButtonTheme.GENERAL}
1822
+ size={EComponentSize.MD}
1823
+ onClick={handleOpenSideOverlayLG}
1824
+ >
1825
+ SideOverlay LG
1826
+ </Button>
1827
+ </Page.Header.Title.Controls>
1828
+ </Page.Header.Title>
1829
+ </Page.Header>
1830
+
1831
+ <Page.Body type={EBodyPageType.SECOND}>
1832
+ {[0, 1, 2].map((index) => (
1833
+ <React.Fragment key={index}>
1834
+ <PoemBlock />
1835
+ {index < 2 && <Gap size={16} />}
1836
+ </React.Fragment>
1837
+ ))}
1838
+ </Page.Body>
1839
+
1840
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
1841
+ <Page.Footer.Description>
1842
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
1843
+ <Page.Footer.Description.Controls>
1844
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
1845
+ Button text
1846
+ </Button>
1847
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
1848
+ Button text
1849
+ </Button>
1850
+ </Page.Footer.Description.Controls>
1851
+ </Page.Footer.Description>
1852
+ </Page.Footer>
1853
+ </Page>
1854
+ </LightBox.Content>
1855
+
1856
+ {renderLightBoxControls()}
1857
+
1858
+ {renderLightBoxSideOverlayLG()}
1859
+ </LightBox>
1860
+ ) : null}
1861
+ </div>
1862
+ );
1863
+ };
1864
+ ```
298
1865
 
299
- - `ModalWindow` — простой диалог, не полноэкранный.
300
- - `Overlay` / `OverlayBase` / `OverlayMask` — основа для SideOverlay.
301
- - `TopOverlay` — верхняя выезжающая панель (реэкспортируется как `LightBox.TopOverlay`).
302
- - `Portal` — используется для render'а.
303
- - `MobileView` — разграничение desktop/mobile.
304
- - `LoaderScreen` — loader внутри Content.
305
- - `focus-trap-react` — FocusTrap.
306
- - `react-resize-detector` — resize-listener в Content и ViewManager.
1866
+ ### WithTopOverlay
307
1867
 
308
- ---
1868
+ `stories/LightBox/examples/WithTopOverlayExample.tsx`
309
1869
 
310
- ## Stories
1870
+ ```tsx
1871
+ import React, { useState } from "react";
1872
+ import {
1873
+ LightBox,
1874
+ Page,
1875
+ Button,
1876
+ EButtonTheme,
1877
+ Gap,
1878
+ Title,
1879
+ Text,
1880
+ EFontType,
1881
+ ETextSize,
1882
+ ETitleSize,
1883
+ EHeaderPageType,
1884
+ EFooterPageType,
1885
+ EBodyPageType,
1886
+ Island,
1887
+ IslandBody,
1888
+ EIslandType,
1889
+ FocusTrapUtils,
1890
+ EComponentSize,
1891
+ Confirm,
1892
+ } from "@sberbusiness/triplex-next";
1893
+
1894
+ const POEM_LINES: string[] = [
1895
+ "Мой дядя самых честных правил,",
1896
+ "Когда не в шутку занемог,",
1897
+ "Он уважать себя заставил",
1898
+ "И лучше выдумать не мог.",
1899
+ "Его пример другим наука;",
1900
+ "Но, боже мой, какая скука",
1901
+ "С больным сидеть и день и ночь,",
1902
+ "Не отходя ни шагу прочь!",
1903
+ "Какое низкое коварство",
1904
+ "Полуживого забавлять,",
1905
+ "Ему подушки поправлять,",
1906
+ "Печально подносить лекарство,",
1907
+ "Вздыхать и думать про себя:",
1908
+ "Когда же черт возьмёт тебя…",
1909
+ ];
1910
+
1911
+ const PoemBlock: React.FC = () => (
1912
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
1913
+ <IslandBody>
1914
+ {POEM_LINES.map((line) => (
1915
+ <React.Fragment key={line}>
1916
+ {line}
1917
+ <br />
1918
+ </React.Fragment>
1919
+ ))}
1920
+ </IslandBody>
1921
+ </Island>
1922
+ );
1923
+
1924
+ export const WithTopOverlayExample = () => {
1925
+ const [isOpen, setIsOpen] = useState(false);
1926
+ const [openedTopOverlay, setOpenedTopOverlay] = useState(false);
1927
+ const [closeConfirmed, setCloseConfirmed] = useState(false);
1928
+
1929
+ const handleOpenTopOverlay = () => setOpenedTopOverlay(true);
1930
+ const handleCloseTopOverlay = () => {
1931
+ if (closeConfirmed) {
1932
+ setIsOpen(false);
1933
+ setCloseConfirmed(false);
1934
+ } else {
1935
+ setOpenedTopOverlay(false);
1936
+ }
1937
+ };
1938
+
1939
+ const handleOpen = () => setIsOpen(true);
1940
+ const handleClose = () => setOpenedTopOverlay(true);
1941
+
1942
+ const renderLightBoxControls = () => (
1943
+ <LightBox.Controls key="controls">
1944
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={handleClose} />
1945
+ <LightBox.Controls.Prev title="Назад" clickByArrowLeft onClick={() => console.log("Prev clicked")} />
1946
+ <LightBox.Controls.Next title="Вперёд" clickByArrowRight onClick={() => console.log("Next clicked")} />
1947
+ </LightBox.Controls>
1948
+ );
1949
+
1950
+ const renderTopOverlay = () => (
1951
+ <LightBox.TopOverlay opened={openedTopOverlay} onClose={handleCloseTopOverlay} onOpen={handleOpenTopOverlay}>
1952
+ <Confirm>
1953
+ <Confirm.Content>
1954
+ <Confirm.Content.Title>Внимание</Confirm.Content.Title>
1955
+ <Confirm.Content.SubTitle>
1956
+ Несохранённые данные будут утеряны. Вы уверены, что хотите покинуть форму редактирования?
1957
+ </Confirm.Content.SubTitle>
1958
+ </Confirm.Content>
1959
+ <Confirm.Controls>
1960
+ <Button
1961
+ theme={EButtonTheme.SECONDARY}
1962
+ size={EComponentSize.MD}
1963
+ onClick={() => setOpenedTopOverlay(false)}
1964
+ >
1965
+ Отмена
1966
+ </Button>
1967
+ <Button
1968
+ theme={EButtonTheme.DANGER}
1969
+ size={EComponentSize.MD}
1970
+ onClick={() => {
1971
+ handleCloseTopOverlay();
1972
+ setCloseConfirmed(true);
1973
+ }}
1974
+ >
1975
+ Покинуть форму
1976
+ </Button>
1977
+ </Confirm.Controls>
1978
+ <Confirm.Close
1979
+ title="Закрыть"
1980
+ // Закрыть по Esc, если TopOverlay открыт.
1981
+ clickByEsc={openedTopOverlay}
1982
+ onClick={() => setOpenedTopOverlay(false)}
1983
+ />
1984
+ </Confirm>
1985
+ </LightBox.TopOverlay>
1986
+ );
1987
+
1988
+ return (
1989
+ <div>
1990
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={handleOpen}>
1991
+ Открыть LightBox
1992
+ </Button>
1993
+
1994
+ {isOpen ? (
1995
+ <LightBox isLoading={false} isSideOverlayOpened={false} isTopOverlayOpened={openedTopOverlay}>
1996
+ <LightBox.Content key="content" isLoading={false}>
1997
+ <Page>
1998
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
1999
+ <Page.Header.Title>
2000
+ <Page.Header.Title.Content>
2001
+ <Title
2002
+ tag="h1"
2003
+ size={ETitleSize.H1}
2004
+ tabIndex={-1}
2005
+ // Устанавливает фокус на первый элемент при открытии LightBox.
2006
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
2007
+ >
2008
+ Евгений Онегин
2009
+ </Title>
2010
+ <Gap size={8} />
2011
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
2012
+ Русский поэт, драматург и прозаик, заложивший основы русского
2013
+ реалистического направления.
2014
+ </Text>
2015
+ </Page.Header.Title.Content>
2016
+ <Page.Header.Title.Controls>
2017
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
2018
+ Button text
2019
+ </Button>
2020
+ </Page.Header.Title.Controls>
2021
+ </Page.Header.Title>
2022
+ </Page.Header>
2023
+
2024
+ <Page.Body type={EBodyPageType.SECOND}>
2025
+ {[0, 1, 2].map((index) => (
2026
+ <React.Fragment key={index}>
2027
+ <PoemBlock />
2028
+ {index < 2 && <Gap size={16} />}
2029
+ </React.Fragment>
2030
+ ))}
2031
+ </Page.Body>
2032
+
2033
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
2034
+ <Page.Footer.Description>
2035
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
2036
+ <Page.Footer.Description.Controls>
2037
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
2038
+ Button text
2039
+ </Button>
2040
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
2041
+ Button text
2042
+ </Button>
2043
+ </Page.Footer.Description.Controls>
2044
+ </Page.Footer.Description>
2045
+ </Page.Footer>
2046
+ </Page>
2047
+
2048
+ {renderTopOverlay()}
2049
+ </LightBox.Content>
2050
+
2051
+ {renderLightBoxControls()}
2052
+ </LightBox>
2053
+ ) : null}
2054
+ </div>
2055
+ );
2056
+ };
2057
+ ```
311
2058
 
312
- `stories/LightBox/LightBox.stories.tsx`
313
- `stories/LightBox/LightBoxInitialOpened.stories.tsx`
314
- Примеры: `stories/LightBox/examples/`
2059
+ ### WithTopOverlayInSideOverlay
315
2060
 
316
- ---
2061
+ `stories/LightBox/examples/WithTopOverlayInSideOverlayExample.tsx`
317
2062
 
318
- ## История изменений
2063
+ ```tsx
2064
+ import React, { useState } from "react";
2065
+ import {
2066
+ LightBox,
2067
+ Page,
2068
+ Button,
2069
+ EButtonTheme,
2070
+ Gap,
2071
+ Title,
2072
+ Text,
2073
+ EFontType,
2074
+ ETextSize,
2075
+ ETitleSize,
2076
+ EHeaderPageType,
2077
+ EFooterPageType,
2078
+ EBodyPageType,
2079
+ Island,
2080
+ IslandBody,
2081
+ EIslandType,
2082
+ FocusTrapUtils,
2083
+ EComponentSize,
2084
+ MobileView,
2085
+ Confirm,
2086
+ EConfirmParentComponent,
2087
+ } from "@sberbusiness/triplex-next";
2088
+
2089
+ const POEM_LINES: string[] = [
2090
+ "Мой дядя самых честных правил,",
2091
+ "Когда не в шутку занемог,",
2092
+ "Он уважать себя заставил",
2093
+ "И лучше выдумать не мог.",
2094
+ "Его пример другим наука;",
2095
+ "Но, боже мой, какая скука",
2096
+ "С больным сидеть и день и ночь,",
2097
+ "Не отходя ни шагу прочь!",
2098
+ "Какое низкое коварство",
2099
+ "Полуживого забавлять,",
2100
+ "Ему подушки поправлять,",
2101
+ "Печально подносить лекарство,",
2102
+ "Вздыхать и думать про себя:",
2103
+ "Когда же черт возьмёт тебя…",
2104
+ ];
2105
+
2106
+ const PoemBlock: React.FC = () => (
2107
+ <Island type={EIslandType.TYPE_1} size={EComponentSize.MD}>
2108
+ <IslandBody>
2109
+ {POEM_LINES.map((line) => (
2110
+ <React.Fragment key={line}>
2111
+ {line}
2112
+ <br />
2113
+ </React.Fragment>
2114
+ ))}
2115
+ </IslandBody>
2116
+ </Island>
2117
+ );
2118
+
2119
+ export const WithTopOverlayInSideOverlayExample = () => {
2120
+ const [isOpen, setIsOpen] = useState(false);
2121
+ const [openedSideOverlayMD, setOpenedSideOverlayMD] = useState(false);
2122
+
2123
+ const [openedTopOverlay, setOpenedTopOverlay] = useState(false);
2124
+ const [closeConfirmed, setCloseConfirmed] = useState(false);
2125
+
2126
+ const handleOpenTopOverlay = () => setOpenedTopOverlay(true);
2127
+ const handleCloseTopOverlay = () => {
2128
+ if (closeConfirmed) {
2129
+ setOpenedSideOverlayMD(false);
2130
+ setCloseConfirmed(false);
2131
+ } else {
2132
+ setOpenedTopOverlay(false);
2133
+ }
2134
+ };
2135
+
2136
+ const handleOpenSideOverlayMD = () => setOpenedSideOverlayMD(true);
2137
+ const handleCloseSideOverlayMD = () => setOpenedTopOverlay(true);
2138
+
2139
+ const handleOpen = () => setIsOpen(true);
2140
+ const handleClose = () => setIsOpen(false);
2141
+
2142
+ const renderLightBoxControls = () => (
2143
+ <LightBox.Controls key="controls">
2144
+ <LightBox.Controls.Close title="Закрыть" data-test-id="lightbox-close" onClick={handleClose} />
2145
+ <LightBox.Controls.Prev title="Назад" clickByArrowLeft onClick={() => console.log("Prev clicked")} />
2146
+ <LightBox.Controls.Next title="Вперёд" clickByArrowRight onClick={() => console.log("Next clicked")} />
2147
+ </LightBox.Controls>
2148
+ );
2149
+
2150
+ const renderTopOverlay = () => (
2151
+ <LightBox.TopOverlay opened={openedTopOverlay} onClose={handleCloseTopOverlay} onOpen={handleOpenTopOverlay}>
2152
+ <Confirm parentComponent={EConfirmParentComponent.SIDE_OVERLAY_MD}>
2153
+ <Confirm.Content>
2154
+ <Confirm.Content.Title>Внимание</Confirm.Content.Title>
2155
+ <Confirm.Content.SubTitle>
2156
+ Несохранённые данные будут утеряны. Вы уверены, что хотите покинуть форму редактирования?
2157
+ </Confirm.Content.SubTitle>
2158
+ </Confirm.Content>
2159
+ <Confirm.Controls>
2160
+ <Button
2161
+ theme={EButtonTheme.SECONDARY}
2162
+ size={EComponentSize.MD}
2163
+ onClick={() => setOpenedTopOverlay(false)}
2164
+ >
2165
+ Отмена
2166
+ </Button>
2167
+ <Button
2168
+ theme={EButtonTheme.DANGER}
2169
+ size={EComponentSize.MD}
2170
+ onClick={() => {
2171
+ handleCloseTopOverlay();
2172
+ setCloseConfirmed(true);
2173
+ }}
2174
+ >
2175
+ Покинуть форму
2176
+ </Button>
2177
+ </Confirm.Controls>
2178
+ <Confirm.Close
2179
+ title="Закрыть"
2180
+ // Закрыть по Esc, если TopOverlay открыт.
2181
+ clickByEsc={openedTopOverlay}
2182
+ onClick={() => setOpenedTopOverlay(false)}
2183
+ />
2184
+ </Confirm>
2185
+ </LightBox.TopOverlay>
2186
+ );
2187
+
2188
+ const renderLightBoxSideOverlayMD = () => (
2189
+ <LightBox.SideOverlay
2190
+ key="sideOverlayMD"
2191
+ opened={openedSideOverlayMD}
2192
+ size={EComponentSize.MD}
2193
+ isTopLevelSideOverlayOpened={false}
2194
+ isTopOverlayOpened={openedTopOverlay}
2195
+ >
2196
+ <Page>
2197
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
2198
+ <Page.Header.Title>
2199
+ <Page.Header.Title.Content>
2200
+ <MobileView
2201
+ fallback={
2202
+ <Title
2203
+ tag="h1"
2204
+ size={ETitleSize.H1}
2205
+ tabIndex={-1}
2206
+ // Устанавливает фокус на первый элемент при открытии LightBox.
2207
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
2208
+ >
2209
+ Евгений Онегин
2210
+ </Title>
2211
+ }
2212
+ >
2213
+ <Title tag="h2" size={ETitleSize.H2}>
2214
+ Евгений Онегин
2215
+ </Title>
2216
+ </MobileView>
2217
+ </Page.Header.Title.Content>
2218
+ <Page.Header.Title.Controls>
2219
+ <LightBox.SideOverlay.CloseMobile
2220
+ data-test-id="lightbox-side-overlay-close"
2221
+ onClick={handleCloseSideOverlayMD}
2222
+ />
2223
+ </Page.Header.Title.Controls>
2224
+ </Page.Header.Title>
2225
+ </Page.Header>
2226
+
2227
+ <Page.Body type={EBodyPageType.SECOND}>
2228
+ {[0, 1, 2].map((index) => (
2229
+ <React.Fragment key={index}>
2230
+ <PoemBlock />
2231
+ {index < 2 && <Gap size={16} />}
2232
+ </React.Fragment>
2233
+ ))}
2234
+ </Page.Body>
2235
+
2236
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
2237
+ <Page.Footer.Description>
2238
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
2239
+ <Page.Footer.Description.Controls>
2240
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
2241
+ Button text
2242
+ </Button>
2243
+ </Page.Footer.Description.Controls>
2244
+ </Page.Footer.Description>
2245
+ </Page.Footer>
2246
+ </Page>
2247
+
2248
+ <LightBox.SideOverlay.CloseDesktop
2249
+ data-test-id="lightbox-side-overlay-close"
2250
+ clickByEsc={!openedTopOverlay}
2251
+ onClick={handleCloseSideOverlayMD}
2252
+ />
2253
+
2254
+ {renderTopOverlay()}
2255
+ </LightBox.SideOverlay>
2256
+ );
2257
+
2258
+ return (
2259
+ <div>
2260
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={handleOpen}>
2261
+ Открыть LightBox
2262
+ </Button>
2263
+
2264
+ {isOpen ? (
2265
+ <LightBox isLoading={false} isSideOverlayOpened={openedSideOverlayMD} isTopOverlayOpened={false}>
2266
+ <LightBox.Content key="content" isLoading={false}>
2267
+ <Page>
2268
+ <Page.Header type={EHeaderPageType.FIRST} sticky>
2269
+ <Page.Header.Title>
2270
+ <Page.Header.Title.Content>
2271
+ <MobileView
2272
+ fallback={
2273
+ <Title
2274
+ tag="h1"
2275
+ size={ETitleSize.H1}
2276
+ tabIndex={-1}
2277
+ // Устанавливает фокус на первый элемент при открытии LightBox.
2278
+ {...{ [FocusTrapUtils.firstInteractionElementDataAttr]: true }}
2279
+ >
2280
+ Евгений Онегин
2281
+ </Title>
2282
+ }
2283
+ >
2284
+ <Title tag="h2" size={ETitleSize.H2}>
2285
+ Евгений Онегин
2286
+ </Title>
2287
+ </MobileView>
2288
+
2289
+ <Gap size={8} />
2290
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
2291
+ Русский поэт, драматург и прозаик, заложивший основы русского
2292
+ реалистического направления.
2293
+ </Text>
2294
+ </Page.Header.Title.Content>
2295
+ <Page.Header.Title.Controls>
2296
+ <Button
2297
+ theme={EButtonTheme.GENERAL}
2298
+ size={EComponentSize.MD}
2299
+ onClick={handleOpenSideOverlayMD}
2300
+ >
2301
+ SideOverlay MD
2302
+ </Button>
2303
+ </Page.Header.Title.Controls>
2304
+ </Page.Header.Title>
2305
+ </Page.Header>
2306
+
2307
+ <Page.Body type={EBodyPageType.SECOND}>
2308
+ {[0, 1, 2].map((index) => (
2309
+ <React.Fragment key={index}>
2310
+ <PoemBlock />
2311
+ {index < 2 && <Gap size={16} />}
2312
+ </React.Fragment>
2313
+ ))}
2314
+ </Page.Body>
2315
+
2316
+ <Page.Footer type={EFooterPageType.FIRST} sticky>
2317
+ <Page.Footer.Description>
2318
+ <Page.Footer.Description.Content>А. С. Пушкин</Page.Footer.Description.Content>
2319
+ <Page.Footer.Description.Controls>
2320
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
2321
+ Button text
2322
+ </Button>
2323
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
2324
+ Button text
2325
+ </Button>
2326
+ </Page.Footer.Description.Controls>
2327
+ </Page.Footer.Description>
2328
+ </Page.Footer>
2329
+ </Page>
2330
+ </LightBox.Content>
2331
+
2332
+ {renderLightBoxControls()}
2333
+
2334
+ {renderLightBoxSideOverlayMD()}
2335
+ </LightBox>
2336
+ ) : null}
2337
+ </div>
2338
+ );
2339
+ };
2340
+ ```
319
2341
 
320
- | Дата | Изменение |
321
- |---|---|
322
- | 2026-04-18 | Создан документ |