@chat21/chat21-web-widget 5.1.34 → 5.2.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 (191) hide show
  1. package/.angular-mcp-cache/package.json +1 -0
  2. package/.cursor/angular18-accessibility-auditor-skill.md +442 -0
  3. package/.cursor/mcp.json +15 -0
  4. package/.github/workflows/playwright.yml +27 -0
  5. package/CHANGELOG.md +25 -0
  6. package/Dockerfile +4 -5
  7. package/README.md +1 -1
  8. package/angular.json +21 -3
  9. package/docs/ACCESSIBILITY-STATEMENT.md +388 -0
  10. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_ALIGNMENT.md +60 -0
  11. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +386 -0
  12. package/env.sample +3 -2
  13. package/mocks/voice-websocket-mock/server.cjs +245 -0
  14. package/package.json +10 -3
  15. package/playwright.config.ts +41 -0
  16. package/src/app/app.component.html +2 -2
  17. package/src/app/app.component.scss +25 -14
  18. package/src/app/app.component.spec.ts +21 -6
  19. package/src/app/app.module.ts +13 -0
  20. package/src/app/component/conversation-detail/conversation/conversation.component.html +25 -11
  21. package/src/app/component/conversation-detail/conversation/conversation.component.scss +38 -0
  22. package/src/app/component/conversation-detail/conversation/conversation.component.spec.ts +644 -75
  23. package/src/app/component/conversation-detail/conversation/conversation.component.ts +70 -2
  24. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.html +25 -13
  25. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.spec.ts +123 -5
  26. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.ts +1 -0
  27. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +23 -10
  28. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +18 -0
  29. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +241 -149
  30. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +8 -5
  31. package/src/app/component/conversation-detail/conversation-emojii/conversation-emojii.component.spec.ts +53 -3
  32. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +203 -110
  33. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +212 -1
  34. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +458 -78
  35. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +288 -76
  36. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.html +113 -53
  37. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.scss +12 -4
  38. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.spec.ts +274 -29
  39. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.html +23 -9
  40. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.spec.ts +80 -8
  41. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.html +29 -23
  42. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.spec.ts +185 -16
  43. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.ts +34 -14
  44. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +46 -0
  45. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +83 -0
  46. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
  47. package/src/app/component/error-alert/error-alert.component.spec.ts +65 -5
  48. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.html +16 -7
  49. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.scss +21 -0
  50. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.spec.ts +89 -7
  51. package/src/app/component/form/form-builder/form-builder.component.html +1 -1
  52. package/src/app/component/form/form-builder/form-builder.component.spec.ts +163 -21
  53. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.html +8 -4
  54. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.scss +10 -5
  55. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.spec.ts +90 -16
  56. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.ts +26 -0
  57. package/src/app/component/form/inputs/form-label/form-label.component.spec.ts +45 -11
  58. package/src/app/component/form/inputs/form-radio-button/form-radio-button.component.spec.ts +24 -6
  59. package/src/app/component/form/inputs/form-select/form-select.component.spec.ts +14 -5
  60. package/src/app/component/form/inputs/form-text/form-text.component.html +14 -12
  61. package/src/app/component/form/inputs/form-text/form-text.component.scss +11 -1
  62. package/src/app/component/form/inputs/form-text/form-text.component.spec.ts +113 -17
  63. package/src/app/component/form/inputs/form-text/form-text.component.ts +35 -3
  64. package/src/app/component/form/inputs/form-textarea/form-textarea.component.html +13 -11
  65. package/src/app/component/form/inputs/form-textarea/form-textarea.component.scss +6 -5
  66. package/src/app/component/form/inputs/form-textarea/form-textarea.component.spec.ts +149 -13
  67. package/src/app/component/form/inputs/form-textarea/form-textarea.component.ts +26 -0
  68. package/src/app/component/form/prechat-form/prechat-form.component.html +14 -11
  69. package/src/app/component/form/prechat-form/prechat-form.component.spec.ts +102 -10
  70. package/src/app/component/form/prechat-form/prechat-form.component.ts +8 -1
  71. package/src/app/component/form/prechat-form-test-mock.ts +35 -0
  72. package/src/app/component/home/home.component.html +38 -31
  73. package/src/app/component/home/home.component.scss +4 -2
  74. package/src/app/component/home/home.component.spec.ts +226 -11
  75. package/src/app/component/home-conversations/home-conversations.component.html +30 -26
  76. package/src/app/component/home-conversations/home-conversations.component.scss +3 -0
  77. package/src/app/component/home-conversations/home-conversations.component.spec.ts +212 -36
  78. package/src/app/component/last-message/last-message.component.html +15 -9
  79. package/src/app/component/last-message/last-message.component.scss +16 -2
  80. package/src/app/component/last-message/last-message.component.spec.ts +204 -23
  81. package/src/app/component/launcher-button/launcher-button.component.html +8 -13
  82. package/src/app/component/launcher-button/launcher-button.component.spec.ts +104 -8
  83. package/src/app/component/list-all-conversations/list-all-conversations.component.html +12 -17
  84. package/src/app/component/list-all-conversations/list-all-conversations.component.scss +2 -0
  85. package/src/app/component/list-conversations/list-conversations.component.html +22 -22
  86. package/src/app/component/menu-options/menu-options.component.html +30 -20
  87. package/src/app/component/menu-options/menu-options.component.spec.ts +125 -9
  88. package/src/app/component/message/audio/audio.component.html +13 -15
  89. package/src/app/component/message/audio/audio.component.spec.ts +140 -5
  90. package/src/app/component/message/audio/audio.component.ts +1 -5
  91. package/src/app/component/message/audio-sync/audio-sync.component.html +18 -0
  92. package/src/app/component/message/audio-sync/audio-sync.component.scss +65 -0
  93. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +112 -0
  94. package/src/app/component/message/audio-sync/audio-sync.component.ts +714 -0
  95. package/src/app/component/message/avatar/avatar.component.html +2 -2
  96. package/src/app/component/message/avatar/avatar.component.spec.ts +99 -7
  97. package/src/app/component/message/bubble-message/bubble-message.component.html +41 -51
  98. package/src/app/component/message/bubble-message/bubble-message.component.scss +54 -1
  99. package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +147 -57
  100. package/src/app/component/message/bubble-message/bubble-message.component.ts +95 -13
  101. package/src/app/component/message/buttons/action-button/action-button.component.html +3 -4
  102. package/src/app/component/message/buttons/action-button/action-button.component.spec.ts +49 -5
  103. package/src/app/component/message/buttons/link-button/link-button.component.scss +5 -8
  104. package/src/app/component/message/buttons/link-button/link-button.component.spec.ts +50 -5
  105. package/src/app/component/message/buttons/text-button/text-button.component.spec.ts +44 -5
  106. package/src/app/component/message/carousel/carousel.component.html +29 -16
  107. package/src/app/component/message/carousel/carousel.component.scss +20 -8
  108. package/src/app/component/message/carousel/carousel.component.spec.ts +80 -3
  109. package/src/app/component/message/carousel/carousel.component.ts +16 -0
  110. package/src/app/component/message/frame/frame.component.html +9 -4
  111. package/src/app/component/message/frame/frame.component.spec.ts +34 -15
  112. package/src/app/component/message/frame/frame.component.ts +7 -2
  113. package/src/app/component/message/html/html.component.html +1 -1
  114. package/src/app/component/message/html/html.component.scss +1 -1
  115. package/src/app/component/message/html/html.component.spec.ts +24 -7
  116. package/src/app/component/message/image/image.component.html +12 -10
  117. package/src/app/component/message/image/image.component.scss +16 -0
  118. package/src/app/component/message/image/image.component.spec.ts +101 -15
  119. package/src/app/component/message/image/image.component.ts +90 -51
  120. package/src/app/component/message/info-message/info-message.component.spec.ts +26 -14
  121. package/src/app/component/message/json-sources/json-sources.component.html +6 -5
  122. package/src/app/component/message/json-sources/json-sources.component.scss +26 -18
  123. package/src/app/component/message/json-sources/json-sources.component.ts +41 -0
  124. package/src/app/component/message/like-unlike/like-unlike.component.html +7 -9
  125. package/src/app/component/message/like-unlike/like-unlike.component.spec.ts +31 -3
  126. package/src/app/component/message/return-receipt/return-receipt.component.spec.ts +38 -17
  127. package/src/app/component/message/text/text.component.html +3 -3
  128. package/src/app/component/message/text/text.component.scss +80 -86
  129. package/src/app/component/message/text/text.component.spec.ts +106 -13
  130. package/src/app/component/message-attachment/message-attachment.component.spec.ts +134 -13
  131. package/src/app/component/selection-department/selection-department.component.html +21 -23
  132. package/src/app/component/selection-department/selection-department.component.spec.ts +159 -14
  133. package/src/app/component/selection-department/selection-department.component.ts +8 -1
  134. package/src/app/component/send-button/send-button.component.html +5 -13
  135. package/src/app/component/send-button/send-button.component.spec.ts +2 -2
  136. package/src/app/component/star-rating-widget/star-rating-widget.component.html +51 -81
  137. package/src/app/directives/tooltip.directive.spec.ts +8 -4
  138. package/src/app/modals/confirm-close/confirm-close.component.html +20 -8
  139. package/src/app/modals/confirm-close/confirm-close.component.scss +3 -0
  140. package/src/app/modals/confirm-close/confirm-close.component.spec.ts +13 -4
  141. package/src/app/modals/confirm-close/confirm-close.component.ts +8 -1
  142. package/src/app/pipe/html-entites-encode.pipe.spec.ts +35 -2
  143. package/src/app/pipe/marked.pipe.spec.ts +38 -2
  144. package/src/app/pipe/marked.pipe.ts +51 -41
  145. package/src/app/providers/app-config.service.ts +4 -2
  146. package/src/app/providers/brand.service.spec.ts +23 -2
  147. package/src/app/providers/brand.service.ts +1 -1
  148. package/src/app/providers/global-settings.service.spec.ts +1009 -14
  149. package/src/app/providers/global-settings.service.ts +40 -2
  150. package/src/app/providers/json-sources-parser.service.ts +13 -1
  151. package/src/app/providers/translator.service.ts +24 -7
  152. package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +116 -0
  153. package/src/app/providers/tts-audio-playback-coordinator.service.ts +122 -0
  154. package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +12 -0
  155. package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +156 -0
  156. package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +39 -0
  157. package/src/app/providers/voice/audio.types.ts +40 -0
  158. package/src/app/providers/voice/vad.service.spec.ts +28 -0
  159. package/src/app/providers/voice/vad.service.ts +70 -0
  160. package/src/app/providers/voice/voice-streaming.service.spec.ts +23 -0
  161. package/src/app/providers/voice/voice-streaming.service.ts +702 -0
  162. package/src/app/providers/voice/voice-streaming.types.ts +112 -0
  163. package/src/app/providers/voice/voice.service.spec.ts +227 -0
  164. package/src/app/providers/voice/voice.service.ts +969 -0
  165. package/src/app/sass/_variables.scss +2 -0
  166. package/src/app/sass/animations.scss +19 -1
  167. package/src/app/shims/onnxruntime-web-wasm.ts +4 -0
  168. package/src/app/utils/globals.ts +14 -0
  169. package/src/app/utils/utils-resources.ts +1 -1
  170. package/src/assets/i18n/en.json +128 -100
  171. package/src/assets/i18n/es.json +128 -100
  172. package/src/assets/i18n/fr.json +128 -100
  173. package/src/assets/i18n/it.json +128 -98
  174. package/src/assets/onnx/ort-wasm-simd-threaded.mjs +59 -0
  175. package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
  176. package/src/assets/sounds/keyboard.mp3 +0 -0
  177. package/src/assets/vad/silero_vad_legacy.onnx +0 -0
  178. package/src/assets/vad/vad.worklet.bundle.min.js +1 -0
  179. package/src/chat21-core/models/message.ts +2 -1
  180. package/src/chat21-core/providers/chat-manager.spec.ts +72 -0
  181. package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +3 -2
  182. package/src/chat21-core/providers/firebase/firebase-init-service.ts +5 -5
  183. package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +12 -0
  184. package/src/chat21-core/providers/scripts/script.service.spec.ts +12 -2
  185. package/src/chat21-core/utils/utils-message.ts +7 -0
  186. package/src/widget-config-template.json +3 -1
  187. package/src/widget-config.json +28 -27
  188. package/tests/widget-form-rich.spec.ts +67 -0
  189. package/tests/widget-index-dev-settings.spec.ts +52 -0
  190. package/tests/widget-twp-iframe.spec.ts +39 -0
  191. package/tsconfig.json +5 -0
