@adatechnology/conversations-ui 0.1.0-rc.26 → 0.1.0-rc.27

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 (75) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
  3. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  4. package/dist/flows/index.d.ts +2 -0
  5. package/dist/flows/index.js +61 -18
  6. package/dist/index.d.ts +37 -10
  7. package/dist/index.js +694 -297
  8. package/dist/preview/index.js +3 -3
  9. package/dist/styles.css +119 -6
  10. package/package.json +1 -1
  11. package/src/AudioPlayer.tsx +8 -0
  12. package/src/AudioRecorderButton.test.tsx +3 -3
  13. package/src/AudioRecorderButton.tsx +3 -3
  14. package/src/AudioTranscription.tsx +4 -1
  15. package/src/Avatar.tsx +1 -1
  16. package/src/ConversationContextPanel.tsx +2 -2
  17. package/src/ConversationDocumentsPanel.tsx +11 -6
  18. package/src/ConversationHeader.test.tsx +66 -0
  19. package/src/ConversationHeader.tsx +147 -47
  20. package/src/ConversationListItem.tsx +3 -2
  21. package/src/ConversationRow.tsx +31 -7
  22. package/src/DocumentsLibrary.tsx +9 -4
  23. package/src/EmojiPicker.tsx +2 -1
  24. package/src/InteractiveMessage.tsx +3 -0
  25. package/src/Lightbox.tsx +1 -1
  26. package/src/MediaRenderer.tsx +2 -0
  27. package/src/MessageBubble.test.tsx +41 -0
  28. package/src/MessageBubble.tsx +27 -8
  29. package/src/MessageComposer.tsx +11 -8
  30. package/src/RichMessageComposer.test.tsx +42 -12
  31. package/src/RichMessageComposer.tsx +216 -45
  32. package/src/SimpleEmojiPicker.tsx +5 -3
  33. package/src/StatusTicks.tsx +1 -1
  34. package/src/Toast.tsx +4 -0
  35. package/src/Tooltip.test.ts +42 -0
  36. package/src/Tooltip.tsx +164 -0
  37. package/src/WhatsAppMessageEditor.tsx +4 -4
  38. package/src/WindowExpiredNotice.tsx +12 -4
  39. package/src/composer.constant.ts +33 -0
  40. package/src/conversationWindow.ts +7 -5
  41. package/src/documents/DocumentsWorkspace.tsx +10 -6
  42. package/src/flows/FlowGroupHeader.tsx +2 -2
  43. package/src/flows/FlowMapNode.tsx +2 -1
  44. package/src/flows/FlowNodeCard.tsx +3 -3
  45. package/src/flows/FlowNodePanel.tsx +6 -5
  46. package/src/flows/FlowPalette.tsx +7 -1
  47. package/src/flows/FlowPortalNode.tsx +1 -1
  48. package/src/flows/FlowsWorkspace.tsx +13 -4
  49. package/src/flows/labels.ts +4 -0
  50. package/src/hooks/useContainerWidth.ts +35 -0
  51. package/src/hooks/useConversationRealtime.ts +10 -8
  52. package/src/icon.constant.ts +12 -0
  53. package/src/index.ts +1 -0
  54. package/src/lib/composer-formatting.test.ts +78 -0
  55. package/src/lib/composer-formatting.ts +145 -0
  56. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  57. package/src/lib/whatsapp-formatting.tsx +28 -3
  58. package/src/listing/index.tsx +5 -1
  59. package/src/pagination.constant.ts +10 -0
  60. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  61. package/src/providers/ConversationsProvider.tsx +8 -6
  62. package/src/settings/MessagesWorkspace.tsx +3 -0
  63. package/src/settings/TopicsForm.tsx +2 -0
  64. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  65. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  66. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  67. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  68. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  69. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  70. package/src/styles.css +100 -4
  71. package/src/workspace/BulkTemplateModal.tsx +4 -2
  72. package/src/workspace/ConversationPane.tsx +49 -19
  73. package/src/workspace/ConversationsInboxList.tsx +17 -8
  74. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  75. package/src/workspace/useConversationsInbox.ts +5 -2
@@ -12,9 +12,30 @@
12
12
  */
13
13
 
14
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 } from 'lucide-react'
15
+ import { Bold, Italic, Strikethrough, Code, Paperclip, SendHorizonal, Braces, Type } from 'lucide-react'
16
16
 
17
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'
18
39
  import { htmlToWA, waToHTML } from './lib/whatsapp-formatting'
19
40
  import { SimpleEmojiPicker } from './SimpleEmojiPicker'
20
41
  import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
@@ -41,6 +62,8 @@ export interface RichComposerTooltips {
41
62
  attach: string
42
63
  send: string
43
64
  variables: string
65
+ /** Botão que abre a formatação recolhida, quando a barra está estreita demais para os quatro. */
66
+ formatting: string
44
67
  }
45
68
 
