@hh.ru/magritte-ui-chat-input 1.2.2 → 1.2.3

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 (66) hide show
  1. package/index.css +117 -117
  2. package/index.d.ts +10 -10
  3. package/index.js +1 -1
  4. package/index.mock.d.ts +9 -9
  5. package/index.mock.js +37 -36
  6. package/index.mock.js.map +1 -1
  7. package/internal/ChatInputContext.d.ts +1 -1
  8. package/internal/ChatInputContext.js.map +1 -1
  9. package/internal/ChatInputSlotButton.js +13 -13
  10. package/internal/ChatInputSlotButton.js.map +1 -1
  11. package/internal/FocusTarget.js +3 -10
  12. package/internal/FocusTarget.js.map +1 -1
  13. package/internal/ModeTransition.js +41 -11
  14. package/internal/ModeTransition.js.map +1 -1
  15. package/internal/TextInput.d.ts +1 -1
  16. package/internal/TextInput.js +146 -126
  17. package/internal/TextInput.js.map +1 -1
  18. package/internal/VoiceRecord.d.ts +1 -1
  19. package/internal/VoiceRecord.js +66 -24
  20. package/internal/VoiceRecord.js.map +1 -1
  21. package/internal/VoiceRecordIndicator.js +22 -9
  22. package/internal/VoiceRecordIndicator.js.map +1 -1
  23. package/internal/flattenFragments.js +9 -13
  24. package/internal/flattenFragments.js.map +1 -1
  25. package/internal/focusGroup.js +18 -28
  26. package/internal/focusGroup.js.map +1 -1
  27. package/internal/useHasSound.js +49 -64
  28. package/internal/useHasSound.js.map +1 -1
  29. package/internal/useObjectUrl.js +15 -21
  30. package/internal/useObjectUrl.js.map +1 -1
  31. package/internal/useRovingFocusGroup.js +102 -137
  32. package/internal/useRovingFocusGroup.js.map +1 -1
  33. package/internal/useSendOnEnter.js +44 -46
  34. package/internal/useSendOnEnter.js.map +1 -1
  35. package/internal/useVoiceRecordSession.d.ts +1 -1
  36. package/internal/useVoiceRecordSession.js +185 -219
  37. package/internal/useVoiceRecordSession.js.map +1 -1
  38. package/internal/voiceSlotRegistry.d.ts +1 -1
  39. package/internal/voiceSlotRegistry.js +3 -3
  40. package/internal/voiceSlotRegistry.js.map +1 -1
  41. package/package.json +11 -11
  42. package/public/ChatInput.d.ts +6 -6
  43. package/public/ChatInput.js +129 -73
  44. package/public/ChatInput.js.map +1 -1
  45. package/public/ChatInputChips.js +4 -4
  46. package/public/ChatInputChips.js.map +1 -1
  47. package/public/ChatInputFile.d.ts +1 -1
  48. package/public/ChatInputFile.js +107 -47
  49. package/public/ChatInputFile.js.map +1 -1
  50. package/public/ChatInputFiles.js +10 -3
  51. package/public/ChatInputFiles.js.map +1 -1
  52. package/public/ChatInputPreview.js +14 -9
  53. package/public/ChatInputPreview.js.map +1 -1
  54. package/public/ChatInputSendButton.d.ts +1 -1
  55. package/public/ChatInputSendButton.js +34 -23
  56. package/public/ChatInputSendButton.js.map +1 -1
  57. package/public/ChatInputVoiceButton.d.ts +1 -1
  58. package/public/ChatInputVoiceButton.js +87 -91
  59. package/public/ChatInputVoiceButton.js.map +1 -1
  60. package/public/getChatInputFileType.d.ts +1 -1
  61. package/public/getChatInputFileType.js +7 -10
  62. package/public/getChatInputFileType.js.map +1 -1
  63. package/styles-hEUo5BXU.js +5 -0
  64. package/styles-hEUo5BXU.js.map +1 -0
  65. package/styles-CHMi0EYL.js +0 -5
  66. package/styles-CHMi0EYL.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"useHasSound.js","sources":["src/internal/useHasSound.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\n// Порог RMS, выше которого считаем, что звук есть (речь по RMS обычно ~0.1+, шумовой пол — ниже)\nconst SOUND_RMS_THRESHOLD = 0.02;\n// Сколько держим флаг «звук есть» после последнего громкого кадра — чтобы паузы между словами\n// не роняли индикатор в тишину и он не мигал\nconst RELEASE_MS = 350;\n// Период опроса громкости: флаг булев с гистерезисом RELEASE_MS, детализация кадра дисплея тут\n// не нужна — 100 мс дают запас в ~3.5 замера на окно удержания при кратно меньшей нагрузке, чем rAF\nconst SAMPLE_INTERVAL_MS = 100;\n\ntype AudioContextConstructor = typeof AudioContext;\n\nconst getAudioContextConstructor = (): AudioContextConstructor | null => {\n if (typeof window === 'undefined') {\n return null;\n }\n const scoped = window as unknown as {\n AudioContext?: AudioContextConstructor;\n webkitAudioContext?: AudioContextConstructor;\n };\n return scoped.AudioContext ?? scoped.webkitAudioContext ?? null;\n};\n\n/**\n * Флаг «в стриме микрофона сейчас есть звук». Пока стрим есть — держит rAF-цикл и читает\n * RMS через AnalyserNode, сравнивая с порогом (флаг ещё RELEASE_MS удерживается после последнего\n * громкого кадра, чтобы не мигать на паузах между словами). Ререндер только на смене флага.\n * Треки стрима не трогаем — ими владеет сессия записи (`useVoiceRecordSession`), только читаем.\n */\nexport const useHasSound = (stream: MediaStream | null): boolean => {\n const [hasSound, setHasSound] = useState(false);\n\n useEffect(() => {\n const AudioCtx = getAudioContextConstructor();\n if (!stream || !AudioCtx || stream.getAudioTracks().length === 0) {\n return undefined;\n }\n\n const audioContext = new AudioCtx();\n // ctx может стартовать в suspended (autoplay policy); старт всегда из клика по микрофону,\n // так что resume безопасен и обычно сразу переводит в running\n void audioContext.resume();\n\n const source = audioContext.createMediaStreamSource(stream);\n const analyser = audioContext.createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n\n // Web Audio API принимает данные только в типизированный массив\n // oxlint-disable-next-line magritte/no-restricted-syntax\n const buffer = new Uint8Array(analyser.fftSize);\n let lastLoudAt = 0;\n let current = false;\n\n const tick = () => {\n analyser.getByteTimeDomainData(buffer);\n let sumSquares = 0;\n for (let i = 0; i < buffer.length; i += 1) {\n const sample = (buffer[i] - 128) / 128;\n sumSquares += sample * sample;\n }\n const rms = Math.sqrt(sumSquares / buffer.length);\n const now = performance.now();\n if (rms > SOUND_RMS_THRESHOLD) {\n lastLoudAt = now;\n }\n const next = now - lastLoudAt < RELEASE_MS;\n if (next !== current) {\n current = next;\n setHasSound(next);\n }\n };\n tick();\n const intervalId = setInterval(tick, SAMPLE_INTERVAL_MS);\n\n return () => {\n clearInterval(intervalId);\n source.disconnect();\n analyser.disconnect();\n void audioContext.close();\n setHasSound(false);\n };\n }, [stream]);\n\n return hasSound;\n};\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useHasSound.js","sources":["src/internal/useHasSound.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\n// Порог RMS, выше которого считаем, что звук есть (речь по RMS обычно ~0.1+, шумовой пол — ниже)\nconst SOUND_RMS_THRESHOLD = 0.02;\n// Сколько держим флаг «звук есть» после последнего громкого кадра — чтобы паузы между словами\n// не роняли индикатор в тишину и он не мигал\nconst RELEASE_MS = 350;\n// Период опроса громкости: флаг булев с гистерезисом RELEASE_MS, детализация кадра дисплея тут\n// не нужна — 100 мс дают запас в ~3.5 замера на окно удержания при кратно меньшей нагрузке, чем rAF\nconst SAMPLE_INTERVAL_MS = 100;\n\ntype AudioContextConstructor = typeof AudioContext;\n\nconst getAudioContextConstructor = (): AudioContextConstructor | null => {\n if (typeof window === 'undefined') {\n return null;\n }\n const scoped = window as unknown as {\n AudioContext?: AudioContextConstructor;\n webkitAudioContext?: AudioContextConstructor;\n };\n return scoped.AudioContext ?? scoped.webkitAudioContext ?? null;\n};\n\n/**\n * Флаг «в стриме микрофона сейчас есть звук». Пока стрим есть — держит rAF-цикл и читает\n * RMS через AnalyserNode, сравнивая с порогом (флаг ещё RELEASE_MS удерживается после последнего\n * громкого кадра, чтобы не мигать на паузах между словами). Ререндер только на смене флага.\n * Треки стрима не трогаем — ими владеет сессия записи (`useVoiceRecordSession`), только читаем.\n */\nexport const useHasSound = (stream: MediaStream | null): boolean => {\n const [hasSound, setHasSound] = useState(false);\n\n useEffect(() => {\n const AudioCtx = getAudioContextConstructor();\n if (!stream || !AudioCtx || stream.getAudioTracks().length === 0) {\n return undefined;\n }\n\n const audioContext = new AudioCtx();\n // ctx может стартовать в suspended (autoplay policy); старт всегда из клика по микрофону,\n // так что resume безопасен и обычно сразу переводит в running\n void audioContext.resume();\n\n const source = audioContext.createMediaStreamSource(stream);\n const analyser = audioContext.createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n\n // Web Audio API принимает данные только в типизированный массив\n // oxlint-disable-next-line magritte/no-restricted-syntax\n const buffer = new Uint8Array(analyser.fftSize);\n let lastLoudAt = 0;\n let current = false;\n\n const tick = () => {\n analyser.getByteTimeDomainData(buffer);\n let sumSquares = 0;\n for (let i = 0; i < buffer.length; i += 1) {\n const sample = (buffer[i] - 128) / 128;\n sumSquares += sample * sample;\n }\n const rms = Math.sqrt(sumSquares / buffer.length);\n const now = performance.now();\n if (rms > SOUND_RMS_THRESHOLD) {\n lastLoudAt = now;\n }\n const next = now - lastLoudAt < RELEASE_MS;\n if (next !== current) {\n current = next;\n setHasSound(next);\n }\n };\n tick();\n const intervalId = setInterval(tick, SAMPLE_INTERVAL_MS);\n\n return () => {\n clearInterval(intervalId);\n source.disconnect();\n analyser.disconnect();\n void audioContext.close();\n setHasSound(false);\n };\n }, [stream]);\n\n return hasSound;\n};\n"],"names":[],"mappings":"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"}
