@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.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 (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Barra de composição do atendente: texto rico com a formatação do WhatsApp, respostas rápidas e
3
+ * anexos.
4
+ *
5
+ * Distinto do `MessageComposer`, que é um `textarea` simples: aqui o operador vê o negrito em
6
+ * negrito enquanto escreve, e não os asteriscos. Quem só precisa de uma caixa de texto continua no
7
+ * `MessageComposer`.
8
+ *
9
+ * Nada aqui sabe de produto. O que aparece na barra é decidido por `toolbar`, o texto de cada dica
10
+ * por `tooltips`, e as respostas rápidas chegam prontas por `quickReplies` — rótulo e conteúdo são
11
+ * do host, a mecânica é daqui.
12
+ */
13
+
14
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type KeyboardEvent, type ChangeEvent, type FormEvent, type ReactNode } from 'react'
15
+ import { Bold, Italic, Strikethrough, Code, Paperclip, SendHorizonal, Braces, Type } from 'lucide-react'
16
+
17
+ import { cn } from './lib/cn'
18
+ import {
19
+ COMPOSER_BAR_CLASS,
20
+ COMPOSER_COMPACT_WIDTH,
21
+ COMPOSER_MONOSPACE_CLASS,
22
+ COMPOSER_TOOL_BUTTON_ACTIVE_CLASS,
23
+ COMPOSER_TOOL_BUTTON_CLASS,
24
+ COMPOSER_TOOL_BUTTON_IDLE_CLASS,
25
+ QUICK_REPLY_PILL_CLASS,
26
+ } from './composer.constant'
27
+ import { useContainerWidth } from './hooks/useContainerWidth'
28
+ import {
29
+ FORMATTING_ACTION,
30
+ activeFormattingIn,
31
+ codeAncestorOf,
32
+ exitMonospaceAfter,
33
+ isFormattingActive,
34
+ startMonospaceAt,
35
+ toggleFormattingCommand,
36
+ unwrapMonospace,
37
+ type FormattingAction,
38
+ } from './lib/composer-formatting'
39
+ import { htmlToWA, waToHTML } from './lib/whatsapp-formatting'
40
+ import { SimpleEmojiPicker } from './SimpleEmojiPicker'
41
+ import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
42
+
43
+ /** Cada ação que a barra sabe oferecer. `toolbar` decide quais delas aparecem. */
44
+ export const RICH_COMPOSER_ACTION = {
45
+ BOLD: 'bold',
46
+ ITALIC: 'italic',
47
+ STRIKETHROUGH: 'strikethrough',
48
+ MONOSPACE: 'monospace',
49
+ EMOJI: 'emoji',
50
+ ATTACH: 'attach',
51
+ VARIABLES: 'variables',
52
+ } as const
53
+ export type RichComposerAction = (typeof RICH_COMPOSER_ACTION)[keyof typeof RICH_COMPOSER_ACTION]
54
+
55
+ /** Texto das dicas. O host troca só o que quiser; o resto fica no padrão. */
56
+ export interface RichComposerTooltips {
57
+ bold: string
58
+ italic: string
59
+ strikethrough: string
60
+ monospace: string
61
+ emoji: string
62
+ attach: string
63
+ send: string
64
+ variables: string
65
+ /** Botão que abre a formatação recolhida, quando a barra está estreita demais para os quatro. */
66
+ formatting: string
67
+ }
68
+
69
+ export const DEFAULT_RICH_COMPOSER_TOOLTIPS: RichComposerTooltips = {
70
+ bold: 'Negrito',
71
+ italic: 'Itálico',
72
+ strikethrough: 'Tachado',
73
+ monospace: 'Monoespaçado',
74
+ emoji: 'Emojis',
75
+ attach: 'Anexar arquivo',
76
+ send: 'Enviar',
77
+ variables: 'Variáveis disponíveis',
78
+ formatting: 'Formatação',
79
+ }
80
+
81
+ /**
82
+ * Um valor que o operador pode inserir no texto sem digitar. `value` é o que entra no campo — pode
83
+ * ser o dado já resolvido ("Anderson") ou um marcador a resolver depois ("{{nome}}"): quem decide é
84
+ * o host, porque só ele sabe se a substituição acontece aqui ou no envio.
85
+ */
86
+ export interface RichComposerVariable {
87
+ id: string
88
+ label: string
89
+ value: string
90
+ /** Prévia do que será inserido, para o operador conferir antes de tocar. */
91
+ tooltip?: string
92
+ }
93
+
94
+ /** Uma resposta rápida: o que o operador lê no chip e o que cai no campo ao tocar nele. */
95
+ export interface RichComposerQuickReply {
96
+ id: string
97
+ label: string
98
+ text: string
99
+ /** Dica ao passar o mouse. Sem isto, o chip não tem `title` — rótulo curto já se explica. */
100
+ tooltip?: string
101
+ }
102
+
103
+ /**
104
+ * Cada formatação vira o elemento correspondente dentro do campo, não os asteriscos crus: o ponto
105
+ * do editor rico é o operador ver o negrito em negrito. A conversão para a notação do WhatsApp
106
+ * acontece na saída, no `htmlToWA`.
107
+ */
108
+ const FORMATTING_ELEMENT = {
109
+ bold: 'strong',
110
+ italic: 'em',
111
+ strikethrough: 'del',
112
+ monospace: 'code',
113
+ } as const
114
+
115
+ /** Handle imperativo: `contentEditable` controlado pelo React perde o cursor a cada tecla. */
116
+ export interface RichMessageComposerHandle {
117
+ setContent: (text: string) => void
118
+ clear: () => void
119
+ focus: () => void
120
+ }
121
+
122
+ export interface RichMessageComposerProps {
123
+ /** Texto na notação do WhatsApp (`*negrito*`), não HTML. */
124
+ value: string
125
+ onChange: (value: string) => void
126
+ onSend: () => void
127
+ onAttachFiles?: (files: FileList) => void
128
+ quickReplies?: RichComposerQuickReply[]
129
+ /** Variáveis que o operador pode inserir no texto. Vazio ou ausente, o botão não aparece. */
130
+ variables?: RichComposerVariable[]
131
+ /**
132
+ * Quais ações aparecem. Ausente, todas aparecem — menos as que não têm como funcionar (anexo sem
133
+ * `onAttachFiles` some sozinho, porque botão que não faz nada é pior que botão nenhum).
134
+ */
135
+ toolbar?: Partial<Record<RichComposerAction, boolean>>
136
+ tooltips?: Partial<RichComposerTooltips>
137
+ placeholder?: string
138
+ disabled?: boolean
139
+ isSending?: boolean
140
+ /** Ocupa o lugar do enviar enquanto não há nada para enviar — onde o WhatsApp põe o microfone. */
141
+ idleAction?: ReactNode
142
+ /** Prévia dos anexos já escolhidos, desenhada pelo host acima da barra. */
143
+ attachmentsPreview?: ReactNode
144
+ /**
145
+ * Há anexo na fila esperando envio. O campo não conhece a fila — é do host — e sem essa dica ele
146
+ * concluía que não havia nada a enviar: com uma imagem anexada e nenhum texto, o botão de enviar
147
+ * dava lugar ao `idleAction` e a imagem ficava presa.
148
+ */
149
+ hasQueuedAttachments?: boolean
150
+ acceptedFileTypes?: string
151
+ className?: string
152
+ }
153
+
154
+ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMessageComposerProps>(function RichMessageComposer({
155
+ value,
156
+ onChange,
157
+ onSend,
158
+ onAttachFiles,
159
+ quickReplies,
160
+ variables,
161
+ toolbar,
162
+ tooltips,
163
+ placeholder,
164
+ disabled = false,
165
+ isSending = false,
166
+ idleAction,
167
+ attachmentsPreview,
168
+ hasQueuedAttachments = false,
169
+ acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
170
+ className,
171
+ }, ref) {
172
+ const editorRef = useRef<HTMLDivElement>(null)
173
+ const fileInputRef = useRef<HTMLInputElement>(null)
174
+ const variablesRef = useRef<HTMLDivElement>(null)
175
+ const formattingRef = useRef<HTMLDivElement>(null)
176
+ const barRef = useRef<HTMLDivElement>(null)
177
+ const [isVariablesOpen, setIsVariablesOpen] = useState(false)
178
+ const [isFormattingOpen, setIsFormattingOpen] = useState(false)
179
+ const [activeFormatting, setActiveFormatting] = useState('')
180
+
181
+ const barWidth = useContainerWidth(barRef)
182
+ const isCompact = barWidth !== undefined && barWidth < COMPOSER_COMPACT_WIDTH
183
+
184
+ /**
185
+ * O último texto que este campo emitiu. É o que distingue o eco do próprio `onChange` — que deve
186
+ * ser ignorado, senão o cursor volta ao início a cada tecla — de um `value` vindo de fora, que
187
+ * precisa ser escrito no campo. Sem essa distinção o texto continuava na tela depois de enviado.
188
+ */
189
+ const lastEmittedRef = useRef('')
190
+
191
+ const emitChange = useCallback((html: string) => {
192
+ const text = htmlToWA(html)
193
+ lastEmittedRef.current = text
194
+ onChange(text)
195
+ }, [onChange])
196
+
197
+ useEffect(() => {
198
+ const editor = editorRef.current
199
+ if (!editor || value === lastEmittedRef.current) return
200
+ lastEmittedRef.current = value
201
+ editor.innerHTML = waToHTML(value)
202
+ }, [value])
203
+
204
+ const refreshActiveFormatting = useCallback(() => {
205
+ const editor = editorRef.current
206
+ if (editor) setActiveFormatting(activeFormattingIn(editor))
207
+ }, [])
208
+
209
+ // `selectionchange` é do documento: não existe evento de "o cursor andou" no próprio campo.
210
+ useEffect(() => {
211
+ document.addEventListener('selectionchange', refreshActiveFormatting)
212
+ return () => document.removeEventListener('selectionchange', refreshActiveFormatting)
213
+ }, [refreshActiveFormatting])
214
+
215
+ useEffect(() => {
216
+ if (!isVariablesOpen) return
217
+ const closeOnOutsideClick = (event: MouseEvent) => {
218
+ if (!variablesRef.current?.contains(event.target as Node)) setIsVariablesOpen(false)
219
+ }
220
+ document.addEventListener('mousedown', closeOnOutsideClick)
221
+ return () => document.removeEventListener('mousedown', closeOnOutsideClick)
222
+ }, [isVariablesOpen])
223
+
224
+ useEffect(() => {
225
+ if (!isFormattingOpen) return
226
+ const closeOnOutsideClick = (event: MouseEvent) => {
227
+ if (!formattingRef.current?.contains(event.target as Node)) setIsFormattingOpen(false)
228
+ }
229
+ document.addEventListener('mousedown', closeOnOutsideClick)
230
+ return () => document.removeEventListener('mousedown', closeOnOutsideClick)
231
+ }, [isFormattingOpen])
232
+
233
+ // A barra voltou a ser larga (a prévia fechou): os botões reaparecem na linha e o popover ficaria
234
+ // aberto ancorado num botão que não existe mais.
235
+ useEffect(() => {
236
+ if (!isCompact) setIsFormattingOpen(false)
237
+ }, [isCompact])
238
+
239
+ const tooltipOf = (action: keyof RichComposerTooltips): string =>
240
+ tooltips?.[action] ?? DEFAULT_RICH_COMPOSER_TOOLTIPS[action]
241
+ const shows = (action: RichComposerAction): boolean => toolbar?.[action] !== false
242
+
243
+ /** Escreve no campo sem passar pelo React: `contentEditable` controlado perde o cursor a cada tecla. */
244
+ const replaceContent = useCallback((text: string) => {
245
+ const editor = editorRef.current
246
+ if (!editor) return
247
+ editor.innerHTML = waToHTML(text)
248
+ emitChange(editor.innerHTML)
249
+ editor.focus()
250
+ }, [emitChange])
251
+
252
+ useImperativeHandle(ref, () => ({
253
+ setContent: replaceContent,
254
+ clear: () => {
255
+ const editor = editorRef.current
256
+ if (!editor) return
257
+ editor.innerHTML = ''
258
+ emitChange('')
259
+ },
260
+ focus: () => editorRef.current?.focus(),
261
+ }), [emitChange, replaceContent])
262
+
263
+ /** Range vivo dentro do campo, ou `undefined` se o cursor está em outro lugar da página. */
264
+ const currentRange = useCallback((): Range | undefined => {
265
+ const editor = editorRef.current
266
+ if (!editor) return undefined
267
+ editor.focus()
268
+ const selection = window.getSelection()
269
+ if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return undefined
270
+ return selection.getRangeAt(0)
271
+ }, [])
272
+
273
+ const commitRange = useCallback((range: Range, node: Node) => {
274
+ const selection = window.getSelection()
275
+ range.setStartAfter(node)
276
+ range.collapse(true)
277
+ selection?.removeAllRanges()
278
+ selection?.addRange(range)
279
+ emitChange(editorRef.current?.innerHTML ?? '')
280
+ }, [emitChange])
281
+
282
+ const insertAtCursor = useCallback((fragment: string) => {
283
+ const range = currentRange()
284
+ if (!range) return
285
+ range.deleteContents()
286
+ const textNode = document.createTextNode(fragment)
287
+ range.insertNode(textNode)
288
+ commitRange(range, textNode)
289
+ }, [commitRange, currentRange])
290
+
291
+ /**
292
+ * Envolve a seleção no elemento da formatação. Sem seleção não faz nada: abrir a marcação e
293
+ * esperar que o operador digite dentro dela é o caminho que sai com marcador sobrando quando ele
294
+ * clica noutro lugar antes.
295
+ */
296
+ const wrapSelection = useCallback((action: keyof typeof FORMATTING_ELEMENT) => {
297
+ const range = currentRange()
298
+ const selectedText = range?.toString()
299
+ if (!range || !selectedText) return
300
+ range.deleteContents()
301
+ const wrapper = document.createElement(FORMATTING_ELEMENT[action])
302
+ if (action === 'monospace') wrapper.className = COMPOSER_MONOSPACE_CLASS
303
+ wrapper.textContent = selectedText
304
+ range.insertNode(wrapper)
305
+ commitRange(range, wrapper)
306
+ }, [commitRange, currentRange])
307
+
308
+ /**
309
+ * Uma regra só para os quatro botões, a mesma dos editores de texto:
310
+ *
311
+ * - cursor vago — liga ou desliga daqui para a frente, sem tocar no que já está escrito;
312
+ * - com seleção — aplica na seleção, ou tira dela se ela já estiver toda formatada.
313
+ *
314
+ * `execCommand` faz isso sozinho para negrito, itálico e tachado. Monoespaçado não tem comando
315
+ * nativo e é feito à mão, com a mesma regra. Onde nem o comando existe (ambiente sem editor de
316
+ * verdade), sobra o wrap da seleção — que era tudo o que a barra fazia antes.
317
+ */
318
+ const applyFormatting = useCallback((action: FormattingAction) => {
319
+ const editor = editorRef.current
320
+ const range = currentRange()
321
+ if (!editor || !range) return
322
+
323
+ if (action === FORMATTING_ACTION.MONOSPACE) {
324
+ const code = codeAncestorOf({ node: range.startContainer, editor })
325
+ if (range.collapsed) {
326
+ if (code) exitMonospaceAfter(code)
327
+ else startMonospaceAt(range)
328
+ } else if (code) {
329
+ unwrapMonospace(code)
330
+ } else {
331
+ wrapSelection(action)
332
+ }
333
+ emitChange(editor.innerHTML)
334
+ refreshActiveFormatting()
335
+ return
336
+ }
337
+
338
+ if (toggleFormattingCommand(action)) emitChange(editor.innerHTML)
339
+ else wrapSelection(action)
340
+ refreshActiveFormatting()
341
+ }, [currentRange, emitChange, refreshActiveFormatting, wrapSelection])
342
+
343
+ const handleInput = useCallback((event: FormEvent<HTMLDivElement>) => {
344
+ emitChange(event.currentTarget.innerHTML)
345
+ refreshActiveFormatting()
346
+ }, [emitChange, refreshActiveFormatting])
347
+
348
+ const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
349
+ if (event.key === 'Enter' && !event.shiftKey) {
350
+ event.preventDefault()
351
+ // Sem esta guarda, cada Enter durante o upload dispara outro envio — foi o que duplicou a imagem.
352
+ if (!disabled && !isSending) onSend()
353
+ }
354
+ }, [disabled, isSending, onSend])
355
+
356
+ const handleFileChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
357
+ if (event.target.files?.length) onAttachFiles?.(event.target.files)
358
+ if (fileInputRef.current) fileInputRef.current.value = ''
359
+ }, [onAttachFiles])
360
+
361
+ const canSend = value.trim().length > 0 || hasQueuedAttachments
362
+
363
+ // Os mesmos botões servem à linha e ao popover: duas listas separadas divergiriam na primeira
364
+ // formatação nova.
365
+ const formattingButtons = ([
366
+ [RICH_COMPOSER_ACTION.BOLD, Bold],
367
+ [RICH_COMPOSER_ACTION.ITALIC, Italic],
368
+ [RICH_COMPOSER_ACTION.STRIKETHROUGH, Strikethrough],
369
+ [RICH_COMPOSER_ACTION.MONOSPACE, Code],
370
+ ] as const)
371
+ .filter(([action]) => shows(action))
372
+ .map(([action, Icon]) => {
373
+ const isActive = isFormattingActive({ active: activeFormatting, action })
374
+ return (
375
+ <button
376
+ key={action}
377
+ type="button"
378
+ // O clique tira o foco do campo antes do `onClick`, e com ele a seleção que a
379
+ // formatação precisa — segurar o mousedown é o que mantém o cursor onde está.
380
+ onMouseDown={(event) => event.preventDefault()}
381
+ onClick={() => applyFormatting(action)}
382
+ data-cv-tooltip={tooltipOf(action)}
383
+ aria-label={tooltipOf(action)}
384
+ aria-pressed={isActive}
385
+ className={cn(
386
+ COMPOSER_TOOL_BUTTON_CLASS,
387
+ isActive ? COMPOSER_TOOL_BUTTON_ACTIVE_CLASS : COMPOSER_TOOL_BUTTON_IDLE_CLASS,
388
+ )}
389
+ >
390
+ <Icon size={18} />
391
+ </button>
392
+ )
393
+ })
394
+
395
+ return (
396
+ <div className={cn(COMPOSER_BAR_CLASS, 'flex flex-col', className)}>
397
+ {attachmentsPreview}
398
+
399
+ {quickReplies?.length ? (
400
+ /* Uma linha só, rolando na horizontal. Deixar quebrar em várias linhas faz a barra crescer
401
+ conforme o número de respostas rápidas e empurrar a conversa para cima — o composer
402
+ precisa ter altura previsível, independente de quantos atalhos o host configurou. */
403
+ <div className="mb-2 flex flex-nowrap gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
404
+ {quickReplies.map((quickReply) => (
405
+ <button
406
+ key={quickReply.id}
407
+ type="button"
408
+ data-cv-tooltip={quickReply.tooltip} aria-label={quickReply.tooltip}
409
+ onClick={() => replaceContent(quickReply.text)}
410
+ className={cn(QUICK_REPLY_PILL_CLASS, 'flex-shrink-0')}
411
+ >
412
+ {quickReply.label}
413
+ </button>
414
+ ))}
415
+ </div>
416
+ ) : null}
417
+
418
+ {/* Uma escala só para a barra inteira: todo botão é 36px com ícone de 18, o mesmo gap entre
419
+ todos e nenhuma margem própria. Com tamanhos e espaçamentos diferentes por grupo, o
420
+ `items-end` alinhava as bases mas os ícones ficavam em alturas e ritmos distintos. */}
421
+ {/* Uma linha só, e ela não quebra: `flex-nowrap` com o campo em `min-w-0` faz o texto ceder
422
+ espaço quando a coluna estreita — que é o que ligar a prévia faz — em vez de empurrar
423
+ botão para uma segunda faixa. */}
424
+ <div ref={barRef} className="flex flex-nowrap items-end gap-1">
425
+ {/* Formatação só no desktop: no celular não há como selecionar texto e tocar no botão sem
426
+ perder a seleção, e a formatação sai digitada à mão de qualquer jeito.
427
+
428
+ Com a barra estreita os quatro recolhem num botão só. Ceder a largura ao campo é a
429
+ escolha certa aqui: escrever é a ação da barra, formatar é o acessório — e é o campo,
430
+ não o botão, que fica inutilizável quando encolhe. */}
431
+ {formattingButtons.length > 0 ? (
432
+ isCompact ? (
433
+ <div ref={formattingRef} className="relative hidden flex-shrink-0 sm:block">
434
+ <button
435
+ type="button"
436
+ onMouseDown={(event) => event.preventDefault()}
437
+ onClick={() => setIsFormattingOpen((open) => !open)}
438
+ data-cv-tooltip={tooltipOf('formatting')}
439
+ aria-label={tooltipOf('formatting')}
440
+ aria-expanded={isFormattingOpen}
441
+ className={cn(
442
+ COMPOSER_TOOL_BUTTON_CLASS,
443
+ activeFormatting || isFormattingOpen
444
+ ? COMPOSER_TOOL_BUTTON_ACTIVE_CLASS
445
+ : COMPOSER_TOOL_BUTTON_IDLE_CLASS,
446
+ )}
447
+ >
448
+ <Type size={18} />
449
+ </button>
450
+ {isFormattingOpen ? (
451
+ <div className="absolute bottom-full left-0 z-20 mb-2 flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1 py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
452
+ {formattingButtons}
453
+ </div>
454
+ ) : null}
455
+ </div>
456
+ ) : (
457
+ <div className="hidden flex-shrink-0 items-center gap-1 sm:flex">{formattingButtons}</div>
458
+ )
459
+ ) : null}
460
+
461
+ {shows(RICH_COMPOSER_ACTION.EMOJI) ? (
462
+ <SimpleEmojiPicker onSelect={insertAtCursor} label={tooltipOf('emoji')} />
463
+ ) : null}
464
+
465
+ {variables?.length && shows(RICH_COMPOSER_ACTION.VARIABLES) ? (
466
+ <div ref={variablesRef} className="relative">
467
+ <button
468
+ type="button"
469
+ onClick={() => setIsVariablesOpen((open) => !open)}
470
+ data-cv-tooltip={tooltipOf('variables')}
471
+ aria-label={tooltipOf('variables')}
472
+ aria-expanded={isVariablesOpen}
473
+ className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
474
+ >
475
+ <Braces size={18} />
476
+ </button>
477
+ {isVariablesOpen ? (
478
+ <div className="absolute bottom-full left-0 z-20 mb-2 max-h-56 w-64 overflow-y-auto rounded-xl border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
479
+ {variables.map((variable) => (
480
+ <button
481
+ key={variable.id}
482
+ type="button"
483
+ data-cv-tooltip={variable.tooltip ?? variable.value} aria-label={variable.tooltip ?? variable.value}
484
+ onClick={() => { insertAtCursor(variable.value); setIsVariablesOpen(false) }}
485
+ className="flex w-full flex-col items-start px-3 py-1.5 text-left hover:bg-teal-50 dark:hover:bg-teal-950/40"
486
+ >
487
+ <span className="text-sm text-gray-800 dark:text-gray-100">{variable.label}</span>
488
+ <span className="font-mono text-xs text-gray-400 dark:text-gray-500">{variable.value}</span>
489
+ </button>
490
+ ))}
491
+ </div>
492
+ ) : null}
493
+ </div>
494
+ ) : null}
495
+
496
+ {onAttachFiles && shows(RICH_COMPOSER_ACTION.ATTACH) ? (
497
+ <>
498
+ <input
499
+ ref={fileInputRef}
500
+ type="file"
501
+ multiple
502
+ accept={acceptedFileTypes}
503
+ onChange={handleFileChange}
504
+ className="hidden"
505
+ />
506
+ <button
507
+ type="button"
508
+ onClick={() => fileInputRef.current?.click()}
509
+ data-cv-tooltip={tooltipOf('attach')}
510
+ aria-label={tooltipOf('attach')}
511
+ className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
512
+ >
513
+ <Paperclip size={18} />
514
+ </button>
515
+ </>
516
+ ) : null}
517
+
518
+ <div
519
+ ref={editorRef}
520
+ contentEditable={!disabled}
521
+ suppressContentEditableWarning
522
+ role="textbox"
523
+ aria-multiline="true"
524
+ aria-label={placeholder}
525
+ data-placeholder={placeholder}
526
+ onInput={handleInput}
527
+ onKeyDown={handleKeyDown}
528
+ style={{ minHeight: '36px', maxHeight: '120px' }}
529
+ // `min-w-0` em vez de uma largura mínima em px: com o mínimo fixo o campo se recusava a
530
+ // encolher junto com a coluna e era ele quem estourava a linha ao ligar a prévia.
531
+ className="ml-1 min-w-0 flex-1 overflow-y-auto rounded-2xl border border-transparent bg-white px-4 py-2 text-sm text-gray-800 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-teal-300 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 empty:before:content-[attr(data-placeholder)] empty:before:text-gray-400 dark:empty:before:text-gray-500 before:pointer-events-none [&_strong]:font-semibold [&_b]:font-semibold [&_em]:italic [&_i]:italic [&_del]:line-through [&_s]:line-through [&_strike]:line-through [&_code]:rounded [&_code]:bg-black/5 [&_code]:px-0.5 [&_code]:font-mono [&_code]:text-sm dark:[&_code]:bg-white/10"
532
+ />
533
+
534
+ {!canSend && idleAction ? (
535
+ <div className="flex-shrink-0">{idleAction}</div>
536
+ ) : (
537
+ <button
538
+ type="button"
539
+ onClick={onSend}
540
+ disabled={disabled || isSending || !canSend}
541
+ data-cv-tooltip={tooltipOf('send')}
542
+ aria-label={tooltipOf('send')}
543
+ className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-teal-600 text-white transition-colors hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-40"
544
+ >
545
+ {isSending ? <span className="text-xs">⏳</span> : <SendHorizonal size={16} />}
546
+ </button>
547
+ )}
548
+ </div>
549
+ </div>
550
+ )
551
+ })
@@ -42,10 +42,11 @@ export function SimpleEmojiPicker({ onSelect, label = 'Emojis', pickerWidth = '2
42
42
  <button
43
43
  type="button"
44
44
  onClick={() => setOpen((v) => !v)}
45
- className="w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 dark:text-gray-500 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 transition-colors"
46
- title={label}
45
+ className="w-9 h-9 flex items-center justify-center rounded-full text-gray-400 dark:text-gray-500 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 transition-colors"
46
+ data-cv-tooltip={label}
47
+ aria-label={label}
47
48
  >
48
- <Smile size={15} />
49
+ <Smile size={18} />
49
50
  </button>
50
51
 
51
52
  {open && (
@@ -56,6 +57,7 @@ export function SimpleEmojiPicker({ onSelect, label = 'Emojis', pickerWidth = '2
56
57
  >
57
58
  {EMOJIS.map((emoji) => (
58
59
  <button
60
+ data-cv-tooltip={emoji} aria-label={emoji}
59
61
  key={emoji}
60
62
  type="button"
61
63
  onClick={() => { onSelect(emoji); setOpen(false) }}
@@ -32,7 +32,7 @@ export function StatusTicks({ status, title }: StatusTicksProps) {
32
32
  const colorClass = STATUS_COLOR_CLASS[status] ?? STATUS_COLOR_CLASS.sent
33
33
 
34
34
  return (
35
- <span className={`cursor-help leading-none flex items-center ${colorClass}`} title={title}>
35
+ <span className={`cursor-help leading-none flex items-center ${colorClass}`} data-cv-tooltip={title}>
36
36
  {status === 'failed' ? <AlertTriangle size={11} /> : <Ticks double={status !== 'sent'} />}
37
37
  </span>
38
38
  )
package/src/Toast.tsx CHANGED
@@ -26,6 +26,8 @@ export const toast = {
26
26
  },
27
27
  }
28
28
 
29
+ const TOAST_CLOSE_LABEL = 'Fechar aviso'
30
+
29
31
  const typeStyles: Record<ToastType, string> = {
30
32
  success: 'bg-green-600',
31
33
  error: 'bg-red-600',
@@ -77,6 +79,8 @@ function ToastItemComponent({ item, onRemove }: { item: ToastItem; onRemove: (id
77
79
  setExiting(true)
78
80
  setTimeout(() => onRemove(item.id), 300)
79
81
  }}
82
+ data-cv-tooltip={TOAST_CLOSE_LABEL}
83
+ aria-label={TOAST_CLOSE_LABEL}
80
84
  className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
81
85
  >
82
86
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { tooltipPositionOf } from './Tooltip'
4
+
5
+ const VIEWPORT_WIDTH = 1024
6
+
7
+ describe('tooltipPositionOf', () => {
8
+ test('desenha acima do alvo e centralizado nele', () => {
9
+ const position = tooltipPositionOf({
10
+ targetRect: { top: 300, bottom: 336, left: 400, width: 36 },
11
+ viewportWidth: VIEWPORT_WIDTH,
12
+ })
13
+
14
+ expect(position.placement).toBe('top')
15
+ expect(position.left).toBe(418)
16
+ expect(position.top).toBe(292)
17
+ })
18
+
19
+ test('desce quando o alvo está colado no topo da janela', () => {
20
+ const position = tooltipPositionOf({
21
+ targetRect: { top: 12, bottom: 48, left: 400, width: 36 },
22
+ viewportWidth: VIEWPORT_WIDTH,
23
+ })
24
+
25
+ expect(position.placement).toBe('bottom')
26
+ expect(position.top).toBe(56)
27
+ })
28
+
29
+ test('afasta da borda o balão de um alvo no canto', () => {
30
+ const esquerda = tooltipPositionOf({
31
+ targetRect: { top: 300, bottom: 336, left: 0, width: 36 },
32
+ viewportWidth: VIEWPORT_WIDTH,
33
+ })
34
+ const direita = tooltipPositionOf({
35
+ targetRect: { top: 300, bottom: 336, left: VIEWPORT_WIDTH - 36, width: 36 },
36
+ viewportWidth: VIEWPORT_WIDTH,
37
+ })
38
+
39
+ expect(esquerda.left).toBe(72)
40
+ expect(direita.left).toBe(VIEWPORT_WIDTH - 72)
41
+ })
42
+ })