@sberbusiness/triplex-next-mcp-server 1.40.0-beta → 1.41.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,18 @@
1
1
  ---
2
2
  component: Tooltip
3
- category: Overlay
4
- figma-node: ""
5
- figma-file: ""
6
- related: [Dropdown, MobileView, MediaWidth]
7
- tokens: []
3
+ category: Tooltip
4
+ related: [HelpBox, SMSField, Dropdown, DropdownMobile, Portal, MobileView, ThemeProvider, ButtonIcon]
5
+ tokens:
6
+ - --triplex-next-Tooltip-Background
7
+ - --triplex-next-Tooltip-Color
8
+ - --triplex-next-Tooltip-Link_Desktop_Color_Default
9
+ - --triplex-next-Tooltip-Link_Desktop_Color_Hover
10
+ - --triplex-next-Tooltip-Link_Desktop_Color_Active
11
+ - --triplex-next-Tooltip-Link_Desktop_Shadow_Focus
12
+ - --triplex-next-Tooltip-Link_Mobile_Color_Default
13
+ - --triplex-next-Tooltip-Link_Mobile_Color_Hover
14
+ - --triplex-next-Tooltip-Link_Mobile_Color_Active
15
+ - --triplex-next-Tooltip-Link_Mobile_Shadow_Focus
8
16
  stories: stories/Tooltip/Tooltip.stories.tsx
9
17
  version: "1.0"
10
18
  ---
@@ -13,242 +21,474 @@ version: "1.0"
13
21
 
14
22
  ## Назначение
15
23
 
16
- Всплывающая подсказка с автоматическим позиционированием
17
- (`above`/`below`/`right`/`left`) и адаптивным переключением desktop/mobile.
18
- Контент компонуется через слоты: `Target` (триггер) + `Body` (контент) +
19
- опционально `Link`, `XButton` (закрыть), `MobileHeader`. Управление —
20
- hover или click.
24
+ Всплывающая подсказка, привязанная к целевому элементу. Рендерится через `Portal` — по умолчанию в `document.body`, либо в контейнер из `renderContainer` — и позиционируется относительно `targetRef`. На мобильной ширине экрана (<768px) вместо всплывающей подсказки открывается полноэкранный оверлей снизу на базе `DropdownMobile`.
21
25
 
22
- Используй когда: нужна подсказка возле элемента (label-описание, ошибка,
23
- информация). Для модальных диалогов — `Modal`. Для меню действий — `Dropdown`.
26
+ Используй когда: нужно пояснить элемент интерфейса коротким текстом, при необходимости со ссылкой на подробное описание.
27
+ Не используй когда:
28
+
29
+ - нужна готовая иконка-вопрос с подсказкой и мобильным заголовком — используй `HelpBox` (обёртка над `Tooltip`);
30
+ - нужно выпадающее меню или произвольный интерактивный блок — используй `Dropdown` / `ButtonDropdownExtended`;
31
+ - содержимое подсказки требует фокусируемых элементов кроме `Tooltip.Link`: компонент не перехватывает фокус и не строит focus trap.
24
32
 
25
33
  ---
26
34
 
27
- ## Файловая структура
35
+ ## Состав (compound-компонент)
28
36
 
29
- ```text
30
- src/components/Tooltip/
31
- ├── Tooltip.tsx # FC + Composition; MobileView switcher; Provider
32
- ├── TootlipContext.ts # ITooltipContext (опечатка имени файла зафиксирована)
33
- ├── enums.ts # ETooltipSize, ETooltipPreferPlace, ETooltipAlign + внутренние enum'ы позиционирования
34
- ├── types.ts # все интерфейсы
35
- ├── components/
36
- │ ├── common/
37
- │ │ ├── TooltipTarget.tsx # клонирование child'а с onClick/onKeyDown
38
- │ │ ├── TooltipBody.tsx # forwardRef<HTMLDivElement>; MobileView для Text-обёртки
39
- │ │ ├── TooltipLink.tsx
40
- │ │ └── TooltipXButton.tsx # children: never
41
- │ ├── desktop/
42
- │ │ ├── TooltipDesktop.tsx
43
- │ │ └── components/ # позиционирование, портал
44
- │ └── mobile/
45
- │ ├── TooltipMobile.tsx
46
- │ └── components/TooltipMobileHeader.tsx
47
- ├── utils/
48
- │ ├── Positioning.ts # геометрия для desktop размещения
49
- │ └── useTooltipTheme.ts # инжект темы в renderContainer
50
- ├── styles/
51
- └── index.ts
52
- ```
37
+ Состав задаётся субкомпонентами внутри `children`. Компонент разбирает `children` **по типу элемента**, поэтому порядок в разметке не важен, а произвольные узлы (текст, `<div>`, фрагменты) просто игнорируются.
38
+
39
+ | Субкомпонент | Обязателен | Что делает |
40
+ |---|---|---|
41
+ | `Tooltip.Target` | Да | Клонирует единственный дочерний элемент, навешивая обработчики открытия. Собственной разметки не рендерит |
42
+ | `Tooltip.Body` | Практически всегда | Текст подсказки. На десктопе — `<div>`, в мобильной версии — `Text` (`ETextSize.B3`) |
43
+ | `Tooltip.Link` | Нет | Гиперссылка под текстом. При `target="_blank"` сам подставляет `rel="noopener"` |
44
+ | `Tooltip.XButton` | Нет | Кнопка закрытия. Её наличие добавляет корню класс `closable` (отступ справа у контента). В адаптивном режиме кнопка закрытия есть всегда, независимо от `Tooltip.XButton`, — он лишь передаёт ей свои props (в том числе `aria-label`) |
45
+ | `Tooltip.MobileHeader` | Нет | Заголовок мобильной версии. На десктопе не рендерится |
53
46
 
