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

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 (81) 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 +75 -2
  5. package/dist/flows/index.js +176 -95
  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 +2 -2
  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/buildOutput.test.ts +79 -0
  40. package/src/composer.constant.ts +33 -0
  41. package/src/conversationWindow.ts +7 -5
  42. package/src/documents/DocumentsWorkspace.tsx +10 -6
  43. package/src/flows/FlowGroupHeader.tsx +2 -2
  44. package/src/flows/FlowMapNode.tsx +2 -1
  45. package/src/flows/FlowNodeCard.tsx +3 -3
  46. package/src/flows/FlowNodePanel.tsx +6 -5
  47. package/src/flows/FlowPalette.tsx +7 -1
  48. package/src/flows/FlowPortalNode.tsx +1 -1
  49. package/src/flows/FlowsWorkspace.tsx +69 -102
  50. package/src/flows/flowEditorOps.test.ts +241 -0
  51. package/src/flows/flowEditorOps.ts +177 -0
  52. package/src/flows/flowGraph.ts +1 -1
  53. package/src/flows/index.ts +16 -0
  54. package/src/flows/labels.ts +4 -0
  55. package/src/flows/workspaceContract.test.ts +95 -0
  56. package/src/hooks/useContainerWidth.ts +35 -0
  57. package/src/hooks/useConversationRealtime.ts +10 -8
  58. package/src/icon.constant.ts +12 -0
  59. package/src/index.ts +1 -0
  60. package/src/lib/composer-formatting.test.ts +78 -0
  61. package/src/lib/composer-formatting.ts +145 -0
  62. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  63. package/src/lib/whatsapp-formatting.tsx +28 -3
  64. package/src/listing/index.tsx +5 -1
  65. package/src/pagination.constant.ts +10 -0
  66. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  67. package/src/providers/ConversationsProvider.tsx +8 -6
  68. package/src/settings/MessagesWorkspace.tsx +3 -0
  69. package/src/settings/TopicsForm.tsx +2 -0
  70. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  71. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  72. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  73. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  74. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  75. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  76. package/src/styles.css +100 -4
  77. package/src/workspace/BulkTemplateModal.tsx +4 -2
  78. package/src/workspace/ConversationPane.tsx +49 -19
  79. package/src/workspace/ConversationsInboxList.tsx +17 -8
  80. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  81. package/src/workspace/useConversationsInbox.ts +5 -2
@@ -8,8 +8,8 @@ import {
8
8
  DocumentsLibrary,
9
9
  MessageBubble,
10
10
  MessageComposer
11
- } from "../chunk-TV4OQRGH.js";
12
- import "../chunk-2AYDBWNE.js";
11
+ } from "../chunk-UZJBYD5O.js";
12
+ import "../chunk-WCBDXZ3X.js";
13
13
 
14
14
  // src/preview/previewStore.ts
15
15
  var GLOBAL_CHANNEL = "global";
@@ -1059,7 +1059,7 @@ function ConversationSimulatorPanel({
1059
1059
  ] }),
1060
1060
  /* @__PURE__ */ jsxs2("div", { className: "cv-simulator-panel__actions", children: [
1061
1061
  headerActions,
1062
- /* @__PURE__ */ jsx2("button", { type: "button", onClick: onClose, title: text.close, "aria-label": text.close, className: "cv-simulator-panel__close", children: /* @__PURE__ */ jsx2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M18 6 6 18M6 6l12 12" }) }) })
1062
+ /* @__PURE__ */ jsx2("button", { type: "button", onClick: onClose, "data-cv-tooltip": text.close, "aria-label": text.close, className: "cv-simulator-panel__close", children: /* @__PURE__ */ jsx2("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M18 6 6 18M6 6l12 12" }) }) })
1063
1063
  ] })
1064
1064
  ] }),
1065
1065
  /* @__PURE__ */ jsx2("div", { className: "cv-simulator-panel__body", children: /* @__PURE__ */ jsx2(ConversationPreview, { ...previewProps, placeholder: text.placeholder }) })
package/dist/styles.css CHANGED
@@ -10,6 +10,9 @@
10
10
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%2319232a' stroke-width='1.2' opacity='0.9'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
11
11
  }