@@ -1,25 +1,40 @@
1
- import { Component, EventEmitter, HostBinding, Input, Output } from '@angular/core';
1
+ import { Component, EventEmitter, HostBinding, Input, OnDestroy, OnInit, Output } from '@angular/core';
2
+ import { Observable, Subscription } from 'rxjs';
3
+ import { map, startWith } from 'rxjs/operators';
2
4
  import { DomSanitizer } from '@angular/platform-browser';
3
5
  import { MessageModel } from 'src/chat21-core/models/message';
4
- import { MESSAGE_TYPE_MINE, MESSAGE_TYPE_OTHERS, TYPE_MSG_URL_PREVIEW } from 'src/chat21-core/utils/constants';
6
+ import { MESSAGE_TYPE_MINE, MESSAGE_TYPE_OTHERS } from 'src/chat21-core/utils/constants';
5
7
  import { convertColorToRGBA } from 'src/chat21-core/utils/utils';
6
- import { calcImageSize, isAudio, isFile, isFrame, isImage, isJsonSources, messageType } from 'src/chat21-core/utils/utils-message';
8
+ import { calcImageSize, isAudio, isAudioTTS, isFile, isFrame, isImage, isJsonSources, messageType } from 'src/chat21-core/utils/utils-message';
7
9
  import { getColorBck } from 'src/chat21-core/utils/utils-user';