@@ -1,29 +1,23 @@
1
1
  import './../index.css';
2
2
  import { useState, useEffect } from 'react';
3
3
 
4
- const isImageBlob = (blob) => !blob.type || blob.type.startsWith('image/');
5
- /**
6
- * Url для отображения картинки: строку возвращает как есть, для Blob/File создаёт локальный object URL
7
- * и отзывает его при смене источника и размонтировании. Blob с известным не-image типом пропускаем.
8
- * URL создаётся в эффекте (не в рендере), поэтому StrictMode и SSR его не теряют и не плодят
9
- */
4
+ const isImageBlob = (blob) => !blob.type || blob.type.startsWith("image/");
10
5
  const useObjectUrl = (source) => {
11
- const blob = source instanceof Blob && isImageBlob(source) ? source : undefined;
12
- const [objectUrl, setObjectUrl] = useState();
13
- useEffect(() => {
14
- if (!blob) {
15
- setObjectUrl(undefined);
16
- return undefined;
17
- }
18
- const url = URL.createObjectURL(blob);
19
- setObjectUrl({ blob, url });
20
- return () => URL.revokeObjectURL(url);
21
- }, [blob]);
22
- if (typeof source === 'string') {
23
- return source;
6
+ const blob = source instanceof Blob && isImageBlob(source) ? source : void 0;
7
+ const [objectUrl, setObjectUrl] = useState();
8
+ useEffect(() => {
9
+ if (!blob) {
10
+ setObjectUrl(void 0);
11
+ return void 0;
24
12
  }
25
- // url от предыдущего Blob уже отозван — до эффекта для нового ничего не отдаём
26
- return objectUrl && objectUrl.blob === blob ? objectUrl.url : undefined;
13
+ const url = URL.createObjectURL(blob);
14
+ setObjectUrl({ blob, url });
15
+ return () => URL.revokeObjectURL(url);
16
+ }, [blob]);
17
+ if (typeof source === "string") {
18
+ return source;
19
+ }
20
+ return objectUrl && objectUrl.blob === blob ? objectUrl.url : void 0;
27
21
  };
28
22
 
29
23
  export { useObjectUrl };
@@ -1 +1 @@
1
- {"version":3,"file":"useObjectUrl.js","sources":["src/internal/useObjectUrl.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\nconst isImageBlob = (blob: Blob): boolean => !blob.type || blob.type.startsWith('image/');\n\n/**\n * Url для отображения картинки: строку возвращает как есть, для Blob/File создаёт локальный object URL\n * и отзывает его при смене источника и размонтировании. Blob с известным не-image типом пропускаем.\n * URL создаётся в эффекте (не в рендере), поэтому StrictMode и SSR его не теряют и не плодят\n */\nexport const useObjectUrl = (source: string | Blob | undefined): string | undefined => {\n const blob = source instanceof Blob && isImageBlob(source) ? source : undefined;\n const [objectUrl, setObjectUrl] = useState<{ blob: Blob; url: string }>();\n\n useEffect(() => {\n if (!blob) {\n setObjectUrl(undefined);\n return undefined;\n }\n const url = URL.createObjectURL(blob);\n setObjectUrl({ blob, url });\n return () => URL.revokeObjectURL(url);\n }, [blob]);\n\n if (typeof source === 'string') {\n return source;\n }\n // url от предыдущего Blob уже отозван — до эффекта для нового ничего не отдаём\n return objectUrl && objectUrl.blob === blob ? objectUrl.url : undefined;\n};\n"],"names":[],"mappings":";;AAEA,MAAM,WAAW,GAAG,CAAC,IAAU,KAAc,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;AAE1F;;;;AAIG;AACU,MAAA,YAAY,GAAG,CAAC,MAAiC,KAAwB;AAClF,IAAA,MAAM,IAAI,GAAG,MAAM,YAAY,IAAI,IAAI,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,SAAS,CAAC;IAChF,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,EAA+B,CAAC;IAE1E,SAAS,CAAC,MAAK;QACX,IAAI,CAAC,IAAI,EAAE;YACP,YAAY,CAAC,SAAS,CAAC,CAAC;AACxB,YAAA,OAAO,SAAS,CAAC;SACpB;QACD,MAAM,GAAG,GAAG,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;AACtC,QAAA,YAAY,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;QAC5B,OAAO,MAAM,GAAG,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;AAC1C,KAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;AAEX,IAAA,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE;AAC5B,QAAA,OAAO,MAAM,CAAC;KACjB;;AAED,IAAA,OAAO,SAAS,IAAI,SAAS,CAAC,IAAI,KAAK,IAAI,GAAG,SAAS,CAAC,GAAG,GAAG,SAAS,CAAC;AAC5E;;;;"}
1
+ {"version":3,"file":"useObjectUrl.js","sources":["src/internal/useObjectUrl.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\nconst isImageBlob = (blob: Blob): boolean => !blob.type || blob.type.startsWith('image/');\n\n/**\n * Url для отображения картинки: строку возвращает как есть, для Blob/File создаёт локальный object URL\n * и отзывает его при смене источника и размонтировании. Blob с известным не-image типом пропускаем.\n * URL создаётся в эффекте (не в рендере), поэтому StrictMode и SSR его не теряют и не плодят\n */\nexport const useObjectUrl = (source: string | Blob | undefined): string | undefined => {\n const blob = source instanceof Blob && isImageBlob(source) ? source : undefined;\n const [objectUrl, setObjectUrl] = useState<{ blob: Blob; url: string }>();\n\n useEffect(() => {\n if (!blob) {\n setObjectUrl(undefined);\n return undefined;\n }\n const url = URL.createObjectURL(blob);\n setObjectUrl({ blob, url });\n return () => URL.revokeObjectURL(url);\n }, [blob]);\n\n if (typeof source === 'string') {\n return source;\n }\n // url от предыдущего Blob уже отозван — до эффекта для нового ничего не отдаём\n return objectUrl && objectUrl.blob === blob ? objectUrl.url : undefined;\n};\n"],"names":[],"mappings":";;AAEA,MAAM,WAAA,GAAc,CAAC,IAAwB,KAAA,CAAC,KAAK,IAAQ,IAAA,IAAA,CAAK,IAAK,CAAA,UAAA,CAAW,QAAQ,CAAA,CAAA;AAO3E,MAAA,YAAA,GAAe,CAAC,MAA0D,KAAA;AACnF,EAAA,MAAM,OAAO,MAAkB,YAAA,IAAA,IAAQ,WAAY,CAAA,MAAM,IAAI,MAAS,GAAA,KAAA,CAAA,CAAA;AACtE,EAAA,MAAM,CAAC,SAAA,EAAW,YAAY,CAAA,GAAI,QAAsC,EAAA,CAAA;AAExE,EAAA,SAAA,CAAU,MAAM;AACZ,IAAA,IAAI,CAAC,IAAM,EAAA;AACP,MAAA,YAAA,CAAa,KAAS,CAAA,CAAA,CAAA;AACtB,MAAO,OAAA,KAAA,CAAA,CAAA;AAAA,KACX;AACA,IAAM,MAAA,GAAA,GAAM,GAAI,CAAA,eAAA,CAAgB,IAAI,CAAA,CAAA;AACpC,IAAa,YAAA,CAAA,EAAE,IAAM,EAAA,GAAA,EAAK,CAAA,CAAA;AAC1B,IAAO,OAAA,MAAM,GAAI,CAAA,eAAA,CAAgB,GAAG,CAAA,CAAA;AAAA,GACxC,EAAG,CAAC,IAAI,CAAC,CAAA,CAAA;AAET,EAAI,IAAA,OAAO,WAAW,QAAU,EAAA;AAC5B,IAAO,OAAA,MAAA,CAAA;AAAA,GACX;AAEA,EAAA,OAAO,SAAa,IAAA,SAAA,CAAU,IAAS,KAAA,IAAA,GAAO,UAAU,GAAM,GAAA,KAAA,CAAA,CAAA;AAClE;;;;"}
@@ -3,148 +3,113 @@ import { useRef, useCallback, useEffect } from 'react';
3
3
  import { keyboardKeys, keyboardMatches, keyboardMatch } from '@hh.ru/magritte-common-keyboard';
4
4
  import { FOCUS_PRIORITY_ATTR } from './focusGroup.js';
5
5
 
6
- // Кандидаты в группу: интерактивные элементы, но без исключения [tabindex="-1"] (в отличие от
7
- // FOCUSABLE_SELECTOR из common-keyboard) — менеджер сам ставит участникам -1 и должен продолжать
8
- // их видеть при повторных сканах
9
6
  const CANDIDATE_SELECTOR = [
10
- 'button:not([disabled])',
11
- 'a[href]',
12
- 'input:not([disabled]):not([type="hidden"])',
13
- 'select:not([disabled])',
14
- 'textarea:not([disabled])',
15
- '[tabindex]',
16
- ].join(', ');
7
+ "button:not([disabled])",
8
+ "a[href]",
9
+ 'input:not([disabled]):not([type="hidden"])',
10
+ "select:not([disabled])",
11
+ "textarea:not([disabled])",
12
+ "[tabindex]"
13
+ ].join(", ");
17
14
  const NAV_KEYS = [keyboardKeys.ArrowLeft, keyboardKeys.ArrowRight, keyboardKeys.Home, keyboardKeys.End];