54
47
  ---
55
48
 
56
- ## Композиция
49
+ ## Варианты и props
57
50
 
58
- ```tsx
59
- <Tooltip
60
- targetRef={btnRef}
61
- size={ETooltipSize.LG}
62
- toggleType="hover"
63
- preferPlace={ETooltipPreferPlace.ABOVE}
64
- isOpen={open}
65
- toggle={setOpen}
66
- >
67
- <Tooltip.Target>
68
- <button ref={btnRef}>?</button>
69
- </Tooltip.Target>
70
- <Tooltip.Body>Подсказка</Tooltip.Body>
71
- <Tooltip.Link href="/help">Подробнее</Tooltip.Link>
72
- <Tooltip.XButton aria-label="Закрыть" />
73
- <Tooltip.MobileHeader>Заголовок</Tooltip.MobileHeader>
74
- </Tooltip>
75
- ```
51
+ `ITooltipProps` расширяет `React.HTMLAttributes<HTMLDivElement>`. Неизвестные компоненту атрибуты уходят на корневой `<div>` десктопной подсказки (и на контейнер мобильного оверлея в адаптивном режиме). Исключение — `onKeyDown`: см. «Ограничения».
76
52
 
77
- Все слоты — статические свойства `Tooltip.X = X`. Дети распознаются по
78
- типу через `React.Children.map` (см. `getChildrenElements`); порядок
79
- расположения в JSX неважен.
53
+ ### Обязательные props
80
54
 
81
- ---
55
+ | Prop | Тип | Описание |
56
+ |---|---|---|
57
+ | `size` | `ETooltipSize` | Ширина тела подсказки в десктопной версии: `SM` — 192px, `LG` — 384px. В мобильной версии не влияет ни на что |
58
+ | `targetRef` | `React.MutableRefObject<HTMLElement \| null>` | Ссылка на целевой элемент. От его `getBoundingClientRect()` считается положение подсказки и на него вешаются hover-обработчики. Тот же ref нужно передать и в сам элемент внутри `Tooltip.Target` |
82
59
 
83
- ## Публичный API
60
+ ### Опциональные props
84
61
 
85
- ### `ITooltipProps`
62
+ | Prop | Тип | По умолчанию | Описание |
63
+ |---|---|---|---|
64
+ | `toggleType` | `"hover" \| "click"` | — | Способ открытия. Без значения подсказка сама не открывается — состоянием управляет потребитель через `isOpen` |
65
+ | `preferPlace` | `ETooltipPreferPlace` | — | Предпочитаемое место. Если подсказка туда не помещается, выбирается зона, где помещается. Без значения зона выбирается автоматически |
66
+ | `alignTip` | `ETooltipAlign` | — | Принудительное выравнивание указателя (стрелочки): `START` / `END`. Без значения указатель центрируется по целевому элементу |
67
+ | `isOpen` | `boolean` | — | Управляемый режим. Пока prop задан (в том числе `false`), внутреннее состояние открытости игнорируется |
68
+ | `toggle` | `(open: boolean) => void` | — | Запрос на смену состояния. Вызывается всегда — и в управляемом, и в неуправляемом режиме |
69
+ | `onShow` | `(node: HTMLDivElement) => void` | — | Вызывается при открытии, аргумент — DOM-нода подсказки (десктоп) или контентная нода оверлея (мобильная версия) |
70
+ | `renderContainer` | `Element` | `document.body` | Контейнер портала для десктопной версии. Мобильная версия всегда рендерится в `document.body` |
71
+ | `disableAdaptiveMode` | `boolean` | `false` | Отключает мобильную версию: подсказка остаётся всплывающей на любой ширине экрана |
72
+ | `className` | `string` | — | Дополнительный класс на корневом элементе подсказки |
73
+
74
+ ### Ограничения
75
+
76
+ - **`Tooltip` — не `forwardRef`-компонент.** Это провайдер контекста без собственной разметки: ссылка на подсказку отдаётся через `onShow`, ссылка на целевой элемент — это `targetRef` потребителя. `forwardRef` есть у `Tooltip.Body`, `Tooltip.Link` и внутреннего `TooltipDesktopTip`.
77
+ - `Tooltip.Target` принимает **ровно один** React-элемент, который умеет принимать `onClick` / `onKeyDown`.
78
+ - `Tooltip.XButton` не принимает `children` (`children?: never`) — иконка крестика внутри фиксированная.
79
+ - `size`, `preferPlace`, `alignTip` и `renderContainer` действуют только в десктопной версии.
80
+ - `onKeyDown`, переданный в `Tooltip`, на десктопе до корневого элемента подсказки не доходит: `TooltipDesktopBase` вырезает его из props и никуда не прокидывает. В адаптивном режиме он, наоборот, попадает на контейнер оверлея — поведение одного и того же prop в двух режимах различается. Клавиатурный обработчик вешай на элемент внутри `Tooltip.Target`.
81
+ - `Tooltip.MobileHeader` подставляет кнопку закрытия сам, но слот `controlButtons` при этом остаётся открытым: переданное потребителем значение спредится **после** значения по умолчанию и молча заменяет кнопку закрытия. Сузить тип нельзя без breaking change — на это рассчитывать не стоит.
82
+ - Компонент не хардкодит текст: `aria-label` для целевого элемента и для `Tooltip.XButton` передаёт потребитель (библиотека мультиязычная).
86
83
 
87
- ```ts
88
- extends HTMLAttributes<HTMLDivElement>
89
- ```
84
+ ---
90
85
 
