@ryuzaki13/react-foundation-lib 1.4.0 → 1.4.2

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 (101) hide show
  1. package/README.md +172 -114
  2. package/dist/chunks/{formatters-CBgrBeSA.js → formatters-D33Uh3si.js} +7 -8
  3. package/dist/chunks/{formatters-CBgrBeSA.js.map → formatters-D33Uh3si.js.map} +1 -1
  4. package/dist/chunks/odata-service-CWkHASdw.js +2211 -0
  5. package/dist/chunks/odata-service-CWkHASdw.js.map +1 -0
  6. package/dist/chunks/{validate-BW-7BZy_.js → validate-DyzojM0w.js} +2 -2
  7. package/dist/chunks/{validate-BW-7BZy_.js.map → validate-DyzojM0w.js.map} +1 -1
  8. package/dist/error-report/index.js +2 -2
  9. package/dist/excel/index.js +20 -21
  10. package/dist/excel/index.js.map +1 -1
  11. package/dist/formatters/index.js +4 -5
  12. package/dist/{odata → odata-service}/buildSeparatedArrays.d.ts +1 -1
  13. package/dist/odata-service/buildSeparatedArrays.d.ts.map +1 -0
  14. package/dist/{odata/types.d.ts → odata-service/collectionTypes.d.ts} +1 -1
  15. package/dist/odata-service/collectionTypes.d.ts.map +1 -0
  16. package/dist/{odata → odata-service}/dateUtils.d.ts +1 -1
  17. package/dist/odata-service/dateUtils.d.ts.map +1 -0
  18. package/dist/{odata → odata-service}/findCollectionPairs.d.ts +1 -1
  19. package/dist/odata-service/findCollectionPairs.d.ts.map +1 -0
  20. package/dist/odata-service/index.d.ts +5 -0
  21. package/dist/odata-service/index.d.ts.map +1 -1
  22. package/dist/odata-service/index.js +2 -2
  23. package/dist/{odata/store.d.ts → odata-service/odataCollectionStore.d.ts} +1 -1
  24. package/dist/odata-service/odataCollectionStore.d.ts.map +1 -0
  25. package/dist/query-client/index.js.map +1 -1
  26. package/dist/query-client/queryClient.d.ts +1 -3
  27. package/dist/query-client/queryClient.d.ts.map +1 -1
  28. package/package.json +1 -5
  29. package/src/array/README.mdx +1630 -0
  30. package/src/binary/README.mdx +1405 -0
  31. package/src/bounded-copy-stack/README.mdx +257 -0
  32. package/src/context-menu/README.mdx +326 -0
  33. package/src/copy/README.mdx +992 -0
  34. package/src/crypto/README.mdx +1052 -0
  35. package/src/currency/README.mdx +155 -0
  36. package/src/date-segments/README.mdx +319 -0
  37. package/src/dom/README.mdx +2179 -0
  38. package/src/error/README.mdx +95 -0
  39. package/src/error-report/README.mdx +612 -0
  40. package/src/excel/README.mdx +449 -0
  41. package/src/file/README.mdx +1957 -0
  42. package/src/fingerprint/README.mdx +100 -0
  43. package/src/form/README.mdx +101 -0
  44. package/src/formatters/README.mdx +469 -0
  45. package/src/formatters/boolean/README.mdx +494 -0
  46. package/src/formatters/date/README.mdx +732 -0
  47. package/src/formatters/number/README.mdx +542 -0
  48. package/src/formatters/pipeline/README.mdx +629 -0
  49. package/src/formatters/rowBased/README.mdx +486 -0
  50. package/src/formatters/strings/README.mdx +442 -0
  51. package/src/formatters/valueState/README.mdx +459 -0
  52. package/src/formulas/README.mdx +1283 -0
  53. package/src/foundationLib.mdx +70 -54
  54. package/src/hooks/README.mdx +988 -0
  55. package/src/http/README.mdx +126 -0
  56. package/src/media/README.mdx +1853 -0
  57. package/src/notifications/README.mdx +323 -0
  58. package/src/number-scale/README.mdx +318 -0
  59. package/src/odata-service/README.mdx +2916 -0
  60. package/src/presets/README.mdx +136 -0
  61. package/src/pwa/README.mdx +394 -0
  62. package/src/query-client/README.mdx +2500 -0
  63. package/src/range-output/README.mdx +161 -0
  64. package/src/seo/README.mdx +279 -0
  65. package/src/session-storage/README.mdx +125 -0
  66. package/src/string-comparison/README.mdx +96 -0
  67. package/src/table/README.mdx +3106 -0
  68. package/src/three-scene/README.mdx +248 -0
  69. package/src/types/README.mdx +172 -0
  70. package/src/utils/README.mdx +1724 -0
  71. package/src/validators/README.mdx +239 -0
  72. package/src/virtualizer/README.mdx +138 -0
  73. package/src/xml/README.mdx +149 -0
  74. package/dist/chunks/dateUtils-B5V5aXB-.js +0 -847
  75. package/dist/chunks/dateUtils-B5V5aXB-.js.map +0 -1
  76. package/dist/chunks/odata-service-B6TxBSLm.js +0 -1277
  77. package/dist/chunks/odata-service-B6TxBSLm.js.map +0 -1
  78. package/dist/odata/buildSeparatedArrays.d.ts.map +0 -1
  79. package/dist/odata/dateUtils.d.ts.map +0 -1
  80. package/dist/odata/findCollectionPairs.d.ts.map +0 -1
  81. package/dist/odata/index.d.ts +0 -6
  82. package/dist/odata/index.d.ts.map +0 -1
  83. package/dist/odata/index.js +0 -102
  84. package/dist/odata/index.js.map +0 -1
  85. package/dist/odata/store.d.ts.map +0 -1
  86. package/dist/odata/types.d.ts.map +0 -1
  87. package/src/binary/README.md +0 -110
  88. package/src/docs/browserAndReact.mdx +0 -150
  89. package/src/docs/filesAndExcel.mdx +0 -163
  90. package/src/docs/formatters-runtime.mdx +0 -455
  91. package/src/docs/formatters.mdx +0 -380
  92. package/src/docs/odataAndTables.mdx +0 -178
  93. package/src/docs/pureUtilities.mdx +0 -96
  94. package/src/docs/runtimeAndState.mdx +0 -155
  95. package/src/file/README.md +0 -289
  96. package/src/formatters/date/README.md +0 -520
  97. package/src/formatters/number/README.md +0 -123
  98. package/src/formatters/valueState/README.md +0 -275
  99. package/src/formulas/README.md +0 -42
  100. package/src/pwa/README.md +0 -638
  101. package/src/query-client/README.md +0 -565