18
- // метка «tabindex этому элементу писали мы»: его "-1" — не чужое намерение, а членство в группе.
19
- // Держим её в DOM, а не в JS-коллекции: элементы приложения приходят и уходят без нашего ведома
20
- const FOCUS_MANAGED_ATTR = 'data-magritte-chat-input-focus-managed';
21
- // Registry — сам DOM: участники и их порядок вычисляются сканом на каждое обращение, поэтому
22
- // список никогда не устаревает (кнопки приложения в слотах могут появляться и исчезать без
23
- // ререндера ChatInput)
15
+ const FOCUS_MANAGED_ATTR = "data-magritte-chat-input-focus-managed";
24
16
  const collectGroup = (root) => {
25
- const items = [];
26
- const all = [];
27
- root.querySelectorAll(CANDIDATE_SELECTOR).forEach((el, index) => {
28
- // элемент вне приоритетных контейнеров (textarea, произвольный контент вне слотов) —
29
- // не участник, его tabindex не трогаем
30
- const container = el.closest(`[${FOCUS_PRIORITY_ATTR}]`);
31
- if (!container || !root.contains(container)) {
32
- return;
33
- }
34
- // чужой явный tabindex="-1" уважаем (приложение само вывело элемент из фокуса);
35
- // свой (с меткой managed) — просто признак участника группы
36
- if (el.getAttribute('tabindex') === '-1' && !el.hasAttribute(FOCUS_MANAGED_ATTR)) {
37
- return;
38
- }
39
- all.push(el);
40
- // скрытый слой не участвует в обходе, но остаётся под управлением (см. applyTabStops)
41
- if (el.closest('[aria-hidden="true"]')) {
42
- return;
43
- }
44
- items.push({ el, index, priority: Number(container.getAttribute(FOCUS_PRIORITY_ATTR)) });
45
- });
46
- // сортировка стабильная: при равном приоритете сохраняем исходный DOM-порядок
47
- const members = items.sort((a, b) => a.priority - b.priority || a.index - b.index).map(({ el }) => el);
48
- return { members, all };
17
+ const items = [];
18
+ const all = [];
19
+ root.querySelectorAll(CANDIDATE_SELECTOR).forEach((el, index) => {
20
+ const container = el.closest(`[${FOCUS_PRIORITY_ATTR}]`);
21
+ if (!container || !root.contains(container)) {
22
+ return;
23
+ }
24
+ if (el.getAttribute("tabindex") === "-1" && !el.hasAttribute(FOCUS_MANAGED_ATTR)) {
25
+ return;
26
+ }
27
+ all.push(el);
28
+ if (el.closest('[aria-hidden="true"]')) {
29
+ return;
30
+ }
31
+ items.push({ el, index, priority: Number(container.getAttribute(FOCUS_PRIORITY_ATTR)) });
32
+ });
33
+ const members = items.sort((a, b) => a.priority - b.priority || a.index - b.index).map(({ el }) => el);
34
+ return { members, all };
49
35
  };