8
- import { JsonSourcesParserService } from 'src/app/providers/json-sources-parser.service';
10
+ import { JsonSourcesParserService, UrlPreviewDisplayFields } from 'src/app/providers/json-sources-parser.service';
9
11
  import { JsonSourceItem } from '../json-sources/json-sources.component';
12
+ import { VoiceService } from 'src/app/providers/voice/voice.service';
13
+ import { VoiceTtsKaraokeWord } from 'src/app/providers/voice/voice-streaming.types';
10
14
 
11
15
  @Component({
12
16
  selector: 'chat-bubble-message',
13
17
  templateUrl: './bubble-message.component.html',
14
18
  styleUrls: ['./bubble-message.component.scss']
15
19
  })
16
- export class BubbleMessageComponent {
20
+ export class BubbleMessageComponent implements OnInit, OnDestroy {
17
21
 
18
22
  @Input() message: MessageModel;
19
23
  @Input() isSameSender: boolean;
20
24
  @Input() fontColor: string;
21
25
  @Input() stylesMap: Map<string, string>;
22
-
26
+ @Input() translationMap: Map<string, string>;
27
+
28
+ /** When true, a newly-arrived bot text message reveals its words one by one. */
29
+ @Input() streamOnArrival = false;
30
+ /** One-shot flag: set once in ngOnChanges, never reverts so animation isn't replayed. */
31
+ _isStreaming = false;
32
+ /** Precomputed word list; rebuilt only when the message text changes. */
33
+ _streamingWords: Array<{ word: string; index: number }> = [];
34
+ /** Live karaoke word states driven by voiceTtsKaraoke$ during an active WSS session. */
35
+ _wssKaraokeWords$?: Observable<VoiceTtsKaraokeWord[]>;
36
+
37
+ private _kSub?: Subscription;
23
38
  @Output() onBeforeMessageRender = new EventEmitter();
24
39
  @Output() onAfterMessageRender = new EventEmitter();
25
40
  @Output() onElementRendered = new EventEmitter<{ element: string; status: boolean }>();
@@ -39,10 +54,43 @@ export class BubbleMessageComponent {
39
54
  return !!(msg.text && String(msg.text).trim().length > 0);
40
55
  }
41
56
 
57
+ ngOnInit() {
58
+ // If this TTS message arrived while the voice proxy was active, mark it so
59
+ // audio-sync never replays it after the session ends.
60
+ if (isAudioTTS(this.message) && this.voiceService.isWssVoiceActive && this.message?.uid) {
61
+ this.voiceService.markProxyHandled(this.message.uid);
62
+ }
63
+
64
+ // Set up karaoke observable for TTS messages during WSS sessions.
65
+ if (isAudioTTS(this.message) && this.message?.text) {
66
+ const text = this.message.text;
67
+ const rawWords = text.trim().split(/\s+/).filter((w) => w.length > 0);
68
+ // Always start as 'past' (fully visible). The karaoke RAF loop will drive
69
+ // words through future→active→past for the current speaking turn; using
70
+ // 'future' here would dimm old/history messages the moment voice opens.
71
+ const initialWords: VoiceTtsKaraokeWord[] = rawWords.map((w) => ({ text: w, state: 'past' as const }));
72
+
73
+ this._wssKaraokeWords$ = this.voiceService.voiceTtsKaraoke$.pipe(
74
+ startWith({ text, words: initialWords, activeIndex: -1 }),
75
+ map((frame) =>
76
+ frame.text === text
77
+ ? (frame.words as VoiceTtsKaraokeWord[])
78
+ : initialWords,
79
+ ),
80
+ );
81
+ }
82
+ }
83
+
84
+ ngOnDestroy(): void {
85
+ this._kSub?.unsubscribe();
86
+ this._kSub = undefined;
87
+ }
88
+
42
89
  readonly isImage = isImage;
43
90
  readonly isFile = isFile;
44
91
  readonly isFrame = isFrame;
45
92
  readonly isAudio = isAudio;
93
+ readonly isAudioTTS = isAudioTTS;
46
94
  readonly isJsonSources = isJsonSources;
47
95
  readonly messageType = messageType;
48
96
  readonly convertColorToRGBA = convertColorToRGBA;
@@ -53,12 +101,15 @@ export class BubbleMessageComponent {
53
101
  fullnameColor: string;
54
102
  jsonSources: JsonSourceItem[] | null = null;
55
103
  isUrlPreviewMessage = false;
104
+ jsonSourcesDisplayFields?: UrlPreviewDisplayFields;
105
+ jsonSourcesBackgroundColor?: string;
56
106
 
57
107
  private urlPreviewReqId = 0;
58
108
 
59
109
  constructor(
60
110
  public sanitizer: DomSanitizer,
61
- private jsonSourcesParser: JsonSourcesParserService
111
+ private jsonSourcesParser: JsonSourcesParserService,
112
+ public voiceService: VoiceService,
62
113
  ) {}
63
114
 
64
115
  ngOnChanges(): void {
@@ -74,16 +125,47 @@ export class BubbleMessageComponent {
74
125
  this.fullnameColor = getColorBck(this.message.sender_fullname);
75
126
  }
76
127
 
128
+ // One-shot: activate word streaming for newly-arrived bot text messages during a voice session.
129
+ // Reset isJustRecived so the animation never replays on subsequent change detection cycles.
130
+ if (
131
+ !this._isStreaming &&
132
+ this.streamOnArrival &&
133
+ this.message?.isJustRecived === true &&
134
+ this.messageType(this.MESSAGE_TYPE_OTHERS, this.message) &&
135
+ !this.isAudio(this.message) &&
136
+ !this.isAudioTTS(this.message) &&
137
+ this.message?.type !== 'html'
138
+ ) {
139
+ this._isStreaming = true;
140
+ this._streamingWords = (this.message.text ?? '')
141
+ .trim()
142
+ .split(/\s+/)
143
+ .filter(w => w.length > 0)
144
+ .map((word, index) => ({ word, index }));
145
+ this.message.isJustRecived = false;
146
+ }
147
+
77
148
  // Reset on every message change: we must not "leak" sources across different messages.
78
149
  this.jsonSources = null;
150
+ this.jsonSourcesDisplayFields = undefined;
151
+ this.jsonSourcesBackgroundColor = undefined;
79
152
 
80
153
  // url_preview payload can live on message root OR inside metadata/attributes depending on the integration.
81
- const urlPreviewLike =
82
- this.message?.type === TYPE_MSG_URL_PREVIEW
83
- || this.message?.metadata?.type === TYPE_MSG_URL_PREVIEW
84
- || this.message?.attributes?.type === TYPE_MSG_URL_PREVIEW;
85
- this.isUrlPreviewMessage = !!urlPreviewLike;
86
- if (urlPreviewLike) this.loadJsonSourcesFromUrlPreviewMessage();
154
+ const urlPreviewPayload = this.jsonSourcesParser.getUrlPreviewPayload(this.message);
155
+ this.isUrlPreviewMessage = !!urlPreviewPayload;
156
+ if (urlPreviewPayload) {
157
+ this.jsonSourcesDisplayFields = urlPreviewPayload.displayFields;
158
+ this.jsonSourcesBackgroundColor = urlPreviewPayload.previewBackgroundColor;
159
+ this.loadJsonSourcesFromUrlPreviewMessage();
160
+ }
161
+ }
162
+
163
+ trackWord(_index: number, item: { word: string; index: number }): number {
164
+ return item.index;
165
+ }
166
+
167
+ trackKaraokeWord(index: number): number {
168
+ return index;
87
169
  }
88
170
 
89
171
  private async loadJsonSourcesFromUrlPreviewMessage(): Promise<void> {
@@ -1,8 +1,7 @@
1
- <div #actionButton id="actionButton" class="button-in-msg action"
1
+ <div #actionButton class="button-in-msg action action-button"
2
2
  [ngClass]="{'disabled': isConversationArchived}"
3
- (click)="actionButtonAction()"
4
- (mouseover)="onMouseOver($event)"
3
+ (click)="actionButtonAction()"
4
+ (mouseover)="onMouseOver($event)"
5
5
  (mouseout)="onMouseOut($event)">
6
6
  {{button?.value}}
7
7
  </div>
8
- <!-- title="{{button?.value}}" -->
@@ -1,4 +1,6 @@
1
- import { async, ComponentFixture, TestBed } from '@angular/core/testing';
1
+ import { SimpleChange } from '@angular/core';
2
+ import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing';
3
+ import { By } from '@angular/platform-browser';
2
4
 
3
5
  import { ActionButtonComponent } from './action-button.component';
4
6
 
@@ -6,20 +8,62 @@ describe('ActionButtonComponent', () => {
6
8
  let component: ActionButtonComponent;
7
9
  let fixture: ComponentFixture<ActionButtonComponent>;
8
10
 
9
- beforeEach(async(() => {
11
+ beforeEach(waitForAsync(() => {
10
12
  TestBed.configureTestingModule({
11
- declarations: [ ActionButtonComponent ]
12
- })
13
- .compileComponents();
13
+ declarations: [ActionButtonComponent],
14
+ }).compileComponents();
14
15
  }));
15
16
 
16
17
  beforeEach(() => {
17
18
  fixture = TestBed.createComponent(ActionButtonComponent);
18
19
  component = fixture.componentInstance;
20
+ component.button = { value: 'OK', action: 'go' };
19
21
  fixture.detectChanges();
20
22
  });
21
23
 
22
24
  it('should create', () => {
23
25
  expect(component).toBeTruthy();
24
26
  });
27
+
28
+ it('ngOnChanges should map theme CSS variables onto .action', () => {
29
+ component.fontSize = '16px';
30
+ component.backgroundColor = '#111';
31
+ component.textColor = '#222';
32
+ component.hoverBackgroundColor = '#333';
33
+ component.hoverTextColor = '#444';
34
+ component.ngOnChanges({
35
+ fontSize: new SimpleChange(null, '16px', true),
36
+ });
37
+ const el = fixture.nativeElement.querySelector('.action') as HTMLElement;
38
+ expect(el.style.getPropertyValue('--buttonFontSize').trim()).toBe('16px');
39
+ expect(el.style.getPropertyValue('--buttonBackgroundColor').trim()).toBeTruthy();
40
+ });
41
+
42
+ it('actionButtonAction should emit when action present', fakeAsync(() => {
43
+ spyOn(component.onButtonClicked, 'emit');
44
+ const de = fixture.debugElement.query(By.css('.action'));
45
+ de.triggerEventHandler('click', {});
46
+ tick(500);
47
+ expect(component.onButtonClicked.emit).toHaveBeenCalled();
48
+ }));
49
+
50
+ it('actionButtonAction should no-op when button has no action', () => {
51
+ component.button = { value: 'X', action: '' };
52
+ fixture.detectChanges();
53
+ spyOn(component.onButtonClicked, 'emit');
54
+ component.actionButtonAction();
55
+ expect(component.onButtonClicked.emit).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('mouseover and mouseout should not throw', () => {
59
+ expect(() => component.onMouseOver({} as any)).not.toThrow();
60
+ expect(() => component.onMouseOut({} as any)).not.toThrow();
61
+ });
62
+
63
+ it('template should mark archived conversations as disabled', () => {
64
+ component.isConversationArchived = true;
65
+ fixture.detectChanges();
66
+ const el = fixture.nativeElement.querySelector('.action');
67
+ expect(el.classList.contains('disabled')).toBe(true);
68
+ });
25
69
  });
@@ -42,20 +42,17 @@
42
42
  transition: background-color .6s ease;
43
43
  .icon-button-action {
44
44
  position: absolute;
45
- top: 50%;
46
- right: 8px;
47
- transform: translateY(-50%);
45
+ top: -1px;
46
+ right: 1px;
48
47
  svg {
49
- fill: var(--textColor);
48
+ fill: var(--buttonTextColor);
50
49
  }
51
50
  }
52
51
  .icon-button-action-self{
53
52
  position: absolute;
54
- top: 50%;
55
- right: 8px;
56
- transform: translateY(-50%);
53
+ right: 1px;
57
54
  svg {
58
- fill: var(--textColor);
55
+ fill: var(--buttonTextColor);
59
56
  }
60
57
  }
61
58
  &:focus,
@@ -1,4 +1,6 @@
1
- import { async, ComponentFixture, TestBed } from '@angular/core/testing';
1
+ import { SimpleChange } from '@angular/core';
2
+ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
3
+ import { By } from '@angular/platform-browser';
2
4
 
3
5
  import { LinkButtonComponent } from './link-button.component';
4
6
 
@@ -6,20 +8,63 @@ describe('LinkButtonComponent', () => {
6
8
  let component: LinkButtonComponent;
7
9
  let fixture: ComponentFixture<LinkButtonComponent>;
8
10
 
9
- beforeEach(async(() => {
11
+ beforeEach(waitForAsync(() => {
10
12
  TestBed.configureTestingModule({
11
- declarations: [ LinkButtonComponent ]
12
- })
13
- .compileComponents();
13
+ declarations: [LinkButtonComponent],
14
+ }).compileComponents();
14
15
  }));
15
16
 
16
17
  beforeEach(() => {
17
18
  fixture = TestBed.createComponent(LinkButtonComponent);
18
19
  component = fixture.componentInstance;
20
+ component.button = { value: 'Open', link: 'https://x.test', target: 'blank' };
19
21
  fixture.detectChanges();
20
22
  });
21
23
 
22
24
  it('should create', () => {
23
25
  expect(component).toBeTruthy();
24
26
  });
27
+
28
+ it('ngOnChanges should set CSS variables on .url', () => {
29
+ component.fontSize = '12px';
30
+ component.backgroundColor = '#aaa';
31
+ component.textColor = '#bbb';
32
+ component.hoverBackgroundColor = '#ccc';
33
+ component.hoverTextColor = '#ddd';
34
+ component.ngOnChanges({
35
+ fontSize: new SimpleChange(null, '12px', true),
36
+ });
37
+ const el = fixture.nativeElement.querySelector('.url') as HTMLElement;
38
+ expect(el.style.getPropertyValue('--buttonFontSize').trim()).toBe('12px');
39
+ });
40
+
41
+ it('actionButtonUrl should emit when link set', () => {
42
+ spyOn(component.onButtonClicked, 'emit');
43
+ component.actionButtonUrl();
44
+ expect(component.onButtonClicked.emit).toHaveBeenCalled();
45
+ });
46
+
47
+ it('actionButtonUrl should not emit when link empty', () => {
48
+ component.button = { value: 'x', link: '' };
49
+ spyOn(component.onButtonClicked, 'emit');
50
+ component.actionButtonUrl();
51
+ expect(component.onButtonClicked.emit).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('should render external icon when target is not self', () => {
55
+ component.button = { value: 'L', link: 'u', target: 'blank' };
56
+ fixture.detectChanges();
57
+ expect(fixture.debugElement.query(By.css('.icon-button-action'))).toBeTruthy();
58
+ });
59
+
60
+ it('should render self icon when target is self', () => {
61
+ component.button = { value: 'L', link: 'u', target: 'self' };
62
+ fixture.detectChanges();
63
+ expect(fixture.debugElement.query(By.css('.icon-button-action-self'))).toBeTruthy();
64
+ });
65
+
66
+ it('mouseover and mouseout should not throw', () => {
67
+ expect(() => component.onMouseOver({} as any)).not.toThrow();
68
+ expect(() => component.onMouseOut({} as any)).not.toThrow();
69
+ });
25
70
  });
@@ -1,4 +1,6 @@
1
- import { async, ComponentFixture, TestBed } from '@angular/core/testing';
1
+ import { SimpleChange } from '@angular/core';
2
+ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
3
+ import { By } from '@angular/platform-browser';
2
4
 
3
5
  import { TextButtonComponent } from './text-button.component';
4
6
 
@@ -6,20 +8,57 @@ describe('TextButtonComponent', () => {
6
8
  let component: TextButtonComponent;
7
9
  let fixture: ComponentFixture<TextButtonComponent>;
8
10
 
9
- beforeEach(async(() => {
11
+ beforeEach(waitForAsync(() => {
10
12
  TestBed.configureTestingModule({
11
- declarations: [ TextButtonComponent ]
12
- })
13
- .compileComponents();
13
+ declarations: [TextButtonComponent],
14
+ }).compileComponents();
14
15
  }));
15
16
 
16
17
  beforeEach(() => {
17
18
  fixture = TestBed.createComponent(TextButtonComponent);
18
19
  component = fixture.componentInstance;
20
+ component.button = { value: 'Reply' };
21
+ component.isConversationArchived = false;
19
22
  fixture.detectChanges();
20
23
  });
21
24
 
22
25
  it('should create', () => {
23
26
  expect(component).toBeTruthy();
24
27
  });
28
+
29
+ it('ngOnChanges should set CSS variables on .text', () => {
30
+ component.fontSize = '13px';
31
+ component.backgroundColor = '#111';
32
+ component.textColor = '#222';
33
+ component.hoverBackgroundColor = '#333';
34
+ component.hoverTextColor = '#444';
35
+ component.ngOnChanges({
36
+ fontSize: new SimpleChange(null, '13px', true),
37
+ });
38
+ const el = fixture.nativeElement.querySelector('.text') as HTMLElement;
39
+ expect(el.style.getPropertyValue('--buttonFontSize').trim()).toBe('13px');
40
+ });
41
+
42
+ it('actionButtonText should emit click payload', () => {
43
+ spyOn(component.onButtonClicked, 'emit');
44
+ component.actionButtonText();
45
+ expect(component.onButtonClicked.emit).toHaveBeenCalled();
46
+ });
47
+
48
+ it('click on template should invoke actionButtonText', () => {
49
+ spyOn(component, 'actionButtonText');
50
+ fixture.debugElement.query(By.css('.text')).triggerEventHandler('click', {});
51
+ expect(component.actionButtonText).toHaveBeenCalled();
52
+ });
53
+
54
+ it('should add disabled class when conversation archived', () => {
55
+ component.isConversationArchived = true;
56
+ fixture.detectChanges();
57
+ expect(fixture.nativeElement.querySelector('.text').classList.contains('disabled')).toBe(true);
58
+ });
59
+
60
+ it('mouseover and mouseout should not throw', () => {
61
+ expect(() => component.onMouseOver({} as any)).not.toThrow();
62
+ expect(() => component.onMouseOut({} as any)).not.toThrow();
63
+ });
25
64
  });
@@ -1,19 +1,28 @@
1
- <div class="wrapper">
2
- <div id="left" class="arrow left" (click)="goTo('previous')" *ngIf="activeElement > 1">
3
- <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000">
1
+ <div class="wrapper"
2
+ role="region"
3
+ aria-roledescription="carousel"
4
+ [attr.aria-label]="translationMap?.get('CAROUSEL_LABEL') || 'Cards carousel'">
5
+ <button type="button"
6
+ id="left"
7
+ class="arrow left c21-button-clean"
8
+ [attr.aria-label]="translationMap?.get('CAROUSEL_PREVIOUS') || 'Previous slide'"
9
+ (click)="goTo('previous')" *ngIf="activeElement > 1">
10
+ <svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000">
4
11
  <path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12l4.58-4.59z"/>
5
12
  </svg>
6
- </div>
13
+ </button>
7
14
  <div class="carousel">
8
- <!-- <div class="card" style="width: 17px;"></div> -->
9
- <div class="card" *ngFor="let card of gallery; let i = index">
10
- <!-- <div [style.opacity]="i+1 === activeElement? 1: 0.5"> -->
15
+ <div class="card"
16
+ *ngFor="let card of gallery; let i = index"
17
+ role="group"
18
+ aria-roledescription="slide"
19
+ [attr.aria-label]="getSlideLabel(i + 1, gallery?.length)">
11
20
  <div>
12
21
  <div class="card-image" *ngIf="card?.preview?.src !== ''">
13
- <img [src]="card?.preview?.src" alt="img" draggable="false">
22
+ <img [src]="card?.preview?.src" [attr.alt]="card?.title || 'Carousel image'" draggable="false">
14
23
  </div>
15
24
  <div class="card-image card-image-placeholder" *ngIf="card?.preview?.src == ''">
16
- <img src="assets/images/icons/no-image.svg" alt="img" draggable="false">
25
+ <img src="assets/images/icons/no-image.svg" alt="" draggable="false">
17
26
  <span>Image not available</span>
18
27
  </div>
19
28
  <div class="card-content">
@@ -21,20 +30,24 @@
21
30
  <div class="card-description">{{card?.description}}</div>
22
31
  </div>
23
32
  <div class="buttons" *ngIf="card?.buttons && card?.buttons.length > 0">
24
- <div *ngFor="let button of card?.buttons"
25
- class="single-button action"
33
+ <button type="button" *ngFor="let button of card?.buttons"
34
+ class="single-button action c21-button-clean"
26
35
  [ngClass]="{'disabled': (isConversationArchived || (!isLastMessage && button.type !== TYPE_BUTTON.URL)), 'active': button?.active}"
36
+ [attr.aria-label]="button?.value"
27
37
  (click)="actionButtonClick($event, button, i)" >
28
38
  {{button.value}}
29
- </div>
39
+ </button>
30
40
  </div>
31
41
  </div>
32
42
  </div>
33
- <!-- <div class="card" style="width: 17px;"></div> -->
34
43
  </div>
35
- <div id="right" class="arrow right" (click)="goTo('next')" *ngIf="activeElement !== gallery.length">
36
- <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000">
44
+ <button type="button"
45
+ id="right"
46
+ class="arrow right c21-button-clean"
47
+ [attr.aria-label]="translationMap?.get('CAROUSEL_NEXT') || 'Next slide'"
48
+ (click)="goTo('next')" *ngIf="activeElement !== gallery.length">
49
+ <svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000">
37
50
  <path d="M0 0h24v24H0V0z" fill="none"/><path d="M10.02 6L8.61 7.41 13.19 12l-4.58 4.59L10.02 18l6-6-6-6z"/>
38
51
  </svg>
39
- </div>
52
+ </button>
40
53
  </div>
@@ -24,13 +24,17 @@
24
24
  font-size: 14px;
25
25
  // margin: 0 25px;
26
26
  }
27
- .wrapper div.arrow {
27
+ .wrapper button.arrow {
28
28
  top: 50%;
29
29
  height: 40px;
30
30
  width: 40px;
31
31
  cursor: pointer;
32
32
  position: absolute;
33
33
  background: #fff;
34
+ border: none;
35
+ padding: 0;
36
+ -webkit-appearance: none;
37
+ appearance: none;
34
38
  border-radius: 50%;
35
39
  box-shadow: 0 3px 6px rgba(0,0,0,0.23);
36
40
  transform: translateY(-50%);
@@ -41,13 +45,13 @@
41
45
  justify-content: center;
42
46
  align-items: center;
43
47
  }
44
- .wrapper div.arrow:active{
48
+ .wrapper button.arrow:active{
45
49
  transform: translateY(-50%) scale(0.85);
46
50
  }
47
- .wrapper div.arrow:first-child{
51
+ .wrapper button.arrow:first-child{
48
52
  left: -22px;
49
53
  }
50
- .wrapper div.arrow:last-child{
54
+ .wrapper button.arrow:last-child{
51
55
  right: -22px;
52
56
  }
53
57
  .wrapper .carousel{
@@ -192,13 +196,18 @@
192
196
 
193
197
 
194
198
  .single-button{
195
- // border-top-color: rgb(219, 225, 232);
196
-
199
+ width: 100%;
200
+ box-sizing: border-box;
201
+ margin: 0;
202
+ border: none;
203
+ border-top: 1px dashed rgba(0, 0, 0, 0.08);
204
+ text-align: center;
205
+ -webkit-appearance: none;
206
+ appearance: none;
197
207
  -webkit-box-align: center;
198
208
  -ms-flex-align: center;
199
209
  align-items: center;
200
210
  justify-content: center;
201
- border-top: 1px dashed rgba(0, 0, 0, 0.08);
202
211
  cursor: pointer;
203
212
  display: -webkit-box;
204
213
  display: -ms-flexbox;
@@ -222,7 +231,6 @@
222
231
  line-height: 16px;
223
232
  padding: 8px 16px!important;
224
233
 
225
- &:focus,
226
234
  &:hover {
227
235
  color: var(--hoverTextColor);
228
236
  background: var(--hoverBackgroundColor);
@@ -233,6 +241,10 @@
233
241
  }
234
242
  }
235
243
  }
244
+ &:focus-visible {
245
+ outline: 2px solid var(--hoverBackgroundColor, #1565c0);
246
+ outline-offset: 2px;
247
+ }
236
248
  &:after {
237
249
  content: "";
238
250
  position: absolute;
@@ -1,4 +1,6 @@
1
1
  import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { By } from '@angular/platform-browser';
3
+ import { TYPE_BUTTON } from 'src/chat21-core/utils/constants';
2
4
 
3
5
  import { CarouselComponent } from './carousel.component';
4
6
 
@@ -6,18 +8,93 @@ describe('CarouselComponent', () => {
6
8
  let component: CarouselComponent;
7
9
  let fixture: ComponentFixture<CarouselComponent>;
8
10
 
11
+ const btnA = { type: TYPE_BUTTON.TEXT, value: 'Go', action: '', link: '', text: 'x', active: false };
12
+ const gallery = [
13
+ { preview: { src: 'https://a/img.png' }, title: 'A', description: 'da', buttons: [btnA] },
14
+ { preview: { src: 'https://b/img.png' }, title: 'B', description: 'db', buttons: [{ type: TYPE_BUTTON.TEXT, value: 'B2', action: 'a', text: 't', active: false }] },
15
+ ];
16
+
9
17
  beforeEach(async () => {
10
18
  await TestBed.configureTestingModule({
11
- declarations: [ CarouselComponent ]
12
- })
13
- .compileComponents();
19
+ declarations: [CarouselComponent],
20
+ }).compileComponents();
14
21
 
15
22
  fixture = TestBed.createComponent(CarouselComponent);
16
23
  component = fixture.componentInstance;
24
+ component.message = { attributes: { attachment: { gallery } } } as any;
25
+ component.gallery = gallery;
26
+ component.stylesMap = new Map<string, string>([
27
+ ['buttonFontSize', '14px'],
28
+ ['buttonBackgroundColor', '#111'],
29
+ ['buttonTextColor', '#222'],
30
+ ['buttonHoverBackgroundColor', '#333'],
31
+ ['buttonHoverTextColor', '#444'],
32
+ ]);
33
+ component.isConversationArchived = false;
34
+ component.isLastMessage = true;
17
35
  fixture.detectChanges();
18
36
  });
19
37
 
20
38
  it('should create', () => {
21
39
  expect(component).toBeTruthy();
40
+ expect(component.gallery.length).toBe(2);
41
+ });
42
+
43
+ it('ngOnChanges should apply style map to wrapper', () => {
44
+ component.ngOnChanges({
45
+ stylesMap: {
46
+ previousValue: undefined,
47
+ currentValue: component.stylesMap,
48
+ firstChange: true,
49
+ isFirstChange: () => true,
50
+ },
51
+ });
52
+ const wrap = fixture.nativeElement.querySelector('.wrapper') as HTMLElement;
53
+ expect(wrap.style.getPropertyValue('--buttonFontSize').trim()).toBe('14px');
54
+ });
55
+
56
+ it('goTo should bump activeElement for next and previous', () => {
57
+ const carouselEl = component.carousel;
58
+ spyOnProperty(carouselEl, 'offsetWidth', 'get').and.returnValue(400);
59
+ const cards = carouselEl.querySelectorAll('.card');
60
+ expect(cards.length).toBeGreaterThanOrEqual(2);
61
+ const card1 = cards[1] as HTMLElement;
62
+ spyOnProperty(card1, 'offsetWidth', 'get').and.returnValue(180);
63
+ const startEl = component.activeElement;
64
+ component.goTo('next');
65
+ expect(component.activeElement).toBe(startEl + 1);
66
+ component.goTo('previous');
67
+ expect(component.activeElement).toBe(startEl);
68
+ });
69
+
70
+ it('actionButtonClick should emit onAttachmentButtonClicked', () => {
71
+ spyOn(component.onAttachmentButtonClicked, 'emit');
72
+ const ev = { target: { classList: { add: jasmine.createSpy('add') } } };
73
+ component.gallery = gallery;
74
+ component.actionButtonClick(ev as any, btnA, 0);
75
+ expect(component.onAttachmentButtonClicked.emit).toHaveBeenCalled();
76
+ expect(btnA.active).toBe(true);
77
+ });
78
+
79
+ it('actionButtonClick should ignore empty button', () => {
80
+ spyOn(component.onAttachmentButtonClicked, 'emit');
81
+ const empty = { type: TYPE_BUTTON.TEXT, value: '', action: '', link: '', text: '', active: false };
82
+ component.actionButtonClick({ target: { classList: { add: () => {} } } } as any, empty, 0);
83
+ expect(component.onAttachmentButtonClicked.emit).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('arrow clicks should invoke goTo', () => {
87
+ spyOn(component, 'goTo');
88
+ component.activeElement = 1;
89
+ component.gallery = gallery;
90
+ fixture.detectChanges();
91
+ const right = fixture.debugElement.query(By.css('.arrow.right'));
92
+ expect(right).toBeTruthy();
93
+ right!.triggerEventHandler('click', {});
94
+ expect(component.goTo).toHaveBeenCalledWith('next');
95
+ });
96
+
97
+ it('TYPE_BUTTON should be exposed for template', () => {
98
+ expect(component.TYPE_BUTTON).toBe(TYPE_BUTTON);
22
99
  });
23
100
  });