@@ -0,0 +1,2179 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation Lib/API/DOM" />
4
+
5
+ # Browser DOM и focus helpers через `@ryuzaki13/react-foundation-lib/dom`
6
+
7
+ Модуль `dom` содержит небольшие browser helpers и React hooks для типовых инфраструктурных задач:
8
+
9
+ - скачать `Blob`, JSON или уже созданный object URL;
10
+ - получить общий DOM-контейнер для React portal;
11
+ - отреагировать на `mousedown` за пределами одного или нескольких элементов;
12
+ - закрыть активный overlay по `Escape`;
13
+ - установить начальный фокус, зациклить `Tab` и восстановить прежний фокус;
14
+ - узнать высоту элемента через `ResizeObserver`;
15
+ - узнать, пересекается ли элемент с viewport или другим root;
16
+ - определить наличие touch/coarse-pointer возможностей.
17
+
18
+ Модуль является низкоуровневым browser foundation-слоем. Он не создаёт готовые кнопки, dialog, modal, popover, tooltip или download UI. Визуальный компонент по-прежнему отвечает за семантический HTML, ARIA, стили, анимацию, стек overlay, сообщения об ошибках и пользовательский сценарий.
19
+
20
+ README рассчитан на разработчика, который использует опубликованный пакет без прямого доступа к исходному коду. Здесь объясняются базовые понятия DOM и React, все публичные функции, точный lifecycle, defaults, cleanup, ограничения доступности, тестирование и частые ошибки.
21
+
22
+ ## Содержание
23
+
24
+ - [Назначение и границы модуля](#назначение-и-границы-модуля)
25
+ - [Установка и импорт](#установка-и-импорт)
26
+ - [Runtime и browser boundary](#runtime-и-browser-boundary)
27
+ - [Минимальные понятия](#минимальные-понятия)
28
+ - [Как выбрать API](#как-выбрать-api)
29
+ - [Быстрый старт](#быстрый-старт)
30
+ - [Скачивание файлов](#скачивание-файлов)
31
+ - [`getOrCreatePortalRoot`](#getorcreateportalroot)
32
+ - [`useClickOutside`](#useclickoutside)
33
+ - [`useEscapeDismiss`](#useescapedismiss)
34
+ - [`useElementHeightObserver`](#useelementheightobserver)
35
+ - [`useIntersectionObserver`](#useintersectionobserver)
36
+ - [`useIsTouchDevice`](#useistouchdevice)
37
+ - [`useOverlayFocus`](#useoverlayfocus)
38
+ - [`useFocusTrap`](#usefocustrap)
39
+ - [Сборка доступного dialog](#сборка-доступного-dialog)
40
+ - [Композиция overlay hooks](#композиция-overlay-hooks)
41
+ - [SSR, Strict Mode и lifecycle](#ssr-strict-mode-и-lifecycle)
42
+ - [Производительность](#производительность)
43
+ - [Тестирование](#тестирование)
44
+ - [Ограничения и частые ошибки](#ограничения-и-частые-ошибки)
45
+ - [Краткий справочник API](#краткий-справочник-api)
46
+ - [Вопросы и ответы](#вопросы-и-ответы)
47
+
48
+ ## Назначение и границы модуля
49
+
50
+ ### Что делает `dom`
51
+
52
+ - создаёт браузерную загрузку через временный `<a download>`;
53
+ - берёт на себя создание и последующее освобождение object URL в download-сценариях;
54
+ - сериализует JSON с отступом в два пробела;
55
+ - создаёт или переиспользует `HTMLElement` в `document.body`;
56
+ - подписывает React-компонент на глобальные `mousedown`, `keydown` и `resize`;
57
+ - подключает `ResizeObserver` и `IntersectionObserver` с cleanup;
58
+ - управляет начальными и возвратными переходами focus;
59
+ - предоставляет простую focus trap для modal-like overlay.
60
+
61
+ ### Чего модуль не делает
62
+
63
+ - не получает файл по HTTP/OData;
64
+ - не декодирует base64 в `Blob`;
65
+ - не читает пользовательский `File`;
66
+ - не проверяет MIME или содержимое скачиваемого файла;
67
+ - не показывает progress, toast или ошибку скачивания;
68
+ - не создаёт React portal самостоятельно;
69
+ - не удаляет portal root после использования;
70
+ - не реализует полноценный dialog/modal/popover;
71
+ - не назначает `role`, `aria-modal`, `aria-labelledby` или `aria-describedby`;
72
+ - не блокирует прокрутку страницы;
73
+ - не делает фон `inert`;
74
+ - не управляет стеком вложенных overlay;
75
+ - не блокирует программный уход фокуса;
76
+ - не предоставляет polyfill для observer APIs;
77
+ - не определяет фактический текущий способ ввода пользователя;
78
+ - не заменяет CSS media queries.
79
+
80
+ ### Соседние модули
81
+
82
+ | Задача | Предпочтительный владелец |
83
+ | -------------------------------------------- | --------------------------------------------- |
84
+ | Base64/data URL → `Blob` | `@ryuzaki13/react-foundation-lib/binary` |
85
+ | Чтение выбранного пользователем файла | `@ryuzaki13/react-foundation-lib/file` |
86
+ | Скачивание готового `Blob` | `@ryuzaki13/react-foundation-lib/dom` |
87
+ | Готовый dialog/modal/popover | UI-пакет или UI-слой приложения |
88
+ | Низкоуровневый focus/outside/Escape contract | `@ryuzaki13/react-foundation-lib/dom` |
89
+ | Responsive styling | CSS и `@ryuzaki13/react-foundation-lib/media` |
90
+ | Копирование plain text в clipboard | `@ryuzaki13/react-foundation-lib/copy` |
91
+
92
+ Если приложению нужен готовый UI primitive, сначала ищите его в `@ryuzaki13/react-foundation-ui`. Модуль `dom` нужен авторам primitives и для специальных browser-сценариев, а не для повторной сборки локальных modal/dialog в каждом feature.
93
+
94
+ ## Установка и импорт
95
+
96
+ Установите пакет:
97
+
98
+ ```bash
99
+ npm install @ryuzaki13/react-foundation-lib
100
+ ```
101
+
102
+ Entrypoint экспортирует React hooks, поэтому host-приложение должно предоставить совместимые peer-зависимости:
103
+
104
+ ```bash
105
+ npm install react react-dom
106
+ ```
107
+
108
+ Текущий контракт пакета требует React `>=19.2.0 <20.0.0`.
109
+
110
+ Импортируйте API только из опубликованного subpath `/dom`:
111
+
112
+ ```ts
113
+ import {
114
+ downloadFileFromBlob,
115
+ downloadFileFromJson,
116
+ downloadFileFromObjectURL,
117
+ getOrCreatePortalRoot,
118
+ useClickOutside,
119
+ useElementHeightObserver,
120
+ useEscapeDismiss,
121
+ useFocusTrap,
122
+ useIntersectionObserver,
123
+ useIsTouchDevice,
124
+ useOverlayFocus
125
+ } from "@ryuzaki13/react-foundation-lib/dom";
126
+
127
+ import type { UseOverlayFocusOptions } from "@ryuzaki13/react-foundation-lib/dom";
128
+ ```
129
+
130
+ Корневой импорт пакета не поддерживается:
131
+
132
+ ```ts
133
+ // Неправильно.
134
+ import { useOverlayFocus } from "@ryuzaki13/react-foundation-lib";
135
+
136
+ // Правильно.
137
+ import { useOverlayFocus } from "@ryuzaki13/react-foundation-lib/dom";
138
+ ```
139
+
140
+ Модуль поставляется как ESM.
141
+
142
+ ## Runtime и browser boundary
143
+
144
+ Почти весь модуль опирается на Web APIs:
145
+
146
+ - `window`;
147
+ - `document`;
148
+ - `HTMLElement` и `Node`;
149
+ - `Blob`;
150
+ - `URL.createObjectURL` и `URL.revokeObjectURL`;
151
+ - `ResizeObserver`;
152
+ - `IntersectionObserver`;
153
+ - `matchMedia`;
154
+ - focus и keyboard events;
155
+ - `requestAnimationFrame`.
156
+
157
+ `getOrCreatePortalRoot` имеет явную проверку отсутствующего `document` и возвращает `null`. Остальные helpers не превращаются в универсальный server API: вызывайте их на browser boundary.
158
+
159
+ React effects не выполняются во время обычного server render, но это не означает, что любой импорт или вызов автоматически безопасен в любой SSR/test среде. Для Node-only кода используйте соответствующий server API, а для тестов предоставляйте DOM и нужные mocks.
160
+
161
+ ## Минимальные понятия
162
+
163
+ ### DOM
164
+
165
+ DOM — объектное представление HTML-документа. Браузер превращает разметку:
166
+
167
+ ```html
168
+ <button type="button">Сохранить</button>
169
+ ```
170
+
171
+ в объект `HTMLButtonElement`, у которого есть свойства, методы, focus и события.
172
+
173
+ ### `window` и `document`
174
+
175
+ - `window` представляет браузерное окно и глобальные browser APIs;
176
+ - `document` представляет текущий HTML-документ;
177
+ - `document.body` — элемент `<body>`.
178
+
179
+ Эти объекты отсутствуют в обычном Node.js process и могут быть частично реализованы в тестовой DOM-среде.
180
+
181
+ ### React ref
182
+
183
+ Ref хранит ссылку на реальный DOM-элемент:
184
+
185
+ ```tsx
186
+ const buttonRef = useRef<HTMLButtonElement>(null);
187
+
188
+ return <button ref={buttonRef}>Кнопка</button>;
189
+ ```
190
+
191
+ До commit/mount `buttonRef.current` равен `null`. После commit React помещает туда `HTMLButtonElement`. Читайте ref в event handler или effect, а не во время render.
192
+
193
+ ### Effect и cleanup
194
+
195
+ Effect синхронизирует компонент с внешней системой:
196
+
197
+ ```tsx
198
+ useEffect(() => {
199
+ document.addEventListener("example", handler);
200
+
201
+ return () => {
202
+ document.removeEventListener("example", handler);
203
+ };
204
+ }, [handler]);
205
+ ```
206
+
207
+ Возвращаемая функция называется cleanup. React вызывает её перед повторной подпиской и при unmount. Hooks этого модуля сами очищают свои listeners и observers, если component lifecycle дошёл до cleanup.
208
+
209
+ ### Event bubbling и capture
210
+
211
+ DOM-событие проходит несколько фаз:
212
+
213
+ 1. capture — от документа к target;
214
+ 2. target;
215
+ 3. bubbling — от target обратно к документу.
216
+
217
+ `useEscapeDismiss` слушает `keydown` в capture-фазе. `useClickOutside` слушает `mousedown` в обычной bubbling-фазе.
218
+
219
+ ### Portal
220
+
221
+ React portal рендерит subtree в другом месте DOM:
222
+
223
+ ```tsx
224
+ createPortal(<Dialog />, portalRoot);
225
+ ```
226
+
227
+ Компонент остаётся частью того же React tree, но его DOM может находиться прямо в `document.body`, вне layout-контейнера приложения.
228
+
229
+ ### `Blob`
230
+
231
+ `Blob` хранит последовательность байтов и MIME:
232
+
233
+ ```ts
234
+ const blob = new Blob(["Пример"], {
235
+ type: "text/plain;charset=utf-8"
236
+ });
237
+ ```
238
+
239
+ Blob сам по себе не является URL. Для browser download из него создаётся временный object URL.
240
+
241
+ ### Object URL
242
+
243
+ Object URL — временная browser-ссылка на `Blob`:
244
+
245
+ ```ts
246
+ const objectUrl = URL.createObjectURL(blob);
247
+ ```
248
+
249
+ Примерный вид:
250
+
251
+ ```text
252
+ blob:https://example.test/...
253
+ ```
254
+
255
+ Object URL удерживает ресурс в памяти, пока не будет вызван `URL.revokeObjectURL` либо пока документ не будет выгружен.
256
+
257
+ ### Observer
258
+
259
+ `ResizeObserver` сообщает об изменении размера элемента. `IntersectionObserver` сообщает, пересекается ли элемент с viewport или заданным root.
260
+
261
+ Оба API работают асинхронно: первое значение hook может быть default, а реальное состояние приходит позднее через callback.
262
+
263
+ ### Focus
264
+
265
+ Focus определяет, какой элемент получает keyboard input. `document.activeElement` показывает текущий focus target.
266
+
267
+ Кнопки, ссылки и form controls обычно focusable сами. Обычный `<div>` не становится focusable автоматически. Для программного focus контейнеру часто нужен:
268
+
269
+ ```tsx
270
+ <div tabIndex={-1}>...</div>
271
+ ```
272
+
273
+ `tabIndex={-1}` разрешает `.focus()`, но не добавляет контейнер в обычный Tab-порядок.
274
+
275
+ ## Как выбрать API
276
+
277
+ | Нужно | API |
278
+ | -------------------------------------------------------------------- | --------------------------------- |
279
+ | Скачать уже созданный object URL и сразу передать ownership helper-у | `downloadFileFromObjectURL` |
280
+ | Скачать готовый `Blob` | `downloadFileFromBlob` |
281
+ | Сериализовать значение в readable JSON и скачать | `downloadFileFromJson` |
282
+ | Получить общий container для `createPortal` | `getOrCreatePortalRoot` |
283
+ | Закрыть UI по `mousedown` вне одного элемента | `useClickOutside` |
284
+ | Считать trigger и portal content одной «внутренней» областью | `useClickOutside` с массивом refs |
285
+ | Закрыть активный overlay по `Escape` | `useEscapeDismiss` |
286
+ | Измерять высоту элемента | `useElementHeightObserver` |
287
+ | Узнать, пересекается ли элемент с viewport/root | `useIntersectionObserver` |
288
+ | Получить touch/coarse-pointer heuristic | `useIsTouchDevice` |
289
+ | Настроить initial focus и optional trap/restore | `useOverlayFocus` |
290
+ | Быстро подключить фиксированную focus trap для `HTMLDivElement` | `useFocusTrap` |
291
+ | Получить полностью доступный modal/dialog | Готовый UI primitive |
292
+
293
+ ## Быстрый старт
294
+
295
+ ### Скачать `Blob`
296
+
297
+ ```tsx
298
+ import { downloadFileFromBlob } from "@ryuzaki13/react-foundation-lib/dom";
299
+
300
+ export function DownloadTextButton() {
301
+ const handleDownload = () => {
302
+ const blob = new Blob(["Привет!"], {
303
+ type: "text/plain;charset=utf-8"
304
+ });
305
+
306
+ downloadFileFromBlob("hello.txt", blob);
307
+ };
308
+
309
+ return (
310
+ <button type="button" onClick={handleDownload}>
311
+ Скачать
312
+ </button>
313
+ );
314
+ }
315
+ ```
316
+
317
+ ### Скачать JSON
318
+
319
+ ```tsx
320
+ import { downloadFileFromJson } from "@ryuzaki13/react-foundation-lib/dom";
321
+
322
+ const settings = {
323
+ theme: "dark",
324
+ pageSize: 50
325
+ };
326
+
327
+ export function ExportSettingsButton() {
328
+ return (
329
+ <button type="button" onClick={() => downloadFileFromJson("settings.json", settings)}>
330
+ Экспортировать настройки
331
+ </button>
332
+ );
333
+ }
334
+ ```
335
+
336
+ ### Создать portal root во время browser bootstrap
337
+
338
+ ```tsx
339
+ import { createRoot } from "react-dom/client";
340
+ import { getOrCreatePortalRoot } from "@ryuzaki13/react-foundation-lib/dom";
341
+
342
+ const appRoot = document.getElementById("root");
343
+ const overlayRoot = getOrCreatePortalRoot("app-overlay-root");
344
+
345
+ if (!appRoot || !overlayRoot) {
346
+ throw new Error("Не удалось подготовить DOM roots приложения");
347
+ }
348
+
349
+ createRoot(appRoot).render(<App overlayRoot={overlayRoot} />);
350
+ ```
351
+
352
+ Так DOM mutation выполняется на явной application boundary, а не скрывается внутри render компонента.
353
+
354
+ ### Закрыть popover по клику снаружи
355
+
356
+ ```tsx
357
+ import { useCallback, useRef, useState } from "react";
358
+ import { useClickOutside } from "@ryuzaki13/react-foundation-lib/dom";
359
+
360
+ export function SimplePopover() {
361
+ const [open, setOpen] = useState(false);
362
+ const panelRef = useRef<HTMLDivElement>(null);
363
+
364
+ const close = useCallback(() => {
365
+ setOpen(false);
366
+ }, []);
367
+
368
+ useClickOutside(panelRef, close);
369
+
370
+ return (
371
+ <div>
372
+ <button type="button" onClick={() => setOpen(true)}>
373
+ Открыть
374
+ </button>
375
+ {open ? <div ref={panelRef}>Содержимое</div> : null}
376
+ </div>
377
+ );
378
+ }
379
+ ```
380
+
381
+ В этом упрощённом примере trigger не включён во внутреннюю область. `mousedown` по trigger может сначала вызвать outside handler, а затем его `click`. Для toggle/popover обычно передают два refs.
382
+
383
+ ### Наблюдать пересечение
384
+
385
+ ```tsx
386
+ import { useMemo, useRef } from "react";
387
+ import { useIntersectionObserver } from "@ryuzaki13/react-foundation-lib/dom";
388
+
389
+ export function LazySection() {
390
+ const sectionRef = useRef<HTMLElement>(null);
391
+ const options = useMemo<IntersectionObserverInit>(
392
+ () => ({
393
+ rootMargin: "200px 0px",
394
+ threshold: 0.1
395
+ }),
396
+ []
397
+ );
398
+ const { isIntersecting } = useIntersectionObserver(sectionRef, options);
399
+
400
+ return <section ref={sectionRef}>{isIntersecting ? <ExpensiveContent /> : <Placeholder />}</section>;
401
+ }
402
+ ```
403
+
404
+ Стабильный `options` не заставляет effect пересоздавать observer при каждом render.
405
+
406
+ ## Скачивание файлов
407
+
408
+ Модуль экспортирует три синхронные функции:
409
+
410
+ ```ts
411
+ declare function downloadFileFromObjectURL(filename: string, objectUrl: string): void;
412
+ declare function downloadFileFromBlob(filename: string, blob: Blob): void;
413
+ declare function downloadFileFromJson(filename: string, payload: unknown): void;
414
+ ```
415
+
416
+ Они запускают browser download и ничего не возвращают. Результат `void` не означает, что пользователь сохранил файл: helper не получает подтверждение от download manager браузера.
417
+
418
+ ### `downloadFileFromObjectURL`
419
+
420
+ Создаёт `<a>`, устанавливает:
421
+
422
+ ```ts
423
+ link.href = objectUrl;
424
+ link.download = filename;
425
+ ```
426
+
427
+ затем вызывает `link.click()`. Элемент не добавляется в `document.body`.
428
+
429
+ После вызова `click` helper планирует:
430
+
431
+ ```ts
432
+ window.setTimeout(() => {
433
+ window.URL.revokeObjectURL(objectUrl);
434
+ }, 0);
435
+ ```
436
+
437
+ #### Ownership object URL
438
+
439
+ Передавая URL в `downloadFileFromObjectURL`, считайте, что отдаёте helper-у ownership. Он освободит URL в следующем macrotask:
440
+
441
+ ```ts
442
+ const objectUrl = URL.createObjectURL(blob);
443
+ downloadFileFromObjectURL("report.pdf", objectUrl);
444
+
445
+ // Не используйте objectUrl для preview или второго download:
446
+ // helper уже запланировал revokeObjectURL.
447
+ ```
448
+
449
+ Если один URL нужен и для preview, и для download, создайте отдельный URL для каждой lifetime-задачи либо управляйте download самостоятельно.
450
+
451
+ #### Параметр `filename`
452
+
453
+ `filename` передаётся в свойство `download` буквально:
454
+
455
+ ```ts
456
+ downloadFileFromObjectURL("Отчёт за август.pdf", objectUrl);
457
+ ```
458
+
459
+ Helper:
460
+
461
+ - не добавляет расширение;
462
+ - не удаляет path separators;
463
+ - не исправляет недопустимые для ОС символы;
464
+ - не проверяет MIME;
465
+ - не гарантирует точное финальное имя.
466
+
467
+ Браузер и ОС могут изменить или проигнорировать предложенное имя.
468
+
469
+ #### Это именно object URL
470
+
471
+ Функция вызывает `URL.revokeObjectURL`, поэтому предназначена для URL, созданного через `URL.createObjectURL`. Не используйте её как общий helper для произвольного HTTP URL.
472
+
473
+ ### `downloadFileFromBlob`
474
+
475
+ Принимает готовый `Blob`:
476
+
477
+ ```ts
478
+ downloadFileFromBlob("report.txt", blob);
479
+ ```
480
+
481
+ Алгоритм:
482
+
483
+ 1. создаёт object URL через `window.URL.createObjectURL(blob)`;
484
+ 2. передаёт его в `downloadFileFromObjectURL`;
485
+ 3. запускает click;
486
+ 4. планирует освобождение URL.
487
+
488
+ Это предпочтительный API, если вызывающий код уже имеет `Blob`.
489
+
490
+ #### Blob не уничтожается
491
+
492
+ `revokeObjectURL` освобождает связь URL с данными. Он не изменяет сам `Blob`:
493
+
494
+ ```ts
495
+ downloadFileFromBlob("first.bin", blob);
496
+
497
+ // Сам blob можно передать повторно:
498
+ downloadFileFromBlob("second.bin", blob);
499
+ ```
500
+
501
+ Каждый вызов создаст собственный временный URL.
502
+
503
+ ### `downloadFileFromJson`
504
+
505
+ Сериализует значение:
506
+
507
+ ```ts
508
+ JSON.stringify(payload, null, 2);
509
+ ```
510
+
511
+ Затем создаёт:
512
+
513
+ ```ts
514
+ new Blob([serialized], {
515
+ type: "application/json;charset=utf-8"
516
+ });
517
+ ```
518
+
519
+ и делегирует скачивание `downloadFileFromBlob`.
520
+
521
+ #### Формат
522
+
523
+ - отступ — два пробела;
524
+ - encoding Blob — UTF-8-compatible browser string;
525
+ - MIME — `application/json;charset=utf-8`;
526
+ - BOM не добавляется;
527
+ - перевод строки в конец специально не добавляется.
528
+
529
+ Пример:
530
+
531
+ ```ts
532
+ downloadFileFromJson("user.json", {
533
+ id: 42,
534
+ name: "Анна"
535
+ });
536
+ ```
537
+
538
+ Содержимое:
539
+
540
+ ```json
541
+ {
542
+ "id": 42,
543
+ "name": "Анна"
544
+ }
545
+ ```
546
+
547
+ #### Правила `JSON.stringify`
548
+
549
+ Helper наследует стандартное поведение JavaScript:
550
+
551
+ - object properties со значением `undefined`, function или `symbol` пропускаются;
552
+ - такие значения внутри array превращаются в `null`;
553
+ - `Date` обычно превращается в ISO string через `toJSON`;
554
+ - custom `toJSON` выполняется;
555
+ - circular reference вызывает `TypeError`;
556
+ - `BigInt` без custom serialization вызывает `TypeError`;
557
+ - для root `undefined`, function или symbol `JSON.stringify` возвращает `undefined`; browser преобразует этот BlobPart в текст `"undefined"`, то есть скачанный файл не будет корректным JSON.
558
+
559
+ Ошибки сериализации не перехватываются:
560
+
561
+ ```ts
562
+ const cyclic: Record<string, unknown> = {};
563
+ cyclic.self = cyclic;
564
+
565
+ downloadFileFromJson("cyclic.json", cyclic); // TypeError.
566
+ ```
567
+
568
+ Если payload пришёл с внешней boundary, валидируйте и подготавливайте его до вызова.
569
+
570
+ ### Пользовательское действие
571
+
572
+ Браузер может блокировать автоматические downloads, не связанные с user activation. Предпочтительно вызывать helper из click/keyboard command:
573
+
574
+ ```tsx
575
+ <button type="button" onClick={handleDownload}>
576
+ Скачать
577
+ </button>
578
+ ```
579
+
580
+ Не запускайте download во время render.
581
+
582
+ ### Ошибки download helpers
583
+
584
+ Функции не используют `try/catch` и не возвращают `Promise`. Ошибка доступа к DOM, создания URL, сериализации или синтетического click будет выброшена синхронно.
585
+
586
+ Если `link.click()` выбросит ошибку, timer освобождения URL ещё не будет запланирован. Вызывающий boundary должен обработать ошибку и cleanup, если это значимо для среды.
587
+
588
+ ## `getOrCreatePortalRoot`
589
+
590
+ Возвращает существующий `HTMLElement` с заданным ID либо создаёт новый `<div>` в `document.body`.
591
+
592
+ ### Сигнатура
593
+
594
+ ```ts
595
+ declare function getOrCreatePortalRoot(id: string): HTMLElement | null;
596
+ ```
597
+
598
+ ### Алгоритм
599
+
600
+ 1. Если global `document` отсутствует, возвращает `null`.
601
+ 2. Выполняет `document.getElementById(id)`.
602
+ 3. Если результат является `HTMLElement`, возвращает его.
603
+ 4. Иначе создаёт `document.createElement("div")`.
604
+ 5. Устанавливает `portalRoot.id = id`.
605
+ 6. Добавляет элемент в `document.body`.
606
+ 7. Возвращает созданный element.
607
+
608
+ ### Повторное использование
609
+
610
+ ```ts
611
+ const first = getOrCreatePortalRoot("dialog-root");
612
+ const second = getOrCreatePortalRoot("dialog-root");
613
+
614
+ console.log(first === second); // true в обычном browser document.
615
+ ```
616
+
617
+ Idempotency действует, когда:
618
+
619
+ - ID непустой и стабильный;
620
+ - существующий element является `HTMLElement`;
621
+ - никто не удалил или не заменил element между вызовами.
622
+
623
+ ### Использование с `createPortal`
624
+
625
+ ```tsx
626
+ import { createPortal } from "react-dom";
627
+
628
+ function Overlay({ portalRoot }: { portalRoot: HTMLElement }) {
629
+ return createPortal(<div role="presentation">Overlay content</div>, portalRoot);
630
+ }
631
+ ```
632
+
633
+ `getOrCreatePortalRoot` только возвращает container. Сам React portal создаётся функцией `createPortal` из `react-dom`.
634
+
635
+ ### Browser bootstrap предпочтительнее render
636
+
637
+ Функция изменяет DOM. Render React-компонента должен оставаться чистым, поэтому предпочтительно:
638
+
639
+ - объявить portal container в HTML shell;
640
+ - получить его во время application bootstrap;
641
+ - создать его на явной browser boundary;
642
+ - передать готовый element компонентам или provider.
643
+
644
+ Не вызывайте функцию в `useMemo`: memoization не предназначена для side effects.
645
+
646
+ ### Функция не удаляет root
647
+
648
+ Созданный element остаётся в `document.body`, даже если все portals закрыты. Это подходит для application-wide roots:
649
+
650
+ ```text
651
+ dialog-root
652
+ popover-root
653
+ notifications-root
654
+ ```
655
+
656
+ Для временного root с ограниченным lifetime вызывающий код сам владеет удалением.
657
+
658
+ ### Ограничения ID и body
659
+
660
+ - ID не очищается и не валидируется;
661
+ - пустой ID не является безопасным application contract;
662
+ - пользовательский ввод не должен становиться ID без validation;
663
+ - если element с тем же ID существует, но не является `HTMLElement` (например, SVG element), helper создаст новый `div` с тем же ID;
664
+ - duplicate IDs делают DOM невалидным и ломают однозначный поиск;
665
+ - если `document` существует, но `document.body` ещё не создан, append завершится ошибкой.
666
+
667
+ Вызывайте helper после подготовки `body` и используйте контролируемый уникальный ID.
668
+
669
+ ### SSR
670
+
671
+ На server:
672
+
673
+ ```ts
674
+ getOrCreatePortalRoot("dialog-root"); // null, если document отсутствует.
675
+ ```
676
+
677
+ Не кешируйте этот `null` в server-loaded module и не ожидайте, что он автоматически станет HTMLElement на client. Portal target должен разрешаться на корректной browser lifecycle boundary.
678
+
679
+ ## `useClickOutside`
680
+
681
+ Вызывает handler на `mousedown`, если target находится вне одного ref или вне всех refs из массива.
682
+
683
+ ### Сигнатура
684
+
685
+ ```ts
686
+ declare function useClickOutside(ref: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[], handler: () => void): void;
687
+ ```
688
+
689
+ ### Один ref
690
+
691
+ ```tsx
692
+ const panelRef = useRef<HTMLDivElement>(null);
693
+
694
+ useClickOutside(panelRef, close);
695
+
696
+ return <div ref={panelRef}>...</div>;
697
+ ```
698
+
699
+ Handler вызывается, когда:
700
+
701
+ - `panelRef.current` существует;
702
+ - `panelRef.current.contains(event.target)` возвращает `false`.
703
+
704
+ Если единственный ref равен `null`, handler не вызывается.
705
+
706
+ ### Несколько refs
707
+
708
+ Массив нужен, когда несколько разнесённых DOM-subtrees считаются одной внутренней областью. Типичный popover:
709
+
710
+ - trigger находится в обычном application tree;
711
+ - floating content находится в portal.
712
+
713
+ ```tsx
714
+ const triggerRef = useRef<HTMLButtonElement>(null);
715
+ const panelRef = useRef<HTMLDivElement>(null);
716
+ const insideRefs = useMemo<RefObject<HTMLElement | null>[]>(() => [triggerRef, panelRef], []);
717
+
718
+ useClickOutside(insideRefs, close);
719
+ ```
720
+
721
+ `mousedown` внутри любого непустого ref не вызывает handler. Событие снаружи всех refs вызывает.
722
+
723
+ ### Важное поведение null refs в массиве
724
+
725
+ Для массива алгоритм начинает с предположения «снаружи» и проверяет только непустые refs. Поэтому:
726
+
727
+ - null-элементы массива игнорируются;
728
+ - если все refs равны `null`, любой `mousedown` будет считаться внешним;
729
+ - это отличается от single-ref режима, где null ref блокирует вызов.
730
+
731
+ Не передавайте массив refs до того, как жизненный цикл open/closed состояния продуман. Handler должен уметь безопасно проигнорировать событие, когда overlay закрыт.
732
+
733
+ ### Событие `mousedown`
734
+
735
+ Hook слушает:
736
+
737
+ ```ts
738
+ document.addEventListener("mousedown", handleClickOutside);
739
+ ```
740
+
741
+ Следствия:
742
+
743
+ - handler срабатывает раньше последующего `click`;
744
+ - touch/pointer abstraction отдельно не используется;
745
+ - hook не передаёт исходный `MouseEvent` в callback;
746
+ - hook не вызывает `preventDefault`;
747
+ - hook не вызывает `stopPropagation`.
748
+
749
+ Если продукту нужен единый pointer contract, right-click policy или event metadata, текущий API может быть недостаточен.
750
+
751
+ ### Descendants считаются внутренними
752
+
753
+ `HTMLElement.contains` учитывает сам element и всех DOM-потомков:
754
+
755
+ ```tsx
756
+ <div ref={panelRef}>
757
+ <button type="button">Внутренняя кнопка</button>
758
+ </div>
759
+ ```
760
+
761
+ `mousedown` по кнопке считается внутренним.
762
+
763
+ React ownership не влияет на `contains`. Portal content является отдельным DOM-subtree и требует отдельного ref.
764
+
765
+ ### Stable handler и массив
766
+
767
+ Effect зависит от `ref` и `handler`. Новая ссылка приводит к remove/add listener.
768
+
769
+ Для handler используйте `useCallback`, когда referential stability важна:
770
+
771
+ ```tsx
772
+ const close = useCallback(() => {
773
+ setOpen(false);
774
+ }, []);
775
+ ```
776
+
777
+ Массив refs также следует стабилизировать через `useMemo` либо хранить вне частых renders.
778
+
779
+ ### Как условно отключить
780
+
781
+ Hooks нельзя вызывать условно:
782
+
783
+ ```tsx
784
+ // Неправильно.
785
+ if (open) {
786
+ useClickOutside(panelRef, close);
787
+ }
788
+ ```
789
+
790
+ У `useClickOutside` нет параметра `enabled`. Сохраняйте вызов hook и проверяйте state внутри callback:
791
+
792
+ ```tsx
793
+ const closeOutside = useCallback(() => {
794
+ if (open) {
795
+ setOpen(false);
796
+ }
797
+ }, [open]);
798
+
799
+ useClickOutside(panelRef, closeOutside);
800
+ ```
801
+
802
+ Либо монтируйте отдельный component с hook только на время жизни overlay.
803
+
804
+ ### Cleanup
805
+
806
+ При unmount или изменении dependencies удаляется тот же document listener.
807
+
808
+ Hook всегда использует global `document`. Передать document iframe или другого realm нельзя.
809
+
810
+ ## `useEscapeDismiss`
811
+
812
+ Подписывает активный overlay на закрытие по клавише `Escape`.
813
+
814
+ ### Сигнатура
815
+
816
+ Публичная функция принимает объект:
817
+
818
+ ```ts
819
+ declare function useEscapeDismiss(options: {
820
+ active: boolean;
821
+ onDismiss: () => void;
822
+ enabled?: boolean;
823
+ documentTarget?: Document | null;
824
+ containerRef?: RefObject<HTMLElement | null>;
825
+ }): void;
826
+ ```
827
+
828
+ Named type options отдельно не экспортируется текущим public API.
829
+
830
+ ### Параметры
831
+
832
+ | Параметр | Тип | Default | Назначение |
833
+ | ---------------- | -------------------------------- | --------------- | -------------------------------------------- |
834
+ | `active` | `boolean` | Обязательный | Подключать ли listener для текущего overlay. |
835
+ | `onDismiss` | `() => void` | Обязательный | Команда закрытия. |
836
+ | `enabled` | `boolean` | `true` | Разрешено ли закрытие по Escape. |
837
+ | `documentTarget` | `Document \| null` | Global document | Document, на котором слушать keydown. |
838
+ | `containerRef` | `RefObject<HTMLElement \| null>` | Не задан | Ограничить dismiss focus-областью container. |
839
+
840
+ ### Условия подписки
841
+
842
+ Listener не устанавливается, если:
843
+
844
+ - `active === false`;
845
+ - `enabled === false`;
846
+ - не удалось получить target document.
847
+
848
+ В остальных случаях подписка использует capture:
849
+
850
+ ```ts
851
+ targetDocument.addEventListener("keydown", handleKeyDown, true);
852
+ ```
853
+
854
+ ### Проверка клавиши
855
+
856
+ Обрабатывается только:
857
+
858
+ ```ts
859
+ event.key === "Escape";
860
+ ```
861
+
862
+ Legacy значение `"Esc"` не обрабатывается.
863
+
864
+ При dismiss hook:
865
+
866
+ 1. вызывает `event.preventDefault()`;
867
+ 2. вызывает актуальный `onDismiss`.
868
+
869
+ Он не вызывает `stopPropagation`.
870
+
871
+ ### `onDismiss` всегда актуален
872
+
873
+ Внутри используется React `useEffectEvent`. Listener не обязан пересоздаваться только потому, что callback получил новую ссылку: при событии вызывается последняя committed версия `onDismiss`.
874
+
875
+ Не передавайте Effect Event наружу и не вызывайте hook вне React-компонента/custom hook.
876
+
877
+ ### Ограничение через `containerRef`
878
+
879
+ Если container существует, Escape срабатывает только когда `targetDocument.activeElement`:
880
+
881
+ - равен самому container;
882
+ - является его DOM-потомком.
883
+
884
+ Если focus находится снаружи, dismiss игнорируется.
885
+
886
+ ```tsx
887
+ const panelRef = useRef<HTMLDivElement>(null);
888
+
889
+ useEscapeDismiss({
890
+ active: open,
891
+ onDismiss: close,
892
+ containerRef: panelRef
893
+ });
894
+ ```
895
+
896
+ Это помогает нескольким обычным overlay не реагировать на Escape, когда keyboard focus принадлежит другой области.
897
+
898
+ Если `containerRef.current` равен `null`, focus scope не применяется и Escape может вызвать dismiss.
899
+
900
+ ### Без `containerRef`
901
+
902
+ Любой Escape в target document вызывает dismiss, пока hook active/enabled. Для нескольких активных hooks это может привести к нескольким вызовам.
903
+
904
+ Вложенный overlay manager должен передавать `active: true` только верхнему слою.
905
+
906
+ ### `documentTarget`
907
+
908
+ Если параметр не передан, используется global `document`.
909
+
910
+ Реализация использует nullish fallback:
911
+
912
+ ```ts
913
+ documentTarget ?? document;
914
+ ```
915
+
916
+ Поэтому явный `documentTarget: null` при наличии global document не отключает hook. Для отключения используйте `active: false` или `enabled: false`.
917
+
918
+ Custom document полезен для специальных same-origin document scenarios, но container/focus elements должны принадлежать согласованной DOM-среде.
919
+
920
+ ### Уже отменённое событие
921
+
922
+ Текущий контракт не проверяет `event.defaultPrevented`. Даже если предыдущий capture listener уже вызвал `preventDefault`, hook всё равно может выполнить dismiss.
923
+
924
+ Если приложение требует приоритетов между overlay, задавайте их через `active`, а не только через `preventDefault`.
925
+
926
+ ### Hook не управляет фокусом
927
+
928
+ `useEscapeDismiss` только вызывает callback. Начальный focus и restore выполняет `useOverlayFocus` либо UI-owner.
929
+
930
+ ## `useElementHeightObserver`
931
+
932
+ Возвращает актуальную высоту element, полученную через `ResizeObserver`.
933
+
934
+ ### Сигнатура
935
+
936
+ ```ts
937
+ declare function useElementHeightObserver<TElement extends HTMLElement>(elementRef: RefObject<TElement | null>): number;
938
+ ```
939
+
940
+ ### Начальное значение
941
+
942
+ До первого observer callback возвращается:
943
+
944
+ ```ts
945
+ 0;
946
+ ```
947
+
948
+ Hook не вызывает `getBoundingClientRect` синхронно при setup только для initial value. В обычном браузере ResizeObserver вскоре сообщает текущее измерение, но consumer должен корректно обработать `0`.
949
+
950
+ ### Источник высоты
951
+
952
+ Callback выбирает:
953
+
954
+ ```ts
955
+ entries[0]?.contentRect.height;
956
+ ```
957
+
958
+ Если первая entry отсутствует, используется:
959
+
960
+ ```ts
961
+ element.getBoundingClientRect().height;
962
+ ```
963
+
964
+ Обычно `contentRect.height` описывает content box. Это не обязательно равно:
965
+
966
+ - CSS `height`;
967
+ - border-box высоте;
968
+ - `offsetHeight`;
969
+ - визуальной высоте с transform;
970
+ - целому числу.
971
+
972
+ Результат может быть дробным.
973
+
974
+ ### Пример
975
+
976
+ ```tsx
977
+ function HeightLabel() {
978
+ const panelRef = useRef<HTMLDivElement>(null);
979
+ const height = useElementHeightObserver(panelRef);
980
+
981
+ return (
982
+ <div>
983
+ <div ref={panelRef}>Изменяемое содержимое</div>
984
+ <output>Высота content box: {height}px</output>
985
+ </div>
986
+ );
987
+ }
988
+ ```
989
+
990
+ ### Lifecycle
991
+
992
+ Hook использует `useLayoutEffect`:
993
+
994
+ 1. после DOM commit получает `elementRef.current`;
995
+ 2. если element отсутствует, ничего не наблюдает;
996
+ 3. создаёт `ResizeObserver`;
997
+ 4. вызывает `observer.observe(element)`;
998
+ 5. при cleanup вызывает `observer.disconnect()`.
999
+
1000
+ ### Стабильный ref и смена элемента
1001
+
1002
+ Hook рассчитан на React ref, установленный на актуальный element. Простое изменение `ref.current` само по себе не вызывает render. Для динамической замены target используйте понятную component boundary/key или обеспечьте render, на котором hook сможет переподключиться.
1003
+
1004
+ Не мутируйте ref вручную во время render.
1005
+
1006
+ ### Отсутствующий `ResizeObserver`
1007
+
1008
+ Fallback/polyfill отсутствует. Если global `ResizeObserver` не определён, effect завершится runtime error.
1009
+
1010
+ Для старой browser/test среды подключите совместимый polyfill на уровне application environment либо mock в тесте.
1011
+
1012
+ ### Не создавайте feedback loop
1013
+
1014
+ Осторожно с кодом, где измеренная высота сразу меняет CSS-высоту того же элемента. Это может создать цепочку:
1015
+
1016
+ ```text
1017
+ resize → setState → layout change → resize → ...
1018
+ ```
1019
+
1020
+ Используйте измерение только там, где оно действительно необходимо, и не дублируйте задачу, решаемую CSS.
1021
+
1022
+ ## `useIntersectionObserver`
1023
+
1024
+ Возвращает boolean `isIntersecting` для одного DOM `Element`.
1025
+
1026
+ ### Сигнатура
1027
+
1028
+ ```ts
1029
+ declare function useIntersectionObserver(
1030
+ ref: React.RefObject<Element | null>,
1031
+ options?: IntersectionObserverInit
1032
+ ): {
1033
+ isIntersecting: boolean;
1034
+ };
1035
+ ```
1036
+
1037
+ ### Начальное значение
1038
+
1039
+ ```ts
1040
+ false;
1041
+ ```
1042
+
1043
+ Даже если element видим, первый render возвращает `false`, пока native observer не вызовет callback.
1044
+
1045
+ Не интерпретируйте initial `false` как доказательство, что element точно находится вне viewport.
1046
+
1047
+ ### Options
1048
+
1049
+ `IntersectionObserverInit` — native browser contract:
1050
+
1051
+ | Поле | Назначение |
1052
+ | ------------ | ---------------------------------------------------------------- |
1053
+ | `root` | Scroll container; `null`/отсутствие означает viewport. |
1054
+ | `rootMargin` | Внешний/внутренний отступ области наблюдения в CSS-like формате. |
1055
+ | `threshold` | Доля пересечения или массив порогов от `0` до `1`. |
1056
+
1057
+ Пример preloading до появления в viewport:
1058
+
1059
+ ```tsx
1060
+ const options = useMemo<IntersectionObserverInit>(
1061
+ () => ({
1062
+ root: null,
1063
+ rootMargin: "300px 0px",
1064
+ threshold: 0
1065
+ }),
1066
+ []
1067
+ );
1068
+ ```
1069
+
1070
+ ### Stable options
1071
+
1072
+ Effect зависит от ссылки `options`. Такой код создаёт новый object на каждом render:
1073
+
1074
+ ```tsx
1075
+ useIntersectionObserver(ref, {
1076
+ rootMargin: "100px"
1077
+ });
1078
+ ```
1079
+
1080
+ Это приводит к disconnect и созданию нового observer после каждого render.
1081
+
1082
+ Стабилизируйте options:
1083
+
1084
+ ```tsx
1085
+ const options = useMemo(
1086
+ () => ({
1087
+ rootMargin: "100px"
1088
+ }),
1089
+ []
1090
+ );
1091
+
1092
+ const { isIntersecting } = useIntersectionObserver(ref, options);
1093
+ ```
1094
+
1095
+ ### Lifecycle
1096
+
1097
+ Если `ref.current` существует:
1098
+
1099
+ 1. создаётся `IntersectionObserver(callback, options)`;
1100
+ 2. вызывается `observe(element)`;
1101
+ 3. callback берёт первую entry;
1102
+ 4. state обновляется значением `entry.isIntersecting`;
1103
+ 5. cleanup вызывает `unobserve(element)`;
1104
+ 6. затем вызывает `disconnect()`.
1105
+
1106
+ Если ref пустой во время effect, observer не создаётся.
1107
+
1108
+ ### Возвращается только boolean
1109
+
1110
+ Hook не возвращает:
1111
+
1112
+ - `intersectionRatio`;
1113
+ - `boundingClientRect`;
1114
+ - `rootBounds`;
1115
+ - `time`;
1116
+ - саму `IntersectionObserverEntry`;
1117
+ - `observe/unobserve` commands.
1118
+
1119
+ Если они нужны, текущий API не подходит.
1120
+
1121
+ ### Видимость и бизнес-истина
1122
+
1123
+ `isIntersecting` является browser layout signal, а не security/business fact. Его можно использовать для:
1124
+
1125
+ - lazy rendering;
1126
+ - предварительной загрузки;
1127
+ - запуска/остановки лёгкой анимации;
1128
+ - UI-оптимизации.
1129
+
1130
+ Нельзя использовать пересечение как authorization, подтверждение просмотра юридического текста или гарантию, что пользователь действительно увидел содержимое.
1131
+
1132
+ ### Отсутствующий `IntersectionObserver`
1133
+
1134
+ Hook не содержит fallback. В среде без API effect выбросит ошибку.
1135
+
1136
+ ## `useIsTouchDevice`
1137
+
1138
+ Возвращает heuristic наличия touch/coarse-pointer возможностей.
1139
+
1140
+ ### Сигнатура
1141
+
1142
+ ```ts
1143
+ declare const useIsTouchDevice: () => boolean;
1144
+ ```
1145
+
1146
+ ### Алгоритм
1147
+
1148
+ Результат становится `true`, если выполняется хотя бы одно условие:
1149
+
1150
+ ```ts
1151
+ window.matchMedia("(pointer: coarse)").matches || "ontouchstart" in window || navigator.maxTouchPoints > 0;
1152
+ ```
1153
+
1154
+ ### Начальное значение
1155
+
1156
+ Первый render возвращает:
1157
+
1158
+ ```ts
1159
+ false;
1160
+ ```
1161
+
1162
+ После mount effect запускает detection и обновляет state. Поэтому возможен переход `false → true` и один дополнительный render.
1163
+
1164
+ Не используйте initial `false` для необратимой команды.
1165
+
1166
+ ### Обновление
1167
+
1168
+ Hook повторяет detection на событии:
1169
+
1170
+ ```ts
1171
+ window.addEventListener("resize", checkTouchDevice);
1172
+ ```
1173
+
1174
+ При unmount listener удаляется.
1175
+
1176
+ Hook не подписывается напрямую на `MediaQueryList.change`, `pointerchange` или подключение внешнего устройства. Изменение обнаруживается при mount или следующем `resize`.
1177
+
1178
+ ### Что означает `true`
1179
+
1180
+ `true` означает, что среда выглядит touch-capable/coarse-pointer-capable. Это не гарантирует, что:
1181
+
1182
+ - пользователь сейчас касается экрана;
1183
+ - touch является primary input;
1184
+ - мыши нет;
1185
+ - hover недоступен;
1186
+ - viewport маленький;
1187
+ - устройство является телефоном;
1188
+ - пользователь не использует keyboard или assistive technology.
1189
+
1190
+ Hybrid laptop может одновременно поддерживать touch, mouse и keyboard.
1191
+
1192
+ ### Не скрывайте функциональность
1193
+
1194
+ Плохой пример:
1195
+
1196
+ ```tsx
1197
+ {
1198
+ isTouchDevice ? null : <button>Важная команда</button>;
1199
+ }
1200
+ ```
1201
+
1202
+ Touch heuristic нельзя использовать, чтобы лишить пользователя функции. Адаптируйте удобство interaction, сохраняя keyboard/pointer доступность.
1203
+
1204
+ Для responsive layout и hover styling предпочитайте CSS:
1205
+
1206
+ ```css
1207
+ @media (pointer: coarse) {
1208
+ .target {
1209
+ min-block-size: 44px;
1210
+ }
1211
+ }
1212
+ ```
1213
+
1214
+ ### Runtime
1215
+
1216
+ Hook ожидает `window.matchMedia` и browser globals. В test environment их нужно mock.
1217
+
1218
+ ## `useOverlayFocus`
1219
+
1220
+ Управляет focus lifecycle одного overlay:
1221
+
1222
+ - запоминает ранее активный element;
1223
+ - ждёт появления container ref;
1224
+ - устанавливает initial focus;
1225
+ - опционально зацикливает `Tab`;
1226
+ - опционально возвращает focus при cleanup.
1227
+
1228
+ ### Сигнатура
1229
+
1230
+ ```ts
1231
+ export interface UseOverlayFocusOptions<T extends HTMLElement> {
1232
+ active: boolean;
1233
+ trapFocus?: boolean;
1234
+ restoreFocus?: boolean;
1235
+ initialFocus?: "auto" | "container" | ((container: T) => HTMLElement | null | undefined);
1236
+ restoreFocusTarget?: () => HTMLElement | null | undefined;
1237
+ containerRef?: RefObject<T | null>;
1238
+ }
1239
+
1240
+ declare function useOverlayFocus<T extends HTMLElement>(options: UseOverlayFocusOptions<T>): RefObject<T | null>;
1241
+ ```
1242
+
1243
+ ### Options
1244
+
1245
+ | Option | Default | Поведение |
1246
+ | -------------------- | -------- | ------------------------------------------------------- |
1247
+ | `active` | Required | Запускает focus lifecycle. |
1248
+ | `trapFocus` | `false` | Зацикливает Tab на границах container. |
1249
+ | `restoreFocus` | `true` | Возвращает focus при cleanup. |
1250
+ | `initialFocus` | `"auto"` | Выбирает первый focus target. |
1251
+ | `restoreFocusTarget` | Не задан | Позволяет вернуть focus в явно вычисленный element. |
1252
+ | `containerRef` | Не задан | Позволяет передать существующий ref вместо внутреннего. |
1253
+
1254
+ Hook возвращает ref, который нужно установить на overlay container:
1255
+
1256
+ ```tsx
1257
+ const overlayRef = useOverlayFocus<HTMLDivElement>({
1258
+ active: open
1259
+ });
1260
+
1261
+ return open ? <div ref={overlayRef}>...</div> : null;
1262
+ ```
1263
+
1264
+ ### Internal и external ref
1265
+
1266
+ Если `containerRef` не передан, hook создаёт собственный `useRef`.
1267
+
1268
+ Если передан, используется и возвращается именно он:
1269
+
1270
+ ```tsx
1271
+ const existingRef = useRef<HTMLDivElement>(null);
1272
+ const overlayRef = useOverlayFocus({
1273
+ active: open,
1274
+ containerRef: existingRef
1275
+ });
1276
+
1277
+ console.log(overlayRef === existingRef); // true.
1278
+ ```
1279
+
1280
+ Это полезно для объединения с Floating UI callback ref или другим imperative owner.
1281
+
1282
+ ### Activation lifecycle
1283
+
1284
+ При переходе в active effect:
1285
+
1286
+ 1. сохраняет текущий `document.activeElement`, если это `HTMLElement`;
1287
+ 2. пытается прочитать `overlayRef.current`;
1288
+ 3. если ref ещё пустой, повторяет попытку через `requestAnimationFrame`;
1289
+ 4. когда container найден, вычисляет initial focus;
1290
+ 5. вызывает `.focus()`, только если target — подключённый `HTMLElement`;
1291
+ 6. при `trapFocus: true` добавляет `keydown` listener на container.
1292
+
1293
+ Если `active: true`, но container никогда не монтируется, requestAnimationFrame retry продолжается до cleanup. Всегда согласовывайте `active` с реальным mount overlay.
1294
+
1295
+ ### `initialFocus: "auto"`
1296
+
1297
+ Это default. Hook собирает descendants по selector:
1298
+
1299
+ ```css
1300
+ button:not([disabled]),
1301
+ [href],
1302
+ input:not([disabled]),
1303
+ select:not([disabled]),
1304
+ textarea:not([disabled]),
1305
+ [tabindex]:not([tabindex="-1"]):not([disabled])
1306
+ ```
1307
+
1308
+ Затем:
1309
+
1310
+ 1. удаляет elements, у которых на самом element есть `aria-hidden`;
1311
+ 2. ищет element с `autofocus`/`autoFocus` attribute;
1312
+ 3. иначе берёт первый focusable element в DOM-порядке;
1313
+ 4. если candidates нет, использует container.
1314
+
1315
+ Пример:
1316
+
1317
+ ```tsx
1318
+ <div ref={overlayRef} tabIndex={-1}>
1319
+ <button type="button" autoFocus>
1320
+ Основное действие
1321
+ </button>
1322
+ <button type="button">Отмена</button>
1323
+ </div>
1324
+ ```
1325
+
1326
+ React `autoFocus` имеет собственное commit-поведение, а hook дополнительно ищет соответствующий DOM attribute. Для однозначного выбора initial target используйте callback.
1327
+
1328
+ ### Ограничения auto selector
1329
+
1330
+ Это компактный selector, а не полноценный tabbability engine. Он:
1331
+
1332
+ - не проверяет `display: none` или `visibility: hidden`;
1333
+ - не проверяет hidden/inert ancestors;
1334
+ - не учитывает disabled `fieldset` полностью;
1335
+ - не проверяет фактический layout box;
1336
+ - не включает все возможные focusable browser элементы;
1337
+ - не исключает произвольный negative `tabindex`, кроме точного `-1`;
1338
+ - проверяет `aria-hidden` только на candidate, а не на ancestors.
1339
+
1340
+ Для сложного accessibility primitive используйте проверенный UI-компонент или специализированный focus manager.
1341
+
1342
+ ### `initialFocus: "container"`
1343
+
1344
+ Hook вызывает focus у container:
1345
+
1346
+ ```tsx
1347
+ const overlayRef = useOverlayFocus<HTMLDivElement>({
1348
+ active: open,
1349
+ initialFocus: "container"
1350
+ });
1351
+
1352
+ return (
1353
+ <div ref={overlayRef} tabIndex={-1}>
1354
+ ...
1355
+ </div>
1356
+ );
1357
+ ```
1358
+
1359
+ Без `tabIndex={-1}` обычный `div` может не принять focus.
1360
+
1361
+ ### Custom initial focus
1362
+
1363
+ ```tsx
1364
+ const overlayRef = useOverlayFocus<HTMLDivElement>({
1365
+ active: open,
1366
+ initialFocus: (container) => container.querySelector<HTMLElement>("[data-initial-focus]")
1367
+ });
1368
+ ```
1369
+
1370
+ Если callback возвращает `null` или `undefined`, используется container.
1371
+
1372
+ Если callback возвращает подключённый `HTMLElement` вне container, hook всё равно сфокусирует его. Ответственность за корректность target лежит на consumer.
1373
+
1374
+ Если возвращён disconnected HTMLElement, focus не выполняется; дополнительного fallback после проверки `isConnected` нет.
1375
+
1376
+ ### Focus trap
1377
+
1378
+ При `trapFocus: true` container получает bubbling `keydown` listener.
1379
+
1380
+ На каждый `Tab` focusable elements вычисляются заново, поэтому динамически добавленные controls могут участвовать.
1381
+
1382
+ #### Обычный Tab
1383
+
1384
+ Если focus находится на последнем candidate:
1385
+
1386
+ 1. вызывается `preventDefault()`;
1387
+ 2. focus переходит на первый candidate.
1388
+
1389
+ #### Shift+Tab
1390
+
1391
+ Если focus находится:
1392
+
1393
+ - на первом candidate;
1394
+ - на самом container;
1395
+
1396
+ то event отменяется и focus переходит на последний candidate.
1397
+
1398
+ #### Нет candidates
1399
+
1400
+ Hook отменяет Tab и вызывает:
1401
+
1402
+ ```ts
1403
+ container.focus();
1404
+ ```
1405
+
1406
+ Container должен быть программно focusable.
1407
+
1408
+ ### Граница focus trap
1409
+
1410
+ Это boundary wrap, а не абсолютная focus containment:
1411
+
1412
+ - listener срабатывает только для keydown, прошедшего через container;
1413
+ - программный `.focus()` снаружи не перехватывается;
1414
+ - mouse/pointer focus снаружи не возвращается;
1415
+ - focus в другом portal не является descendant;
1416
+ - iframe и shadow DOM требуют отдельного проектирования;
1417
+ - background не становится `inert`;
1418
+ - browser chrome и assistive technology имеют собственное поведение.
1419
+
1420
+ Не используйте этот hook как единственную гарантию доступности или безопасности.
1421
+
1422
+ ### Restore focus
1423
+
1424
+ При activation hook запоминает ранее активный `HTMLElement`.
1425
+
1426
+ При cleanup с `restoreFocus: true` выбирается:
1427
+
1428
+ ```ts
1429
+ restoreFocusTarget?.() ?? previousActiveElement;
1430
+ ```
1431
+
1432
+ Focus возвращается, только если выбранный element всё ещё `isConnected`.
1433
+
1434
+ Типичный сценарий:
1435
+
1436
+ ```tsx
1437
+ const triggerRef = useRef<HTMLButtonElement>(null);
1438
+
1439
+ const overlayRef = useOverlayFocus<HTMLDivElement>({
1440
+ active: open,
1441
+ restoreFocus: true,
1442
+ restoreFocusTarget: () => triggerRef.current
1443
+ });
1444
+ ```
1445
+
1446
+ Если custom callback возвращает `null`/`undefined`, используется ранее активный element.
1447
+
1448
+ Если callback возвращает disconnected element, fallback к предыдущему element уже не выполняется: target был выбран, но проверку `isConnected` не прошёл.
1449
+
1450
+ ### Когда выполняется cleanup
1451
+
1452
+ Restore может произойти:
1453
+
1454
+ - при `active: true → false`;
1455
+ - при unmount;
1456
+ - при изменении effect dependencies, например `trapFocus`, `restoreFocus` или ссылки `containerRef`.
1457
+
1458
+ Не меняйте эти options без необходимости во время открытого overlay.
1459
+
1460
+ ### Exit animation
1461
+
1462
+ Если DOM overlay остаётся видимым на время exit animation, но `active` уже стал `false`, hook восстановит focus при cleanup сразу, а не после окончания animation.
1463
+
1464
+ Для такого UI:
1465
+
1466
+ - передайте `restoreFocus: false`;
1467
+ - сохраните trigger отдельно;
1468
+ - восстановите focus в callback окончания exit animation;
1469
+ - убедитесь, что background уже перестал быть inert.
1470
+
1471
+ ### Изменение `initialFocus`
1472
+
1473
+ `initialFocus` читается через Effect Event и не входит в effect dependencies. Изменение option во время уже подключённого active overlay само по себе не запускает повторный initial focus. Initial focus — действие открытия, а не derived state каждого render.
1474
+
1475
+ ### Nested overlays
1476
+
1477
+ Hook не знает о стеке modal/dialog/popover. Если несколько hooks имеют `active: true`, каждый управляет своей подпиской и restore lifecycle.
1478
+
1479
+ Application/UI manager должен:
1480
+
1481
+ - определить top overlay;
1482
+ - активировать Escape/trap только для него;
1483
+ - согласовать inert и z-index;
1484
+ - не позволить нижнему overlay восстановить focus поверх верхнего.
1485
+
1486
+ ## `useFocusTrap`
1487
+
1488
+ Совместимый wrapper с фиксированными options.
1489
+
1490
+ ### Сигнатура
1491
+
1492
+ ```ts
1493
+ declare const useFocusTrap: (isOpen: boolean) => RefObject<HTMLDivElement | null>;
1494
+ ```
1495
+
1496
+ Эквивалентен:
1497
+
1498
+ ```ts
1499
+ useOverlayFocus<HTMLDivElement>({
1500
+ active: isOpen,
1501
+ trapFocus: true,
1502
+ restoreFocus: true,
1503
+ initialFocus: "auto"
1504
+ });
1505
+ ```
1506
+
1507
+ ### Пример
1508
+
1509
+ ```tsx
1510
+ function LegacyPanel({ open }: { open: boolean }) {
1511
+ const panelRef = useFocusTrap(open);
1512
+
1513
+ return open ? (
1514
+ <div ref={panelRef} tabIndex={-1}>
1515
+ <button type="button">Подтвердить</button>
1516
+ <button type="button">Отмена</button>
1517
+ </div>
1518
+ ) : null;
1519
+ }
1520
+ ```
1521
+
1522
+ Wrapper не позволяет:
1523
+
1524
+ - отключить restore;
1525
+ - выбрать custom initial focus;
1526
+ - передать existing ref;
1527
+ - отключить trap при active panel;
1528
+ - выбрать другой HTMLElement generic.
1529
+
1530
+ Для нового настраиваемого кода используйте `useOverlayFocus`.
1531
+
1532
+ ## Сборка доступного dialog
1533
+
1534
+ `useOverlayFocus` и `useEscapeDismiss` решают только часть задачи. Минимальный dialog должен также иметь:
1535
+
1536
+ - семантический `role="dialog"`;
1537
+ - `aria-modal="true"` только для действительно modal поведения;
1538
+ - accessible name через `aria-labelledby` или `aria-label`;
1539
+ - при необходимости description через `aria-describedby`;
1540
+ - доступную кнопку закрытия;
1541
+ - focusable container fallback;
1542
+ - top-layer policy;
1543
+ - background/inert policy;
1544
+ - scroll lock при необходимости;
1545
+ - восстановление focus;
1546
+ - keyboard и pointer способы закрытия;
1547
+ - визуальный focus indicator.
1548
+
1549
+ Ниже учебный пример. Для production сначала ищите готовый UI primitive.
1550
+
1551
+ ```tsx
1552
+ import { useId } from "react";
1553
+ import { createPortal } from "react-dom";
1554
+ import { useEscapeDismiss, useOverlayFocus } from "@ryuzaki13/react-foundation-lib/dom";
1555
+
1556
+ type ConfirmDialogProps = {
1557
+ description: string;
1558
+ onClose: () => void;
1559
+ onConfirm: () => void;
1560
+ open: boolean;
1561
+ portalRoot: HTMLElement;
1562
+ title: string;
1563
+ };
1564
+
1565
+ export function ConfirmDialog({ description, onClose, onConfirm, open, portalRoot, title }: ConfirmDialogProps) {
1566
+ const titleId = useId();
1567
+ const descriptionId = useId();
1568
+ const panelRef = useOverlayFocus<HTMLDivElement>({
1569
+ active: open,
1570
+ trapFocus: true,
1571
+ restoreFocus: true,
1572
+ initialFocus: (container) => container.querySelector<HTMLButtonElement>("[data-confirm]")
1573
+ });
1574
+
1575
+ useEscapeDismiss({
1576
+ active: open,
1577
+ onDismiss: onClose,
1578
+ containerRef: panelRef
1579
+ });
1580
+
1581
+ if (!open) {
1582
+ return null;
1583
+ }
1584
+
1585
+ return createPortal(
1586
+ <div className="dialogBackdrop">
1587
+ <div ref={panelRef} role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId} tabIndex={-1}>
1588
+ <h2 id={titleId}>{title}</h2>
1589
+ <p id={descriptionId}>{description}</p>
1590
+ <button type="button" data-confirm onClick={onConfirm}>
1591
+ Подтвердить
1592
+ </button>
1593
+ <button type="button" onClick={onClose}>
1594
+ Отмена
1595
+ </button>
1596
+ </div>
1597
+ </div>,
1598
+ portalRoot
1599
+ );
1600
+ }
1601
+ ```
1602
+
1603
+ Этот пример всё ещё не реализует inert/background manager, scroll lock, nested modal stack и animation exit coordination. Эти обязанности должны находиться у UI-owner.
1604
+
1605
+ ### Почему нужен `useId`
1606
+
1607
+ `useId` создаёт React ID для accessibility connections:
1608
+
1609
+ ```tsx
1610
+ <h2 id={titleId}>...</h2>
1611
+ <div aria-labelledby={titleId}>...</div>
1612
+ ```
1613
+
1614
+ Не используйте случайный UUID на каждом render.
1615
+
1616
+ ### Почему container имеет `tabIndex={-1}`
1617
+
1618
+ Если custom initial target отсутствует либо focusable elements нет, hook использует container. Без программной focusability fallback может ничего не сделать.
1619
+
1620
+ ### Почему `active` совпадает с mount
1621
+
1622
+ Когда `open === false`, container не рендерится и hook inactive. Когда `open === true`, container появляется и hook может получить ref. Это предотвращает бесконечный requestAnimationFrame retry.
1623
+
1624
+ ## Композиция overlay hooks
1625
+
1626
+ ### Popover: trigger и panel считаются внутренними
1627
+
1628
+ ```tsx
1629
+ import { type RefObject, useCallback, useMemo, useRef, useState } from "react";
1630
+ import { useClickOutside, useEscapeDismiss, useOverlayFocus } from "@ryuzaki13/react-foundation-lib/dom";
1631
+
1632
+ function Popover() {
1633
+ const [open, setOpen] = useState(false);
1634
+ const triggerRef = useRef<HTMLButtonElement>(null);
1635
+ const panelRef = useOverlayFocus<HTMLDivElement>({
1636
+ active: open,
1637
+ initialFocus: "container",
1638
+ restoreFocus: true
1639
+ });
1640
+ const insideRefs = useMemo<RefObject<HTMLElement | null>[]>(() => [triggerRef, panelRef], [panelRef]);
1641
+
1642
+ const close = useCallback(() => {
1643
+ setOpen(false);
1644
+ }, []);
1645
+
1646
+ const closeOutside = useCallback(() => {
1647
+ if (open) {
1648
+ close();
1649
+ }
1650
+ }, [close, open]);
1651
+
1652
+ useClickOutside(insideRefs, closeOutside);
1653
+ useEscapeDismiss({
1654
+ active: open,
1655
+ onDismiss: close,
1656
+ containerRef: panelRef
1657
+ });
1658
+
1659
+ return (
1660
+ <>
1661
+ <button ref={triggerRef} type="button" aria-expanded={open} onClick={() => setOpen((current) => !current)}>
1662
+ Открыть
1663
+ </button>
1664
+ {open ? (
1665
+ <div ref={panelRef} tabIndex={-1}>
1666
+ Содержимое
1667
+ </div>
1668
+ ) : null}
1669
+ </>
1670
+ );
1671
+ }
1672
+ ```
1673
+
1674
+ Если panel находится в portal, логика refs не меняется: `contains` проверяет реальный DOM каждого ref.
1675
+
1676
+ ### Только верхний overlay активен
1677
+
1678
+ ```tsx
1679
+ useEscapeDismiss({
1680
+ active: open && isTopOverlay,
1681
+ onDismiss: close,
1682
+ containerRef: panelRef
1683
+ });
1684
+ ```
1685
+
1686
+ Конкретная stack и focus policy принадлежит UI manager. Нельзя механически переключать `useOverlayFocus.active` у нижнего слоя при каждом открытии верхнего: cleanup нижнего hook может преждевременно восстановить focus. Менеджер должен согласовать activation, restore, inert и exit lifecycle всей стопки. Модуль `dom` не предоставляет store/registry overlay.
1687
+
1688
+ ### Non-modal popover
1689
+
1690
+ Не каждому popover нужна focus trap:
1691
+
1692
+ ```tsx
1693
+ const panelRef = useOverlayFocus<HTMLDivElement>({
1694
+ active: open,
1695
+ trapFocus: false,
1696
+ restoreFocus: true,
1697
+ initialFocus: "container"
1698
+ });
1699
+ ```
1700
+
1701
+ `aria-modal="true"` также не следует ставить на non-modal UI автоматически.
1702
+
1703
+ ## SSR, Strict Mode и lifecycle
1704
+
1705
+ ### Browser-only side effects
1706
+
1707
+ Не вызывайте download helpers или DOM mutations:
1708
+
1709
+ - во время render;
1710
+ - в Node-only utility;
1711
+ - в server loader;
1712
+ - до появления `document.body`;
1713
+ - без проверки целевого browser runtime.
1714
+
1715
+ User-triggered effects выполняйте в handlers. Subscriptions и observers уже находятся внутри hooks.
1716
+
1717
+ ### Первый render может содержать default
1718
+
1719
+ | Hook | Initial render |
1720
+ | -------------------------- | -------------- |
1721
+ | `useElementHeightObserver` | `0` |
1722
+ | `useIntersectionObserver` | `false` |
1723
+ | `useIsTouchDevice` | `false` |
1724
+
1725
+ После mount/observer callback state может измениться. UI должен выдерживать этот переход без опасного решения и сильного layout shift.
1726
+
1727
+ ### Strict Mode
1728
+
1729
+ React development Strict Mode может выполнить setup → cleanup → setup для проверки симметрии effects.
1730
+
1731
+ Hooks удаляют listeners и disconnect observers в cleanup. Consumer callbacks также должны быть безопасны при повторном lifecycle.
1732
+
1733
+ Не используйте render для:
1734
+
1735
+ - скачивания;
1736
+ - создания случайного URL;
1737
+ - focus;
1738
+ - append в body;
1739
+ - ручной мутации ref.
1740
+
1741
+ ### Conditional hooks запрещены
1742
+
1743
+ Нельзя:
1744
+
1745
+ ```tsx
1746
+ if (open) {
1747
+ useEscapeDismiss({ active: true, onDismiss: close });
1748
+ }
1749
+ ```
1750
+
1751
+ Правильно:
1752
+
1753
+ ```tsx
1754
+ useEscapeDismiss({
1755
+ active: open,
1756
+ onDismiss: close
1757
+ });
1758
+ ```
1759
+
1760
+ ### Ref становится доступен после commit
1761
+
1762
+ `useOverlayFocus` умеет ждать ref через requestAnimationFrame. Observer hooks читают ref в effect. Не пытайтесь читать `ref.current` во время render для условного вызова hook.
1763
+
1764
+ ## Производительность
1765
+
1766
+ ### Global listeners
1767
+
1768
+ Каждый экземпляр:
1769
+
1770
+ - `useClickOutside` добавляет свой `document.mousedown`;
1771
+ - активный `useEscapeDismiss` добавляет свой `document.keydown`;
1772
+ - `useIsTouchDevice` добавляет свой `window.resize`.
1773
+
1774
+ Для десятков/сотен экземпляров оцените общий owner или монтируйте interaction hook только в реально активном UI component.
1775
+
1776
+ ### Stable dependencies
1777
+
1778
+ - стабилизируйте массив refs для `useClickOutside`;
1779
+ - используйте `useCallback` для handler, когда нужно избежать listener churn;
1780
+ - стабилизируйте `IntersectionObserverInit`;
1781
+ - не меняйте focus options без причины во время active overlay.
1782
+
1783
+ Не добавляйте memoization механически: она нужна там, где ссылка является effect contract.
1784
+
1785
+ ### Observer callbacks
1786
+
1787
+ `ResizeObserver` и `IntersectionObserver` могут вызывать state update много раз. Не выполняйте тяжёлую синхронную работу прямо во время каждого render, зависящего от observer state.
1788
+
1789
+ ### CSS прежде измерений
1790
+
1791
+ Если layout можно выразить через Grid, Flexbox, container queries, `min/max/clamp` или media queries, CSS обычно проще и дешевле `ResizeObserver → state → render`.
1792
+
1793
+ ### Object URL
1794
+
1795
+ `downloadFileFromBlob` освобождает созданный URL автоматически. Не создавайте ещё один URL вокруг него:
1796
+
1797
+ ```ts
1798
+ // Лишнее создание URL.
1799
+ const url = URL.createObjectURL(blob);
1800
+ downloadFileFromBlob("file.bin", blob);
1801
+ ```
1802
+
1803
+ Либо передайте Blob, либо создайте URL и передайте его в `downloadFileFromObjectURL`.
1804
+
1805
+ ## Тестирование
1806
+
1807
+ DOM hooks требуют DOM test environment. Для Vitest файл может начинаться:
1808
+
1809
+ ```ts
1810
+ // @vitest-environment jsdom
1811
+ ```
1812
+
1813
+ ### Cleanup React root
1814
+
1815
+ После каждого теста:
1816
+
1817
+ - unmount React root;
1818
+ - удаляйте container;
1819
+ - восстанавливайте mocks;
1820
+ - возвращайте real timers;
1821
+ - удаляйте portal roots из `document.body`.
1822
+
1823
+ ### Download helpers
1824
+
1825
+ Mock:
1826
+
1827
+ - `document.createElement`/anchor `click`;
1828
+ - `URL.createObjectURL`;
1829
+ - `URL.revokeObjectURL`;
1830
+ - timers.
1831
+
1832
+ ```ts
1833
+ vi.useFakeTimers();
1834
+
1835
+ const click = vi.fn();
1836
+ const revokeObjectURL = vi.fn();
1837
+
1838
+ // После вызова helper:
1839
+ expect(click).toHaveBeenCalledOnce();
1840
+
1841
+ vi.runAllTimers();
1842
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:test");
1843
+ ```
1844
+
1845
+ Проверяйте revoke после выполнения timer, а не сразу после функции.
1846
+
1847
+ ### `ResizeObserver`
1848
+
1849
+ JSDOM не выполняет реальный layout. Используйте controllable fake:
1850
+
1851
+ ```ts
1852
+ let resizeCallback: ResizeObserverCallback | undefined;
1853
+
1854
+ class FakeResizeObserver {
1855
+ constructor(callback: ResizeObserverCallback) {
1856
+ resizeCallback = callback;
1857
+ }
1858
+
1859
+ observe = vi.fn();
1860
+ disconnect = vi.fn();
1861
+ }
1862
+
1863
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
1864
+ ```
1865
+
1866
+ Затем вызовите callback внутри React `act`:
1867
+
1868
+ ```ts
1869
+ await act(async () => {
1870
+ resizeCallback?.([{ contentRect: { height: 42 } } as ResizeObserverEntry], {} as ResizeObserver);
1871
+ });
1872
+ ```
1873
+
1874
+ ### `IntersectionObserver`
1875
+
1876
+ ```ts
1877
+ let intersectionCallback: IntersectionObserverCallback | undefined;
1878
+
1879
+ class FakeIntersectionObserver {
1880
+ constructor(callback: IntersectionObserverCallback) {
1881
+ intersectionCallback = callback;
1882
+ }
1883
+
1884
+ observe = vi.fn();
1885
+ unobserve = vi.fn();
1886
+ disconnect = vi.fn();
1887
+ }
1888
+
1889
+ vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
1890
+ ```
1891
+
1892
+ Передавайте fake entry:
1893
+
1894
+ ```ts
1895
+ await act(async () => {
1896
+ intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
1897
+ });
1898
+ ```
1899
+
1900
+ ### `useClickOutside`
1901
+
1902
+ Dispatch именно `mousedown`, а не только `click`:
1903
+
1904
+ ```ts
1905
+ outside.dispatchEvent(
1906
+ new MouseEvent("mousedown", {
1907
+ bubbles: true
1908
+ })
1909
+ );
1910
+ ```
1911
+
1912
+ Проверьте:
1913
+
1914
+ - inside одного ref;
1915
+ - inside второго ref;
1916
+ - outside всех refs;
1917
+ - null refs;
1918
+ - cleanup после unmount.
1919
+
1920
+ ### `useEscapeDismiss`
1921
+
1922
+ ```ts
1923
+ const event = new KeyboardEvent("keydown", {
1924
+ key: "Escape",
1925
+ bubbles: true,
1926
+ cancelable: true
1927
+ });
1928
+
1929
+ document.dispatchEvent(event);
1930
+
1931
+ expect(onDismiss).toHaveBeenCalledOnce();
1932
+ expect(event.defaultPrevented).toBe(true);
1933
+ ```
1934
+
1935
+ Отдельно проверьте focus внутри и снаружи container.
1936
+
1937
+ ### `useOverlayFocus`
1938
+
1939
+ JSDOM не имеет полноценной browser Tab navigation. Проверяйте управляемые boundary cases:
1940
+
1941
+ - initial focus;
1942
+ - Tab на последнем element;
1943
+ - Shift+Tab на первом;
1944
+ - container fallback без candidates;
1945
+ - restore target;
1946
+ - disconnected restore target;
1947
+ - cleanup listener.
1948
+
1949
+ `requestAnimationFrame` можно mock, но callback должен выполняться в контролируемом `act`.
1950
+
1951
+ ### `useIsTouchDevice`
1952
+
1953
+ Mock:
1954
+
1955
+ - `window.matchMedia`;
1956
+ - `navigator.maxTouchPoints`;
1957
+ - при необходимости наличие `ontouchstart`;
1958
+ - `window.resize`.
1959
+
1960
+ Проверяйте initial `false` отдельно от состояния после effect.
1961
+
1962
+ ## Ограничения и частые ошибки
1963
+
1964
+ ### Вызов download во время render
1965
+
1966
+ ```tsx
1967
+ function BadComponent({ blob }: { blob: Blob }) {
1968
+ downloadFileFromBlob("file.bin", blob);
1969
+ return null;
1970
+ }
1971
+ ```
1972
+
1973
+ Render может выполняться повторно. Download является user-facing side effect и должен запускаться из command handler.
1974
+
1975
+ ### Повторное использование отозванного object URL
1976
+
1977
+ `downloadFileFromObjectURL` планирует revoke. Не сохраняйте переданный URL как долговечный preview source.
1978
+
1979
+ ### Ожидание Promise или подтверждения download
1980
+
1981
+ Helpers возвращают `void`. Они не знают, сохранил ли пользователь файл и куда.
1982
+
1983
+ ### Невалидный JSON
1984
+
1985
+ Root `undefined`, circular data и `BigInt` не превращаются в надёжный JSON export автоматически. Подготавливайте serializable payload.
1986
+
1987
+ ### Portal root внутри pure render
1988
+
1989
+ `getOrCreatePortalRoot` меняет `document.body`. Создавайте root на browser bootstrap или используйте element из HTML shell.
1990
+
1991
+ ### Случайный/пустой portal ID
1992
+
1993
+ Это ломает reuse и может создать duplicate IDs. Используйте стабильную application constant.
1994
+
1995
+ ### Trigger не включён в outside refs
1996
+
1997
+ Для toggle popover `mousedown` по trigger может считаться внешним, а следующий `click` снова изменить state. Передайте trigger и panel refs как единую область.
1998
+
1999
+ ### Inline refs array
2000
+
2001
+ ```tsx
2002
+ useClickOutside([triggerRef, panelRef], close);
2003
+ ```
2004
+
2005
+ Функционально понятно, но новая array reference пересоздаёт listener после каждого render. Для часто обновляемого компонента стабилизируйте массив.
2006
+
2007
+ ### Условный вызов hook
2008
+
2009
+ Не помещайте hooks внутрь `if (open)`. Передавайте `active`/`enabled` либо выделите mounted overlay component.
2010
+
2011
+ ### Несколько active Escape hooks
2012
+
2013
+ `preventDefault` не останавливает другие listeners. Только top overlay должен быть active.
2014
+
2015
+ ### Container без focus
2016
+
2017
+ `initialFocus: "container"` и zero-candidate trap требуют `tabIndex={-1}` для обычного `div`.
2018
+
2019
+ ### Focus trap без семантики
2020
+
2021
+ Focus trap не добавляет `role="dialog"`, accessible name, inert background и close command.
2022
+
2023
+ ### Focus trap на non-modal UI
2024
+
2025
+ Не зацикливайте focus автоматически в tooltip или простом non-modal popover. Это может мешать keyboard navigation.
2026
+
2027
+ ### Restore до завершения animation
2028
+
2029
+ Cleanup выполняется при деактивации hook. Если exit animation ещё идёт, управляйте restore отдельно.
2030
+
2031
+ ### Active overlay без mounted ref
2032
+
2033
+ `useOverlayFocus` будет повторять requestAnimationFrame до появления element или cleanup.
2034
+
2035
+ ### Inline observer options
2036
+
2037
+ Новый object на каждом render пересоздаёт `IntersectionObserver`.
2038
+
2039
+ ### Observer как синхронное измерение
2040
+
2041
+ Initial height/visibility — defaults. Реальный callback приходит позднее.
2042
+
2043
+ ### Отсутствующие observer APIs
2044
+
2045
+ Hooks не предоставляют polyfills. Настройте target browsers и test environment.
2046
+
2047
+ ### Touch равен mobile
2048
+
2049
+ Touch-capable laptop не обязательно mobile. Не связывайте бизнес/UI доступность только с результатом heuristic.
2050
+
2051
+ ### ResizeObserver вместо CSS
2052
+
2053
+ Не измеряйте layout через JavaScript, если задача полностью решается CSS.
2054
+
2055
+ ## Краткий справочник API
2056
+
2057
+ | API | Вход | Результат | Default/событие | Главное ограничение |
2058
+ | --------------------------- | ----------------------------- | ----------------------------------- | ------------------------------------- | -------------------------------------------------------- |
2059
+ | `downloadFileFromObjectURL` | filename, object URL | `void` | revoke через `setTimeout(0)` | Принимает ownership URL; нет подтверждения download. |
2060
+ | `downloadFileFromBlob` | filename, `Blob` | `void` | Сам создаёт/revokes URL | Browser-only, ошибки синхронные. |
2061
+ | `downloadFileFromJson` | filename, `unknown` | `void` | JSON с 2 spaces, UTF-8 MIME | Наследует ошибки и потери `JSON.stringify`. |
2062
+ | `getOrCreatePortalRoot` | `id: string` | `HTMLElement \| null` | Создаёт `div` в body | DOM mutation; root не удаляется. |
2063
+ | `useClickOutside` | один ref/массив refs, handler | `void` | Global bubbling `mousedown` | Нет enabled/event arg/custom document. |
2064
+ | `useEscapeDismiss` | options object | `void` | enabled=true, capture `keydown` | Не управляет stack/focus; defaultPrevented игнорируется. |
2065
+ | `useElementHeightObserver` | element ref | `number` | Initial `0` | ContentRect, требует ResizeObserver. |
2066
+ | `useIntersectionObserver` | element ref, native options | `{ isIntersecting }` | Initial `false` | Только boolean, требует IntersectionObserver. |
2067
+ | `useIsTouchDevice` | Нет | `boolean` | Initial false, update на mount/resize | Capability heuristic, не текущий input mode. |
2068
+ | `useOverlayFocus` | `UseOverlayFocusOptions<T>` | `RefObject<T \| null>` | auto focus, no trap, restore=true | Не полноценный accessibility/overlay manager. |
2069
+ | `useFocusTrap` | `isOpen: boolean` | `RefObject<HTMLDivElement \| null>` | auto + trap + restore | Фиксированный compatibility wrapper. |
2070
+
2071
+ ## Вопросы и ответы
2072
+
2073
+ ### Как скачать base64?
2074
+
2075
+ Сначала преобразуйте base64 в `Blob` через модуль `/binary`, затем передайте Blob:
2076
+
2077
+ ```ts
2078
+ const blob = binaryToBlob(base64, "application/pdf", true);
2079
+ downloadFileFromBlob("report.pdf", blob);
2080
+ ```
2081
+
2082
+ ### Нужно ли самостоятельно вызывать `URL.revokeObjectURL` после `downloadFileFromBlob`?
2083
+
2084
+ Нет. Helper создаёт URL и планирует revoke самостоятельно.
2085
+
2086
+ ### Нужно ли вызывать revoke после `downloadFileFromObjectURL`?
2087
+
2088
+ Нет, если URL передан этому helper: ownership уже отдан. Не используйте URL повторно.
2089
+
2090
+ ### Можно ли передать обычный HTTPS URL?
2091
+
2092
+ API предназначен для object URL и вызывает `revokeObjectURL`. Для навигации/скачивания remote URL используйте отдельный contract с учётом same-origin, headers и browser policy.
2093
+
2094
+ ### Почему JSON export упал на объекте?
2095
+
2096
+ Наиболее частые причины — circular reference, `BigInt` или исключение в custom `toJSON`.
2097
+
2098
+ ### Создаёт ли `getOrCreatePortalRoot` React portal?
2099
+
2100
+ Нет. Он создаёт только DOM container. Portal создаётся через `createPortal`.
2101
+
2102
+ ### Почему функция вернула `null`?
2103
+
2104
+ Global `document` отсутствовал. Обычно это server/Node boundary.
2105
+
2106
+ ### Нужно ли удалять portal root при закрытии dialog?
2107
+
2108
+ Обычно application-wide root оставляют до выгрузки страницы. Helper не удаляет его. Временный root должен очищать его owner.
2109
+
2110
+ ### Почему outside handler сработал при клике по popover?
2111
+
2112
+ Portal content или trigger мог не входить в refs. Передайте все DOM-subtrees, считающиеся внутренними.
2113
+
2114
+ ### Почему outside handler сработал, пока elements ещё не mounted?
2115
+
2116
+ В array mode все null refs считаются отсутствующими внутренними областями, поэтому событие является outside. Проверяйте `open` в callback или монтируйте hook вместе с overlay component.
2117
+
2118
+ ### Почему Escape не закрыл overlay?
2119
+
2120
+ Проверьте:
2121
+
2122
+ - `active`;
2123
+ - `enabled`;
2124
+ - точное `event.key === "Escape"`;
2125
+ - target document;
2126
+ - focus внутри `containerRef`;
2127
+ - существует ли container.
2128
+
2129
+ ### Почему Escape закрыл несколько overlay?
2130
+
2131
+ Несколько hooks были active. Модуль не управляет stack и не вызывает `stopPropagation`. Активируйте только top overlay.
2132
+
2133
+ ### Можно ли отключить Escape через `documentTarget: null`?
2134
+
2135
+ Нет при наличии global document: nullish fallback выберет его. Используйте `active: false` или `enabled: false`.
2136
+
2137
+ ### Почему focus не установился на container?
2138
+
2139
+ Обычному `div` нужен `tabIndex={-1}`. Также target должен быть подключён к DOM.
2140
+
2141
+ ### Почему focus ушёл из trap программно?
2142
+
2143
+ Hook зацикливает только keyboard Tab на boundary container. Он не перехватывает произвольный `.focus()`, pointer focus или другой portal.
2144
+
2145
+ ### Почему focus восстановился до окончания анимации?
2146
+
2147
+ Restore выполняется в effect cleanup при деактивации. Для exit animation отключите встроенный restore и выполните его после фактического удаления overlay.
2148
+
2149
+ ### Почему `useElementHeightObserver` сначала вернул `0`?
2150
+
2151
+ Это initial state. Hook ждёт callback ResizeObserver.
2152
+
2153
+ ### Какую высоту возвращает observer?
2154
+
2155
+ В первую очередь `ResizeObserverEntry.contentRect.height`, обычно content box. Это не обязательно `offsetHeight` или визуальный border-box.
2156
+
2157
+ ### Почему `useIntersectionObserver` пересоздаётся?
2158
+
2159
+ Вероятно, `options` создаётся inline и имеет новую ссылку на каждом render. Стабилизируйте object.
2160
+
2161
+ ### Можно ли использовать `isIntersecting` как доказательство просмотра?
2162
+
2163
+ Нет. Это layout signal, а не гарантия человеческого внимания или бизнес-событие.
2164
+
2165
+ ### Почему touch hook сначала возвращает `false`?
2166
+
2167
+ Detection выполняется после mount в effect. Initial state равен `false`.
2168
+
2169
+ ### Можно ли по `useIsTouchDevice` определить телефон?
2170
+
2171
+ Нет. Результат означает touch/coarse capability heuristic. Он не определяет класс устройства, размер экрана или единственный input method.
2172
+
2173
+ ### Что выбрать для modal: `useFocusTrap` или `useOverlayFocus`?
2174
+
2175
+ Для нового кода обычно `useOverlayFocus`: у него есть явные options, custom initial/restore target и external ref. `useFocusTrap` — фиксированный compatibility wrapper.
2176
+
2177
+ ### Достаточно ли `useOverlayFocus({ trapFocus: true })` для доступного modal?
2178
+
2179
+ Нет. Нужны semantic role, accessible name, close command, inert/background policy, stack management, scroll policy и проверка keyboard/screen-reader сценария.