46
69
  export const DEFAULT_RICH_COMPOSER_TOOLTIPS: RichComposerTooltips = {
@@ -52,6 +75,7 @@ export const DEFAULT_RICH_COMPOSER_TOOLTIPS: RichComposerTooltips = {
52
75
  attach: 'Anexar arquivo',
53
76
  send: 'Enviar',
54
77
  variables: 'Variáveis disponíveis',
78
+ formatting: 'Formatação',
55
79
  }
56
80
 
57
81
  /**
@@ -88,8 +112,6 @@ const FORMATTING_ELEMENT = {
88
112
  monospace: 'code',
89
113
  } as const
90
114
 
91
- const MONOSPACE_CLASS = 'bg-black/5 dark:bg-white/10 rounded px-0.5 font-mono text-sm'
92
-
93
115
  /** Handle imperativo: `contentEditable` controlado pelo React perde o cursor a cada tecla. */
94
116
  export interface RichMessageComposerHandle {
95
117
  setContent: (text: string) => void
@@ -119,6 +141,12 @@ export interface RichMessageComposerProps {
119
141
  idleAction?: ReactNode
120
142
  /** Prévia dos anexos já escolhidos, desenhada pelo host acima da barra. */
121
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
122
150
  acceptedFileTypes?: string
123
151
  className?: string
124
152
  }
@@ -137,13 +165,52 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
137
165
  isSending = false,
138
166
  idleAction,
139
167
  attachmentsPreview,
168
+ hasQueuedAttachments = false,
140
169
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
141
170
  className,
142
171
  }, ref) {
143
172
  const editorRef = useRef<HTMLDivElement>(null)
144
173
  const fileInputRef = useRef<HTMLInputElement>(null)
145
174
  const variablesRef = useRef<HTMLDivElement>(null)
175
+ const formattingRef = useRef<HTMLDivElement>(null)
176
+ const barRef = useRef<HTMLDivElement>(null)
146
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])
147
214
 
148
215
  useEffect(() => {
149
216
  if (!isVariablesOpen) return
@@ -154,6 +221,21 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
154
221
  return () => document.removeEventListener('mousedown', closeOnOutsideClick)
155
222
  }, [isVariablesOpen])
156
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
+
157
239
  const tooltipOf = (action: keyof RichComposerTooltips): string =>
158
240
  tooltips?.[action] ?? DEFAULT_RICH_COMPOSER_TOOLTIPS[action]
159
241
  const shows = (action: RichComposerAction): boolean => toolbar?.[action] !== false
@@ -163,9 +245,9 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
163
245
  const editor = editorRef.current
164
246
  if (!editor) return
165
247
  editor.innerHTML = waToHTML(text)
166
- onChange(htmlToWA(editor.innerHTML))
248
+ emitChange(editor.innerHTML)
167
249
  editor.focus()
168
- }, [onChange])
250
+ }, [emitChange])
169
251
 
170
252
  useImperativeHandle(ref, () => ({
171
253
  setContent: replaceContent,
@@ -173,10 +255,10 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
173
255
  const editor = editorRef.current
174
256
  if (!editor) return
175
257
  editor.innerHTML = ''
176
- onChange('')
258
+ emitChange('')
177
259
  },
178
260
  focus: () => editorRef.current?.focus(),
179
- }), [onChange, replaceContent])
261
+ }), [emitChange, replaceContent])
180
262
 
181
263
  /** Range vivo dentro do campo, ou `undefined` se o cursor está em outro lugar da página. */
182
264
  const currentRange = useCallback((): Range | undefined => {
@@ -194,8 +276,8 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
194
276
  range.collapse(true)
195
277
  selection?.removeAllRanges()
196
278
  selection?.addRange(range)
197
- onChange(htmlToWA(editorRef.current?.innerHTML ?? ''))
198
- }, [onChange])
279
+ emitChange(editorRef.current?.innerHTML ?? '')
280
+ }, [emitChange])
199
281
 
200
282
  const insertAtCursor = useCallback((fragment: string) => {
201
283
  const range = currentRange()
@@ -217,32 +299,101 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
217
299
  if (!range || !selectedText) return
218
300
  range.deleteContents()
219
301
  const wrapper = document.createElement(FORMATTING_ELEMENT[action])
220
- if (action === 'monospace') wrapper.className = MONOSPACE_CLASS
302
+ if (action === 'monospace') wrapper.className = COMPOSER_MONOSPACE_CLASS
221
303
  wrapper.textContent = selectedText
222
304
  range.insertNode(wrapper)
223
305
  commitRange(range, wrapper)
224
306
  }, [commitRange, currentRange])
225
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
+
226
343
  const handleInput = useCallback((event: FormEvent<HTMLDivElement>) => {
227
- onChange(htmlToWA(event.currentTarget.innerHTML))
228
- }, [onChange])
344
+ emitChange(event.currentTarget.innerHTML)
345
+ refreshActiveFormatting()
346
+ }, [emitChange, refreshActiveFormatting])
229
347
 
230
348
  const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
231
349
  if (event.key === 'Enter' && !event.shiftKey) {
232
350
  event.preventDefault()
233
- if (!disabled) onSend()
351
+ // Sem esta guarda, cada Enter durante o upload dispara outro envio — foi o que duplicou a imagem.
352
+ if (!disabled && !isSending) onSend()
234
353
  }
235
- }, [disabled, onSend])
354
+ }, [disabled, isSending, onSend])
236
355
 