91
- | Prop | Тип | Default | Описание |
92
- |---|---|---|---|
93
- | `size` | `ETooltipSize` (`SM` \| `LG`) | — | **Обязателен**. |
94
- | `targetRef` | `MutableRefObject<HTMLElement \| null>` | — | **Обязателен**. Ref на якорный элемент (используется для расчёта позиции). |
95
- | `toggleType?` | `"click" \| "hover"` | — | Способ открытия. Если не передан — Tooltip управляется только программно через `isOpen`/`toggle`. |
96
- | `preferPlace?` | `ETooltipPreferPlace` (`ABOVE`/`BELOW`/`LEFT`/`RIGHT`) | — | Предпочтительная сторона. Если не помещается — flip автоматически. |
97
- | `alignTip?` | `ETooltipAlign` (`START`/`CENTER`/`END`) | — | Выравнивание указателя относительно target'а. |
98
- | `renderContainer?` | `Element` | `document.body` | Контейнер для портала. На mobile с включённой адаптивностью — игнорируется (всегда `document.body`). |
99
- | `disableAdaptiveMode?` | `boolean` | `false` | Отключить переключение на mobile-вариант. |
100
- | `isOpen?` | `boolean` | — | Контролируемое открытие. Если не передан — внутренний `openState`. |
101
- | `toggle?` | `(open: boolean) => void` | — | Колбэк изменения. |
102
- | `onShow?` | `(node: HTMLDivElement) => void` | — | После появления tooltip-DOM. |
103
- | `children` | `ReactNode` | — | Слоты `Tooltip.*`. |
104
-
105
- ### Слоты
106
-
107
- #### `Tooltip.Target`
108
-
109
- ```ts
110
- { children: ReactElement } // ровно один React-элемент
86
+ ## Дизайн-токены
87
+
88
+ ```text
89
+ --triplex-next-Tooltip-Background
90
+ --triplex-next-Tooltip-Color
91
+ --triplex-next-Tooltip-Link_Desktop_Color_Default
92
+ --triplex-next-Tooltip-Link_Desktop_Color_Hover
93
+ --triplex-next-Tooltip-Link_Desktop_Color_Active
94
+ --triplex-next-Tooltip-Link_Desktop_Shadow_Focus
95
+ --triplex-next-Tooltip-Link_Mobile_Color_Default
96
+ --triplex-next-Tooltip-Link_Mobile_Color_Hover
97
+ --triplex-next-Tooltip-Link_Mobile_Color_Active
98
+ --triplex-next-Tooltip-Link_Mobile_Shadow_Focus
111
99
  ```
112
100
 
113
- Клонирует child через `React.cloneElement`, добавляя `onClick` и `onKeyDown`:
114
- - `onClick` (если `toggleType === "click"` или `hover` + не висим в hovered-состоянии): `setTooltipOpen(!tooltipOpen)`.
115
- - `onKeyDown TAB` при открытом — закрывает.
116
- - Существующие хендлеры на child'е сохраняются (вызываются после).
101
+ Мобильная версия дополнительно наследует токены `DropdownMobile` (`--triplex-next-DropdownMobile-*`), так как построена на его субкомпонентах.
117
102
 
118
- #### `Tooltip.Body`
103
+ ---
119
104
 
120
- ```ts
121
- extends HTMLAttributes<HTMLDivElement>
122
- ```
105
+ ## Инварианты
106
+
107
+ - Публичный barrel `src/components/Tooltip/index.ts` реэкспортирует `Tooltip`, `enums` и `types` целиком. Компонент используется внутри `HelpBox`, `SMSField.Tooltip` и косвенно (через типы) в `Dropdown`, поэтому удаление любого экспорта ломает библиотеку изнутри.
108
+ - `IDropdownMobileHeaderProps` объявлен **в `src/components/Tooltip/types.ts`**, но используется `DropdownMobileHeader` из семейства `Dropdown`. Переносить тип нельзя без синхронной правки `Dropdown` (и это breaking change для deep-импортов).
109
+ - Файл контекста называется `TootlipContext.ts` — опечатка в имени зафиксирована в публикуемых путях подпакета; переименование ломает deep-импорты потребителей.
110
+ - `Tooltip` — `React.FC` со статическими субкомпонентами (`Tooltip.Target` и т.д.). Набор статических свойств — часть публичного API.
111
+ - Разбор `children` по типу элемента (`child.type === TooltipTarget`) означает, что обёртка субкомпонента в собственный компонент-прокси **не сработает** — узел просто не попадёт ни в один слот. Это осознанное ограничение, на нём построен весь состав.
112
+ - Десктопная версия отслеживает положение целевого элемента опросом с интервалом 200мс (`REFRESH_INTERVAL_MS`) плюс слушатели `scroll` / `resize`. Отписка в `componentWillUnmount` и при закрытии обязательна.
113
+ - Анимации появления/исчезновения длятся 500мс (`ENTER_EXIT_TRANSITION_DURATION_MS`). На это же время завязано снятие класса темы в `useTooltipTheme` — значения нужно менять синхронно.
114
+ - `useTooltipTheme` вешает класс темы на контейнер рендера и считает количество открытых подсказок в data-атрибуте `data-tooltip-theme-{scopeClassName}-counter`. Счётчик нужен, чтобы одна закрывшаяся подсказка не сняла тему у остальных.
115
+ - `TooltipDesktop` и `TooltipDesktopBase` — классовые компоненты. Это исключение из правила «только функциональные компоненты» в `docs/ai/codestyle.md`: перевод на хуки затрагивает всю логику позиционирования и анимаций и требует отдельной задачи с визуальной регрессией.
116
+ - Внутренние утилиты (`utils/Positioning.ts`, `utils/useTooltipTheme.ts`) через barrel не экспортируются.
123
117
 
124
- `forwardRef<HTMLDivElement>`. На desktop — `<div>`, на mobile — `<Text tag="div" size=B3 line=NORMAL>` (через `MobileView`).
118
+ ---
125
119
 
126
- #### `Tooltip.Link`
120
+ ## Accessibility
127
121
 
128
- ```ts
129
- extends AnchorHTMLAttributes<HTMLAnchorElement>
130
- ```
122
+ - Собственных ARIA-ролей и атрибутов компонент **не выставляет** — ни `role="tooltip"`, ни `aria-describedby`. Связать целевой элемент с текстом подсказки должен потребитель (пример такой связки — `SMSField.Tooltip`, который прокидывает `id` подсказки в поле).
123
+ - Целевой элемент отвечает за собственную доступность: он должен быть фокусируемым (`ButtonIcon` в примерах) и иметь `aria-label`, так как иконка-триггер не содержит текста.
124
+ - Клавиатура: `Escape` закрывает подсказку (обработчик на `document`, пока она открыта), `Tab` с целевого элемента закрывает открытую подсказку. Собственных обработчиков `Enter` / `Space` у `Tooltip.Target` нет — открытие с клавиатуры работает через нативный клик кнопки.
125
+ - Закрытие также срабатывает по `mousedown` вне подсказки и вне целевого элемента.
126
+ - Открытие по наведению: подсказка закрывается не сразу после ухода курсора, а через 500мс — за это время курсор можно довести до самой подсказки (её нода тоже слушает `mouseenter`/`mouseleave`).
127
+ - Фокус компонент не перехватывает и не возвращает: при открытии фокус остаётся на целевом элементе. Поэтому интерактивное содержимое сложнее `Tooltip.Link` в подсказку класть не стоит.
128
+ - Мобильная версия — оверлей `DropdownMobile`; кнопке закрытия `aria-label` задаёт потребитель через props `Tooltip.XButton`. Кнопка там рендерится всегда, поэтому без `Tooltip.XButton` она остаётся без доступного имени — в адаптивном сценарии `Tooltip.XButton` с `aria-label` стоит передавать, даже если на десктопе крестик не нужен.
131
129
 
132
- Ссылка внутри tooltip'а.
130
+ ---
133
131
 
134
- #### `Tooltip.XButton`
132
+ ## Связанные компоненты
135
133
 
136
- ```ts
137
- extends ButtonHTMLAttributes<HTMLButtonElement>
138
- { children: never }
139
- ```
134
+ Отдельного AI.md нет ни у одного субкомпонента семейства — они описаны здесь.
135
+ Это область задачи TRI-98 («Scope: только Tooltip»), а не вывод по критериям
136
+ `docs/ai/CONTEXT.md` → «Когда создавать `{ComponentName}-ai.md`». По этим критериям
137
+ собственного AI.md заслуживают разве что `HelpBox` (отдельная строка в ROADMAP),
138
+ остальные части семейства — внутренние и через barrel не экспортируются.
140
139
 
141
- Кнопка закрытия (без своего content'а — рендерится X-иконка).
140
+ **Части составного компонента (`components/common/`, `components/mobile/`):**
142
141
 
143
- #### `Tooltip.MobileHeader`
142
+ - `TooltipTarget` — целевой элемент: `cloneElement` с обработчиками клика и `Tab`.
143
+ - `TooltipBody` — тело подсказки; на мобильной ширине рендерится через `Text`.
144
+ - `TooltipLink` — ссылка; защита от reverse tabnabbing через `getSafeRel`.
145
+ - `TooltipXButton` — кнопка закрытия десктопной версии (обёртка над `ButtonIcon`).
146
+ - `TooltipMobileHeader` — шапка мобильной версии; кнопку закрытия подставляет сам, наследуя props у `Tooltip.XButton`.
147
+ - `TooltipMobileCloseButton` — кнопка закрытия мобильной версии (обёртка над `DropdownMobileClose`).
144
148
 
145
- ```ts
146
- extends Omit<IDropdownMobileHeaderProps, "closeButton">
147
- { controlButtons?: ReactNode }
148
- ```
149
+ **Десктопная версия (`components/desktop/`):**
149
150
 
150
- Заголовок мобильного полноэкранного варианта.
151
+ - `TooltipDesktop` — подписки на события документа и целевого элемента (клик вне, Escape, hover).
152
+ - `TooltipDesktopBase` — портал, расчёт положения, анимации.
153
+ - `TooltipDesktopTip` — указатель (стрелочка) подсказки.
151
154
 
152
- ### Enums
155
+ **Инфраструктура:**
153
156
 
154
- - `ETooltipSize`: `SM` | `LG`
155
- - `ETooltipPreferPlace`: `ABOVE` | `BELOW` | `RIGHT` | `LEFT`
156
- - `ETooltipAlign`: `START` | `CENTER` | `END`
157
- - `ETooltipDirection`: `UP` | `DOWN` | `LEFT` | `RIGHT` (для указателя)
157
+ - `TooltipContext` (`TootlipContext.ts`) — разобранные слоты, состояние открытости и сеттер между частями.
158
+ - `utils/Positioning.ts` — чистые функции выбора зоны и расчёта координат.
159
+ - `utils/useTooltipTheme.ts` — класс темы на контейнере портала со счётчиком использования.
160
+ - `Portal`, `MobileView`, `DropdownMobile` — инфраструктура рендера.
158
161
 
159
- Внутренние enum'ы (`ETooltipAxesType`, `ETooltipFlowTypes`, `ETooltipStartCoordinates`, `ETooltipEndCoordinates`, `ETooltipSizeParameter`, `ETooltipTypeName`) — экспортированы, но используются в основном `Positioning.ts`.
162
+ **Потребители внутри библиотеки:**
163
+
164
+ - `HelpBox` — готовая иконка-подсказка поверх `Tooltip`.
165
+ - `SMSField.Tooltip` — подсказка об ошибке у поля СМС-кода.
160
166
 
161
167
  ---
162
168
 
163
- ## Контекст (`TooltipContext`)
169
+ ## Stories
164
170
 
165
- ```ts
166
- {
167
- elements: ITooltipElements; // распознанные слоты
168
- tooltipOpen: boolean;
169
- setTooltipOpen(open): void;
170
- targetHoveredRef: MutableRefObject<boolean>;
171
- toggleType?: TTooltipToggleType;
172
- }
173
- ```
171
+ Основные истории: `stories/Tooltip/Tooltip.stories.tsx`
172
+ Файлы примеров: `stories/Tooltip/examples/`
173
+
174
+ | Story | Example file | Что демонстрирует |
175
+ |---|---|---|
176
+ | `Playground` | `Playground.tsx` | Интерактивный контроль `size`, `toggleType`, `preferPlace`, `alignTip`, адаптивного режима и состава |
177
+ | `Default` | `Default.tsx` | Минимальный состав: целевой элемент и текст, открытие по наведению |
178
+ | `Sizes` | `Sizes.tsx` | Размеры SM (192px) и LG (384px) |
179
+ | `DifferentPlaces` | `DifferentPlaces.tsx` | Все варианты `preferPlace` |
180
+ | `WithLink` | `WithLink.tsx` | Ссылка `Tooltip.Link` под текстом |
181
+ | `WithCloseButton` | `WithCloseButton.tsx` | Кнопка закрытия `Tooltip.XButton` |
182
+ | `MobileHeader` | `MobileHeader.tsx` | Заголовок адаптивной версии (виден на узком экране) |
183
+ | `RenderContainer` | `RenderContainer.tsx` | Рендер подсказки в заданный DOM-элемент |
184
+ | `VisualTests` | `VisualTests.tsx` | `alignTip: start`, `alignTip: end`, широкая подсказка с длинным текстом, ссылкой и кнопкой закрытия |
185
+
186
+ ---
187
+
188
+ ## История изменений
174
189
 
175
- `elements.body/link/closeButton/mobileHeader/target` — `null` или `ReactElement`.
176
- `getChildrenElements` распознаёт child'ов по `child.type === TooltipTarget` и т.п.
190
+ | Дата | Изменение |
191
+ |---|---|
192
+ | 2026-08-03 | Создан документ AI-ready для `Tooltip` (TRI-98). |
193
+ | 2026-08-03 | AI-рефакторинг: разбор `children` вынесен в чистую функцию и мемоизирован, значение контекста стабилизировано, дедуплицирована разметка `TooltipLink`, `switch` в `TooltipDesktopTip` заменён на маппинг, из `calcRelPos` убрано двойное приведение типов, в `useTooltipTheme` вынесен хелпер имени счётчика и добавлен guard на unmount, `displayName` `TooltipDesktopBase` приведён к имени класса, JSDoc на props и приватных методах. Исправлено: подсказка, смонтированная сразу открытой (`isOpen` в управляемом режиме), не закрывалась по действию мышью вне неё — при монтировании вешался слушатель `click`, а снимался `mousedown`. Публичный API не изменён. Добавлены unit-тесты и stories по modern pattern. |
194
+ | 2026-08-03 | Правки по ревью PR #496. Исправлено: класс темы не снимается с контейнера, если в течение задержки закрытия подсказку открыли снова или контейнер занял другой `Tooltip` (в `useTooltipTheme` появились учёт собственного инкремента и перепроверка счётчика). Исправлено: в адаптивном режиме `size` и `alignTip` больше не уходят в DOM (React предупреждал о нераспознанном атрибуте `alignTip`). Исправлено: при размонтировании открытой подсказки с `toggleType="hover"` обработчики наведения снимаются и с самой подсказки. В `collectTooltipElements` приведения через `as` заменены на typed-guard'ы. Публичный API не изменён. |
195
+ | 2026-08-03 | Правки по второму ревью PR #496 (документация и stories, кода не касаются): в «Ограничения» добавлено, что `onKeyDown` на десктопе не доходит до корневого элемента подсказки, а в адаптивном режиме доходит; уточнено, что в адаптивном режиме кнопка закрытия рендерится всегда и без `Tooltip.XButton` остаётся без `aria-label`; в `VisualTests` добавлен блок с `alignTip: end` — раньше это значение enum не было покрыто ни примером, ни тестом. |
196
+ | 2026-08-03 | Правки по третьему ревью PR #496. Исправлено: при смене `toggleType` на лету hover-обработчики синхронизируются и на самой подсказке, а не только на целевом элементе (`setTooltipRef` повторно не вызывается, поэтому раньше на ноде подсказки оставались устаревшие слушатели). Тесты: `Tooltip.Link` в десктопном хелпере вынесен в прямые дети `Tooltip` — раньше слот `elements.link` не исполнялся ни в одном десктопном кейсе; добавлены кейсы на ветку `disableAdaptiveMode`. Публичный API не изменён. |
177
197
 
178
198
  ---
179
199
 
180
- ## Поведение
200
+ ## Примеры использования
181
201
 
182
- ### Адаптивность
202
+ ### Default
183
203
 
184
- - `useMatchMedia(max-width: SM_MAX)` — определяет mobile.
185
- - `disableAdaptiveMode === true` → всегда desktop.
186
- - Иначе — `<MobileView fallback={Desktop}>{Mobile}</MobileView>`.
204
+ `stories/Tooltip/examples/Default.tsx`
187
205
 
188
- ### Контролируемое vs неконтролируемое
206
+ ```tsx
207
+ import React, { useRef } from "react";
208
+ import { QuestioncircleFilledSrvIcon16 } from "@sberbusiness/icons-next";
209
+ import { ButtonIcon, ETooltipPreferPlace, ETooltipSize, Tooltip } from "@sberbusiness/triplex-next";
210
+
211
+ export const Default = () => {
212
+ const targetRef = useRef<HTMLButtonElement | null>(null);
213
+
214
+ return (
215
+ <div style={{ display: "flex", justifyContent: "center", padding: "80px 0" }}>
216
+ <Tooltip
217
+ size={ETooltipSize.SM}
218
+ toggleType="hover"
219
+ preferPlace={ETooltipPreferPlace.BELOW}
220
+ targetRef={targetRef}
221
+ >
222
+ <Tooltip.Target>
223
+ <ButtonIcon aria-label="Показать подсказку" ref={targetRef}>
224
+ <QuestioncircleFilledSrvIcon16 paletteIndex={5} />
225
+ </ButtonIcon>
226
+ </Tooltip.Target>
227
+ <Tooltip.Body>Текст подсказки</Tooltip.Body>
228
+ </Tooltip>
229
+ </div>
230
+ );
231
+ };
232
+ ```
189
233
 
190
- - `isOpen` передан → контролируемое (внешний state). `setTooltipOpen` только зовёт `toggle?.(nextOpen)`, не пишет в `openState`.
191
- - `isOpen` не передан → внутренний `openState`.
192
- - Сброс `targetHoveredRef.current = false` при `openProp === false` (через `useEffect`) — чтобы повторный hover после программного закрытия снова открывал tooltip.
234
+ ### Sizes
193
235
 
194
- ### `useTooltipTheme(open, container)`
236
+ `stories/Tooltip/examples/Sizes.tsx`
195
237
 
196
- Инжектит тему (CSS-переменные) в `renderContainer` (или `document.body`),
197
- чтобы tooltip-портал имел тот же стиль, что и текущий `ThemeProvider`.
238
+ ```tsx
239
+ import React, { useRef } from "react";
240
+ import { QuestioncircleFilledSrvIcon16 } from "@sberbusiness/icons-next";
241
+ import { ButtonIcon, ETooltipPreferPlace, ETooltipSize, Tooltip } from "@sberbusiness/triplex-next";
198
242
 
