@cognigy/chat-components-vue 0.1.0

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/LICENSE +20 -0
  2. package/README.md +178 -0
  3. package/dist/assets/svg/ArrowBackIcon.vue.d.ts +9 -0
  4. package/dist/assets/svg/AudioPauseIcon.vue.d.ts +2 -0
  5. package/dist/assets/svg/AudioPlayIcon.vue.d.ts +2 -0
  6. package/dist/assets/svg/CloseIcon.vue.d.ts +2 -0
  7. package/dist/assets/svg/DownloadIcon.vue.d.ts +2 -0
  8. package/dist/assets/svg/VideoPlayIcon.vue.d.ts +9 -0
  9. package/dist/assets/svg/index.d.ts +7 -0
  10. package/dist/chat-components-vue.css +1 -0
  11. package/dist/chat-components-vue.js +9858 -0
  12. package/dist/components/Message.vue.d.ts +11 -0
  13. package/dist/components/common/ActionButton.vue.d.ts +59 -0
  14. package/dist/components/common/ActionButtons.vue.d.ts +36 -0
  15. package/dist/components/common/ChatBubble.vue.d.ts +22 -0
  16. package/dist/components/common/ChatEvent.vue.d.ts +20 -0
  17. package/dist/components/common/LinkIcon.vue.d.ts +2 -0
  18. package/dist/components/common/TypingIndicator.vue.d.ts +21 -0
  19. package/dist/components/common/Typography.vue.d.ts +38 -0
  20. package/dist/components/messages/AdaptiveCard.vue.d.ts +2 -0
  21. package/dist/components/messages/AudioMessage.vue.d.ts +5 -0
  22. package/dist/components/messages/DatePicker.vue.d.ts +2 -0
  23. package/dist/components/messages/FileMessage.vue.d.ts +2 -0
  24. package/dist/components/messages/Gallery.vue.d.ts +2 -0
  25. package/dist/components/messages/GalleryItem.vue.d.ts +7 -0
  26. package/dist/components/messages/ImageMessage.vue.d.ts +5 -0
  27. package/dist/components/messages/List.vue.d.ts +2 -0
  28. package/dist/components/messages/ListItem.vue.d.ts +16 -0
  29. package/dist/components/messages/TextMessage.vue.d.ts +15 -0
  30. package/dist/components/messages/TextWithButtons.vue.d.ts +2 -0
  31. package/dist/components/messages/VideoMessage.vue.d.ts +5 -0
  32. package/dist/composables/useChannelPayload.d.ts +47 -0
  33. package/dist/composables/useCollation.d.ts +47 -0
  34. package/dist/composables/useImageContext.d.ts +13 -0
  35. package/dist/composables/useMessageContext.d.ts +18 -0
  36. package/dist/composables/useSanitize.d.ts +8 -0
  37. package/dist/index.d.ts +33 -0
  38. package/dist/types/index.d.ts +275 -0
  39. package/dist/utils/helpers.d.ts +56 -0
  40. package/dist/utils/matcher.d.ts +20 -0
  41. package/dist/utils/sanitize.d.ts +28 -0
  42. package/dist/utils/theme.d.ts +18 -0
  43. package/package.json +94 -0
  44. package/src/assets/svg/ArrowBackIcon.vue +30 -0
  45. package/src/assets/svg/AudioPauseIcon.vue +20 -0
  46. package/src/assets/svg/AudioPlayIcon.vue +19 -0
  47. package/src/assets/svg/CloseIcon.vue +10 -0
  48. package/src/assets/svg/DownloadIcon.vue +10 -0
  49. package/src/assets/svg/VideoPlayIcon.vue +25 -0
  50. package/src/assets/svg/index.ts +7 -0
  51. package/src/components/Message.vue +152 -0
  52. package/src/components/common/ActionButton.vue +354 -0
  53. package/src/components/common/ActionButtons.vue +170 -0
  54. package/src/components/common/ChatBubble.vue +109 -0
  55. package/src/components/common/ChatEvent.vue +84 -0
  56. package/src/components/common/LinkIcon.vue +34 -0
  57. package/src/components/common/TypingIndicator.vue +202 -0
  58. package/src/components/common/Typography.vue +196 -0
  59. package/src/components/messages/AdaptiveCard.vue +292 -0
  60. package/src/components/messages/AudioMessage.vue +391 -0
  61. package/src/components/messages/DatePicker.vue +135 -0
  62. package/src/components/messages/FileMessage.vue +195 -0
  63. package/src/components/messages/Gallery.vue +296 -0
  64. package/src/components/messages/GalleryItem.vue +214 -0
  65. package/src/components/messages/ImageMessage.vue +368 -0
  66. package/src/components/messages/List.vue +149 -0
  67. package/src/components/messages/ListItem.vue +344 -0
  68. package/src/components/messages/TextMessage.vue +203 -0
  69. package/src/components/messages/TextWithButtons.vue +119 -0
  70. package/src/components/messages/VideoMessage.vue +343 -0
  71. package/src/composables/useChannelPayload.ts +101 -0
  72. package/src/composables/useCollation.ts +163 -0
  73. package/src/composables/useImageContext.ts +27 -0
  74. package/src/composables/useMessageContext.ts +41 -0
  75. package/src/composables/useSanitize.ts +25 -0
  76. package/src/index.ts +71 -0
  77. package/src/types/index.ts +373 -0
  78. package/src/utils/helpers.ts +164 -0
  79. package/src/utils/matcher.ts +283 -0
  80. package/src/utils/sanitize.ts +133 -0
  81. package/src/utils/theme.ts +58 -0