237
356
  const handleFileChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
238
357
  if (event.target.files?.length) onAttachFiles?.(event.target.files)
239
358
  if (fileInputRef.current) fileInputRef.current.value = ''
240
359
  }, [onAttachFiles])
241
360
 
242
- const canSend = value.trim().length > 0
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
+ })
243
394
 
244
395
  return (
245
- <div className={cn('flex flex-col', className)}>
396
+ <div className={cn(COMPOSER_BAR_CLASS, 'flex flex-col', className)}>
246
397
  {attachmentsPreview}
247
398
 
248
399
  {quickReplies?.length ? (
@@ -254,9 +405,9 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
254
405
  <button
255
406
  key={quickReply.id}
256
407
  type="button"
257
- title={quickReply.tooltip}
408
+ data-cv-tooltip={quickReply.tooltip} aria-label={quickReply.tooltip}
258
409
  onClick={() => replaceContent(quickReply.text)}
259
- className="flex-shrink-0 whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-teal-800 dark:hover:bg-teal-950/40 dark:hover:text-teal-400"
410
+ className={cn(QUICK_REPLY_PILL_CLASS, 'flex-shrink-0')}
260
411
  >
261
412
  {quickReply.label}
262
413
  </button>
@@ -264,30 +415,48 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
264
415
  </div>
265
416
  ) : null}
266
417
 
267
- <div className="flex flex-wrap items-end gap-2">
268
- {/* Só no desktop: no celular não há como selecionar texto e tocar no botão sem perder a
269
- seleção, e a formatação sai digitada à mão de qualquer jeito. */}
270
- <div className="mb-0.5 hidden items-center gap-0.5 sm:flex">
271
- {([
272
- [RICH_COMPOSER_ACTION.BOLD, Bold],
273
- [RICH_COMPOSER_ACTION.ITALIC, Italic],
274
- [RICH_COMPOSER_ACTION.STRIKETHROUGH, Strikethrough],
275
- [RICH_COMPOSER_ACTION.MONOSPACE, Code],
276
- ] as const)
277
- .filter(([action]) => shows(action))
278
- .map(([action, Icon]) => (
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">
279
434
  <button
280
- key={action}
281
435
  type="button"
282
- onClick={() => wrapSelection(action)}
283
- title={tooltipOf(action)}
284
- aria-label={tooltipOf(action)}
285
- className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
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
+ )}
286
447
  >
287
- <Icon size={15} />
448
+ <Type size={18} />
288
449
  </button>
289
- ))}
290
- </div>
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}
291
460
 
292
461
  {shows(RICH_COMPOSER_ACTION.EMOJI) ? (
293
462
  <SimpleEmojiPicker onSelect={insertAtCursor} label={tooltipOf('emoji')} />
@@ -298,10 +467,10 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
298
467
  <button
299
468
  type="button"
300
469
  onClick={() => setIsVariablesOpen((open) => !open)}
301
- title={tooltipOf('variables')}
470
+ data-cv-tooltip={tooltipOf('variables')}
302
471
  aria-label={tooltipOf('variables')}
303
472
  aria-expanded={isVariablesOpen}
304
- className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
473
+ className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
305
474
  >
306
475
  <Braces size={18} />
307
476
  </button>
@@ -311,7 +480,7 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
311
480
  <button
312
481
  key={variable.id}
313
482
  type="button"
314
- title={variable.tooltip ?? variable.value}
483
+ data-cv-tooltip={variable.tooltip ?? variable.value} aria-label={variable.tooltip ?? variable.value}
315
484
  onClick={() => { insertAtCursor(variable.value); setIsVariablesOpen(false) }}
316
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"
317
486
  >
@@ -337,9 +506,9 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
337
506
  <button
338
507
  type="button"
339
508
  onClick={() => fileInputRef.current?.click()}
340
- title={tooltipOf('attach')}
509
+ data-cv-tooltip={tooltipOf('attach')}
341
510
  aria-label={tooltipOf('attach')}
342
- className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
511
+ className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
343
512
  >
344
513
  <Paperclip size={18} />
345
514
  </button>
@@ -357,7 +526,9 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
357
526
  onInput={handleInput}
358
527
  onKeyDown={handleKeyDown}
359
528
  style={{ minHeight: '36px', maxHeight: '120px' }}
360
- className="flex-1 min-w-[180px] overflow-y-auto rounded-2xl border border-gray-200 bg-gray-50 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 [&_em]:italic [&_del]:line-through [&_code]:rounded [&_code]:bg-black/5 [&_code]:px-0.5 [&_code]:font-mono [&_code]:text-sm dark:[&_code]:bg-white/10"
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"
361
532
  />
362
533
 
363
534
  {!canSend && idleAction ? (
@@ -367,7 +538,7 @@ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMes
367
538
  type="button"
368
539
  onClick={onSend}
369
540
  disabled={disabled || isSending || !canSend}
370
- title={tooltipOf('send')}
541
+ data-cv-tooltip={tooltipOf('send')}
371
542
  aria-label={tooltipOf('send')}
372
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"
373
544
  >
@@ -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
+ })