@ryuzaki13/react-foundation-lib 1.4.1 → 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 (73) hide show
  1. package/README.md +154 -100
  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/bounded-copy-stack/README.mdx +257 -0
  30. package/src/context-menu/README.mdx +326 -0
  31. package/src/currency/README.mdx +155 -0
  32. package/src/date-segments/README.mdx +319 -0
  33. package/src/error/README.mdx +95 -0
  34. package/src/error-report/README.mdx +612 -0
  35. package/src/excel/README.mdx +449 -0
  36. package/src/fingerprint/README.mdx +100 -0
  37. package/src/form/README.mdx +101 -0
  38. package/src/foundationLib.mdx +70 -54
  39. package/src/hooks/README.mdx +988 -0
  40. package/src/http/README.mdx +126 -0
  41. package/src/media/README.mdx +1853 -0
  42. package/src/notifications/README.mdx +323 -0
  43. package/src/number-scale/README.mdx +318 -0
  44. package/src/odata-service/README.mdx +2916 -0
  45. package/src/presets/README.mdx +136 -0
  46. package/src/pwa/README.mdx +394 -0
  47. package/src/query-client/README.mdx +2500 -0
  48. package/src/range-output/README.mdx +161 -0
  49. package/src/seo/README.mdx +279 -0
  50. package/src/session-storage/README.mdx +125 -0
  51. package/src/string-comparison/README.mdx +96 -0
  52. package/src/table/README.mdx +3106 -0
  53. package/src/three-scene/README.mdx +248 -0
  54. package/src/types/README.mdx +172 -0
  55. package/src/utils/README.mdx +1724 -0
  56. package/src/validators/README.mdx +239 -0
  57. package/src/virtualizer/README.mdx +138 -0
  58. package/src/xml/README.mdx +149 -0
  59. package/dist/chunks/dateUtils-B5V5aXB-.js +0 -847
  60. package/dist/chunks/dateUtils-B5V5aXB-.js.map +0 -1
  61. package/dist/chunks/odata-service-B6TxBSLm.js +0 -1277
  62. package/dist/chunks/odata-service-B6TxBSLm.js.map +0 -1
  63. package/dist/odata/buildSeparatedArrays.d.ts.map +0 -1
  64. package/dist/odata/dateUtils.d.ts.map +0 -1
  65. package/dist/odata/findCollectionPairs.d.ts.map +0 -1
  66. package/dist/odata/index.d.ts +0 -6
  67. package/dist/odata/index.d.ts.map +0 -1
  68. package/dist/odata/index.js +0 -102
  69. package/dist/odata/index.js.map +0 -1
  70. package/dist/odata/store.d.ts.map +0 -1
  71. package/dist/odata/types.d.ts.map +0 -1
  72. package/src/pwa/README.md +0 -638
  73. package/src/query-client/README.md +0 -565