@@ -0,0 +1,152 @@
1
+ <template>
2
+ <article
3
+ v-if="matchedComponents.length > 0"
4
+ :class="rootClasses"
5
+ :style="cssVariableStyle"
6
+ :data-message-id="dataMessageId"
7
+ >
8
+ <component
9
+ v-for="(matchedComponent, index) in matchedComponents"
10
+ :key="index"
11
+ :is="matchedComponent"
12
+ />
13
+
14
+ <!-- Visually hidden focusable target for better keyboard navigation -->
15
+ <div
16
+ :id="`webchat-focus-target-${dataMessageId}`"
17
+ :tabindex="-1"
18
+ :class="$style.srOnly"
19
+ aria-hidden="true"
20
+ />
21
+ </article>
22
+ </template>
23
+
24
+ <script setup lang="ts">
25
+ import { computed, useCssModule, type Component } from 'vue'
26
+ import { match } from '../utils/matcher'
27
+ import { configColorsToCssVariables } from '../utils/theme'
28
+ import { provideMessageContext } from '../composables/useMessageContext'
29
+ import { getMessageId, isMessagePlugin } from '../types'
30
+ import type { MessageProps } from '../types'
31
+
32
+ const $style = useCssModule()
33
+
34
+ // Import all message type components
35
+ import TextMessage from './messages/TextMessage.vue'
36
+ import ImageMessage from './messages/ImageMessage.vue'
37
+ import VideoMessage from './messages/VideoMessage.vue'
38
+ import AudioMessage from './messages/AudioMessage.vue'
39
+ import TextWithButtons from './messages/TextWithButtons.vue'
40
+ import Gallery from './messages/Gallery.vue'
41
+ import List from './messages/List.vue'
42
+ import FileMessage from './messages/FileMessage.vue'
43
+ import DatePicker from './messages/DatePicker.vue'
44
+ import AdaptiveCard from './messages/AdaptiveCard.vue'
45
+
46
+ const props = withDefaults(defineProps<MessageProps>(), {
47
+ action: undefined,
48
+ config: undefined,
49
+ theme: undefined,
50
+ prevMessage: undefined,
51
+ plugins: undefined,
52
+ onEmitAnalytics: undefined,
53
+ disableHeader: false,
54
+ })
55
+
56
+ // Generate a unique message ID for accessibility
57
+ const dataMessageId = computed(() => getMessageId(props.message))
58
+
59
+ // Provide message context for child components
60
+ provideMessageContext({
61
+ message: props.message,
62
+ config: props.config || {},
63
+ action: props.action || (() => {}),
64
+ onEmitAnalytics: props.onEmitAnalytics || (() => {}),
65
+ })
66
+
67
+ // Component map for internal match rules (maps rule names to Vue components)
68
+ const componentMap: Record<string, Component> = {
69
+ 'Text': TextMessage,
70
+ 'Image': ImageMessage,
71
+ 'Video': VideoMessage,
72
+ 'Audio': AudioMessage,
73
+ 'TextWithButtons': TextWithButtons,
74
+ 'Gallery': Gallery,
75
+ 'List': List,
76
+ 'File': FileMessage,
77
+ 'DatePicker': DatePicker,
78
+ 'AdaptiveCard': AdaptiveCard,
79
+ }
80
+
81
+ // Match message to appropriate components
82
+ const matchedComponents = computed(() => {
83
+ const matched = match(props.message, props.config, props.plugins)
84
+
85
+ if (!Array.isArray(matched) || matched.length < 1) {
86
+ return []
87
+ }
88
+
89
+ // Resolve components from match results
90
+ return matched
91
+ .map(rule => {
92
+ // External plugins (MessagePlugin) provide their own component
93
+ if (isMessagePlugin(rule)) {
94
+ return rule.component
95
+ }
96
+ // Internal rules (MatchRule) use name lookup in componentMap
97
+ return componentMap[rule.name] ?? null
98
+ })
99
+ .filter((c): c is Component => c !== null)
100
+ })
101
+
102
+ // Root element classes
103
+ const rootClasses = computed(() => {
104
+ return [
105
+ 'webchat-message-row',
106
+ props.message.source,
107
+ $style.message,
108
+ props.message.source === 'bot' && $style.bot,
109
+ props.message.source === 'user' && $style.user,
110
+ props.message.source === 'agent' && $style.agent,
111
+ ].filter(Boolean)
112
+ })
113
+
114
+ // CSS variable injection from config colors
115
+ const cssVariableStyle = computed(() => {
116
+ return configColorsToCssVariables(props.config?.settings?.colors)
117
+ })
118
+ </script>
119
+
120
+ <style module>
121
+ .message {
122
+ display: flex;
123
+ flex-direction: column;
124
+ gap: 8px;
125
+ margin-bottom: 16px;
126
+ }
127
+
128
+ .message.bot {
129
+ align-items: flex-start;
130
+ }
131
+
132
+ .message.user {
133
+ align-items: flex-end;
134
+ }
135
+
136
+ .message.agent {
137
+ align-items: flex-start;
138
+ }
139
+
140
+ /* Screen reader only */
141
+ .srOnly {
142
+ position: absolute;
143
+ width: 1px;
144
+ height: 1px;
145
+ padding: 0;
146
+ margin: -1px;
147
+ overflow: hidden;
148
+ clip: rect(0, 0, 0, 0);
149
+ white-space: nowrap;
150
+ border-width: 0;
151
+ }
152
+ </style>
@@ -0,0 +1,354 @@
1
+ <script setup lang="ts">
2
+ import { computed, useCssModule } from 'vue'
3
+ import { sanitizeUrl } from '@braintree/sanitize-url'
4
+ import Typography from './Typography.vue'
5
+ import LinkIcon from './LinkIcon.vue'
6
+ import { sanitizeHTMLWithConfig } from '../../utils/sanitize'
7
+ import { getWebchatButtonLabel, interpolateString, moveFocusToMessageFocusTarget } from '../../utils/helpers'
8
+ import type { IWebchatButton, IWebchatQuickReply, ChatConfig, MessageSender, CustomIcon, AnalyticsEventCallback } from '../../types'
9
+
10
+ type NormalizedActionButton = {
11
+ type?: string
12
+ content_type?: string
13
+ contentType?: string
14
+ title?: string
15
+ payload?: string
16
+ url?: string
17
+ target?: string
18
+ image_url?: string
19
+ imageUrl?: string
20
+ image_alt_text?: string
21
+ imageAltText?: string
22
+ }
23
+
24
+ interface Props {
25
+ button: (IWebchatButton | IWebchatQuickReply) & NormalizedActionButton
26
+ action?: MessageSender
27
+ disabled?: boolean
28
+ total: number
29
+ position: number
30
+ customIcon?: CustomIcon
31
+ showUrlIcon?: boolean
32
+ config?: ChatConfig
33
+ dataMessageId?: string
34
+ onEmitAnalytics?: AnalyticsEventCallback
35
+ size?: 'small' | 'large'
36
+ id?: string
37
+ className?: string
38
+ openXAppOverlay?: (url: string | undefined) => void
39
+ }
40
+
41
+ const props = withDefaults(defineProps<Props>(), {
42
+ action: undefined,
43
+ disabled: false,
44
+ customIcon: undefined,
45
+ showUrlIcon: false,
46
+ config: undefined,
47
+ dataMessageId: undefined,
48
+ onEmitAnalytics: undefined,
49
+ size: 'small',
50
+ id: undefined,
51
+ className: '',
52
+ openXAppOverlay: undefined,
53
+ })
54
+
55
+ const styles = useCssModule()
56
+
57
+ // Determine button type
58
+ const buttonType = computed(() => {
59
+ return props.button.type ?? props.button.content_type ?? props.button.contentType ?? null
60
+ })
61
+
62
+ // Get button image
63
+ const buttonImage = computed(() => {
64
+ if ('image_url' in props.button) return props.button.image_url
65
+ if ('imageUrl' in props.button) return props.button.imageUrl
66
+ return null
67
+ })
68
+
69
+ // Get button image alt text
70
+ const buttonImageAltText = computed(() => {
71
+ if ('image_alt_text' in props.button) return props.button.image_alt_text
72
+ if ('imageAltText' in props.button) return props.button.imageAltText
73
+ return ''
74
+ })
75
+
76
+ // Get button label
77
+ const buttonLabel = computed(() => {
78
+ return getWebchatButtonLabel(props.button) || ''
79
+ })
80
+
81
+ // Sanitize button label HTML
82
+ const sanitizedLabel = computed(() => {
83
+ const customAllowedHtmlTags = props.config?.settings?.widgetSettings?.customAllowedHtmlTags
84
+ return props.config?.settings?.layout?.disableHtmlContentSanitization
85
+ ? buttonLabel.value
86
+ : sanitizeHTMLWithConfig(buttonLabel.value, customAllowedHtmlTags)
87
+ })
88
+
89
+ // Check if phone number button
90
+ const isPhoneNumber = computed(() => {
91
+ return props.button.payload && (buttonType.value === 'phone_number' || buttonType.value === 'user_phone_number')
92
+ })
93
+
94
+ // Check if web URL button
95
+ const isWebURL = computed(() => {
96
+ return 'type' in props.button && props.button.type === 'web_url'
97
+ })
98
+
99
+ // Check if opens in new tab
100
+ const isWebURLButtonTargetBlank = computed(() => {
101
+ return isWebURL.value && props.button.target !== '_self'
102
+ })
103
+
104
+ // Get aria-label
105
+ const ariaLabel = computed(() => {
106
+ const buttonTitle = props.button.title || ''
107
+ const opensInNewTabLabel = props.config?.settings?.customTranslations?.ariaLabels?.opensInNewTab || 'Opens in new tab'
108
+
109
+ const isURLInNewTab = isWebURL.value && isWebURLButtonTargetBlank.value
110
+ const newTabURLButtonTitle = `${buttonTitle}. ${opensInNewTabLabel}`
111
+ const buttonTitleWithTarget = isURLInNewTab ? newTabURLButtonTitle : props.button.title
112
+
113
+ if (props.total > 1) {
114
+ return (
115
+ interpolateString(
116
+ props.config?.settings?.customTranslations?.ariaLabels?.actionButtonPositionText ?? '{position} of {total}',
117
+ {
118
+ position: props.position.toString(),
119
+ total: props.total.toString(),
120
+ }
121
+ ) +
122
+ ': ' +
123
+ buttonTitleWithTarget
124
+ )
125
+ } else if (props.total <= 1 && isURLInNewTab) {
126
+ return newTabURLButtonTitle
127
+ }
128
+
129
+ return undefined
130
+ })
131
+
132
+ // Determine which component to render
133
+ const componentTag = computed(() => {
134
+ const isURLComponent = isWebURL.value || isPhoneNumber.value
135
+ if (!isURLComponent) return 'button'
136
+ return 'a'
137
+ })
138
+
139
+ // Get href for anchor tags
140
+ const href = computed(() => {
141
+ if (isPhoneNumber.value && props.button.payload) {
142
+ return `tel:${props.button.payload}`
143
+ }
144
+ if (isWebURL.value && props.button.url) {
145
+ return props.button.url
146
+ }
147
+ return undefined
148
+ })
149
+
150
+ // Get target for anchor tags
151
+ const target = computed(() => {
152
+ if (isWebURL.value) {
153
+ return props.button.target
154
+ }
155
+ return undefined
156
+ })
157
+
158
+ // CSS classes
159
+ const buttonClasses = computed(() => {
160
+ const classes = [styles.button]
161
+ if (isWebURL.value) classes.push(styles.url)
162
+ if (props.className) classes.push(props.className)
163
+ if (props.disabled) {
164
+ classes.push(styles.disabled)
165
+ classes.push('disabled')
166
+ }
167
+ if (isPhoneNumber.value) classes.push('phone-number-or-url-anchor')
168
+ if (isWebURL.value) classes.push('phone-number-or-url-anchor')
169
+ return classes
170
+ })
171
+
172
+ // Handle button click
173
+ const handleClick = (event: MouseEvent) => {
174
+ event.stopPropagation()
175
+ props.onEmitAnalytics?.('action', props.button)
176
+
177
+ if (isPhoneNumber.value) {
178
+ if (props.disabled) {
179
+ event.preventDefault()
180
+ }
181
+ return
182
+ }
183
+
184
+ if (isWebURL.value) {
185
+ const url = props.config?.settings?.layout?.disableUrlButtonSanitization
186
+ ? props.button.url
187
+ : sanitizeUrl(props.button.url)
188
+
189
+ // Prevent no-ops from sending you to a blank page
190
+ if (url === 'about:blank') return
191
+
192
+ window.open(url, isWebURLButtonTargetBlank.value ? '_blank' : '_self')
193
+ }
194
+
195
+ if (props.disabled) return
196
+
197
+ event.preventDefault()
198
+
199
+ if (isWebURL.value) {
200
+ return
201
+ }
202
+
203
+ if (buttonType.value === 'openXApp') {
204
+ props.openXAppOverlay?.(props.button.payload)
205
+ return
206
+ }
207
+
208
+ props.action?.(props.button.payload, null, { label: props.button.title })
209
+
210
+ focusHandling()
211
+ }
212
+
213
+ // Focus handling after action
214
+ const focusHandling = () => {
215
+ // Focus the input after postback button click, if focusInputAfterPostback is true
216
+ if (props.config?.settings?.behavior?.focusInputAfterPostback) {
217
+ const textMessageInput = document.getElementById('webchatInputMessageInputInTextMode')
218
+ textMessageInput?.focus?.()
219
+ return
220
+ }
221
+
222
+ // Focus the visually hidden focus target after postback
223
+ if (props.dataMessageId) {
224
+ moveFocusToMessageFocusTarget(props.dataMessageId)
225
+ }
226
+ }
227
+
228
+ // Render icon
229
+ const showIcon = computed(() => {
230
+ if (props.customIcon) return true
231
+ if (isWebURL.value && props.showUrlIcon) return true
232
+ return false
233
+ })
234
+ </script>
235
+
236
+ <template>
237
+ <component
238
+ :is="componentTag"
239
+ :id="id"
240
+ :href="href"
241
+ :target="target"
242
+ :class="buttonClasses"
243
+ :aria-label="ariaLabel"
244
+ :aria-disabled="disabled"
245
+ :disabled="componentTag === 'button' ? disabled : undefined"
246
+ :tabindex="disabled ? -1 : 0"
247
+ @click="handleClick"
248
+ >
249
+ <div v-if="buttonImage" :class="styles.buttonImageContainer">
250
+ <img
251
+ :src="buttonImage"
252
+ :alt="buttonImageAltText"
253
+ :class="[
254
+ 'webchat-template-button-image',
255
+ styles.buttonImage,
256
+ disabled && styles.imageDisabled
257
+ ]"
258
+ />
259
+ </div>
260
+
261
+ <Typography
262
+ :variant="size === 'large' ? 'title1-semibold' : 'cta-semibold'"
263
+ component="span"
264
+ :class="buttonImage && styles.buttonLabelWithImage"
265
+ v-html="sanitizedLabel"
266
+ />
267
+
268
+ <slot v-if="customIcon" name="icon" />
269
+ <LinkIcon v-else-if="showIcon && isWebURL && showUrlIcon" />
270
+ </component>
271
+ </template>
272
+
273
+ <style module>
274
+ button.button,
275
+ a.button {
276
+ border-radius: 19px;
277
+ padding: 8px 10px;
278
+ cursor: pointer;
279
+ display: flex;
280
+ align-items: center;
281
+ justify-content: center;
282
+ gap: 10px;
283
+ text-decoration: none;
284
+ background: var(--cc-primary-color);
285
+ color: var(--cc-primary-contrast-color);
286
+ border: none;
287
+ outline: none;
288
+ position: relative;
289
+ }
290
+
291
+ a.button:global(.phone-number-or-url-anchor) {
292
+ background: var(--cc-primary-color);
293
+ }
294
+
295
+ button.button svg,
296
+ a.button svg,
297
+ button.button path,
298
+ a.button svg path {
299
+ fill: var(--cc-primary-contrast-color);
300
+ width: 12px;
301
+ }
302
+
303
+ button.button:hover,
304
+ a.button:hover,
305
+ button.button:focus,
306
+ a.button:focus {
307
+ background: var(--cc-primary-color-hover);
308
+ }
309
+
310
+ /* Explicitly increase the specificity of the :focus-visible selector */
311
+ [data-cognigy-webchat-root] button.button:focus-visible,
312
+ [data-cognigy-webchat-root] a.button:focus-visible,
313
+ [data-cognigy-webchat-root] a.button:global(.phone-number-or-url-anchor):focus-visible {
314
+ outline: 2px solid var(--cc-primary-color-focus);
315
+ outline-offset: 2px;
316
+ box-shadow: 0 0 0 4px var(--cc-primary-contrast-color);
317
+ }
318
+
319
+ button.button:disabled,
320
+ button.button:disabled:hover,
321
+ button.button:disabled:focus,
322
+ a.button.disabled,
323
+ a.button.disabled:hover,
324
+ a.button.disabled:focus {
325
+ background: var(--cc-primary-color-disabled);
326
+ cursor: default;
327
+ pointer-events: none;
328
+ }
329
+
330
+ .buttonLabelWithImage {
331
+ margin-left: 40px;
332
+ }
333
+
334
+ .buttonImage {
335
+ width: 100%;
336
+ height: 100%;
337
+ object-fit: cover;
338
+ border-top-left-radius: 19px;
339
+ border-bottom-left-radius: 19px;
340
+ }
341
+
342
+ .buttonImage.imageDisabled {
343
+ opacity: 0.6;
344
+ }
345
+
346
+ .buttonImageContainer {
347
+ display: flex;
348
+ position: absolute;
349
+ left: 0;
350
+ width: 40px;
351
+ height: 100%;
352
+ border-right: 2px solid var(--cc-primary-contrast-color);
353
+ }
354
+ </style>
@@ -0,0 +1,170 @@
1
+ <script setup lang="ts">
2
+ import { computed, useCssModule, onMounted, CSSProperties } from 'vue'
3
+ import ActionButton from './ActionButton.vue'
4
+ import { getRandomId } from '../../utils/helpers'
5
+ import type { IWebchatButton, IWebchatQuickReply, ChatConfig, MessageSender, CustomIcon, AnalyticsEventCallback } from '../../types'
6
+
7
+ interface Props {
8
+ payload: (IWebchatButton | IWebchatQuickReply)[]
9
+ action?: MessageSender
10
+ className?: string
11
+ containerClassName?: string
12
+ containerStyle?: CSSProperties
13
+ buttonClassName?: string
14
+ buttonListItemClassName?: string
15
+ customIcon?: CustomIcon
16
+ showUrlIcon?: boolean
17
+ config?: ChatConfig
18
+ dataMessageId?: string
19
+ onEmitAnalytics?: AnalyticsEventCallback
20
+ size?: 'small' | 'large'
21
+ templateTextId?: string
22
+ openXAppOverlay?: (url: string | undefined) => void
23
+ }
24
+
25
+ const props = withDefaults(defineProps<Props>(), {
26
+ action: undefined,
27
+ className: '',
28
+ containerClassName: '',
29
+ containerStyle: undefined,
30
+ buttonClassName: '',
31
+ buttonListItemClassName: '',
32
+ customIcon: undefined,
33
+ showUrlIcon: false,
34
+ config: undefined,
35
+ dataMessageId: undefined,
36
+ onEmitAnalytics: undefined,
37
+ size: 'small',
38
+ templateTextId: undefined,
39
+ openXAppOverlay: undefined,
40
+ })
41
+
42
+ const styles = useCssModule()
43
+
44
+ // Generate unique ID for buttons
45
+ const webchatButtonTemplateButtonId = getRandomId('webchatButtonTemplateButton')
46
+
47
+ // Auto-focus first button on mount if enabled
48
+ onMounted(() => {
49
+ if (!props.config?.settings?.widgetSettings?.enableAutoFocus) return
50
+
51
+ const firstButton = document.getElementById(`${webchatButtonTemplateButtonId}-0`)
52
+ const chatHistory = document.getElementById('webchatChatHistoryWrapperLiveLogPanel')
53
+
54
+ if (!chatHistory?.contains(document.activeElement)) return
55
+
56
+ setTimeout(() => {
57
+ firstButton?.focus()
58
+ }, 200)
59
+ })
60
+
61
+ // Filter valid buttons
62
+ const buttons = computed(() => {
63
+ if (!props.payload || props.payload?.length === 0) return []
64
+
65
+ return props.payload.filter((button: Props['payload'][number]) => {
66
+ // Filter by type
67
+ if ('type' in button && !['postback', 'web_url', 'phone_number', 'openXApp'].includes(button.type)) {
68
+ return false
69
+ }
70
+
71
+ // Filter text content_type buttons without title
72
+ if ('content_type' in button && button.content_type === 'text' && !button.title) {
73
+ return false
74
+ }
75
+
76
+ return true
77
+ })
78
+ })
79
+
80
+ // Determine container component type
81
+ const componentTag = computed(() => {
82
+ return buttons.value.length > 1 ? 'ul' : 'div'
83
+ })
84
+
85
+ // Container classes
86
+ const containerClasses = computed(() => {
87
+ const classes = [props.className, styles.buttons, props.containerClassName]
88
+ return classes.filter(Boolean).join(' ')
89
+ })
90
+ </script>
91
+
92
+ <template>
93
+ <component
94
+ :is="componentTag"
95
+ v-if="buttons.length > 0"
96
+ :class="containerClasses"
97
+ :style="containerStyle || {}"
98
+ :aria-labelledby="templateTextId"
99
+ data-testid="action-buttons"
100
+ >
101
+ <template v-if="buttons.length > 1">
102
+ <li
103
+ v-for="(button, index) in buttons"
104
+ :key="`${webchatButtonTemplateButtonId}-${index}`"
105
+ :class="buttonListItemClassName"
106
+ :aria-posinset="index + 1"
107
+ :aria-setsize="buttons.length"
108
+ >
109
+ <ActionButton
110
+ :id="`${webchatButtonTemplateButtonId}-${index}`"
111
+ :button="button"
112
+ :action="action"
113
+ :disabled="action === undefined"
114
+ :position="index + 1"
115
+ :total="buttons.length"
116
+ :custom-icon="customIcon"
117
+ :show-url-icon="showUrlIcon"
118
+ :config="config"
119
+ :on-emit-analytics="onEmitAnalytics"
120
+ :size="size"
121
+ :data-message-id="dataMessageId"
122
+ :class-name="buttonClassName"
123
+ :open-x-app-overlay="openXAppOverlay"
124
+ />
125
+ </li>
126
+ </template>
127
+
128
+ <template v-else>
129
+ <ActionButton
130
+ v-for="(button, index) in buttons"
131
+ :id="`${webchatButtonTemplateButtonId}-${index}`"
132
+ :key="`${webchatButtonTemplateButtonId}-${index}`"
133
+ :button="button"
134
+ :action="action"
135
+ :disabled="action === undefined"
136
+ :position="index + 1"
137
+ :total="buttons.length"
138
+ :custom-icon="customIcon"
139
+ :show-url-icon="showUrlIcon"
140
+ :config="config"
141
+ :on-emit-analytics="onEmitAnalytics"
142
+ :size="size"
143
+ :data-message-id="dataMessageId"
144
+ :class-name="buttonClassName"
145
+ :open-x-app-overlay="openXAppOverlay"
146
+ />
147
+ </template>
148
+ </component>
149
+ </template>
150
+
151
+ <style module>
152
+ .buttons {
153
+ align-items: flex-start;
154
+ display: flex;
155
+ flex-direction: row;
156
+ flex-wrap: wrap;
157
+ gap: 8px;
158
+ max-width: 295px;
159
+ }
160
+
161
+ ul.buttons {
162
+ list-style: none;
163
+ padding-inline-start: 0;
164
+ margin-block: 0;
165
+ }
166
+
167
+ ul.buttons li {
168
+ list-style: none;
169
+ }
170
+ </style>