50
- /**
51
- * Roving tabindex для кнопок ChatInput: вся группа кнопок — один tab stop. У всех участников
52
- * tabindex="-1", у одного (текущего стопа) — "0"; внутри группы фокус ходит стрелками ←/→
53
- * и Home/End (с зацикливанием) в порядке приоритетов (FOCUS_PRIORITY_ATTR на контейнерах).
54
- * Tab не перехватывается: страница → textarea → кнопка-стоп → страница, границы нативные.
55
- *
56
- * Порядок и состав группы вычисляются из DOM лениво, а вот tabindex приходится поддерживать
57
- * эагерно — он должен стоять в DOM до нажатия Tab. Расстановка актуализируется по трём каналам:
58
- * начальный эффект, focusin (стоп следует за фокусом — клик или стрелка) и MutationObserver
59
- * (маунт/анмаунт кнопок приложения, disabled, смена aria-hidden слоя при переключении режима).
60
- */
61
36
  const useRovingFocusGroup = (root) => {
62
- const stopRef = useRef(null);
63
- const applyTabStops = useCallback(() => {
64
- const rootEl = root.current;
65
- if (!rootEl) {
66
- return;
67
- }
68
- const { members, all } = collectGroup(rootEl);
69
- const prevStop = stopRef.current;
70
- const stop = prevStop && members.includes(prevStop) ? prevStop : (members[0] ?? null);
71
- stopRef.current = stop;
72
- all.forEach((el) => {
73
- const tabIndex = el === stop ? 0 : -1;
74
- if (el.tabIndex !== tabIndex) {
75
- el.tabIndex = tabIndex;
76
- el.setAttribute(FOCUS_MANAGED_ATTR, '');
77
- }
78
- });
79
- }, [root]);
80
- const focusStop = useCallback(() => {
81
- // observer срабатывает микротаском — пересобираем синхронно, чтобы стоп был свежим
82
- applyTabStops();
83
- stopRef.current?.focus();
84
- }, [applyTabStops]);
85
- useEffect(() => {
86
- const rootEl = root.current;
87
- if (!rootEl) {
88
- return undefined;
89
- }
90
- applyTabStops();
91
- const handleKeyDown = (event) => {
92
- if (event.defaultPrevented || !keyboardMatches(event, NAV_KEYS)) {
93
- return;
94
- }
95
- if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
96
- return;
97
- }
98
- const { members } = collectGroup(rootEl);
99
- const activeIndex = members.indexOf(document.activeElement);
100
- // фокус вне группы (например, в textarea — там стрелки двигают каретку) — не вмешиваемся
101
- if (activeIndex === -1) {
102
- return;
103
- }
104
- event.preventDefault();
105
- const lastIndex = members.length - 1;
106
- let nextIndex;
107
- if (keyboardMatch(event, keyboardKeys.Home)) {
108
- nextIndex = 0;
109
- }
110
- else if (keyboardMatch(event, keyboardKeys.End)) {
111
- nextIndex = lastIndex;
112
- }
113
- else if (keyboardMatch(event, keyboardKeys.ArrowRight)) {
114
- nextIndex = activeIndex === lastIndex ? 0 : activeIndex + 1;
115
- }
116
- else {
117
- nextIndex = activeIndex === 0 ? lastIndex : activeIndex - 1;
118
- }
119
- // focusin перенесёт стоп на новый элемент
120
- members[nextIndex].focus();
121
- };
122
- // стоп следует за фокусом: кликнутый мышью или сфокусированный стрелкой участник
123
- // становится единственным tab stop группы
124
- const handleFocusIn = (event) => {
125
- const target = event.target;
126
- if (!(target instanceof HTMLElement) || stopRef.current === target) {
127
- return;
128
- }
129
- if (!collectGroup(rootEl).members.includes(target)) {
130
- return;
131
- }
132
- stopRef.current = target;
133
- applyTabStops();
134
- };
135
- // ремонт расстановки: анмаунт держателя стопа, disabled, aria-hidden при смене режима.
136
- // tabindex в фильтре намеренно нет — собственные записи не должны будить observer
137
- const observer = new MutationObserver(applyTabStops);
138
- observer.observe(rootEl, { subtree: true, childList: true, attributeFilter: ['disabled', 'aria-hidden'] });
139
- rootEl.addEventListener('keydown', handleKeyDown);
140
- rootEl.addEventListener('focusin', handleFocusIn);
141
- return () => {
142
- observer.disconnect();
143
- rootEl.removeEventListener('keydown', handleKeyDown);
144
- rootEl.removeEventListener('focusin', handleFocusIn);
145
- };
146
- }, [root, applyTabStops]);
147
- return { focusStop };
37
+ const stopRef = useRef(null);
38
+ const applyTabStops = useCallback(() => {
39
+ const rootEl = root.current;
40
+ if (!rootEl) {
41
+ return;
42
+ }
43
+ const { members, all } = collectGroup(rootEl);
44
+ const prevStop = stopRef.current;
45
+ const stop = prevStop && members.includes(prevStop) ? prevStop : members[0] ?? null;
46
+ stopRef.current = stop;
47
+ all.forEach((el) => {
48
+ const tabIndex = el === stop ? 0 : -1;
49
+ if (el.tabIndex !== tabIndex) {
50
+ el.tabIndex = tabIndex;
51
+ el.setAttribute(FOCUS_MANAGED_ATTR, "");
52
+ }
53
+ });
54
+ }, [root]);
55
+ const focusStop = useCallback(() => {
56
+ applyTabStops();
57
+ stopRef.current?.focus();
58
+ }, [applyTabStops]);
59
+ useEffect(() => {
60
+ const rootEl = root.current;
61
+ if (!rootEl) {
62
+ return void 0;
63
+ }
64
+ applyTabStops();
65
+ const handleKeyDown = (event) => {
66
+ if (event.defaultPrevented || !keyboardMatches(event, NAV_KEYS)) {
67
+ return;
68
+ }
69
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
70
+ return;
71
+ }
72
+ const { members } = collectGroup(rootEl);
73
+ const activeIndex = members.indexOf(document.activeElement);
74
+ if (activeIndex === -1) {
75
+ return;
76
+ }
77
+ event.preventDefault();
78
+ const lastIndex = members.length - 1;
79
+ let nextIndex;
80
+ if (keyboardMatch(event, keyboardKeys.Home)) {
81
+ nextIndex = 0;
82
+ } else if (keyboardMatch(event, keyboardKeys.End)) {
83
+ nextIndex = lastIndex;
84
+ } else if (keyboardMatch(event, keyboardKeys.ArrowRight)) {
85
+ nextIndex = activeIndex === lastIndex ? 0 : activeIndex + 1;
86
+ } else {
87
+ nextIndex = activeIndex === 0 ? lastIndex : activeIndex - 1;
88
+ }
89
+ members[nextIndex].focus();
90
+ };
91
+ const handleFocusIn = (event) => {
92
+ const target = event.target;
93
+ if (!(target instanceof HTMLElement) || stopRef.current === target) {
94
+ return;
95
+ }
96
+ if (!collectGroup(rootEl).members.includes(target)) {
97
+ return;
98
+ }
99
+ stopRef.current = target;
100
+ applyTabStops();
101
+ };
102
+ const observer = new MutationObserver(applyTabStops);
103
+ observer.observe(rootEl, { subtree: true, childList: true, attributeFilter: ["disabled", "aria-hidden"] });
104
+ rootEl.addEventListener("keydown", handleKeyDown);
105
+ rootEl.addEventListener("focusin", handleFocusIn);
106
+ return () => {
107
+ observer.disconnect();
108
+ rootEl.removeEventListener("keydown", handleKeyDown);
109
+ rootEl.removeEventListener("focusin", handleFocusIn);
110
+ };
111
+ }, [root, applyTabStops]);
112
+ return { focusStop };
148
113
  };