@@ -0,0 +1,988 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation Lib/API/Hooks" />
4
+
5
+ # React и browser hooks
6
+
7
+ Модуль `@ryuzaki13/react-foundation-lib/hooks` содержит общие React hooks для задач, которые повторяются в разных приложениях:
8
+
9
+ - задержка часто меняющегося значения;
10
+ - debounce и throttle callback-функций;
11
+ - настройка сенсоров sortable drag-and-drop;
12
+ - управление floating listbox и клавиатурной навигацией;
13
+ - чтение и изменение search-параметров TanStack Router;
14
+ - построение ссылки на текущий route с другими search-параметрами;
15
+ - создание лениво загружаемого React-компонента;
16
+ - диагностика изменения ссылок между render;
17
+ - блокировка browser zoom через `Ctrl + wheel`.
18
+
19
+ Документ рассчитан на разработчиков с небольшой базой JavaScript, TypeScript и React. Здесь объясняются не только сигнатуры, но и lifecycle, ограничения и типичные ошибки.
20
+
21
+ > Все функции этого entrypoint являются React hooks. Их нельзя вызывать как обычные utilities, внутри условий, циклов, event handlers или вложенных функций.
22
+
23
+ ## Граница ответственности
24
+
25
+ Модуль предоставляет низкоуровневое поведение. Он не создаёт готовые кнопки, select, popup, router или drag-and-drop интерфейс.
26
+
27
+ | Задача | Ответственность |
28
+ | ---------------------------------------- | -------------------------------------------------------- |
29
+ | Таймер, debounce или throttle | `@ryuzaki13/react-foundation-lib/hooks` |
30
+ | Готовый Select, Picker или Sortable UI | соответствующий subpath `@ryuzaki13/react-foundation-ui` |
31
+ | Search schema, route и navigation policy | host-приложение и TanStack Router |
32
+ | Получение backend-данных | TanStack Query и API-слой host-приложения |
33
+ | Бизнес-правила выбора и сохранения | entity или feature host-приложения |
34
+
35
+ Если в UI-пакете уже есть готовый `Select`, `Picker` или `Sortable`, используйте его. `useFloatingListbox` и `useDndSortableSensors` нужны при создании нового предметно-независимого primitive.
36
+
37
+ ## Установка
38
+
39
+ ```bash
40
+ npm install @ryuzaki13/react-foundation-lib
41
+ ```
42
+
43
+ `react` является peer dependency пакета. Отдельным hooks также нужны:
44
+
45
+ | API | Peer dependencies |
46
+ | ---------------------------------- | ------------------------------------ |
47
+ | `useDndSortableSensors` | `@dnd-kit/core`, `@dnd-kit/sortable` |
48
+ | `useFloatingListbox` | `@floating-ui/react` |
49
+ | `useSearchParams`, `useSearchLink` | `@tanstack/react-router` |
50
+
51
+ Используйте только опубликованный subpath:
52
+
53
+ ```ts
54
+ // Правильно.
55
+ import { useDebounce } from "@ryuzaki13/react-foundation-lib/hooks";
56
+
57
+ // Неправильно: корневой entrypoint не опубликован.
58
+ // import { useDebounce } from "@ryuzaki13/react-foundation-lib";
59
+
60
+ // Неправильно: private import зависит от структуры исходников.
61
+ // import { useDebounce } from "@ryuzaki13/react-foundation-lib/src/hooks/useDebounce";
62
+ ```
63
+
64
+ ## Публичный API
65
+
66
+ Runtime hooks:
67
+
68
+ ```ts
69
+ import {
70
+ useBlockBrowserZoom,
71
+ useDebounce,
72
+ useDebouncedCallback,
73
+ useDndSortableSensors,
74
+ useFloatingListbox,
75
+ useLazyComponent,
76
+ useReferenceTrace,
77
+ useSearchLink,
78
+ useSearchParams,
79
+ useThrottledCallback
80
+ } from "@ryuzaki13/react-foundation-lib/hooks";
81
+ ```
82
+
83
+ Публичные типы:
84
+
85
+ ```ts
86
+ import type {
87
+ FloatingListboxPlacementStrategy,
88
+ FloatingListboxSizeContext,
89
+ FloatingListboxSizeResolver,
90
+ FloatingListboxSizeStyle,
91
+ ThrottledCallback,
92
+ UseDndSortableSensorsOptions
93
+ } from "@ryuzaki13/react-foundation-lib/hooks";
94
+ ```
95
+
96
+ Всего entrypoint экспортирует 16 публичных имён: 10 hooks и 6 типов.
97
+
98
+ `ParamValue`, `SearchParams`, `SearchParamsUpdater` и `UseFloatingListboxParams` используются внутри деклараций, но не экспортируются из package subpath. Не импортируйте их через private paths.
99
+
100
+ ## Правила React hooks
101
+
102
+ Вызывайте hook только на верхнем уровне React component или собственного hook:
103
+
104
+ ```tsx
105
+ function SearchPanel() {
106
+ const query = useDebounce("text", 300);
107
+ return <div>{query}</div>;
108
+ }
109
+ ```
110
+
111
+ Условный вызов запрещён:
112
+
113
+ ```tsx
114
+ function SearchPanel({ enabled }: { enabled: boolean }) {
115
+ if (enabled) {
116
+ // Неправильно: hook вызывается условно.
117
+ const query = useDebounce("text", 300);
118
+ return <div>{query}</div>;
119
+ }
120
+
121
+ return null;
122
+ }
123
+ ```
124
+
125
+ В event handler вызывайте функцию, которую вернул hook:
126
+
127
+ ```tsx
128
+ function Toolbar() {
129
+ const { call } = useDebouncedCallback((value: string) => {
130
+ console.log(value);
131
+ }, 300);
132
+
133
+ return (
134
+ <button type="button" onClick={() => call("save")}>
135
+ Сохранить
136
+ </button>
137
+ );
138
+ }
139
+ ```
140
+
141
+ В development Strict Mode может выполнить дополнительный setup/cleanup cycle. Timers и listeners очищаются, но callback с `flushOnUnmount: true` является side effect cleanup и должен быть идемпотентным.
142
+
143
+ ## Быстрый выбор
144
+
145
+ | Сценарий | API |
146
+ | ------------------------------------------------- | ----------------------- |
147
+ | Значение после паузы во вводе | `useDebounce` |
148
+ | Callback после паузы от последнего события | `useDebouncedCallback` |
149
+ | Ограничение частоты с leading и trailing вызовами | `useThrottledCallback` |
150
+ | Pointer и Keyboard sensors DnD Kit | `useDndSortableSensors` |
151
+ | Собственный доступный floating listbox | `useFloatingListbox` |
152
+ | Чтение и replace-navigation search state | `useSearchParams` |
153
+ | Построение `href` без navigation | `useSearchLink` |
154
+ | Lazy component внутри component/custom hook | `useLazyComponent` |
155
+ | Диагностика reference identity | `useReferenceTrace` |
156
+ | Блокировка `Ctrl + wheel` | `useBlockBrowserZoom` |
157
+
158
+ ## Debounce и throttle простыми словами
159
+
160
+ Debounce ждёт тишины:
161
+
162
+ ```text
163
+ события: A -- B -- C ----------------
164
+ таймер: x x |--- delay ---|
165
+ callback: C
166
+ ```
167
+
168
+ Throttle ограничивает частоту:
169
+
170
+ ```text
171
+ события: A - B - C - D - E ----------
172
+ callback: A ------- D ------- E
173
+ leading trailing
174
+ ```
175
+
176
+ ## `useDebounce`
177
+
178
+ `useDebounce` возвращает задержанную копию React-значения:
179
+
180
+ ```ts
181
+ function useDebounce<T>(value: T, delay: number): T;
182
+ ```
183
+
184
+ ### Пример
185
+
186
+ ```tsx
187
+ import { useMemo, useState } from "react";
188
+
189
+ import { useDebounce } from "@ryuzaki13/react-foundation-lib/hooks";
190
+
191
+ type User = {
192
+ id: string;
193
+ name: string;
194
+ };
195
+
196
+ function UserList({ users }: { users: readonly User[] }) {
197
+ const [draftQuery, setDraftQuery] = useState("");
198
+ const query = useDebounce(draftQuery, 300);
199
+
200
+ const visibleUsers = useMemo(() => {
201
+ const normalized = query.trim().toLowerCase();
202
+ if (!normalized) return users;
203
+ return users.filter((user) => user.name.toLowerCase().includes(normalized));
204
+ }, [query, users]);
205
+
206
+ return (
207
+ <>
208
+ <input value={draftQuery} onChange={(event) => setDraftQuery(event.target.value)} />
209
+ <ul>
210
+ {visibleUsers.map((user) => (
211
+ <li key={user.id}>{user.name}</li>
212
+ ))}
213
+ </ul>
214
+ </>
215
+ );
216
+ }
217
+ ```
218
+
219
+ ### Точное поведение
220
+
221
+ 1. На первом render возвращается исходный `value`.
222
+ 2. После изменения `value` или `delay` создаётся timeout.
223
+ 3. Новое изменение удаляет предыдущий timeout.
224
+ 4. После delay значение записывается во внутренний state.
225
+ 5. При unmount timeout очищается.
226
+
227
+ ### Ограничения
228
+
229
+ - Новый object/array reference на каждом render перезапускает timeout.
230
+ - `delay` не валидируется. Нулевое или отрицательное значение практически убирает задержку.
231
+ - Hook не отменяет network request, а только задерживает значение.
232
+ - Не копируйте debounced Query data в ещё один state.
233
+
234
+ Нестабильный object:
235
+
236
+ ```tsx
237
+ // Новый объект на каждом render.
238
+ const delayedFilters = useDebounce({ status, query }, 300);
239
+ ```
240
+
241
+ При реальной необходимости стабилизируйте input:
242
+
243
+ ```tsx
244
+ const filters = useMemo(() => ({ status, query }), [query, status]);
245
+ const delayedFilters = useDebounce(filters, 300);
246
+ ```
247
+
248
+ ## `useDebouncedCallback`
249
+
250
+ Hook откладывает callback до окончания периода тишины:
251
+
252
+ ```ts
253
+ function useDebouncedCallback<T>(
254
+ cb: (arg: T) => void,
255
+ delayMs: number,
256
+ options?: { flushOnUnmount?: boolean }
257
+ ): {
258
+ call: (arg: T) => void;
259
+ flush: () => void;
260
+ cancel: () => void;
261
+ };
262
+ ```
263
+
264
+ ### Пример
265
+
266
+ ```tsx
267
+ function DraftEditor({ onSave }: { onSave: (value: string) => void }) {
268
+ const [value, setValue] = useState("");
269
+ const { call, flush, cancel } = useDebouncedCallback(onSave, 500);
270
+
271
+ return (
272
+ <div>
273
+ <input
274
+ value={value}
275
+ onChange={(event) => {
276
+ const next = event.target.value;
277
+ setValue(next);
278
+ call(next);
279
+ }}
280
+ onBlur={flush}
281
+ />
282
+ <button type="button" onClick={cancel}>
283
+ Отменить pending save
284
+ </button>
285
+ </div>
286
+ );
287
+ }
288
+ ```
289
+
290
+ | Функция | Поведение |
291
+ | ------------- | ----------------------------------------------------- |
292
+ | `call(value)` | Сохраняет последнее значение и перезапускает timeout. |
293
+ | `flush()` | Немедленно вызывает callback с pending-значением. |
294
+ | `cancel()` | Удаляет timeout и забывает значение без callback. |
295
+
296
+ Это last-write-wins: после `call("A")`, `call("B")` и `call("C")` callback получит только `"C"`.
297
+
298
+ Актуальная `cb` хранится в ref после commit, поэтому pending timer не использует старое замыкание. `flush` и `cancel` стабильны; `call` меняется при изменении `delayMs`.
299
+
300
+ ### `flushOnUnmount`
301
+
302
+ Default cleanup выполняет `cancel()`. Для попытки применить последнее значение:
303
+
304
+ ```tsx
305
+ const deferred = useDebouncedCallback(onSave, 500, {
306
+ flushOnUnmount: true
307
+ });
308
+ ```
309
+
310
+ Callback должен быть безопасен во время cleanup и не обновлять state размонтированного component. Изменение самого `flushOnUnmount` запускает cleanup предыдущего effect, поэтому держите option стабильной.
311
+
312
+ ### Критическое ограничение `null`
313
+
314
+ `null` используется как внутренний признак «pending-значения нет». Поэтому `call(null)` не вызывает callback, хотя generic формально разрешает nullable type.
315
+
316
+ Заверните nullable value:
317
+
318
+ ```tsx
319
+ type SavePayload = {
320
+ value: string | null;
321
+ };
322
+
323
+ const { call } = useDebouncedCallback<SavePayload>((payload) => {
324
+ saveValue(payload.value);
325
+ }, 300);
326
+
327
+ call({ value: null });
328
+ ```
329
+
330
+ Изменение `delayMs` не пересчитывает уже запланированный timeout. Новый delay используется следующим `call`.
331
+
332
+ ## `useThrottledCallback`
333
+
334
+ Hook вызывает callback не чаще одного раза за interval и сохраняет последнее значение:
335
+
336
+ ```ts
337
+ function useThrottledCallback<T>(cb: (arg: T) => void, intervalMs: number, options?: { flushOnUnmount?: boolean }): ThrottledCallback<T>;
338
+
339
+ type ThrottledCallback<T> = {
340
+ call: (arg: T) => void;
341
+ flush: () => void;
342
+ cancel: () => void;
343
+ };
344
+ ```
345
+
346
+ ### Пример
347
+
348
+ ```tsx
349
+ type Point = {
350
+ x: number;
351
+ y: number;
352
+ };
353
+
354
+ function PointerTracker({ onChange }: { onChange: (point: Point) => void }) {
355
+ const { call, flush, cancel } = useThrottledCallback(onChange, 100);
356
+
357
+ return (
358
+ <div onPointerMove={(event) => call({ x: event.clientX, y: event.clientY })} onPointerUp={flush} onPointerCancel={cancel}>
359
+ Перемещайте указатель
360
+ </div>
361
+ );
362
+ }
363
+ ```
364
+
365
+ ### Точное поведение
366
+
367
+ 1. Первый обычный `call` выполняет callback сразу — leading edge.
368
+ 2. Вызовы внутри `intervalMs` сохраняют только последнее значение.
369
+ 3. В конце окна выполняется trailing callback.
370
+ 4. `flush` немедленно применяет pending value и начинает новое окно.
371
+ 5. `cancel` забывает pending value, но не сбрасывает timestamp последнего callback.
372
+
373
+ Если после `cancel` вызвать `call` до окончания текущего окна, значение всё равно будет отложено.
374
+
375
+ Ограничения:
376
+
377
+ - `null` нельзя использовать как payload; применяйте object-wrapper;
378
+ - interval не валидируется;
379
+ - изменение interval не пересчитывает pending timeout;
380
+ - `flushOnUnmount` имеет те же cleanup-риски;
381
+ - throttle не переносит тяжёлую работу в Worker.
382
+
383
+ ## Сравнение timer hooks
384
+
385
+ | Свойство | `useDebounce` | `useDebouncedCallback` | `useThrottledCallback` |
386
+ | --------------------- | ------------------ | ---------------------- | ---------------------------- |
387
+ | Результат | задержанное value | `call/flush/cancel` | `call/flush/cancel` |
388
+ | Первый callback сразу | не применимо | нет | да |
389
+ | Частые события | value после тишины | callback после тишины | leading + последнее trailing |
390
+ | Ручной flush/cancel | нет | да | да |
391
+ | `null` | допустим | недопустим | недопустим |
392
+
393
+ ## `useDndSortableSensors`
394
+
395
+ Hook создаёт стандартный набор DnD Kit sensors:
396
+
397
+ - `PointerSensor` с activation distance;
398
+ - `KeyboardSensor` с `sortableKeyboardCoordinates`.
399
+
400
+ ```ts
401
+ interface UseDndSortableSensorsOptions {
402
+ activationDistance?: number;
403
+ }
404
+
405
+ function useDndSortableSensors(distanceOrOptions?: number | UseDndSortableSensorsOptions): SensorDescriptor<SensorOptions>[];
406
+ ```
407
+
408
+ ### Использование
409
+
410
+ ```tsx
411
+ import { DndContext } from "@dnd-kit/core";
412
+
413
+ import { useDndSortableSensors } from "@ryuzaki13/react-foundation-lib/hooks";
414
+
415
+ function SortableArea({ children }: { children: React.ReactNode }) {
416
+ const sensors = useDndSortableSensors({
417
+ activationDistance: 8
418
+ });
419
+
420
+ return <DndContext sensors={sensors}>{children}</DndContext>;
421
+ }
422
+ ```
423
+
424
+ Числовой shorthand также поддерживается:
425
+
426
+ ```tsx
427
+ const sensors = useDndSortableSensors(8);
428
+ ```
429
+
430
+ Default distance равен `6` pixels. Значение `0` активирует pointer drag без минимального движения. Значение не валидируется.
431
+
432
+ Hook не создаёт `DndContext`, `SortableContext`, collision detection, modifiers, items или drag overlay. Keyboard sensor не заменяет доступный drag handle: trigger должен иметь понятное имя и keyboard focus.
433
+
434
+ ## `useSearchParams`
435
+
436
+ Hook читает search state текущего TanStack Router route и возвращает replace-navigation.
437
+
438
+ Он должен находиться внутри `RouterProvider`. Вне Router context TanStack Router выбросит ошибку.
439
+
440
+ ### Типизированный пример
441
+
442
+ ```tsx
443
+ import { useSearchParams } from "@ryuzaki13/react-foundation-lib/hooks";
444
+
445
+ type PageSearchKey = "page" | "filter" | "showAll";
446
+
447
+ function PageToolbar() {
448
+ const { params, setParams } = useSearchParams<PageSearchKey>();
449
+ const page = Number(params.page ?? "1");
450
+ const showAll = params.showAll === "X";
451
+
452
+ return (
453
+ <>
454
+ <button type="button" onClick={() => void setParams({ page: page + 1 })}>
455
+ Следующая страница
456
+ </button>
457
+ <button type="button" onClick={() => void setParams({ showAll: showAll ? "" : "X" })}>
458
+ Показать все
459
+ </button>
460
+ </>
461
+ );
462
+ }
463
+ ```
464
+
465
+ ### Возвращаемые поля
466
+
467
+ | Поле | Поведение |
468
+ | ----------------- | --------------------------------------------------------------------- |
469
+ | `params` | Полный search result текущего route в non-strict режиме. |
470
+ | `setParams(next)` | Relative navigation на `"."`, merge `prev` и `next`, `replace: true`. |
471
+ | `filteredParams` | View по `allowedKeys`; без списка совпадает с `params`. |
472
+
473
+ Merge выглядит так:
474
+
475
+ ```ts
476
+ search: (prev) => ({
477
+ ...prev,
478
+ ...next
479
+ });
480
+ ```
481
+
482
+ Ключи, которых нет в `next`, сохраняются. `string`, `number` и `boolean` передаются Router-у; окончательная сериализация зависит от search schema host-приложения.
483
+
484
+ `replace: true` заменяет текущую history entry. Browser Back не получает отдельный шаг для каждого вызова.
485
+
486
+ ### Ограничение `allowedKeys`
487
+
488
+ Runtime фильтрации существует, но текущая public signature объявляет argument как `(keyof K)[]` вместо ожидаемого `K[]`. Обычный intended вызов не компилируется:
489
+
490
+ ```tsx
491
+ type SearchKey = "page" | "filter";
492
+
493
+ // Текущий public type отвергает "page".
494
+ // useSearchParams<SearchKey>(["page"]);
495
+ ```
496
+
497
+ Не обходите это broad cast. Пока API не исправлен, вызывайте hook без списка и создавайте нужный view явно:
498
+
499
+ ```tsx
500
+ const { params } = useSearchParams<SearchKey>();
501
+
502
+ const filteredParams = {
503
+ page: params.page
504
+ };
505
+ ```
506
+
507
+ Это ограничение типов текущей версии, а не TanStack Router.
508
+
509
+ Дополнительно:
510
+
511
+ - `allowedKeys` влияет только на `filteredParams`;
512
+ - `setParams` по-прежнему меняет полный search object;
513
+ - inline `allowedKeys` создаёт новую array reference для `useMemo`;
514
+ - используйте string keys, несмотря на формальный constraint `PropertyKey`.
515
+
516
+ ## `useSearchLink`
517
+
518
+ Hook строит URL текущего route, не выполняя navigation:
519
+
520
+ ```tsx
521
+ import { useSearchLink } from "@ryuzaki13/react-foundation-lib/hooks";
522
+
523
+ type SearchKey = "page" | "filter";
524
+
525
+ function PaginationLink() {
526
+ const { buildLink } = useSearchLink<SearchKey>();
527
+
528
+ return <a href={buildLink({ page: "2" })}>Страница 2</a>;
529
+ }
530
+ ```
531
+
532
+ `buildLink(params)`:
533
+
534
+ 1. копирует `location.searchStr` в `URLSearchParams`;
535
+ 2. устанавливает переданные truthy values через `String(value)`;
536
+ 3. удаляет keys с falsy values;
537
+ 4. сохраняет pathname и hash;
538
+ 5. возвращает string, но не меняет browser URL.
539
+
540
+ Удаление:
541
+
542
+ ```tsx
543
+ const hrefWithoutFilter = buildLink({
544
+ filter: ""
545
+ });
546
+ ```
547
+
548
+ Неуказанные параметры сохраняются.
549
+
550
+ Ограничения:
551
+
552
+ - требуется Router context;
553
+ - `buildLink` создаётся заново на каждом render;
554
+ - runtime удаляет любое falsy value;
555
+ - используется raw `URLSearchParams` без route validation;
556
+ - для programmatic navigation используйте Router API или `useSearchParams`.
557
+
558
+ ## `useLazyComponent`
559
+
560
+ Hook создаёт `LazyExoticComponent` через общий `createLazyComponent` и memoizes результат по `importFn`, `componentName` и `cacheKey`.
561
+
562
+ ```ts
563
+ function useLazyComponent<TProps extends object>(
564
+ importFn: () => Promise<Record<string, ComponentType<TProps>> | null>,
565
+ componentName: string,
566
+ cacheKey?: string
567
+ ): LazyExoticComponent<ComponentType<TProps>>;
568
+ ```
569
+
570
+ ### Пример
571
+
572
+ ```tsx
573
+ import { Suspense } from "react";
574
+
575
+ import { useLazyComponent } from "@ryuzaki13/react-foundation-lib/hooks";
576
+
577
+ type ReportProps = {
578
+ reportId: string;
579
+ };
580
+
581
+ const importReportModule = () =>
582
+ import("./Report").then((module) => ({
583
+ Report: module.Report
584
+ }));
585
+
586
+ function ReportHost({ reportId }: ReportProps) {
587
+ const LazyReport = useLazyComponent<ReportProps>(importReportModule, "Report", "report");
588
+
589
+ return (
590
+ <Suspense fallback={<div>Загрузка отчёта...</div>}>
591
+ <LazyReport reportId={reportId} />
592
+ </Suspense>
593
+ );
594
+ }
595
+ ```
596
+
597
+ `Suspense` показывает loading fallback. Ошибку import/render должен обрабатывать Error Boundary.
598
+
599
+ ### Кеширование
600
+
601
+ Без `cacheKey` кеш определяется reference `importFn` и `componentName`. Inline loader создаёт новую function reference:
602
+
603
+ ```tsx
604
+ // Нежелательно без cacheKey.
605
+ const LazyReport = useLazyComponent(() => import("./Report"), "Report");
606
+ ```
607
+
608
+ Вынесите loader на уровень модуля либо задайте уникальный стабильный `cacheKey`.
609
+
610
+ С `cacheKey` используется общий ключ из cache key и component name. Одинаковая пара для разных loaders возвращает первый кешированный component. Ключ должен однозначно обозначать реальный модуль.
611
+
612
+ ### Выбор export
613
+
614
+ После import helper ищет:
615
+
616
+ 1. export `componentName`;
617
+ 2. `default` export;
618
+ 3. первый export модуля.
619
+
620
+ `null` или отсутствие подходящего component приводит к lazy error.
621
+
622
+ Если component можно объявить один раз на уровне модуля, предпочитайте utility:
623
+
624
+ ```tsx
625
+ import { createLazyComponent } from "@ryuzaki13/react-foundation-lib/utils";
626
+
627
+ const LazyReport = createLazyComponent(importReportModule, "Report", "report");
628
+ ```
629
+
630
+ Hook нужен, когда loader, name или cache key действительно зависят от component/custom hook contract.
631
+
632
+ ## `useReferenceTrace`
633
+
634
+ Diagnostic hook пишет в `console.debug` изменение object reference:
635
+
636
+ ```tsx
637
+ function Table({ columns }: { columns: readonly object[] }) {
638
+ useReferenceTrace("table columns", columns);
639
+ return <div>Колонок: {columns.length}</div>;
640
+ }
641
+ ```
642
+
643
+ После каждого committed render выводится:
644
+
645
+ ```text
646
+ [render trace] table columns
647
+ {
648
+ referenceChanged: true,
649
+ previous: ...,
650
+ current: ...
651
+ }
652
+ ```
653
+
654
+ Сравнение выполняется через `Object.is`. На первом effect previous и current совпадают. Hook не вызывает rerender; в Strict Mode development logs могут повторяться.
655
+
656
+ ### Обязательный `__DEV__`
657
+
658
+ Опубликованный runtime обращается к compile-time global `__DEV__` без fallback. Host bundler должен определить его, например:
659
+
660
+ ```ts
661
+ export default defineConfig(({ mode }) => ({
662
+ define: {
663
+ __DEV__: JSON.stringify(mode !== "production")
664
+ }
665
+ }));
666
+ ```
667
+
668
+ Без replacement effect выбросит `ReferenceError`. При `false` hook ничего не логирует.
669
+
670
+ Не передавайте secrets, tokens и персональные данные: previous/current попадают в console. Hook предназначен для временной диагностики, не для бизнес-логики.
671
+
672
+ ## `useBlockBrowserZoom`
673
+
674
+ Hook подписывается на `window` и вызывает `preventDefault()` для `wheel` с `ctrlKey === true`:
675
+
676
+ ```tsx
677
+ function DiagramViewport({ active }: { active: boolean }) {
678
+ useBlockBrowserZoom(active);
679
+ return <div>Рабочая область диаграммы</div>;
680
+ }
681
+ ```
682
+
683
+ Lifecycle:
684
+
685
+ - при `false` listener отсутствует;
686
+ - при `true` добавляется non-passive `wheel` listener;
687
+ - при смене enabled и unmount listener удаляется;
688
+ - блокируется только `Ctrl + wheel`, но не keyboard zoom shortcuts.
689
+
690
+ Browser zoom важен для accessibility. Не блокируйте его на обычных страницах, формах и текстовом UI. Допустимый сценарий — специальный diagram/canvas viewport с альтернативным управлением масштабом.
691
+
692
+ ## `useFloatingListbox`
693
+
694
+ Этот headless hook объединяет open state, Floating UI positioning/dismiss, active index, disabled options, keyboard navigation, focus, DOM refs, ARIA ids и size resolver. Он не рендерит JSX. Для обычного интерфейса предпочитайте готовые Select/Picker primitives foundation UI.
695
+
696
+ ### Параметры
697
+
698
+ | Параметр | Default | Назначение |
699
+ | ------------------------- | ---------------- | ---------------------------------------- |
700
+ | `options` | обязателен | Ordered options. |
701
+ | `selectedIndex` | обязателен | Выбранный index или `-1`. |
702
+ | `open` / `onOpenChange` | internal state | Controlled open contract. |
703
+ | `getOptionDisabled` | все доступны | Disabled predicate. |
704
+ | `onSelect` | отсутствует | Callback выбранной option. |
705
+ | `disabled` | `false` | Запрещает открытие через open helpers. |
706
+ | `placement` | `"bottom-start"` | Initial Floating UI placement. |
707
+ | `placementStrategy` | `"flip"` | `"flip"` или `"auto"`. |
708
+ | `closeOnSelect` | `true` | Закрыть после select. |
709
+ | `focusFloatingOnOpen` | `true` | Фокусировать popup после открытия. |
710
+ | `allowOpenWithoutOptions` | `false` | Разрешить пустой popup. |
711
+ | `restoreFocusOnClose` | `true` | Фокусировать reference внутри `close()`. |
712
+ | `resolveFloatingSize` | отсутствует | Вычислить CSS sizes/custom properties. |
713
+
714
+ `placement` использует `Placement` из `@floating-ui/react` и не реэкспортируется из `/hooks`.
715
+
716
+ ### Controlled режим
717
+
718
+ ```tsx
719
+ const [open, setOpen] = useState(false);
720
+
721
+ const listbox = useFloatingListbox({
722
+ options,
723
+ selectedIndex,
724
+ open,
725
+ onOpenChange: setOpen
726
+ });
727
+ ```
728
+
729
+ Передавайте `open` и `onOpenChange` вместе. Только `open` оставляет actions без рабочего setter. Только callback не меняет internal state, пока parent не вернёт controlled value.
730
+
731
+ ### Минимальный пример
732
+
733
+ ```tsx
734
+ import { useId, useState } from "react";
735
+
736
+ import { useFloatingListbox } from "@ryuzaki13/react-foundation-lib/hooks";
737
+
738
+ type City = {
739
+ id: string;
740
+ label: string;
741
+ disabled?: boolean;
742
+ };
743
+
744
+ const cities: readonly City[] = [
745
+ { id: "ekb", label: "Екатеринбург" },
746
+ { id: "msk", label: "Москва" },
747
+ { id: "spb", label: "Санкт-Петербург", disabled: true }
748
+ ];
749
+
750
+ function CityListbox() {
751
+ const listId = useId();
752
+ const [selected, setSelected] = useState<City | undefined>();
753
+ const selectedIndex = selected ? cities.findIndex((city) => city.id === selected.id) : -1;
754
+ const {
755
+ open,
756
+ activeIndex,
757
+ floatingStyles,
758
+ setReference,
759
+ setFloating,
760
+ setOptionRef,
761
+ toggleOpen,
762
+ selectOption,
763
+ handleReferenceKeyDown,
764
+ handleFloatingKeyDown,
765
+ getFloatingProps,
766
+ getOptionId,
767
+ getActiveOptionId
768
+ } = useFloatingListbox({
769
+ options: cities,
770
+ selectedIndex,
771
+ getOptionDisabled: (city) => city.disabled === true,
772
+ onSelect: setSelected
773
+ });
774
+
775
+ return (
776
+ <div>
777
+ <button
778
+ ref={setReference}
779
+ type="button"
780
+ aria-haspopup="listbox"
781
+ aria-expanded={open}
782
+ aria-controls={open ? listId : undefined}
783
+ aria-activedescendant={open ? getActiveOptionId(listId) : undefined}
784
+ onClick={toggleOpen}
785
+ onKeyDown={handleReferenceKeyDown}>
786
+ {selected?.label ?? "Выберите город"}
787
+ </button>
788
+
789
+ {open ? (
790
+ <div
791
+ ref={setFloating}
792
+ style={floatingStyles}
793
+ {...getFloatingProps({
794
+ id: listId,
795
+ tabIndex: 0,
796
+ "aria-activedescendant": getActiveOptionId(listId),
797
+ onKeyDown: handleFloatingKeyDown
798
+ })}>
799
+ {cities.map((city, index) => {
800
+ const optionDisabled = city.disabled === true;
801
+
802
+ return (
803
+ <div
804
+ key={city.id}
805
+ id={getOptionId(listId, index)}
806
+ ref={(node) => setOptionRef(index, node)}
807
+ role="option"
808
+ aria-selected={selectedIndex === index}
809
+ aria-disabled={optionDisabled || undefined}
810
+ data-active={activeIndex === index || undefined}>
811
+ <button
812
+ type="button"
813
+ tabIndex={-1}
814
+ disabled={optionDisabled}
815
+ onMouseDown={(event) => event.preventDefault()}
816
+ onClick={() => selectOption(city)}>
817
+ {city.label}
818
+ </button>
819
+ </div>
820
+ );
821
+ })}
822
+ </div>
823
+ ) : null}
824
+ </div>
825
+ );
826
+ }
827
+ ```
828
+
829
+ Production primitive дополнительно отвечает за styles, labels, validation и visible focus.
830
+
831
+ ### Возвращаемые поля
832
+
833
+ | Поле | Назначение |
834
+ | ------------------------------------------- | -------------------------------------------- |
835
+ | `open` / `activeIndex` | Resolved open и enabled active index. |
836
+ | `setOpen` | Raw setter; обходит disabled/options checks. |
837
+ | `refs` / `context` | Floating UI integration. |
838
+ | `floatingStyles` | Position styles popup. |
839
+ | `getFloatingProps` | Dismiss и listbox role props. |
840
+ | `setReference` / `setFloating` | Ref callbacks trigger и popup. |
841
+ | `setOptionRef` | Option node для `scrollIntoView`. |
842
+ | `close` | Close и optional focus restore. |
843
+ | `openList` / `openWithIndex` / `toggleOpen` | Проверяемое открытие/переключение. |
844
+ | `selectOption` / `selectActiveOption` | Select с disabled check. |
845
+ | keyboard handlers | Обработчики reference и popup root. |
846
+ | `getOptionId` / `getActiveOptionId` | ARIA ids. |
847
+
848
+ Используйте open helpers вместо raw `setOpen(true)`, который не проверяет `disabled` и отсутствие options.
849
+
850
+ ### Keyboard
851
+
852
+ | Контекст | Клавиши |
853
+ | ---------------- | ----------------------------------------------------------------- |
854
+ | Reference | ArrowDown/Up открывают или двигают; Home/End; Escape; Tab. |
855
+ | Floating root | ArrowDown/Up без wrap; Home/End; Enter/Space select; Escape; Tab. |
856
+ | Вложенный target | Hook обрабатывает только Escape. |
857
+
858
+ Disabled options пропускаются. Если options пусты или все disabled, active index равен `-1`. Typeahead по буквам отсутствует.
859
+
860
+ ### Positioning и размеры
861
+
862
+ Defaults: fixed strategy, `transform: false`, offset `4`, padding `8`, `autoUpdate`. `"auto"` сравнивает start/end варианты снизу, сверху, слева и справа.
863
+
864
+ Без resolver:
865
+
866
+ ```text
867
+ minWidth = referenceWidth + "px"
868
+ maxHeight = max(availableHeight, 120) + "px"
869
+ ```
870
+
871
+ `FloatingListboxSizeContext` содержит available, viewport и reference width/height. `FloatingListboxSizeStyle`:
872
+
873
+ ```ts
874
+ type FloatingListboxSizeStyle = {
875
+ width?: string;
876
+ minWidth?: string;
877
+ maxWidth?: string;
878
+ maxHeight?: string;
879
+ [customProperty: `--${string}`]: string | undefined;
880
+ };
881
+ ```
882
+
883
+ Пример resolver:
884
+
885
+ ```tsx
886
+ const resolveSize: FloatingListboxSizeResolver = ({ availableWidth, referenceWidth }) => ({
887
+ width: String(Math.min(Math.max(referenceWidth, 280), availableWidth)) + "px",
888
+ "--listbox-available-width": String(availableWidth) + "px"
889
+ });
890
+ ```
891
+
892
+ Resolver применяется после defaults. Старые custom properties очищаются. При открытом popup изменение `options.length` запускает дополнительный `update()`; изменение содержимого при той же длине — нет.
893
+
894
+ ### Focus и accessibility
895
+
896
+ - Popup должен быть focusable, например `tabIndex={0}`.
897
+ - `getFloatingProps` обязательно spread на popup.
898
+ - `close()` может восстановить focus reference.
899
+ - `context` подходит для `FloatingFocusManager`.
900
+ - Внешний `setOpen(false)` не проходит через focus restore `close()`.
901
+
902
+ Consumer обязан настроить trigger semantics, `aria-expanded`, `aria-controls`, `aria-activedescendant`, option ids/refs, `role="option"`, selected/disabled ARIA, label и visible focus.
903
+
904
+ ## Runtime boundaries и identity
905
+
906
+ | Hook | Requirement |
907
+ | ---------------- | -------------------------- |
908
+ | Timer hooks | React и timers. |
909
+ | DnD | DnD Kit. |
910
+ | Floating listbox | DOM и Floating UI. |
911
+ | Search hooks | TanStack `RouterProvider`. |
912
+ | Lazy component | Suspense/Error Boundary. |
913
+ | Reference trace | host-defined `__DEV__`. |
914
+ | Browser zoom | `window` и `WheelEvent`. |
915
+
916
+ Не переносите hooks в Worker или Node tooling.
917
+
918
+ `flush/cancel` timer hooks стабильны; `call` меняется вместе с delay/interval. `buildLink` и большинство методов floating listbox создаются заново на render. Не рассчитывайте на identity без contract.
919
+
920
+ ## Тестирование
921
+
922
+ Timer hooks тестируйте fake timers внутри React `act`:
923
+
924
+ ```tsx
925
+ vi.useFakeTimers();
926
+
927
+ await act(async () => {
928
+ vi.advanceTimersByTime(300);
929
+ });
930
+ ```
931
+
932
+ Проверяйте leading/trailing, last-write-wins, flush, cancel, unmount, новую callback и nullable wrapper.
933
+
934
+ Search hooks требуют test Router. Floating listbox требует DOM и проверок ARIA, keyboard, disabled options, dismiss, focus, dynamic options и size resolver.
935
+
936
+ Repository test покрывает auto placement, update после изменения количества options и актуальный resolver после rerender.
937
+
938
+ ## Частые ошибки
939
+
940
+ | Симптом | Причина / решение |
941
+ | ----------------------------------- | ---------------------------------------------- |
942
+ | Invalid hook call | Вызов вне component/custom hook или условно. |
943
+ | Callback не получил `null` | Передайте `{ value: null }`. |
944
+ | Throttle ждёт после cancel | Cancel не сбрасывает timestamp. |
945
+ | Search allowedKeys не компилируется | Фильтруйте result явно до исправления generic. |
946
+ | Escape/outside click не работают | Не применён `getFloatingProps`. |
947
+ | Active option не scroll-ится | Нет `setOptionRef`. |
948
+ | Popup не фокусируется | Нет `setFloating` или `tabIndex`. |
949
+ | Controlled popup не закрывается | Нужны и `open`, и `onOpenChange`. |
950
+ | Lazy component remount-ится | Стабилизируйте loader/cacheKey. |
951
+ | `__DEV__ is not defined` | Настройте host bundler. |
952
+
953
+ ## API-справочник
954
+
955
+ | Экспорт | Contract |
956
+ | ---------------------------------- | ----------------------------------- |
957
+ | `useBlockBrowserZoom` | Global `Ctrl + wheel` prevention. |
958
+ | `useDebounce` | Debounced value. |
959
+ | `useDebouncedCallback` | Trailing debounce. |
960
+ | `useDndSortableSensors` | Pointer/Keyboard sensors. |
961
+ | `useFloatingListbox` | Headless listbox. |
962
+ | `useLazyComponent` | Memoized lazy factory. |
963
+ | `useReferenceTrace` | Reference diagnostics. |
964
+ | `useSearchLink` | Link builder. |
965
+ | `useSearchParams` | Search read/replace navigation. |
966
+ | `useThrottledCallback` | Leading/trailing throttle. |
967
+ | `FloatingListboxPlacementStrategy` | `"flip" \| "auto"`. |
968
+ | `FloatingListboxSizeContext` | Геометрия popup/reference/viewport. |
969
+ | `FloatingListboxSizeResolver` | Context → style. |
970
+ | `FloatingListboxSizeStyle` | Size fields и CSS properties. |
971
+ | `ThrottledCallback` | `call/flush/cancel`. |
972
+ | `UseDndSortableSensorsOptions` | `activationDistance`. |
973
+
974
+ ## Что модуль не делает
975
+
976
+ - не выполняет requests и не хранит backend state;
977
+ - не создаёт route search schema;
978
+ - не рендерит готовый select или DnD UI;
979
+ - не валидирует intervals/distance;
980
+ - не гарантирует accessibility без JSX consumer-а;
981
+ - не экспортирует внутренние search types.
982
+
983
+ ## Связанные документы
984
+
985
+ - [Обзор пакета](../../README.md)
986
+ - [DOM, focus и overlay helpers](../dom/README.mdx)
987
+ - [Clipboard hooks](../copy/README.mdx)
988
+ - [Public utilities](../utils/index.ts)