@ryuzaki13/react-foundation-lib 1.3.1 → 1.4.1

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 (38) hide show
  1. package/README.md +20 -16
  2. package/dist/table/index.d.ts +1 -0
  3. package/dist/table/index.d.ts.map +1 -1
  4. package/dist/table/index.js +77 -64
  5. package/dist/table/index.js.map +1 -1
  6. package/dist/table/tanStackFeatures.d.ts +19 -0
  7. package/dist/table/tanStackFeatures.d.ts.map +1 -0
  8. package/dist/table/types.d.ts +17 -2
  9. package/dist/table/types.d.ts.map +1 -1
  10. package/package.json +9 -9
  11. package/src/array/README.mdx +1630 -0
  12. package/src/binary/README.mdx +1405 -0
  13. package/src/copy/README.mdx +992 -0
  14. package/src/crypto/README.mdx +1052 -0
  15. package/src/dom/README.mdx +2179 -0
  16. package/src/file/README.mdx +1957 -0
  17. package/src/formatters/README.mdx +469 -0
  18. package/src/formatters/boolean/README.mdx +494 -0
  19. package/src/formatters/date/README.mdx +732 -0
  20. package/src/formatters/number/README.mdx +542 -0
  21. package/src/formatters/pipeline/README.mdx +629 -0
  22. package/src/formatters/rowBased/README.mdx +486 -0
  23. package/src/formatters/strings/README.mdx +442 -0
  24. package/src/formatters/valueState/README.mdx +459 -0
  25. package/src/formulas/README.mdx +1283 -0
  26. package/src/binary/README.md +0 -110
  27. package/src/docs/browserAndReact.mdx +0 -150
  28. package/src/docs/filesAndExcel.mdx +0 -163
  29. package/src/docs/formatters-runtime.mdx +0 -455
  30. package/src/docs/formatters.mdx +0 -380
  31. package/src/docs/odataAndTables.mdx +0 -178
  32. package/src/docs/pureUtilities.mdx +0 -96
  33. package/src/docs/runtimeAndState.mdx +0 -155
  34. package/src/file/README.md +0 -289
  35. package/src/formatters/date/README.md +0 -520
  36. package/src/formatters/number/README.md +0 -123
  37. package/src/formatters/valueState/README.md +0 -275
  38. package/src/formulas/README.md +0 -42