199
- ### Targeting
243
+ const TEXT_BY_SIZE: Record<ETooltipSize, string> = {
244
+ [ETooltipSize.SM]: "Узкая подсказка шириной 192 пикселя.",
245
+ [ETooltipSize.LG]: "Широкая подсказка шириной 384 пикселя. Подходит для текста в несколько строк.",
246
+ };
200
247
 
201
- - `targetRef` обязателен — без него desktop-позиционирование не сработает.
202
- - `toggleType: "hover"` — открывает на hover target'а; `"click"` — на клик.
203
- - Без `toggleType` — tooltip управляется только через `isOpen`/`toggle` (полностью контролируемый сценарий).
248
+ interface ISizeItemProps {
249
+ size: ETooltipSize;
250
+ }
204
251
 
205
- ---
252
+ const SizeItem = ({ size }: ISizeItemProps) => {
253
+ const targetRef = useRef<HTMLButtonElement | null>(null);
254
+
255
+ return (
256
+ <div style={{ display: "flex", alignItems: "center", gap: "16px", padding: "64px 0" }}>
257
+ <div style={{ fontSize: "16px", fontWeight: "700", width: "40px" }}>{size.toUpperCase()}</div>
258
+ <Tooltip
259
+ size={size}
260
+ toggleType="hover"
261
+ preferPlace={ETooltipPreferPlace.BELOW}
262
+ // Подсказка открыта постоянно, чтобы в примере были видны оба размера сразу.
263
+ isOpen
264
+ // Размер влияет только на десктопную версию: в адаптиве подсказка занимает всю ширину экрана.
265
+ disableAdaptiveMode
266
+ targetRef={targetRef}
267
+ >
268
+ <Tooltip.Target>
269
+ <ButtonIcon aria-label={`Подсказка размера ${size}`} ref={targetRef}>
270
+ <QuestioncircleFilledSrvIcon16 paletteIndex={5} />
271
+ </ButtonIcon>
272
+ </Tooltip.Target>
273
+ <Tooltip.Body>{TEXT_BY_SIZE[size]}</Tooltip.Body>
274
+ </Tooltip>
275
+ </div>
276
+ );
277
+ };
278
+
279
+ const SIZES = Object.values(ETooltipSize);
280
+
281
+ export const Sizes = () => (
282
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
283
+ {SIZES.map((size) => (
284
+ <SizeItem key={size} size={size} />
285
+ ))}
286
+ </div>
287
+ );
288
+ ```
206
289
 
207
- ## Инварианты
290
+ ### DifferentPlaces
208
291
 
209
- - **`Tooltip` — `FC` + composition** (`Tooltip.Target = TooltipTarget` и т.д.).
210
- - **`TooltipTarget` — `FC`**, ожидает **ровно одного** child'а (`React.Children.only`); должен быть `ReactElement` (не string/number).
211
- - **`TooltipBody` — `forwardRef<HTMLDivElement>`** — не убирать.
212
- - **Слоты распознаются по типу** (`child.type === TooltipTarget`) — нельзя оборачивать слоты в Fragment'ы или HOC'и (потеряется тип).
213
- - **`size` и `targetRef` обязательны** — проверять при использовании.
214
- - **Файл контекста назван `TootlipContext.ts`** (опечатка) — менять имя считается breaking change для импортов.
215
- - **`renderContainer` игнорируется в mobile + adaptiveMode** — всегда `document.body` (для полноэкранного листа).
216
- - **`toggleType === "hover"` использует `targetHoveredRef`**, чтобы не дёргать toggle при движении внутри target'а; при `isOpen=false` извне ref сбрасывается.
217
- - **На desktop body — `<div>`, на mobile — `<Text tag="div" size=B3>`** (через MobileView). Стили совпадают по className, но обёртка разная.
218
- - **`XButton.children: never`** — иконка X встроена, не передавать content.
219
- - **Все enum'ы экспортируются** — часть публичного API.
292
+ `stories/Tooltip/examples/DifferentPlaces.tsx`
220
293
 
221
- ---
294
+ ```tsx
295
+ import React, { useRef } from "react";
296
+ import { QuestioncircleFilledSrvIcon16 } from "@sberbusiness/icons-next";
297
+ import { ButtonIcon, ETooltipPreferPlace, ETooltipSize, Tooltip } from "@sberbusiness/triplex-next";
222
298
 