149
114
 
150
115
  export { useRovingFocusGroup };
@@ -1 +1 @@
1
- {"version":3,"file":"useRovingFocusGroup.js","sources":["src/internal/useRovingFocusGroup.ts"],"sourcesContent":["import { type RefObject, useCallback, useEffect, useRef } from 'react';\n\nimport { keyboardKeys, keyboardMatch, keyboardMatches } from '@hh.ru/magritte-common-keyboard';\nimport { FOCUS_PRIORITY_ATTR } from '@hh.ru/magritte-ui-chat-input/internal/focusGroup';\n\n// Кандидаты в группу: интерактивные элементы, но без исключения [tabindex=\"-1\"] (в отличие от\n// FOCUSABLE_SELECTOR из common-keyboard) — менеджер сам ставит участникам -1 и должен продолжать\n// их видеть при повторных сканах\nconst CANDIDATE_SELECTOR = [\n 'button:not([disabled])',\n 'a[href]',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]',\n].join(', ');\n\nconst NAV_KEYS = [keyboardKeys.ArrowLeft, keyboardKeys.ArrowRight, keyboardKeys.Home, keyboardKeys.End];\n\n// метка «tabindex этому элементу писали мы»: его \"-1\" — не чужое намерение, а членство в группе.\n// Держим её в DOM, а не в JS-коллекции: элементы приложения приходят и уходят без нашего ведома\nconst FOCUS_MANAGED_ATTR = 'data-magritte-chat-input-focus-managed';\n\ninterface GroupSnapshot {\n /** Участники активного слоя в порядке обхода: приоритет контейнера → DOM-порядок */\n members: HTMLElement[];\n /** Все участники, включая скрытый aria-hidden слой, — им тоже держим tabindex=\"-1\" */\n all: HTMLElement[];\n}\n\n// Registry — сам DOM: участники и их порядок вычисляются сканом на каждое обращение, поэтому\n// список никогда не устаревает (кнопки приложения в слотах могут появляться и исчезать без\n// ререндера ChatInput)\nconst collectGroup = (root: HTMLElement): GroupSnapshot => {\n const items: Array<{ el: HTMLElement; index: number; priority: number }> = [];\n const all: HTMLElement[] = [];\n root.querySelectorAll<HTMLElement>(CANDIDATE_SELECTOR).forEach((el, index) => {\n // элемент вне приоритетных контейнеров (textarea, произвольный контент вне слотов) —\n // не участник, его tabindex не трогаем\n const container = el.closest(`[${FOCUS_PRIORITY_ATTR}]`);\n if (!container || !root.contains(container)) {\n return;\n }\n // чужой явный tabindex=\"-1\" уважаем (приложение само вывело элемент из фокуса);\n // свой (с меткой managed) — просто признак участника группы\n if (el.getAttribute('tabindex') === '-1' && !el.hasAttribute(FOCUS_MANAGED_ATTR)) {\n return;\n }\n all.push(el);\n // скрытый слой не участвует в обходе, но остаётся под управлением (см. applyTabStops)\n if (el.closest('[aria-hidden=\"true\"]')) {\n return;\n }\n items.push({ el, index, priority: Number(container.getAttribute(FOCUS_PRIORITY_ATTR)) });\n });\n // сортировка стабильная: при равном приоритете сохраняем исходный DOM-порядок\n const members = items.sort((a, b) => a.priority - b.priority || a.index - b.index).map(({ el }) => el);\n return { members, all };\n};\n\ninterface RovingFocusGroup {\n /** Перевести фокус на текущий tab stop группы (после смены режима — первый по приоритету\n * элемент активного слоя, т.е. галочка при входе в запись) */\n focusStop: () => void;\n}\n\n/**\n * Roving tabindex для кнопок ChatInput: вся группа кнопок — один tab stop. У всех участников\n * tabindex=\"-1\", у одного (текущего стопа) — \"0\"; внутри группы фокус ходит стрелками ←/→\n * и Home/End (с зацикливанием) в порядке приоритетов (FOCUS_PRIORITY_ATTR на контейнерах).\n * Tab не перехватывается: страница → textarea → кнопка-стоп → страница, границы нативные.\n *\n * Порядок и состав группы вычисляются из DOM лениво, а вот tabindex приходится поддерживать\n * эагерно — он должен стоять в DOM до нажатия Tab. Расстановка актуализируется по трём каналам:\n * начальный эффект, focusin (стоп следует за фокусом — клик или стрелка) и MutationObserver\n * (маунт/анмаунт кнопок приложения, disabled, смена aria-hidden слоя при переключении режима).\n */\nexport const useRovingFocusGroup = (root: RefObject<HTMLElement>): RovingFocusGroup => {\n const stopRef = useRef<HTMLElement | null>(null);\n\n const applyTabStops = useCallback(() => {\n const rootEl = root.current;\n if (!rootEl) {\n return;\n }\n const { members, all } = collectGroup(rootEl);\n const prevStop = stopRef.current;\n const stop = prevStop && members.includes(prevStop) ? prevStop : (members[0] ?? null);\n stopRef.current = stop;\n all.forEach((el) => {\n const tabIndex = el === stop ? 0 : -1;\n if (el.tabIndex !== tabIndex) {\n el.tabIndex = tabIndex;\n el.setAttribute(FOCUS_MANAGED_ATTR, '');\n }\n });\n }, [root]);\n\n const focusStop = useCallback(() => {\n // observer срабатывает микротаском — пересобираем синхронно, чтобы стоп был свежим\n applyTabStops();\n stopRef.current?.focus();\n }, [applyTabStops]);\n\n useEffect(() => {\n const rootEl = root.current;\n if (!rootEl) {\n return undefined;\n }\n\n applyTabStops();\n\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || !keyboardMatches(event, NAV_KEYS)) {\n return;\n }\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {\n return;\n }\n const { members } = collectGroup(rootEl);\n const activeIndex = members.indexOf(document.activeElement as HTMLElement);\n // фокус вне группы (например, в textarea — там стрелки двигают каретку) — не вмешиваемся\n if (activeIndex === -1) {\n return;\n }\n event.preventDefault();\n const lastIndex = members.length - 1;\n let nextIndex: number;\n if (keyboardMatch(event, keyboardKeys.Home)) {\n nextIndex = 0;\n } else if (keyboardMatch(event, keyboardKeys.End)) {\n nextIndex = lastIndex;\n } else if (keyboardMatch(event, keyboardKeys.ArrowRight)) {\n nextIndex = activeIndex === lastIndex ? 0 : activeIndex + 1;\n } else {\n nextIndex = activeIndex === 0 ? lastIndex : activeIndex - 1;\n }\n // focusin перенесёт стоп на новый элемент\n members[nextIndex].focus();\n };\n\n // стоп следует за фокусом: кликнутый мышью или сфокусированный стрелкой участник\n // становится единственным tab stop группы\n const handleFocusIn = (event: FocusEvent) => {\n const target = event.target;\n if (!(target instanceof HTMLElement) || stopRef.current === target) {\n return;\n }\n if (!collectGroup(rootEl).members.includes(target)) {\n return;\n }\n stopRef.current = target;\n applyTabStops();\n };\n\n // ремонт расстановки: анмаунт держателя стопа, disabled, aria-hidden при смене режима.\n // tabindex в фильтре намеренно нет — собственные записи не должны будить observer\n const observer = new MutationObserver(applyTabStops);\n observer.observe(rootEl, { subtree: true, childList: true, attributeFilter: ['disabled', 'aria-hidden'] });\n\n rootEl.addEventListener('keydown', handleKeyDown);\n rootEl.addEventListener('focusin', handleFocusIn);\n return () => {\n observer.disconnect();\n rootEl.removeEventListener('keydown', handleKeyDown);\n rootEl.removeEventListener('focusin', handleFocusIn);\n };\n }, [root, applyTabStops]);\n\n return { focusStop };\n};\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useRovingFocusGroup.js","sources":["src/internal/useRovingFocusGroup.ts"],"sourcesContent":["import { type RefObject, useCallback, useEffect, useRef } from 'react';\n\nimport { keyboardKeys, keyboardMatch, keyboardMatches } from '@hh.ru/magritte-common-keyboard';\nimport { FOCUS_PRIORITY_ATTR } from '@hh.ru/magritte-ui-chat-input/internal/focusGroup';\n\n// Кандидаты в группу: интерактивные элементы, но без исключения [tabindex=\"-1\"] (в отличие от\n// FOCUSABLE_SELECTOR из common-keyboard) — менеджер сам ставит участникам -1 и должен продолжать\n// их видеть при повторных сканах\nconst CANDIDATE_SELECTOR = [\n 'button:not([disabled])',\n 'a[href]',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]',\n].join(', ');\n\nconst NAV_KEYS = [keyboardKeys.ArrowLeft, keyboardKeys.ArrowRight, keyboardKeys.Home, keyboardKeys.End];\n\n// метка «tabindex этому элементу писали мы»: его \"-1\" — не чужое намерение, а членство в группе.\n// Держим её в DOM, а не в JS-коллекции: элементы приложения приходят и уходят без нашего ведома\nconst FOCUS_MANAGED_ATTR = 'data-magritte-chat-input-focus-managed';\n\ninterface GroupSnapshot {\n /** Участники активного слоя в порядке обхода: приоритет контейнера → DOM-порядок */\n members: HTMLElement[];\n /** Все участники, включая скрытый aria-hidden слой, — им тоже держим tabindex=\"-1\" */\n all: HTMLElement[];\n}\n\n// Registry — сам DOM: участники и их порядок вычисляются сканом на каждое обращение, поэтому\n// список никогда не устаревает (кнопки приложения в слотах могут появляться и исчезать без\n// ререндера ChatInput)\nconst collectGroup = (root: HTMLElement): GroupSnapshot => {\n const items: Array<{ el: HTMLElement; index: number; priority: number }> = [];\n const all: HTMLElement[] = [];\n root.querySelectorAll<HTMLElement>(CANDIDATE_SELECTOR).forEach((el, index) => {\n // элемент вне приоритетных контейнеров (textarea, произвольный контент вне слотов) —\n // не участник, его tabindex не трогаем\n const container = el.closest(`[${FOCUS_PRIORITY_ATTR}]`);\n if (!container || !root.contains(container)) {\n return;\n }\n // чужой явный tabindex=\"-1\" уважаем (приложение само вывело элемент из фокуса);\n // свой (с меткой managed) — просто признак участника группы\n if (el.getAttribute('tabindex') === '-1' && !el.hasAttribute(FOCUS_MANAGED_ATTR)) {\n return;\n }\n all.push(el);\n // скрытый слой не участвует в обходе, но остаётся под управлением (см. applyTabStops)\n if (el.closest('[aria-hidden=\"true\"]')) {\n return;\n }\n items.push({ el, index, priority: Number(container.getAttribute(FOCUS_PRIORITY_ATTR)) });\n });\n // сортировка стабильная: при равном приоритете сохраняем исходный DOM-порядок\n const members = items.sort((a, b) => a.priority - b.priority || a.index - b.index).map(({ el }) => el);\n return { members, all };\n};\n\ninterface RovingFocusGroup {\n /** Перевести фокус на текущий tab stop группы (после смены режима — первый по приоритету\n * элемент активного слоя, т.е. галочка при входе в запись) */\n focusStop: () => void;\n}\n\n/**\n * Roving tabindex для кнопок ChatInput: вся группа кнопок — один tab stop. У всех участников\n * tabindex=\"-1\", у одного (текущего стопа) — \"0\"; внутри группы фокус ходит стрелками ←/→\n * и Home/End (с зацикливанием) в порядке приоритетов (FOCUS_PRIORITY_ATTR на контейнерах).\n * Tab не перехватывается: страница → textarea → кнопка-стоп → страница, границы нативные.\n *\n * Порядок и состав группы вычисляются из DOM лениво, а вот tabindex приходится поддерживать\n * эагерно — он должен стоять в DOM до нажатия Tab. Расстановка актуализируется по трём каналам:\n * начальный эффект, focusin (стоп следует за фокусом — клик или стрелка) и MutationObserver\n * (маунт/анмаунт кнопок приложения, disabled, смена aria-hidden слоя при переключении режима).\n */\nexport const useRovingFocusGroup = (root: RefObject<HTMLElement>): RovingFocusGroup => {\n const stopRef = useRef<HTMLElement | null>(null);\n\n const applyTabStops = useCallback(() => {\n const rootEl = root.current;\n if (!rootEl) {\n return;\n }\n const { members, all } = collectGroup(rootEl);\n const prevStop = stopRef.current;\n const stop = prevStop && members.includes(prevStop) ? prevStop : (members[0] ?? null);\n stopRef.current = stop;\n all.forEach((el) => {\n const tabIndex = el === stop ? 0 : -1;\n if (el.tabIndex !== tabIndex) {\n el.tabIndex = tabIndex;\n el.setAttribute(FOCUS_MANAGED_ATTR, '');\n }\n });\n }, [root]);\n\n const focusStop = useCallback(() => {\n // observer срабатывает микротаском — пересобираем синхронно, чтобы стоп был свежим\n applyTabStops();\n stopRef.current?.focus();\n }, [applyTabStops]);\n\n useEffect(() => {\n const rootEl = root.current;\n if (!rootEl) {\n return undefined;\n }\n\n applyTabStops();\n\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || !keyboardMatches(event, NAV_KEYS)) {\n return;\n }\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {\n return;\n }\n const { members } = collectGroup(rootEl);\n const activeIndex = members.indexOf(document.activeElement as HTMLElement);\n // фокус вне группы (например, в textarea — там стрелки двигают каретку) — не вмешиваемся\n if (activeIndex === -1) {\n return;\n }\n event.preventDefault();\n const lastIndex = members.length - 1;\n let nextIndex: number;\n if (keyboardMatch(event, keyboardKeys.Home)) {\n nextIndex = 0;\n } else if (keyboardMatch(event, keyboardKeys.End)) {\n nextIndex = lastIndex;\n } else if (keyboardMatch(event, keyboardKeys.ArrowRight)) {\n nextIndex = activeIndex === lastIndex ? 0 : activeIndex + 1;\n } else {\n nextIndex = activeIndex === 0 ? lastIndex : activeIndex - 1;\n }\n // focusin перенесёт стоп на новый элемент\n members[nextIndex].focus();\n };\n\n // стоп следует за фокусом: кликнутый мышью или сфокусированный стрелкой участник\n // становится единственным tab stop группы\n const handleFocusIn = (event: FocusEvent) => {\n const target = event.target;\n if (!(target instanceof HTMLElement) || stopRef.current === target) {\n return;\n }\n if (!collectGroup(rootEl).members.includes(target)) {\n return;\n }\n stopRef.current = target;\n applyTabStops();\n };\n\n // ремонт расстановки: анмаунт держателя стопа, disabled, aria-hidden при смене режима.\n // tabindex в фильтре намеренно нет — собственные записи не должны будить observer\n const observer = new MutationObserver(applyTabStops);\n observer.observe(rootEl, { subtree: true, childList: true, attributeFilter: ['disabled', 'aria-hidden'] });\n\n rootEl.addEventListener('keydown', handleKeyDown);\n rootEl.addEventListener('focusin', handleFocusIn);\n return () => {\n observer.disconnect();\n rootEl.removeEventListener('keydown', handleKeyDown);\n rootEl.removeEventListener('focusin', handleFocusIn);\n };\n }, [root, applyTabStops]);\n\n return { focusStop };\n};\n"],"names":[],"mappings":"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"}
@@ -2,52 +2,50 @@ import './../index.css';
2
2
  import { useRef, useEffect, useCallback } from 'react';
3
3
  import { keyboardMatch, keyboardKeys } from '@hh.ru/magritte-common-keyboard';
4
4
 
5
- const COARSE_POINTER_QUERY = '(pointer: coarse)';
6
- /**
7
- * Возвращает обработчик keydown для textarea: Enter вызывает `onSend`, Shift+Enter оставляет
8
- * стандартный перенос строки. Пользовательский `onKeyDown` вызывается первым — если он отменил
9
- * событие (`preventDefault`), отправка не срабатывает. Ввод через IME (`isComposing`) не отправляет.
10
- * На тач-устройствах Enter всегда переносит строку (отправка — по кнопке).
11
- */
12
- const useSendOnEnter = ({ onSend, onKeyDown, }) => {
13
- const isCoarsePointerRef = useRef(false);
14
- useEffect(() => {
15
- if (!('matchMedia' in window)) {
16
- return undefined;
17
- }
18
- const mediaQuery = window.matchMedia(COARSE_POINTER_QUERY);
19
- const update = () => {
20
- isCoarsePointerRef.current = mediaQuery.matches;
21
- };
22
- update();
23
- if (typeof mediaQuery.addEventListener === 'function') {
24
- mediaQuery.addEventListener('change', update);
25
- }
26
- else {
27
- mediaQuery.addListener(update);
28
- }
29
- return () => {
30
- if (typeof mediaQuery.removeEventListener === 'function') {
31
- mediaQuery.removeEventListener('change', update);
32
- }
33
- else {
34
- mediaQuery.removeListener(update);
35
- }
36
- };
37
- }, []);
38
- return useCallback((event) => {
39
- onKeyDown?.(event);
40
- if (event.defaultPrevented || event.nativeEvent.isComposing) {
41
- return;
42
- }
43
- if (isCoarsePointerRef.current) {
44
- return;
45
- }
46
- if (keyboardMatch(event, keyboardKeys.Enter) && !event.shiftKey) {
47
- event.preventDefault();
48
- onSend?.();
49
- }
50
- }, [onSend, onKeyDown]);
5
+ const COARSE_POINTER_QUERY = "(pointer: coarse)";
6
+ const useSendOnEnter = ({
7
+ onSend,
8
+ onKeyDown
9
+ }) => {
10
+ const isCoarsePointerRef = useRef(false);
11
+ useEffect(() => {
12
+ if (!("matchMedia" in window)) {
13
+ return void 0;
14
+ }
15
+ const mediaQuery = window.matchMedia(COARSE_POINTER_QUERY);
16
+ const update = () => {
17
+ isCoarsePointerRef.current = mediaQuery.matches;
18
+ };
19
+ update();
20
+ if (typeof mediaQuery.addEventListener === "function") {
21
+ mediaQuery.addEventListener("change", update);
22
+ } else {
23
+ mediaQuery.addListener(update);
24
+ }
25
+ return () => {
26
+ if (typeof mediaQuery.removeEventListener === "function") {
27
+ mediaQuery.removeEventListener("change", update);
28
+ } else {
29
+ mediaQuery.removeListener(update);
30
+ }
31
+ };
32
+ }, []);
33
+ return useCallback(
34
+ (event) => {
35
+ onKeyDown?.(event);
36
+ if (event.defaultPrevented || event.nativeEvent.isComposing) {
37
+ return;
38
+ }
39
+ if (isCoarsePointerRef.current) {
40
+ return;
41
+ }
42
+ if (keyboardMatch(event, keyboardKeys.Enter) && !event.shiftKey) {
43
+ event.preventDefault();
44
+ onSend?.();
45
+ }
46
+ },
47
+ [onSend, onKeyDown]
48
+ );
51
49
  };
52
50
 
53
51
  export { useSendOnEnter };
@@ -1 +1 @@
1
- {"version":3,"file":"useSendOnEnter.js","sources":["src/internal/useSendOnEnter.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, type KeyboardEvent, type KeyboardEventHandler } from 'react';\n\nimport { keyboardKeys, keyboardMatch } from '@hh.ru/magritte-common-keyboard';\n\ninterface UseSendOnEnterParams {\n onSend?: VoidFunction;\n onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;\n}\n\nconst COARSE_POINTER_QUERY = '(pointer: coarse)';\n\n/**\n * Возвращает обработчик keydown для textarea: Enter вызывает `onSend`, Shift+Enter оставляет\n * стандартный перенос строки. Пользовательский `onKeyDown` вызывается первым — если он отменил\n * событие (`preventDefault`), отправка не срабатывает. Ввод через IME (`isComposing`) не отправляет.\n * На тач-устройствах Enter всегда переносит строку (отправка — по кнопке).\n */\nexport const useSendOnEnter = ({\n onSend,\n onKeyDown,\n}: UseSendOnEnterParams): KeyboardEventHandler<HTMLTextAreaElement> => {\n const isCoarsePointerRef = useRef(false);\n useEffect(() => {\n if (!('matchMedia' in window)) {\n return undefined;\n }\n const mediaQuery = window.matchMedia(COARSE_POINTER_QUERY);\n const update = () => {\n isCoarsePointerRef.current = mediaQuery.matches;\n };\n update();\n if (typeof mediaQuery.addEventListener === 'function') {\n mediaQuery.addEventListener('change', update);\n } else {\n mediaQuery.addListener(update);\n }\n return () => {\n if (typeof mediaQuery.removeEventListener === 'function') {\n mediaQuery.removeEventListener('change', update);\n } else {\n mediaQuery.removeListener(update);\n }\n };\n }, []);\n\n return useCallback(\n (event: KeyboardEvent<HTMLTextAreaElement>) => {\n onKeyDown?.(event);\n\n if (event.defaultPrevented || event.nativeEvent.isComposing) {\n return;\n }\n\n if (isCoarsePointerRef.current) {\n return;\n }\n\n if (keyboardMatch(event, keyboardKeys.Enter) && !event.shiftKey) {\n event.preventDefault();\n onSend?.();\n }\n },\n [onSend, onKeyDown]\n );\n};\n"],"names":[],"mappings":";;;AASA,MAAM,oBAAoB,GAAG,mBAAmB,CAAC;AAEjD;;;;;AAKG;AACU,MAAA,cAAc,GAAG,CAAC,EAC3B,MAAM,EACN,SAAS,GACU,KAA+C;AAClE,IAAA,MAAM,kBAAkB,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IACzC,SAAS,CAAC,MAAK;AACX,QAAA,IAAI,EAAE,YAAY,IAAI,MAAM,CAAC,EAAE;AAC3B,YAAA,OAAO,SAAS,CAAC;SACpB;QACD,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC;QAC3D,MAAM,MAAM,GAAG,MAAK;AAChB,YAAA,kBAAkB,CAAC,OAAO,GAAG,UAAU,CAAC,OAAO,CAAC;AACpD,SAAC,CAAC;AACF,QAAA,MAAM,EAAE,CAAC;AACT,QAAA,IAAI,OAAO,UAAU,CAAC,gBAAgB,KAAK,UAAU,EAAE;AACnD,YAAA,UAAU,CAAC,gBAAgB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;SACjD;aAAM;AACH,YAAA,UAAU,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;SAClC;AACD,QAAA,OAAO,MAAK;AACR,YAAA,IAAI,OAAO,UAAU,CAAC,mBAAmB,KAAK,UAAU,EAAE;AACtD,gBAAA,UAAU,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;aACpD;iBAAM;AACH,gBAAA,UAAU,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;aACrC;AACL,SAAC,CAAC;KACL,EAAE,EAAE,CAAC,CAAC;AAEP,IAAA,OAAO,WAAW,CACd,CAAC,KAAyC,KAAI;AAC1C,QAAA,SAAS,GAAG,KAAK,CAAC,CAAC;QAEnB,IAAI,KAAK,CAAC,gBAAgB,IAAI,KAAK,CAAC,WAAW,CAAC,WAAW,EAAE;YACzD,OAAO;SACV;AAED,QAAA,IAAI,kBAAkB,CAAC,OAAO,EAAE;YAC5B,OAAO;SACV;AAED,QAAA,IAAI,aAAa,CAAC,KAAK,EAAE,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE;YAC7D,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,MAAM,IAAI,CAAC;SACd;AACL,KAAC,EACD,CAAC,MAAM,EAAE,SAAS,CAAC,CACtB,CAAC;AACN;;;;"}
1
+ {"version":3,"file":"useSendOnEnter.js","sources":["src/internal/useSendOnEnter.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, type KeyboardEvent, type KeyboardEventHandler } from 'react';\n\nimport { keyboardKeys, keyboardMatch } from '@hh.ru/magritte-common-keyboard';\n\ninterface UseSendOnEnterParams {\n onSend?: VoidFunction;\n onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;\n}\n\nconst COARSE_POINTER_QUERY = '(pointer: coarse)';\n\n/**\n * Возвращает обработчик keydown для textarea: Enter вызывает `onSend`, Shift+Enter оставляет\n * стандартный перенос строки. Пользовательский `onKeyDown` вызывается первым — если он отменил\n * событие (`preventDefault`), отправка не срабатывает. Ввод через IME (`isComposing`) не отправляет.\n * На тач-устройствах Enter всегда переносит строку (отправка — по кнопке).\n */\nexport const useSendOnEnter = ({\n onSend,\n onKeyDown,\n}: UseSendOnEnterParams): KeyboardEventHandler<HTMLTextAreaElement> => {\n const isCoarsePointerRef = useRef(false);\n useEffect(() => {\n if (!('matchMedia' in window)) {\n return undefined;\n }\n const mediaQuery = window.matchMedia(COARSE_POINTER_QUERY);\n const update = () => {\n isCoarsePointerRef.current = mediaQuery.matches;\n };\n update();\n if (typeof mediaQuery.addEventListener === 'function') {\n mediaQuery.addEventListener('change', update);\n } else {\n mediaQuery.addListener(update);\n }\n return () => {\n if (typeof mediaQuery.removeEventListener === 'function') {\n mediaQuery.removeEventListener('change', update);\n } else {\n mediaQuery.removeListener(update);\n }\n };\n }, []);\n\n return useCallback(\n (event: KeyboardEvent<HTMLTextAreaElement>) => {\n onKeyDown?.(event);\n\n if (event.defaultPrevented || event.nativeEvent.isComposing) {\n return;\n }\n\n if (isCoarsePointerRef.current) {\n return;\n }\n\n if (keyboardMatch(event, keyboardKeys.Enter) && !event.shiftKey) {\n event.preventDefault();\n onSend?.();\n }\n },\n [onSend, onKeyDown]\n );\n};\n"],"names":[],"mappings":";;;AASA,MAAM,oBAAuB,GAAA,mBAAA,CAAA;AAQtB,MAAM,iBAAiB,CAAC;AAAA,EAC3B,MAAA;AAAA,EACA,SAAA;AACJ,CAAuE,KAAA;AACnE,EAAM,MAAA,kBAAA,GAAqB,OAAO,KAAK,CAAA,CAAA;AACvC,EAAA,SAAA,CAAU,MAAM;AACZ,IAAI,IAAA,EAAE,gBAAgB,MAAS,CAAA,EAAA;AAC3B,MAAO,OAAA,KAAA,CAAA,CAAA;AAAA,KACX;AACA,IAAM,MAAA,UAAA,GAAa,MAAO,CAAA,UAAA,CAAW,oBAAoB,CAAA,CAAA;AACzD,IAAA,MAAM,SAAS,MAAM;AACjB,MAAA,kBAAA,CAAmB,UAAU,UAAW,CAAA,OAAA,CAAA;AAAA,KAC5C,CAAA;AACA,IAAO,MAAA,EAAA,CAAA;AACP,IAAI,IAAA,OAAO,UAAW,CAAA,gBAAA,KAAqB,UAAY,EAAA;AACnD,MAAW,UAAA,CAAA,gBAAA,CAAiB,UAAU,MAAM,CAAA,CAAA;AAAA,KACzC,MAAA;AACH,MAAA,UAAA,CAAW,YAAY,MAAM,CAAA,CAAA;AAAA,KACjC;AACA,IAAA,OAAO,MAAM;AACT,MAAI,IAAA,OAAO,UAAW,CAAA,mBAAA,KAAwB,UAAY,EAAA;AACtD,QAAW,UAAA,CAAA,mBAAA,CAAoB,UAAU,MAAM,CAAA,CAAA;AAAA,OAC5C,MAAA;AACH,QAAA,UAAA,CAAW,eAAe,MAAM,CAAA,CAAA;AAAA,OACpC;AAAA,KACJ,CAAA;AAAA,GACJ,EAAG,EAAE,CAAA,CAAA;AAEL,EAAO,OAAA,WAAA;AAAA,IACH,CAAC,KAA8C,KAAA;AAC3C,MAAA,SAAA,GAAY,KAAK,CAAA,CAAA;AAEjB,MAAA,IAAI,KAAM,CAAA,gBAAA,IAAoB,KAAM,CAAA,WAAA,CAAY,WAAa,EAAA;AACzD,QAAA,OAAA;AAAA,OACJ;AAEA,MAAA,IAAI,mBAAmB,OAAS,EAAA;AAC5B,QAAA,OAAA;AAAA,OACJ;AAEA,MAAA,IAAI,cAAc,KAAO,EAAA,YAAA,CAAa,KAAK,CAAK,IAAA,CAAC,MAAM,QAAU,EAAA;AAC7D,QAAA,KAAA,CAAM,cAAe,EAAA,CAAA;AACrB,QAAS,MAAA,IAAA,CAAA;AAAA,OACb;AAAA,KACJ;AAAA,IACA,CAAC,QAAQ,SAAS,CAAA;AAAA,GACtB,CAAA;AACJ;;;;"}
@@ -1,4 +1,4 @@
1
- import { type VoiceRecordStatus } from '@hh.ru/magritte-ui-chat-input/types';
1
+ import { type VoiceRecordStatus } from '../types';
2
2
  interface UseVoiceRecordSessionParams {
3
3
  /** Максимальная длительность записи в секундах; по достижении — авто-финализация, как по кнопке
4
4
  * подтверждения. Не задана — пишем без ограничения */