@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.
- package/dist/chunk-DXPSPUWF.js +110 -0
- package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +2 -0
- package/dist/flows/index.js +61 -18
- package/dist/index.d.ts +37 -10
- package/dist/index.js +694 -297
- package/dist/preview/index.js +3 -3
- package/dist/styles.css +119 -6
- package/package.json +1 -1
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +3 -3
- package/src/AudioRecorderButton.tsx +3 -3
- package/src/AudioTranscription.tsx +4 -1
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +2 -2
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +3 -2
- package/src/ConversationRow.tsx +31 -7
- package/src/DocumentsLibrary.tsx +9 -4
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +2 -0
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +27 -8
- package/src/MessageComposer.tsx +11 -8
- package/src/RichMessageComposer.test.tsx +42 -12
- package/src/RichMessageComposer.tsx +216 -45
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +164 -0
- package/src/WhatsAppMessageEditor.tsx +4 -4
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/composer.constant.ts +33 -0
- package/src/conversationWindow.ts +7 -5
- package/src/documents/DocumentsWorkspace.tsx +10 -6
- package/src/flows/FlowGroupHeader.tsx +2 -2
- package/src/flows/FlowMapNode.tsx +2 -1
- package/src/flows/FlowNodeCard.tsx +3 -3
- package/src/flows/FlowNodePanel.tsx +6 -5
- package/src/flows/FlowPalette.tsx +7 -1
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowsWorkspace.tsx +13 -4
- package/src/flows/labels.ts +4 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +1 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +5 -1
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationSimulatorPanel.tsx +1 -1
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/settings/MessagesWorkspace.tsx +3 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
- package/src/settings/TranscriptionSettingsForm.tsx +1 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
- package/src/styles.css +100 -4
- package/src/workspace/BulkTemplateModal.tsx +4 -2
- package/src/workspace/ConversationPane.tsx +49 -19
- package/src/workspace/ConversationsInboxList.tsx +17 -8
- package/src/workspace/ConversationsWorkspace.tsx +24 -16
- 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
|
-
|
|
248
|
+
emitChange(editor.innerHTML)
|
|
167
249
|
editor.focus()
|
|
168
|
-
}, [
|
|
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
|
-
|
|
258
|
+
emitChange('')
|
|
177
259
|
},
|
|
178
260
|
focus: () => editorRef.current?.focus(),
|
|
179
|
-
}), [
|
|
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
|
-
|
|
198
|
-
}, [
|
|
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 =
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
+
data-cv-tooltip={quickReply.tooltip} aria-label={quickReply.tooltip}
|
|
258
409
|
onClick={() => replaceContent(quickReply.text)}
|
|
259
|
-
className=
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
<
|
|
448
|
+
<Type size={18} />
|
|
288
449
|
</button>
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
470
|
+
data-cv-tooltip={tooltipOf('variables')}
|
|
302
471
|
aria-label={tooltipOf('variables')}
|
|
303
472
|
aria-expanded={isVariablesOpen}
|
|
304
|
-
className=
|
|
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
|
-
|
|
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
|
-
|
|
509
|
+
data-cv-tooltip={tooltipOf('attach')}
|
|
341
510
|
aria-label={tooltipOf('attach')}
|
|
342
|
-
className=
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
46
|
-
|
|
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={
|
|
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) }}
|
package/src/StatusTicks.tsx
CHANGED
|
@@ -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}`}
|
|
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
|
+
})
|