223
- ## Accessibility
299
+ interface IPlaceItemProps {
300
+ place: ETooltipPreferPlace;
301
+ }
302
+
303
+ const PlaceItem = ({ place }: IPlaceItemProps) => {
304
+ const targetRef = useRef<HTMLButtonElement | null>(null);
305
+
306
+ return (
307
+ <div style={{ display: "flex", alignItems: "center", gap: "16px", padding: "64px 0" }}>
308
+ <div style={{ fontSize: "16px", fontWeight: "700", width: "56px" }}>{place}</div>
309
+ <Tooltip
310
+ size={ETooltipSize.SM}
311
+ toggleType="hover"
312
+ preferPlace={place}
313
+ // Подсказки открыты постоянно, чтобы в примере были видны все варианты расположения сразу.
314
+ isOpen
315
+ // preferPlace влияет только на десктопную версию: в адаптиве подсказка открывается снизу экрана.
316
+ disableAdaptiveMode
317
+ targetRef={targetRef}
318
+ >
319
+ <Tooltip.Target>
320
+ <ButtonIcon aria-label={`Подсказка ${place}`} ref={targetRef}>
321
+ <QuestioncircleFilledSrvIcon16 paletteIndex={5} />
322
+ </ButtonIcon>
323
+ </Tooltip.Target>
324
+ <Tooltip.Body>Расположение {place}</Tooltip.Body>
325
+ </Tooltip>
326
+ </div>
327
+ );
328
+ };
329
+
330
+ const PLACES = Object.values(ETooltipPreferPlace);
331
+
332
+ export const DifferentPlaces = () => (
333
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
334
+ {PLACES.map((place) => (
335
+ <PlaceItem key={place} place={place} />
336
+ ))}
337
+ </div>
338
+ );
339
+ ```
224
340
 
225
- - `Tooltip.Target` — потребитель сам ставит `aria-describedby`/`aria-haspopup` на child-элементе (компонент это не делает автоматически — TODO).
226
- - TAB на target при открытом tooltip — закрывает (clean-up для tab-навигации).
227
- - `XButton` — `<button>`, `aria-label` ставит потребитель (через `aria-label="Закрыть"`).
228
- - Mobile-вариант — полноэкранный лист (управляется фокусом через `MobileHeader`/`closeButton`).
229
- - На hover-tooltip'ах screen-reader пользователи не видят содержимое — для критичной информации использовать `role="status"` или дублировать в `aria-describedby`.
341
+ ### WithLink
230
342
 
231
- ---
343
+ `stories/Tooltip/examples/WithLink.tsx`
232
344
 
233
- ## Связанные компоненты
345
+ ```tsx
346
+ import React, { useRef } from "react";
347
+ import { QuestioncircleFilledSrvIcon16 } from "@sberbusiness/icons-next";
348
+ import { ButtonIcon, ETooltipPreferPlace, ETooltipSize, Tooltip } from "@sberbusiness/triplex-next";
349
+
350
+ export const WithLink = () => {
351
+ const targetRef = useRef<HTMLButtonElement | null>(null);
352
+
353
+ return (
354
+ <div style={{ display: "flex", justifyContent: "center", paddingBottom: "160px" }}>
355
+ <Tooltip
356
+ size={ETooltipSize.LG}
357
+ toggleType="click"
358
+ preferPlace={ETooltipPreferPlace.BELOW}
359
+ // Подсказка открыта постоянно, чтобы в примере была видна ссылка.
360
+ isOpen
361
+ disableAdaptiveMode
362
+ targetRef={targetRef}
363
+ >
364
+ <Tooltip.Target>
365
+ <ButtonIcon aria-label="Показать подсказку" ref={targetRef}>
366
+ <QuestioncircleFilledSrvIcon16 paletteIndex={5} />
367
+ </ButtonIcon>
368
+ </Tooltip.Target>
369
+ <Tooltip.Body>Ссылка ведёт на страницу с подробным описанием.</Tooltip.Body>
370
+ <Tooltip.Link href="https://triplex-design.ru" target="_blank">
371
+ Подробнее
372
+ </Tooltip.Link>
373
+ </Tooltip>
374
+ </div>
375
+ );
376
+ };
377
+ ```
234
378
 
235
- - `MobileView` / `useMatchMedia` — переключение desktop/mobile.
236
- - `Dropdown` — для меню/списков (другая UX-семантика).
237
- - `Typography.Text` — обёртка body на mobile.
238
- - `ThemeProvider` (через `useTooltipTheme`) — наследование темы в портал.
239
- - `IDropdownMobileHeaderProps` — родитель для `ITooltipMobileHeaderProps`.
379
+ ### WithCloseButton
240
380
 
241
- ---
381
+ `stories/Tooltip/examples/WithCloseButton.tsx`
242
382
 
243
- ## Stories
383
+ ```tsx
384
+ import React, { useRef, useState } from "react";
385
+ import { QuestioncircleFilledSrvIcon16 } from "@sberbusiness/icons-next";
386
+ import { ButtonIcon, ETooltipPreferPlace, ETooltipSize, Tooltip } from "@sberbusiness/triplex-next";
387
+
388
+ export const WithCloseButton = () => {
389
+ const targetRef = useRef<HTMLButtonElement | null>(null);
390
+ const [opened, setOpened] = useState(true);
391
+
392
+ return (
393
+ <div style={{ display: "flex", justifyContent: "center", paddingBottom: "160px" }}>
394
+ <Tooltip
395
+ size={ETooltipSize.LG}
396
+ toggleType="click"
397
+ preferPlace={ETooltipPreferPlace.BELOW}
398
+ isOpen={opened}
399
+ toggle={setOpened}
400
+ disableAdaptiveMode
401
+ targetRef={targetRef}
402
+ >
403
+ <Tooltip.Target>
404
+ <ButtonIcon aria-label="Показать подсказку" ref={targetRef}>
405
+ <QuestioncircleFilledSrvIcon16 paletteIndex={5} />
406
+ </ButtonIcon>
407
+ </Tooltip.Target>
408
+ <Tooltip.Body>
409
+ Подсказку с кнопкой закрытия пользователь закрывает сам — по клику на крестик, по Escape или по
410
+ клику вне подсказки.
411
+ </Tooltip.Body>
412
+ <Tooltip.XButton aria-label="Закрыть подсказку" />
413
+ </Tooltip>
414
+ </div>
415
+ );
416
+ };
417
+ ```
244
418
 
245
- Основные истории: `stories/Tooltip/Tooltip.stories.tsx`
246
- Примеры: `stories/Tooltip/examples/`
419
+ ### MobileHeader
247
420
 
248
- ---
421
+ `stories/Tooltip/examples/MobileHeader.tsx`
249
422
 
250
- ## История изменений
423
+ ```tsx
424
+ import React, { useRef } from "react";
425
+ import { QuestioncircleFilledSrvIcon16 } from "@sberbusiness/icons-next";
426
+ import { ButtonIcon, ETooltipPreferPlace, ETooltipSize, Tooltip } from "@sberbusiness/triplex-next";
427
+
428
+ export const MobileHeader = () => {
429
+ const targetRef = useRef<HTMLButtonElement | null>(null);
430
+
431
+ return (
432
+ <div style={{ display: "flex", justifyContent: "center", padding: "80px 0" }}>
433
+ <Tooltip
434
+ size={ETooltipSize.SM}
435
+ toggleType="hover"
436
+ preferPlace={ETooltipPreferPlace.BELOW}
437
+ targetRef={targetRef}
438
+ >
439
+ <Tooltip.Target>
440
+ <ButtonIcon aria-label="Показать подсказку" ref={targetRef}>
441
+ <QuestioncircleFilledSrvIcon16 paletteIndex={5} />
442
+ </ButtonIcon>
443
+ </Tooltip.Target>
444
+ <Tooltip.Body>Текст подсказки</Tooltip.Body>
445
+ <Tooltip.XButton aria-label="Закрыть подсказку" />
446
+ <Tooltip.MobileHeader>Заголовок для адаптивного режима</Tooltip.MobileHeader>
447
+ </Tooltip>
448
+ </div>
449
+ );
450
+ };
451
+ ```
452
+
453
+ ### RenderContainer
454
+
455
+ `stories/Tooltip/examples/RenderContainer.tsx`
456
+
457
+ ```tsx
458
+ import React, { useRef, useState } from "react";
459
+ import { QuestioncircleFilledSrvIcon16 } from "@sberbusiness/icons-next";
460
+ import { ButtonIcon, ETooltipPreferPlace, ETooltipSize, Tooltip } from "@sberbusiness/triplex-next";
461
+
462
+ export const RenderContainer = () => {
463
+ const targetRef = useRef<HTMLButtonElement | null>(null);
464
+ // Callback-ref вместо useEffect: контейнер попадает в state сразу после монтирования.
465
+ const [container, setContainer] = useState<HTMLElement | null>(null);
466
+
467
+ return (
468
+ <div style={{ display: "flex", justifyContent: "center", paddingBottom: "160px" }}>
469
+ <div ref={setContainer} />
470
+
471
+ {container ? (
472
+ <Tooltip
473
+ size={ETooltipSize.SM}
474
+ toggleType="click"
475
+ preferPlace={ETooltipPreferPlace.BELOW}
476
+ renderContainer={container}
477
+ // Подсказка открыта постоянно, чтобы её было видно в примере.
478
+ isOpen
479
+ disableAdaptiveMode
480
+ targetRef={targetRef}
481
+ >
482
+ <Tooltip.Target>
483
+ <ButtonIcon aria-label="Показать подсказку" ref={targetRef}>
484
+ <QuestioncircleFilledSrvIcon16 paletteIndex={5} />
485
+ </ButtonIcon>
486
+ </Tooltip.Target>
487
+ <Tooltip.Body>Подсказка отрендерена в заданный контейнер.</Tooltip.Body>
488
+ </Tooltip>
489
+ ) : null}
490
+ </div>
491
+ );
492
+ };
493
+ ```
251
494
 
252
- | Дата | Изменение |
253
- |---|---|
254
- | 2026-04-18 | Создан документ |