@@ -0,0 +1,992 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation Lib/API/Copy" />
4
+
5
+ # Копирование текста через `@ryuzaki13/react-foundation-lib/copy`
6
+
7
+ Модуль `copy` содержит React hooks для копирования текста в системный буфер обмена, чтения текста из DOM-элемента и показа короткого состояния «скопировано».
8
+
9
+ Основные сценарии:
10
+
11
+ - скопировать готовую строку;
12
+ - скопировать текст, который сейчас находится внутри элемента;
13
+ - на две секунды заменить подпись или иконку кнопки после успешного копирования.
14
+
15
+ Модуль ориентирован на браузер. Он скрывает технический выбор между современным Clipboard API и legacy fallback, но не отменяет браузерные разрешения и ограничения безопасности.
16
+
17
+ README рассчитан на разработчика, который использует опубликованный пакет без доступа к исходному коду. Здесь объясняются базовые понятия React и DOM, точное поведение каждого hook, обработка ошибок, доступность, тестирование и известные ограничения.
18
+
19
+ ## Содержание
20
+
21
+ - [Назначение и границы модуля](#назначение-и-границы-модуля)
22
+ - [Установка и импорт](#установка-и-импорт)
23
+ - [Минимальные понятия](#минимальные-понятия)
24
+ - [Как выбрать API](#как-выбрать-api)
25
+ - [Быстрый старт](#быстрый-старт)
26
+ - [`useCopyText`](#usecopytext)
27
+ - [`useElementText`](#useelementtext)
28
+ - [`useCopyElementText`](#usecopyelementtext)
29
+ - [`useCopyFeedback`](#usecopyfeedback)
30
+ - [`useCopyElementTextWithFeedback`](#usecopyelementtextwithfeedback)
31
+ - [Как выбирается способ копирования](#как-выбирается-способ-копирования)
32
+ - [Готовые рецепты](#готовые-рецепты)
33
+ - [Доступность и UX](#доступность-и-ux)
34
+ - [SSR и browser boundary](#ssr-и-browser-boundary)
35
+ - [Тестирование](#тестирование)
36
+ - [Ограничения и частые ошибки](#ограничения-и-частые-ошибки)
37
+ - [Краткий справочник API](#краткий-справочник-api)
38
+ - [Вопросы и ответы](#вопросы-и-ответы)
39
+
40
+ ## Назначение и границы модуля
41
+
42
+ ### Что делает `copy`
43
+
44
+ - копирует непустую строку через `navigator.clipboard.writeText`, когда это возможно;
45
+ - использует fallback через временный DOM-элемент и `document.execCommand("copy")`, если современный Clipboard API недоступен или страница не является secure context;
46
+ - возвращает `Promise<boolean>`, чтобы вызывающий код узнал об успехе или неудаче;
47
+ - читает актуальный `textContent` DOM-элемента через React ref;
48
+ - объединяет чтение элемента и копирование;
49
+ - предоставляет двухсекундное состояние `isCopied` для UI-feedback.
50
+
51
+ ### Чего модуль не делает
52
+
53
+ - не читает системный буфер обмена;
54
+ - не копирует изображения, файлы, HTML или произвольные MIME-данные;
55
+ - не запрашивает разрешение отдельным диалогом и не обходит политику браузера;
56
+ - не гарантирует работу вне пользовательского действия;
57
+ - не показывает toast, tooltip или иконку самостоятельно;
58
+ - не предоставляет React-компонент кнопки;
59
+ - не копирует `value` из `<input>` или `<textarea>` через element hooks;
60
+ - не сообщает причину ошибки отдельным объектом;
61
+ - не повторяет операцию автоматически;
62
+ - не синхронизирует feedback между несколькими кнопками.
63
+
64
+ ### Соседние задачи
65
+
66
+ | Задача | Что использовать |
67
+ | ------------------------------------- | ------------------------------------------------------ |
68
+ | Скопировать текст | `@ryuzaki13/react-foundation-lib/copy` |
69
+ | Скачать готовый `Blob` или JSON-файл | `@ryuzaki13/react-foundation-lib/dom` |
70
+ | Прочитать пользовательский файл | `@ryuzaki13/react-foundation-lib/file` |
71
+ | Декодировать base64 в `Blob` | `@ryuzaki13/react-foundation-lib/binary` |
72
+ | Реализовать визуальную кнопку/tooltip | UI-компонент приложения или foundation UI-пакет |
73
+ | Скопировать изображение или rich HTML | Отдельная реализация Clipboard API для нужного формата |
74
+
75
+ ## Установка и импорт
76
+
77
+ Установите пакет:
78
+
79
+ ```bash
80
+ npm install @ryuzaki13/react-foundation-lib
81
+ ```
82
+
83
+ Модуль использует React hooks. Приложение должно предоставить совместимые peer-зависимости React:
84
+
85
+ ```bash
86
+ npm install react react-dom
87
+ ```
88
+
89
+ Текущий контракт пакета требует React `>=19.2.0 <20.0.0`.
90
+
91
+ Импортируйте API только из опубликованного subpath `/copy`:
92
+
93
+ ```ts
94
+ import {
95
+ useCopyElementText,
96
+ useCopyElementTextWithFeedback,
97
+ useCopyFeedback,
98
+ useCopyText,
99
+ useElementText
100
+ } from "@ryuzaki13/react-foundation-lib/copy";
101
+ ```
102
+
103
+ Корневой импорт пакета не поддерживается:
104
+
105
+ ```ts
106
+ // Неправильно.
107
+ import { useCopyText } from "@ryuzaki13/react-foundation-lib";
108
+
109
+ // Правильно.
110
+ import { useCopyText } from "@ryuzaki13/react-foundation-lib/copy";
111
+ ```
112
+
113
+ Модуль поставляется как ESM и использует browser APIs:
114
+
115
+ - `navigator.clipboard`;
116
+ - `window.isSecureContext`;
117
+ - `window.getSelection`;
118
+ - `document.createElement`;
119
+ - `document.createRange`;
120
+ - `document.execCommand`.
121
+
122
+ ## Минимальные понятия
123
+
124
+ ### Буфер обмена
125
+
126
+ Буфер обмена — системное временное хранилище, которое используется командами Copy и Paste. После успешного вызова пользователь сможет вставить скопированную строку в другое поле или приложение.
127
+
128
+ ### React hook
129
+
130
+ Hook — функция React, имя которой начинается с `use`. Hook вызывают только:
131
+
132
+ - на верхнем уровне React-компонента;
133
+ - внутри другого custom hook.
134
+
135
+ Нельзя вызывать hook внутри `if`, цикла, обработчика клика или обычной utility-функции:
136
+
137
+ ```tsx
138
+ function CopyButton() {
139
+ // Правильно: hook вызывается при рендере компонента.
140
+ const { copyToClipboard } = useCopyText();
141
+
142
+ const handleClick = async () => {
143
+ // Правильно: обработчик вызывает функцию, полученную из hook.
144
+ await copyToClipboard("Текст");
145
+ };
146
+
147
+ return (
148
+ <button type="button" onClick={() => void handleClick()}>
149
+ Копировать
150
+ </button>
151
+ );
152
+ }
153
+ ```
154
+
155
+ ### Асинхронный результат и `Promise<boolean>`
156
+
157
+ Копирование может завершиться не сразу. Поэтому функции возвращают `Promise<boolean>`:
158
+
159
+ ```ts
160
+ const success = await copyToClipboard("Текст");
161
+ ```
162
+
163
+ - `true` — операция сообщила об успехе;
164
+ - `false` — строка была пустой либо браузерный способ копирования не сработал.
165
+
166
+ Не проверяйте сам `Promise` как boolean:
167
+
168
+ ```ts
169
+ // Неправильно: result является Promise, а не итоговым boolean.
170
+ const result = copyToClipboard("Текст");
171
+ if (result) {
172
+ // Эта ветка не означает успешное копирование.
173
+ }
174
+
175
+ // Правильно.
176
+ const success = await copyToClipboard("Текст");
177
+ if (success) {
178
+ // Копирование завершилось успешно.
179
+ }
180
+ ```
181
+
182
+ ### React ref
183
+
184
+ Ref хранит ссылку на реальный DOM-элемент:
185
+
186
+ ```tsx
187
+ const ref = useRef<HTMLSpanElement>(null);
188
+
189
+ return <span ref={ref}>Текст</span>;
190
+ ```
191
+
192
+ До монтирования элемента `ref.current` равен `null`. После монтирования React помещает туда `HTMLSpanElement`.
193
+
194
+ ### `textContent`
195
+
196
+ Element hooks читают свойство `textContent`. Оно объединяет текст самого элемента и всех его потомков:
197
+
198
+ ```html
199
+ <span>Заказ <strong>№ 42</strong></span>
200
+ ```
201
+
202
+ Результат:
203
+
204
+ ```text
205
+ Заказ № 42
206
+ ```
207
+
208
+ Это не то же самое, что визуально отрисованный текст:
209
+
210
+ - CSS не участвует в формировании результата;
211
+ - текст скрытого потомка тоже может попасть в результат;
212
+ - подписи вложенных кнопок тоже попадают в результат;
213
+ - `value` у `<input>` и `<textarea>` не является их `textContent`;
214
+ - переносы, созданные только layout/CSS, не добавляются автоматически.
215
+
216
+ ### Secure context
217
+
218
+ Современный Clipboard API доступен только в безопасном контексте. Обычно это:
219
+
220
+ - страница по HTTPS;
221
+ - `localhost` в режиме разработки;
222
+ - среда, которую браузер отдельно признаёт доверенной.
223
+
224
+ Обычная production-страница по HTTP обычно не является secure context.
225
+
226
+ ### Пользовательское действие
227
+
228
+ Браузер может разрешать запись в буфер только во время явного действия пользователя: клика, нажатия клавиши или команды меню. Поэтому вызывайте копирование непосредственно из обработчика такого действия.
229
+
230
+ ## Как выбрать API
231
+
232
+ | Задача | API |
233
+ | ------------------------------------------------------- | ---------------------------------- |
234
+ | Скопировать уже имеющуюся строку | `useCopyText` |
235
+ | Прочитать `textContent` из собственного ref | `useElementText` |
236
+ | Получить готовый ref и скопировать текст элемента | `useCopyElementText` |
237
+ | Только показать состояние «готово» на две секунды | `useCopyFeedback` |
238
+ | Скопировать текст элемента и показать `isCopied` | `useCopyElementTextWithFeedback` |
239
+ | Скопировать текущее значение `<input>` или `<textarea>` | `useCopyText` с переменной `value` |
240
+ | Скопировать строку, которой ещё нет в DOM | `useCopyText` |
241
+ | Копировать HTML, изображение или файл | Этот модуль не подходит |
242
+
243
+ ## Быстрый старт
244
+
245
+ ### Копирование готовой строки
246
+
247
+ ```tsx
248
+ import { useState } from "react";
249
+ import { useCopyText } from "@ryuzaki13/react-foundation-lib/copy";
250
+
251
+ export function CopyOrderNumber() {
252
+ const { copyToClipboard } = useCopyText();
253
+ const [message, setMessage] = useState("");
254
+
255
+ const handleCopy = async () => {
256
+ const success = await copyToClipboard("ORDER-42");
257
+ setMessage(success ? "Номер скопирован" : "Не удалось скопировать");
258
+ };
259
+
260
+ return (
261
+ <div>
262
+ <button type="button" onClick={() => void handleCopy()}>
263
+ Копировать номер
264
+ </button>
265
+ <span aria-live="polite">{message}</span>
266
+ </div>
267
+ );
268
+ }
269
+ ```
270
+
271
+ ### Копирование текста элемента с feedback
272
+
273
+ ```tsx
274
+ import { useCopyElementTextWithFeedback } from "@ryuzaki13/react-foundation-lib/copy";
275
+
276
+ export function CopyAddress() {
277
+ const { containerRef, copyElementText, isCopied } = useCopyElementTextWithFeedback<HTMLSpanElement>();
278
+
279
+ return (
280
+ <div>
281
+ <span ref={containerRef}>г. Екатеринбург, ул. Ленина, 1</span>
282
+ <button type="button" onClick={() => void copyElementText()}>
283
+ {isCopied ? "Скопировано" : "Копировать адрес"}
284
+ </button>
285
+ <span className="visually-hidden" aria-live="polite">
286
+ {isCopied ? "Адрес скопирован" : ""}
287
+ </span>
288
+ </div>
289
+ );
290
+ }
291
+ ```
292
+
293
+ Ref установлен только на `<span>` с адресом. Если поставить его на общий `<div>`, в копируемую строку может попасть текст кнопки и live region.
294
+
295
+ ## `useCopyText`
296
+
297
+ Копирует переданную строку и возвращает результат операции.
298
+
299
+ ### Сигнатура
300
+
301
+ ```ts
302
+ declare const useCopyText: () => {
303
+ copyToClipboard: (text: string) => Promise<boolean>;
304
+ };
305
+ ```
306
+
307
+ ### Параметр `text`
308
+
309
+ `text` — строка, которую нужно поместить в буфер обмена.
310
+
311
+ ```ts
312
+ await copyToClipboard("INV-2026-0001");
313
+ ```
314
+
315
+ ### Возвращаемое значение
316
+
317
+ Hook возвращает объект с функцией `copyToClipboard`.
318
+
319
+ Сама функция возвращает:
320
+
321
+ - `true`, если `navigator.clipboard.writeText` завершился без ошибки;
322
+ - результат `document.execCommand("copy")`, если был выбран fallback;
323
+ - `false`, если строка пустая, состоит только из whitespace или произошла ошибка.
324
+
325
+ Функция стабильна между рендерами компонента: она создаётся через `useCallback` без зависимостей.
326
+
327
+ ### Проверка пустой строки
328
+
329
+ Перед копированием выполняется проверка `text.trim()`:
330
+
331
+ ```ts
332
+ await copyToClipboard(""); // false
333
+ await copyToClipboard(" "); // false
334
+ await copyToClipboard("\n\t"); // false
335
+ ```
336
+
337
+ `trim()` используется только для проверки. Непустой исходный текст копируется без обрезки:
338
+
339
+ ```ts
340
+ await copyToClipboard(" ABC \n");
341
+ ```
342
+
343
+ В буфер попадёт строка с двумя пробелами в начале, двумя пробелами и переводом строки в конце.
344
+
345
+ ### Обработка ошибок
346
+
347
+ Hook не выбрасывает ошибку копирования вызывающему коду. Он возвращает `false`.
348
+
349
+ Если современный `navigator.clipboard.writeText` выбросил ошибку, она дополнительно записывается в консоль:
350
+
351
+ ```text
352
+ Failed to copy text: <исходная ошибка>
353
+ ```
354
+
355
+ Ошибки legacy fallback поглощаются без записи в консоль.
356
+
357
+ ### Важное правило fallback
358
+
359
+ Fallback выбирается только заранее, если Clipboard API отсутствует или `window.isSecureContext` равен `false`.
360
+
361
+ Если современный `writeText` существует, но отклоняет операцию из-за разрешения, iframe policy или отсутствия user activation, повторной попытки через `execCommand` не будет. Итогом станет `false`.
362
+
363
+ ### Пример с полем ввода
364
+
365
+ Для `<input>` передавайте его `value` напрямую:
366
+
367
+ ```tsx
368
+ import { useState } from "react";
369
+ import { useCopyText } from "@ryuzaki13/react-foundation-lib/copy";
370
+
371
+ export function CopyInputValue() {
372
+ const [value, setValue] = useState("Пользовательский текст");
373
+ const { copyToClipboard } = useCopyText();
374
+
375
+ return (
376
+ <label>
377
+ Текст
378
+ <input value={value} onChange={(event) => setValue(event.target.value)} />
379
+ <button type="button" onClick={() => void copyToClipboard(value)}>
380
+ Копировать
381
+ </button>
382
+ </label>
383
+ );
384
+ }
385
+ ```
386
+
387
+ Не используйте для этого `useCopyElementText<HTMLInputElement>`: `textContent` input-элемента не содержит введённое `value`.
388
+
389
+ ## `useElementText`
390
+
391
+ Возвращает функцию, которая читает актуальный `textContent` переданного DOM ref.
392
+
393
+ ### Сигнатура
394
+
395
+ ```ts
396
+ declare const useElementText: <T extends HTMLElement = HTMLElement>(
397
+ ref: RefObject<T | null>
398
+ ) => {
399
+ getElementText: () => string;
400
+ };
401
+ ```
402
+
403
+ ### Параметр
404
+
405
+ `ref` — React ref на `HTMLElement` или его конкретный тип.
406
+
407
+ ```tsx
408
+ const paragraphRef = useRef<HTMLParagraphElement>(null);
409
+ const { getElementText } = useElementText(paragraphRef);
410
+ ```
411
+
412
+ Generic `T` помогает TypeScript проверить, к какому элементу прикрепляется ref. Если конкретный тип не важен, используется `HTMLElement`.
413
+
414
+ ### Точное поведение
415
+
416
+ При каждом вызове `getElementText()` hook:
417
+
418
+ 1. читает текущее `ref.current`;
419
+ 2. читает `ref.current.textContent`;
420
+ 3. обрезает whitespace по краям через `trim()`;
421
+ 4. возвращает пустую строку, если ref ещё не установлен или текста нет.
422
+
423
+ ```tsx
424
+ const text = getElementText();
425
+ ```
426
+
427
+ Текст не сохраняется во время рендера. Он читается именно в момент вызова, поэтому изменения DOM до клика будут учтены.
428
+
429
+ ### Пример без копирования
430
+
431
+ ```tsx
432
+ import { useRef, useState } from "react";
433
+ import { useElementText } from "@ryuzaki13/react-foundation-lib/copy";
434
+
435
+ export function InspectText() {
436
+ const ref = useRef<HTMLDivElement>(null);
437
+ const { getElementText } = useElementText(ref);
438
+ const [result, setResult] = useState("");
439
+
440
+ return (
441
+ <div>
442
+ <div ref={ref}>
443
+ Заказ <strong>№ 42</strong>
444
+ </div>
445
+ <button type="button" onClick={() => setResult(getElementText())}>
446
+ Прочитать DOM-текст
447
+ </button>
448
+ <output>{result}</output>
449
+ </div>
450
+ );
451
+ }
452
+ ```
453
+
454
+ ### `textContent` и скрытый текст
455
+
456
+ Если внутри контейнера есть скрытая подпись, она всё равно может быть прочитана:
457
+
458
+ ```tsx
459
+ <span ref={ref}>
460
+ ABC
461
+ <span className="visually-hidden">Служебное пояснение</span>
462
+ </span>
463
+ ```
464
+
465
+ Результат будет содержать и `ABC`, и «Служебное пояснение». Если копировать нужно строго определённое значение, безопаснее передать эту строку в `useCopyText`.
466
+
467
+ ## `useCopyElementText`
468
+
469
+ Создаёт ref, читает `textContent` соответствующего элемента и копирует его.
470
+
471
+ ### Сигнатура
472
+
473
+ ```ts
474
+ declare const useCopyElementText: <T extends HTMLElement = HTMLElement>() => {
475
+ containerRef: RefObject<T | null>;
476
+ copyElementText: () => Promise<boolean>;
477
+ getElementText: () => string;
478
+ };
479
+ ```
480
+
481
+ ### Возвращаемые поля
482
+
483
+ | Поле | Тип | Назначение |
484
+ | ----------------- | ------------------------ | ---------------------------------------------- |
485
+ | `containerRef` | `RefObject<T \| null>` | Установить на элемент с копируемым текстом. |
486
+ | `copyElementText` | `() => Promise<boolean>` | Прочитать элемент и скопировать его текст. |
487
+ | `getElementText` | `() => string` | Только получить текущий текст без копирования. |
488
+
489
+ ### Алгоритм
490
+
491
+ `copyElementText()`:
492
+
493
+ 1. вызывает `getElementText()`;
494
+ 2. получает trimmed `textContent`;
495
+ 3. возвращает `false`, если результат пустой;
496
+ 4. иначе передаёт строку в `copyToClipboard` из `useCopyText`;
497
+ 5. возвращает итоговый boolean.
498
+
499
+ Поскольку `useElementText` уже применил `trim()`, при копировании из элемента внешний whitespace удаляется. Это отличается от прямого `useCopyText`, который сохраняет внешний whitespace непустой строки.
500
+
501
+ ### Пример с проверкой результата
502
+
503
+ ```tsx
504
+ import { useState } from "react";
505
+ import { useCopyElementText } from "@ryuzaki13/react-foundation-lib/copy";
506
+
507
+ export function CopyCustomerName() {
508
+ const { containerRef, copyElementText } = useCopyElementText<HTMLSpanElement>();
509
+ const [status, setStatus] = useState("");
510
+
511
+ const handleCopy = async () => {
512
+ const success = await copyElementText();
513
+ setStatus(success ? "Имя скопировано" : "Копирование недоступно");
514
+ };
515
+
516
+ return (
517
+ <div>
518
+ <span ref={containerRef}>Анна Иванова</span>
519
+ <button type="button" onClick={() => void handleCopy()}>
520
+ Копировать имя
521
+ </button>
522
+ <span aria-live="polite">{status}</span>
523
+ </div>
524
+ );
525
+ }
526
+ ```
527
+
528
+ ### Где ставить ref
529
+
530
+ Ставьте `containerRef` на самый узкий элемент, содержащий только нужный текст:
531
+
532
+ ```tsx
533
+ // Правильно: кнопка находится вне копируемого элемента.
534
+ <div>
535
+ <span ref={containerRef}>{code}</span>
536
+ <button type="button" onClick={() => void copyElementText()}>
537
+ Копировать
538
+ </button>
539
+ </div>
540
+ ```
541
+
542
+ ```tsx
543
+ // Рискованно: подпись кнопки тоже является textContent общего div.
544
+ <div ref={containerRef}>
545
+ <span>{code}</span>
546
+ <button type="button" onClick={() => void copyElementText()}>
547
+ Копировать
548
+ </button>
549
+ </div>
550
+ ```
551
+
552
+ ## `useCopyFeedback`
553
+
554
+ Предоставляет boolean-состояние, которое становится `true` и автоматически переключается обратно в `false` через 2000 миллисекунд.
555
+
556
+ ### Сигнатура
557
+
558
+ ```ts
559
+ declare const useCopyFeedback: () => {
560
+ isCopied: boolean;
561
+ showFeedback: () => () => void;
562
+ };
563
+ ```
564
+
565
+ ### Точное поведение
566
+
567
+ - начальное значение `isCopied` — `false`;
568
+ - `showFeedback()` сразу запрашивает установку `isCopied = true`;
569
+ - затем создаёт timer на 2000 мс;
570
+ - timer устанавливает `isCopied = false`;
571
+ - `showFeedback()` возвращает cleanup-функцию, отменяющую именно созданный timer.
572
+
573
+ ```tsx
574
+ const { isCopied, showFeedback } = useCopyFeedback();
575
+
576
+ const handleAction = () => {
577
+ showFeedback();
578
+ };
579
+ ```
580
+
581
+ Hook ничего не копирует. Название `isCopied` описывает типовой UI-сценарий, но вызывающий код сам решает, когда вызывать `showFeedback`.
582
+
583
+ ### Cleanup-функция
584
+
585
+ Результат `showFeedback()` имеет тип `() => void`:
586
+
587
+ ```ts
588
+ const cancelTimer = showFeedback();
589
+ cancelTimer();
590
+ ```
591
+
592
+ Cleanup только отменяет переход в `false`. Он не сбрасывает `isCopied` сразу. Если вызвать cleanup, пока компонент продолжает жить, состояние может остаться `true` до другого обновления. Поэтому не используйте cleanup как команду «скрыть сообщение».
593
+
594
+ Текущая реализация hook не сохраняет cleanup автоматически и не отменяет timer при unmount. Это важно для компонентов с очень коротким жизненным циклом.
595
+
596
+ ### Повторные вызовы
597
+
598
+ Каждый вызов `showFeedback()` создаёт отдельный timer. Timers не заменяют друг друга.
599
+
600
+ Если вызвать функцию второй раз через одну секунду, timer первого вызова всё равно сможет установить `false` ещё через одну секунду. Поэтому feedback не гарантирует «ровно две секунды после последнего вызова».
601
+
602
+ Для редких пользовательских кликов это обычно незаметно. Для частых программных вызовов нужен отдельный feedback hook с last-write-wins timer policy.
603
+
604
+ ## `useCopyElementTextWithFeedback`
605
+
606
+ Объединяет `useCopyElementText` и `useCopyFeedback`.
607
+
608
+ ### Сигнатура
609
+
610
+ ```ts
611
+ declare const useCopyElementTextWithFeedback: <T extends HTMLElement = HTMLElement>() => {
612
+ containerRef: RefObject<T | null>;
613
+ copyElementText: () => Promise<boolean>;
614
+ getElementText: () => string;
615
+ isCopied: boolean;
616
+ };
617
+ ```
618
+
619
+ ### Точное поведение
620
+
621
+ При вызове `copyElementText()`:
622
+
623
+ 1. читается trimmed `textContent` элемента;
624
+ 2. выполняется попытка копирования;
625
+ 3. если получен `true`, вызывается `showFeedback()`;
626
+ 4. `isCopied` становится `true` примерно на две секунды;
627
+ 5. возвращается тот же boolean-результат копирования.
628
+
629
+ При `false` feedback не показывается.
630
+
631
+ ### Пример с иконкой и доступной подписью
632
+
633
+ ```tsx
634
+ import { useCopyElementTextWithFeedback } from "@ryuzaki13/react-foundation-lib/copy";
635
+
636
+ export function CopyReference({ value }: { value: string }) {
637
+ const { containerRef, copyElementText, isCopied } = useCopyElementTextWithFeedback<HTMLCodeElement>();
638
+
639
+ return (
640
+ <div>
641
+ <code ref={containerRef}>{value}</code>
642
+ <button type="button" onClick={() => void copyElementText()} aria-label={isCopied ? "Скопировано" : "Копировать значение"}>
643
+ {isCopied ? "✓" : "⧉"}
644
+ </button>
645
+ <span className="visually-hidden" aria-live="polite">
646
+ {isCopied ? "Значение скопировано" : ""}
647
+ </span>
648
+ </div>
649
+ );
650
+ }
651
+ ```
652
+
653
+ Hook наследует ограничения `useCopyFeedback`: timers не объединяются, cleanup не вызывается автоматически при unmount.
654
+
655
+ ## Как выбирается способ копирования
656
+
657
+ Упрощённый алгоритм `useCopyText`:
658
+
659
+ ```text
660
+ text.trim() пустой?
661
+ ├─ да → false
662
+ └─ нет
663
+ ├─ navigator.clipboard существует И window.isSecureContext === true
664
+ │ ├─ writeText завершился → true
665
+ │ └─ writeText выбросил ошибку → console.error + false
666
+ └─ иначе
667
+ ├─ execCommand("copy") вернул true → true
668
+ └─ вернул false или произошла ошибка → false
669
+ ```
670
+
671
+ ### Современный путь
672
+
673
+ Используется:
674
+
675
+ ```ts
676
+ await navigator.clipboard.writeText(text);
677
+ ```
678
+
679
+ На результат могут влиять:
680
+
681
+ - HTTPS/secure context;
682
+ - user activation;
683
+ - разрешение браузера;
684
+ - Permissions Policy родительской страницы;
685
+ - настройки iframe;
686
+ - настройки пользователя или корпоративного браузера.
687
+
688
+ ### Legacy fallback
689
+
690
+ Fallback:
691
+
692
+ 1. создаёт невидимый `contentEditable` `<div>`;
693
+ 2. помещает его в `document.body`;
694
+ 3. записывает строку в `textContent`;
695
+ 4. выделяет содержимое через `Range` и `Selection`;
696
+ 5. вызывает `document.execCommand("copy")`;
697
+ 6. при обычном завершении снимает выделение и удаляет временный элемент.
698
+
699
+ `document.execCommand` является устаревшим и browser-dependent API. Наличие fallback не означает поддержку любого старого браузера.
700
+
701
+ Если внутри fallback произойдёт исключение после добавления временного элемента, текущая реализация не использует `finally`. В таком редком случае она вернёт `false`, но не гарантирует очистку временного элемента и selection.
702
+
703
+ ## Готовые рецепты
704
+
705
+ ### Копирование вычисленного значения
706
+
707
+ ```tsx
708
+ function CopyCoordinates({ latitude, longitude }: { latitude: number; longitude: number }) {
709
+ const { copyToClipboard } = useCopyText();
710
+ const value = `${latitude}, ${longitude}`;
711
+
712
+ return (
713
+ <button type="button" onClick={() => void copyToClipboard(value)}>
714
+ Копировать координаты
715
+ </button>
716
+ );
717
+ }
718
+ ```
719
+
720
+ ### Отдельная обработка ошибки
721
+
722
+ ```tsx
723
+ function CopyToken({ token }: { token: string }) {
724
+ const { copyToClipboard } = useCopyText();
725
+ const [error, setError] = useState("");
726
+
727
+ const handleCopy = async () => {
728
+ const success = await copyToClipboard(token);
729
+ setError(success ? "" : "Разрешите доступ к буферу обмена или скопируйте значение вручную");
730
+ };
731
+
732
+ return (
733
+ <div>
734
+ <button type="button" onClick={() => void handleCopy()}>
735
+ Копировать
736
+ </button>
737
+ {error ? <p role="alert">{error}</p> : null}
738
+ </div>
739
+ );
740
+ }
741
+ ```
742
+
743
+ ### Клавиатурная команда
744
+
745
+ ```tsx
746
+ function CopyOnShortcut({ value }: { value: string }) {
747
+ const { copyToClipboard } = useCopyText();
748
+
749
+ const handleKeyDown = (event: React.KeyboardEvent) => {
750
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
751
+ event.preventDefault();
752
+ void copyToClipboard(value);
753
+ }
754
+ };
755
+
756
+ return (
757
+ <div tabIndex={0} onKeyDown={handleKeyDown}>
758
+ Нажмите Ctrl/Cmd+C, чтобы скопировать значение
759
+ </div>
760
+ );
761
+ }
762
+ ```
763
+
764
+ Не перехватывайте стандартный Ctrl/Cmd+C глобально без явной необходимости: пользователь может копировать выделенный им текст.
765
+
766
+ ### Копирование текста, который меняется в DOM
767
+
768
+ ```tsx
769
+ function CopyLiveCounter({ count }: { count: number }) {
770
+ const { containerRef, copyElementText } = useCopyElementText<HTMLOutputElement>();
771
+
772
+ return (
773
+ <div>
774
+ <output ref={containerRef}>Обработано: {count}</output>
775
+ <button type="button" onClick={() => void copyElementText()}>
776
+ Копировать текущий результат
777
+ </button>
778
+ </div>
779
+ );
780
+ }
781
+ ```
782
+
783
+ При клике будет прочитан текст с актуальным `count`, а не значение первого рендера.
784
+
785
+ ## Доступность и UX
786
+
787
+ ### Используйте настоящую кнопку
788
+
789
+ Для команды копирования предпочтителен `<button type="button">`, а не кликабельный `<div>`. Кнопка уже поддерживает клавиатуру, фокус и семантику действия.
790
+
791
+ ### Не передавайте успех только цветом или иконкой
792
+
793
+ Текст «Скопировано» понятнее, чем одна смена цвета. Для screen reader добавьте live region:
794
+
795
+ ```tsx
796
+ <span aria-live="polite">{isCopied ? "Скопировано" : ""}</span>
797
+ ```
798
+
799
+ ### Не показывайте успех до завершения `await`
800
+
801
+ Операция может быть отклонена. Показывайте успешный feedback только после `true`. `useCopyElementTextWithFeedback` делает это автоматически.
802
+
803
+ ### Оставьте ручной способ копирования
804
+
805
+ Если значение важно, оставьте его видимым и выделяемым. Тогда пользователь сможет скопировать его вручную, даже если browser API заблокирован.
806
+
807
+ ### Осторожно с чувствительными данными
808
+
809
+ Не копируйте пароль, token или персональные данные без явного действия пользователя. Буфер обмена доступен другим приложениям и может сохраняться менеджером истории clipboard.
810
+
811
+ ## SSR и browser boundary
812
+
813
+ Hooks можно импортировать в React-код, но фактическое копирование требует browser globals.
814
+
815
+ Следуйте правилам:
816
+
817
+ - вызывайте `copyToClipboard` и `copyElementText` только на клиенте;
818
+ - связывайте вызов с пользовательским событием после монтирования;
819
+ - не вызывайте копирование во время render;
820
+ - не вызывайте его в server loader, server action или Node-only utility;
821
+ - в тестовой среде предоставляйте DOM и mock Clipboard API.
822
+
823
+ `getElementText()` безопасно вернёт пустую строку, пока ref равен `null`. Но это не означает, что сам сценарий копирования является server API.
824
+
825
+ ## Тестирование
826
+
827
+ ### Что стоит проверять в consumer-компоненте
828
+
829
+ - правильная строка передаётся в `navigator.clipboard.writeText`;
830
+ - UI показывает успех только после результата `true`;
831
+ - при `false` отображается сообщение об ошибке;
832
+ - ref установлен на нужный элемент;
833
+ - hidden/button text не попадает в копируемое значение;
834
+ - feedback исчезает после 2000 мс;
835
+ - повторные клики соответствуют ожидаемой UX-policy.
836
+
837
+ ### Mock современного Clipboard API
838
+
839
+ Пример для Vitest и DOM test environment:
840
+
841
+ ```tsx
842
+ import { vi } from "vitest";
843
+
844
+ const writeText = vi.fn().mockResolvedValue(undefined);
845
+
846
+ Object.defineProperty(navigator, "clipboard", {
847
+ configurable: true,
848
+ value: { writeText }
849
+ });
850
+
851
+ Object.defineProperty(window, "isSecureContext", {
852
+ configurable: true,
853
+ value: true
854
+ });
855
+ ```
856
+
857
+ После пользовательского клика:
858
+
859
+ ```ts
860
+ expect(writeText).toHaveBeenCalledWith("ORDER-42");
861
+ ```
862
+
863
+ ### Проверка ошибки
864
+
865
+ ```ts
866
+ writeText.mockRejectedValueOnce(new DOMException("Not allowed", "NotAllowedError"));
867
+ ```
868
+
869
+ Поскольку hook пишет ошибку в `console.error`, в тесте можно временно установить spy, а после теста обязательно восстановить его.
870
+
871
+ ### Проверка feedback через fake timers
872
+
873
+ ```ts
874
+ vi.useFakeTimers();
875
+
876
+ // После showFeedback() или успешного клика ожидается isCopied === true.
877
+ vi.advanceTimersByTime(2000);
878
+ // После React update ожидается isCopied === false.
879
+
880
+ vi.useRealTimers();
881
+ ```
882
+
883
+ При работе с React Testing Library изменение времени нужно выполнять внутри `act`.
884
+
885
+ ## Ограничения и частые ошибки
886
+
887
+ ### Вызов вне пользовательского события
888
+
889
+ ```tsx
890
+ useEffect(() => {
891
+ void copyToClipboard(value);
892
+ }, [copyToClipboard, value]);
893
+ ```
894
+
895
+ Браузер может заблокировать такой вызов. Кроме того, неожиданное изменение clipboard при открытии страницы является плохим UX.
896
+
897
+ ### Игнорирование `Promise`
898
+
899
+ ```tsx
900
+ // Ошибка: UI сразу говорит об успехе.
901
+ copyToClipboard(value);
902
+ setMessage("Скопировано");
903
+ ```
904
+
905
+ Правильно дождаться результата:
906
+
907
+ ```tsx
908
+ const success = await copyToClipboard(value);
909
+ setMessage(success ? "Скопировано" : "Не удалось скопировать");
910
+ ```
911
+
912
+ ### Ref охватывает лишний UI
913
+
914
+ В `textContent` попадут подписи вложенных элементов. Установите ref только на значение либо используйте `useCopyText(value)`.
915
+
916
+ ### Попытка прочитать `input.value` через `textContent`
917
+
918
+ Element hooks не читают значение form control. Передавайте controlled state или `inputRef.current?.value` в `useCopyText`.
919
+
920
+ ### Ожидание rich clipboard
921
+
922
+ Модуль пишет plain text. Форматирование HTML, изображение и файлы не сохраняются.
923
+
924
+ ### Ожидание fallback после отказа Clipboard API
925
+
926
+ Если `navigator.clipboard.writeText` был выбран и выбросил ошибку, legacy fallback не запускается.
927
+
928
+ ### Несколько быстрых feedback-вызовов
929
+
930
+ Timers независимы. Более ранний timer может скрыть сообщение раньше, чем через две секунды после последнего клика.
931
+
932
+ ### Слепое доверие `true`
933
+
934
+ `true` означает, что выбранный browser API сообщил об успехе. Модуль не читает clipboard обратно и не сравнивает содержимое.
935
+
936
+ ## Краткий справочник API
937
+
938
+ | API | Вход | Результат | Главное ограничение |
939
+ | -------------------------------- | -------------------------- | ---------------------------------------------- | ------------------------------------------------------ |
940
+ | `useCopyText` | Строка в `copyToClipboard` | `Promise<boolean>` | Только plain text; зависит от browser policy. |
941
+ | `useElementText` | `RefObject<T \| null>` | Trimmed `textContent` или `""` | Не читает `input.value`; учитывает hidden descendants. |
942
+ | `useCopyElementText` | Generic типа элемента | Ref, getter и async copy | Внешний whitespace элемента обрезается. |
943
+ | `useCopyFeedback` | Вызов `showFeedback()` | `isCopied = true`, затем `false` через 2000 мс | Timers не объединяются и не очищаются автоматически. |
944
+ | `useCopyElementTextWithFeedback` | Generic типа элемента | Ref, getter, async copy и `isCopied` | Наследует ограничения element text и feedback timer. |
945
+
946
+ Все hooks должны вызываться по правилам React hooks.
947
+
948
+ ## Вопросы и ответы
949
+
950
+ ### Почему функция возвращает `false` на строке из пробелов?
951
+
952
+ Потому что перед копированием проверяется `text.trim()`. Whitespace-only строка считается отсутствующим полезным текстом.
953
+
954
+ ### Обрезает ли `useCopyText` пробелы у нормальной строки?
955
+
956
+ Нет. `trim()` используется только для проверки пустоты. Исходная непустая строка копируется буквально.
957
+
958
+ ### Обрезает ли `useCopyElementText` пробелы?
959
+
960
+ Да. `useElementText` возвращает `textContent.trim()`, и именно этот результат передаётся на копирование.
961
+
962
+ ### Почему на localhost работает, а на HTTP-стенде нет?
963
+
964
+ `localhost` обычно считается доверенным контекстом, а обычный HTTP-хост — нет. На стенде может использоваться legacy fallback, который браузер также вправе заблокировать.
965
+
966
+ ### Почему копирование не работает внутри iframe?
967
+
968
+ Родительская страница или Permissions Policy может запрещать clipboard-write. Проверьте настройки iframe и вызывайте операцию из пользовательского события.
969
+
970
+ ### Можно ли копировать значение input?
971
+
972
+ Да, но через `useCopyText(inputValue)`, а не через `useCopyElementText`.
973
+
974
+ ### Можно ли использовать hook без feedback?
975
+
976
+ Да. Используйте `useCopyText` или `useCopyElementText`.
977
+
978
+ ### Можно ли использовать feedback для другой успешной команды?
979
+
980
+ Технически да: `useCopyFeedback` только переключает boolean. Учитывайте фиксированные 2000 мс и независимые timers.
981
+
982
+ ### Можно ли изменить длительность двух секунд?
983
+
984
+ Нет. В текущем публичном API длительность не настраивается.
985
+
986
+ ### Почему `showFeedback` возвращает функцию?
987
+
988
+ Она отменяет созданный timer. Hook не вызывает её автоматически. Отмена timer не устанавливает `isCopied` обратно в `false`.
989
+
990
+ ### Что выбрать: `useCopyElementText` или `useCopyText`?
991
+
992
+ Если исходная строка уже есть в state, props или переменной, выбирайте `useCopyText`: результат будет предсказуемее. Element hook удобен, когда источником действительно является собранный DOM-текст.