12
12
  .cv-header-action {
13
+ display: inline-flex;
14
+ align-items: center;
15
+ gap: 0.375rem;
13
16
  border: 1px solid rgb(203 213 225);
14
17
  border-radius: 0.375rem;
15
18
  padding: 0.375rem 0.75rem;
@@ -26,6 +29,9 @@
26
29
  color: rgb(226 232 240);
27
30
  }
28
31
  .cv-header-icon {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ justify-content: center;
29
35
  border: 1px solid rgb(203 213 225);
30
36
  border-radius: 0.5rem;
31
37
  padding: 0.375rem 0.5rem;
@@ -128,6 +134,9 @@
128
134
  background: rgb(30 41 59);
129
135
  }
130
136
  .cv-back {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ justify-content: center;
131
140
  border: 1px solid rgb(203 213 225);
132
141
  border-radius: 0.5rem;
133
142
  padding: 0.25rem 0.5rem;
@@ -144,6 +153,11 @@
144
153
  border-color: rgb(71 85 105);
145
154
  color: rgb(226 232 240);
146
155
  }
156
+ .cv-stat {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: 0.25rem;
160
+ }
147
161
  .cv-only-wide {
148
162
  display: none;
149
163
  }
@@ -366,6 +380,9 @@
366
380
  gap: 0.5rem;
367
381
  }
368
382
  .cv-workspace-toggle {
383
+ display: inline-flex;
384
+ align-items: center;
385
+ gap: 0.375rem;
369
386
  border: 1px solid rgb(229 231 235);
370
387
  border-radius: 0.375rem;
371
388
  padding: 0.5rem 0.75rem;
@@ -550,18 +567,26 @@
550
567
  }
551
568
  .cv-workspace-chips {
552
569
  display: flex;
553
- flex-wrap: wrap;
570
+ flex-wrap: nowrap;
554
571
  align-items: center;
555
- gap: 0.5rem;
572
+ gap: 0.375rem;
556
573
  font-size: 0.75rem;
574
+ overflow-x: auto;
575
+ scrollbar-width: none;
576
+ }
577
+ .cv-workspace-chips::-webkit-scrollbar {
578
+ display: none;
557
579
  }
558
580
  .cv-workspace-chips__legend {
559
581
  color: rgb(107 114 128);
582
+ flex-shrink: 0;
560
583
  }
561
584
  .cv-workspace-chip {
562
585
  display: flex;
586
+ flex-shrink: 0;
563
587
  align-items: center;
564
588
  gap: 0.25rem;
589
+ white-space: nowrap;
565
590
  border-radius: 0.375rem;
566
591
  padding: 0.25rem 0.5rem;
567
592
  }
@@ -571,10 +596,53 @@
571
596
  .dark .cv-workspace-chip--on {
572
597
  background: rgb(51 65 85);
573
598
  }
574
- .cv-workspace-dot {
575
- width: 0.5rem;
576
- height: 0.5rem;
577
- border-radius: 9999px;
599
+ .cv-workspace-chip--fresh {
600
+ color: rgb(22 163 74);
601
+ }
602
+ .cv-workspace-chip--warning {
603
+ color: rgb(202 138 4);
604
+ }
605
+ .cv-workspace-chip--critical {
606
+ color: rgb(220 38 38);
607
+ }
608
+ .cv-workspace-chip--expired {
609
+ color: rgb(107 114 128);
610
+ }
611
+ .cv-workspace-chip--fresh.cv-workspace-chip--on {
612
+ background: rgb(220 252 231);
613
+ }
614
+ .cv-workspace-chip--warning.cv-workspace-chip--on {
615
+ background: rgb(254 249 195);
616
+ }
617
+ .cv-workspace-chip--critical.cv-workspace-chip--on {
618
+ background: rgb(254 226 226);
619
+ }
620
+ .cv-workspace-chip--expired.cv-workspace-chip--on {
621
+ background: rgb(229 231 235);
622
+ }
623
+ .dark .cv-workspace-chip--fresh {
624
+ color: rgb(74 222 128);
625
+ }
626
+ .dark .cv-workspace-chip--warning {
627
+ color: rgb(250 204 21);
628
+ }
629
+ .dark .cv-workspace-chip--critical {
630
+ color: rgb(248 113 113);
631
+ }
632
+ .dark .cv-workspace-chip--expired {
633
+ color: rgb(148 163 184);
634
+ }
635
+ .dark .cv-workspace-chip--fresh.cv-workspace-chip--on {
636
+ background: rgb(20 83 45);
637
+ }
638
+ .dark .cv-workspace-chip--warning.cv-workspace-chip--on {
639
+ background: rgb(113 63 18);
640
+ }
641
+ .dark .cv-workspace-chip--critical.cv-workspace-chip--on {
642
+ background: rgb(127 29 29);
643
+ }
644
+ .dark .cv-workspace-chip--expired.cv-workspace-chip--on {
645
+ background: rgb(51 65 85);
578
646
  }
579
647
  .cv-workspace-selectall {
580
648
  display: flex;
@@ -913,3 +981,48 @@
913
981
  .dark .cv-listing-perpage {
914
982
  border-color: rgb(71 85 105);
915
983
  }
984
+ .cv-tooltip {
985
+ position: fixed;
986
+ z-index: 9999;
987
+ max-width: min(16rem, 90vw);
988
+ transform: translate(-50%, -100%);
989
+ border-radius: 0.375rem;
990
+ background: rgb(17 24 39 / 95%);
991
+ padding: 0.3125rem 0.5rem;
992
+ font-size: 0.75rem;
993
+ line-height: 1.2;
994
+ color: #fff;
995
+ text-align: center;
996
+ box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
997
+ pointer-events: none;
998
+ animation: cv-tooltip-in 90ms ease-out;
999
+ }
1000
+ .cv-tooltip--bottom {
1001
+ transform: translate(-50%, 0);
1002
+ }
1003
+ .dark .cv-tooltip {
1004
+ background: rgb(243 244 246 / 96%);
1005
+ color: rgb(17 24 39);
1006
+ }
1007
+ @keyframes cv-tooltip-in {
1008
+ from {
1009
+ opacity: 0;
1010
+ }
1011
+ to {
1012
+ opacity: 1;
1013
+ }
1014
+ }
1015
+ @media (prefers-reduced-motion: reduce) {
1016
+ .cv-tooltip {
1017
+ animation: none;
1018
+ }
1019
+ }
1020
+ .cv-composer-bar {
1021
+ border-top: 1px solid rgb(229 231 235);
1022
+ background: rgb(240 242 245);
1023
+ padding: 0.625rem 0.75rem;
1024
+ }
1025
+ .dark .cv-composer-bar {
1026
+ border-top-color: rgb(51 65 85);
1027
+ background: rgb(17 24 39);
1028
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.26",
3
+ "version": "0.1.0-rc.28",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -51,7 +51,7 @@
51
51
  "@types/react-dom": "^18 || ^19"
52
52
  },
53
53
  "scripts": {
54
- "build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/preview/index.ts src/styles.css --dts --format esm --external react --external react-dom --external @xyflow/react",
54
+ "build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/preview/index.ts src/styles.css --dts --clean --format esm --external react --external react-dom --external @xyflow/react",
55
55
  "build:watch": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/styles.css --watch --dts --format esm --external react --external react-dom --external @xyflow/react",
56
56
  "check": "tsc -p tsconfig.json --noEmit",
57
57
  "test": "bun test"
@@ -15,6 +15,13 @@ const BARS = Array.from({ length: 30 }, (_, i) => {
15
15
  return heights[i % heights.length]
16
16
  })
17
17
 
18
+ // O player não recebe bag de rótulos do host: são duas palavras, e criar um contrato de labels só
19
+ // para elas custaria mais do que resolve.
20
+ const AUDIO_PLAYER_LABELS = {
21
+ play: 'Reproduzir',
22
+ pause: 'Pausar',
23
+ } as const
24
+
18
25
  function fmt(sec: number): string {
19
26
  if (!isFinite(sec)) return '0:00'
20
27
  const m = Math.floor(sec / 60)
@@ -77,6 +84,7 @@ export function AudioPlayer({ src, isMine = false }: AudioPlayerProps) {
77
84
  <audio ref={audioRef} src={src} preload="metadata" />
78
85
 
79
86
  <button
87
+ data-cv-tooltip={playing ? AUDIO_PLAYER_LABELS.pause : AUDIO_PLAYER_LABELS.play} aria-label={playing ? AUDIO_PLAYER_LABELS.pause : AUDIO_PLAYER_LABELS.play}
80
88
  onClick={toggle}
81
89
  className={`flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center transition-colors ${playBtn}`}
82
90
  >
@@ -9,8 +9,8 @@ describe('AudioRecorderButton', () => {
9
9
  it('começa oferecendo gravar, sem painel de revisão à vista', () => {
10
10
  const markup = renderToStaticMarkup(<AudioRecorderButton onRecorded={noop} />)
11
11
 
12
- expect(markup).toContain(`title="${DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start}"`)
13
- expect(markup).not.toContain(`title="${DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.send}"`)
12
+ expect(markup).toContain(`data-cv-tooltip="${DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start}"`)
13
+ expect(markup).not.toContain(`data-cv-tooltip="${DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.send}"`)
14
14
  expect(markup).not.toContain('<audio')
15
15
  })
16
16
 
@@ -19,7 +19,7 @@ describe('AudioRecorderButton', () => {
19
19
  <AudioRecorderButton onRecorded={noop} labels={{ start: 'Gravar recado' }} />,
20
20
  )
21
21
 
22
- expect(markup).toContain('title="Gravar recado"')
22
+ expect(markup).toContain('data-cv-tooltip="Gravar recado"')
23
23
  })
24
24
 
25
25
  it('mantém a caixa de 40px do botão de enviar para a barra não pular', () => {
@@ -205,7 +205,7 @@ export function AudioRecorderButton({
205
205
  <button
206
206
  type="button"
207
207
  onClick={discard}
208
- title={labelOf('discard')}
208
+ data-cv-tooltip={labelOf('discard')}
209
209
  aria-label={labelOf('discard')}
210
210
  className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:hover:bg-gray-700"
211
211
  >
@@ -214,7 +214,7 @@ export function AudioRecorderButton({
214
214
  <button
215
215
  type="button"
216
216
  onClick={confirm}
217
- title={labelOf('send')}
217
+ data-cv-tooltip={labelOf('send')}
218
218
  aria-label={labelOf('send')}
219
219
  className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
220
220
  >
@@ -226,7 +226,7 @@ export function AudioRecorderButton({
226
226
  type="button"
227
227
  disabled={disabled || pending !== undefined}
228
228
  onClick={() => (isRecording ? stop() : void start())}
229
- title={toggleLabel}
229
+ data-cv-tooltip={toggleLabel}
230
230
  aria-label={toggleLabel}
231
231
  aria-pressed={isRecording}
232
232
  /* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
@@ -136,7 +136,7 @@ export function AudioTranscription({ transcription, onTranscribe, isMine = false
136
136
  {hasText && (
137
137
  <button
138
138
  onClick={handleCopy}
139
- title={locales.copy}
139
+ data-cv-tooltip={locales.copy}
140
140
  aria-label={hasCopied ? locales.copied : locales.copy}
141
141
  className="ml-auto flex flex-shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
142
142
  >
@@ -182,6 +182,7 @@ export function AudioTranscription({ transcription, onTranscribe, isMine = false
182
182
  tela, não sobre o que o operador cola no sistema interno. */}
183
183
  {isLong && (
184
184
  <button
185
+ data-cv-tooltip={isExpanded ? locales.showLess : locales.showMore} aria-label={isExpanded ? locales.showLess : locales.showMore}
185
186
  onClick={() => setIsExpanded((current) => !current)}
186
187
  className="mt-1 text-[11px] font-medium text-gray-500 underline decoration-dotted hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
187
188
  >
@@ -194,6 +195,7 @@ export function AudioTranscription({ transcription, onTranscribe, isMine = false
194
195
  {/* Retranscrever fica escondido atrás do hover do balão: é ação rara e paga cota de engine. */}
195
196
  {onTranscribe && (
196
197
  <button
198
+ data-cv-tooltip={isTranscribing ? locales.transcribing : locales.retry} aria-label={isTranscribing ? locales.transcribing : locales.retry}
197
199
  onClick={handleTranscribe}
198
200
  disabled={isTranscribing}
199
201
  className="mt-1 flex items-center gap-1 text-[11px] text-gray-400 opacity-0 transition-opacity hover:text-gray-600 focus:opacity-100 group-hover:opacity-100 disabled:opacity-50 dark:text-gray-500 dark:hover:text-gray-300"
@@ -234,6 +236,7 @@ function TranscribeButton({
234
236
  }) {
235
237
  return (
236
238
  <button
239
+ data-cv-tooltip={label} aria-label={label}
237
240
  onClick={onClick}
238
241
  disabled={isBusy || isDisabled}
239
242
  className="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 disabled:opacity-60 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
package/src/Avatar.tsx CHANGED
@@ -81,7 +81,7 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '', labels }:
81
81
  return (
82
82
  <div
83
83
  className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 ${className}`}
84
- title={name}
84
+ data-cv-tooltip={name}
85
85
  >
86
86
  {getInitials(name)}
87
87
  </div>
@@ -156,7 +156,7 @@ export function ConversationContextPanel({
156
156
  type="button"
157
157
  onClick={() => setManualOpen(!open)}
158
158
  aria-expanded={open}
159
- title={open ? labels.collapse : labels.expand}
159
+ data-cv-tooltip={open ? labels.collapse : labels.expand} aria-label={open ? labels.collapse : labels.expand}
160
160
  className={cn(
161
161
  'flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-2.5 text-left transition-colors hover:bg-blue-100/40 dark:hover:bg-blue-900/20',
162
162
  classNames?.toggle,
@@ -263,7 +263,7 @@ export function ConversationContextPanel({
263
263
  ? 'text-gray-900 dark:text-gray-100'
264
264
  : 'italic font-normal text-gray-400 dark:text-gray-500',
265
265
  )}
266
- title={entry.value ?? labels.notCollected}
266
+ data-cv-tooltip={entry.value ?? labels.notCollected}
267
267
  >
268
268
  {entry.value ?? labels.notCollected}
269
269
  </dd>
@@ -13,6 +13,7 @@ import { useConversationDocuments } from './hooks/useConversationDocuments'
13
13
  import { useConversations } from './providers/ConversationsProvider'
14
14
  import { FileIcon } from './FileIcon'
15
15
  import { cn } from './lib/cn'
16
+ import { PAGINATION_LABELS } from './pagination.constant'
16
17
  import { formatDateTime, formatFileSize } from './lib/format'
17
18
 
18
19
  /** Origens que o filtro oferece. `all` não é origem — é a ausência de filtro. */
@@ -239,6 +240,7 @@ export function ConversationDocumentsPanel({
239
240
  </select>
240
241
 
241
242
  <button
243
+ data-cv-tooltip={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest} aria-label={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
242
244
  type="button"
243
245
  onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
244
246
  className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
@@ -250,6 +252,7 @@ export function ConversationDocumentsPanel({
250
252
  {/* Só aparece com filtro ativo: botão que não faz nada visível é ruído. */}
251
253
  {hasFilters ? (
252
254
  <button
255
+ data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters}
253
256
  type="button"
254
257
  onClick={() =>
255
258
  applyFilter(() => {
@@ -291,7 +294,7 @@ export function ConversationDocumentsPanel({
291
294
  {labels.selectAll}
292
295
  </label>
293
296
  {selectedIds.length > 0 ? (
294
- <button type="button" onClick={() => void handleDownloadSelected()} className="cv-header-action">
297
+ <button data-cv-tooltip={labels.downloadSelected(selectedIds.length)} aria-label={labels.downloadSelected(selectedIds.length)} type="button" onClick={() => void handleDownloadSelected()} className="cv-header-action">
295
298
  {labels.downloadSelected(selectedIds.length)}
296
299
  </button>
297
300
  ) : null}
@@ -347,7 +350,7 @@ export function ConversationDocumentsPanel({
347
350
  {/* `title` no nome: truncado, o atendente não tem outro jeito de ler inteiro. */}
348
351
  <div
349
352
  className={cn('truncate text-sm font-medium', classNames?.filename)}
350
- title={document.filename}
353
+ data-cv-tooltip={document.filename}
351
354
  >
352
355
  {document.filename}
353
356
  </div>
@@ -366,7 +369,7 @@ export function ConversationDocumentsPanel({
366
369
  <button
367
370
  type="button"
368
371
  onClick={() => void handleOpen(document.id, 'inline')}
369
- title={labels.view}
372
+ data-cv-tooltip={labels.view}
370
373
  aria-label={`${labels.view}: ${document.filename}`}
371
374
  className={cn('cv-header-icon', classNames?.viewButton)}
372
375
  >
@@ -375,7 +378,7 @@ export function ConversationDocumentsPanel({
375
378
  <button
376
379
  type="button"
377
380
  onClick={() => void handleOpen(document.id, 'attachment')}
378
- title={labels.download}
381
+ data-cv-tooltip={labels.download}
379
382
  aria-label={`${labels.download}: ${document.filename}`}
380
383
  className={cn('cv-header-icon', classNames?.downloadButton)}
381
384
  >
@@ -401,7 +404,8 @@ export function ConversationDocumentsPanel({
401
404
  type="button"
402
405
  onClick={() => setPage(page - 1)}
403
406
  disabled={page <= 1}
404
- aria-label={labels.sortOldest}
407
+ aria-label={PAGINATION_LABELS.previous}
408
+ data-cv-tooltip={PAGINATION_LABELS.previous}
405
409
  className="cv-header-icon disabled:opacity-40"
406
410
  >
407
411
  ‹
@@ -411,7 +415,8 @@ export function ConversationDocumentsPanel({
411
415
  type="button"
412
416
  onClick={() => setPage(page + 1)}
413
417
  disabled={page >= lastPage}
414
- aria-label={labels.sortMostRecent}
418
+ aria-label={PAGINATION_LABELS.next}
419
+ data-cv-tooltip={PAGINATION_LABELS.next}
415
420
  className="cv-header-icon disabled:opacity-40"
416
421
  >
417
422
  ›
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ConversationHeader } from './ConversationHeader'
5
+ import type { ConversationSummary } from './providers/types'
6
+
7
+ const noop = () => {}
8
+
9
+ const CONVERSATION: ConversationSummary = {
10
+ id: 'conv-1',
11
+ clientName: 'Anderson Silva',
12
+ whatsappNumber: '5511988887777',
13
+ channel: 'whatsapp',
14
+ mode: 'human',
15
+ assignedUserId: null,
16
+ waitingHuman: false,
17
+ unread: 0,
18
+ currentState: 'idle',
19
+ lastAt: '2026-01-01T12:00:00.000Z',
20
+ lastInboundAt: null,
21
+ }
22
+
23
+ describe('ConversationHeader sem medida da faixa', () => {
24
+ // Sem `ResizeObserver` (SSR e este teste de markup) o cabeçalho nasce completo. O recolhimento é
25
+ // medido no próprio elemento, e é verificado no navegador.
26
+ it('mostra utilitários e ações na faixa, sem duplicar no menu', () => {
27
+ const markup = renderToStaticMarkup(
28
+ <ConversationHeader
29
+ conversation={CONVERSATION}
30
+ onDownload={noop}
31
+ onOpenDocuments={noop}
32
+ onReturnToBot={noop}
33
+ onFinish={noop}
34
+ />,
35
+ )
36
+
37
+ expect(markup).toContain('aria-label="Baixar conversa"')
38
+ expect(markup).toContain('aria-label="Finalizar"')
39
+ expect(markup).not.toContain('aria-label="Mais ações"')
40
+ })
41
+
42
+ it('escreve o canal e o modo por extenso quando há espaço', () => {
43
+ const markup = renderToStaticMarkup(<ConversationHeader conversation={CONVERSATION} />)
44
+
45
+ expect(markup).toContain('atendimento humano')
46
+ })
47
+
48
+ // Emoji desenha diferente em cada sistema e não herda a cor do controle: os ícones do cabeçalho
49
+ // são SVG da biblioteca, como no resto do produto.
50
+ it('desenha os ícones como SVG, sem emoji no meio dos rótulos', () => {
51
+ const markup = renderToStaticMarkup(
52
+ <ConversationHeader
53
+ conversation={CONVERSATION}
54
+ onBack={noop}
55
+ onDownload={noop}
56
+ onOpenDocuments={noop}
57
+ onReturnToBot={noop}
58
+ onFinish={noop}
59
+ />,
60
+ )
61
+
62
+ expect(markup).toContain('lucide-download')
63
+ expect(markup).not.toContain('✕')
64
+ expect(markup).not.toContain('⬇️')
65
+ })
66
+ })