@cognigy/chat-components-vue 0.1.0 → 0.3.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 (40) hide show
  1. package/dist/chat-components-vue.css +1 -1
  2. package/dist/chat-components-vue.js +12386 -5741
  3. package/dist/components/Message.vue.d.ts +4 -0
  4. package/dist/components/common/Typography.vue.d.ts +1 -1
  5. package/dist/components/messages/AdaptiveCardRenderer.vue.d.ts +35 -0
  6. package/dist/components/messages/ListItem.vue.d.ts +1 -1
  7. package/dist/composables/useLiveRegion.d.ts +30 -0
  8. package/dist/index.d.ts +3 -2
  9. package/dist/types/index.d.ts +105 -1
  10. package/dist/utils/helpers.d.ts +3 -2
  11. package/dist/utils/matcher.d.ts +3 -3
  12. package/dist/utils/theme.d.ts +12 -1
  13. package/package.json +8 -3
  14. package/src/components/Message.vue +98 -55
  15. package/src/components/common/ActionButton.vue +16 -7
  16. package/src/components/common/ChatBubble.vue +8 -6
  17. package/src/components/common/ChatEvent.vue +5 -2
  18. package/src/components/common/TypingIndicator.vue +4 -1
  19. package/src/components/common/Typography.vue +56 -67
  20. package/src/components/messages/AdaptiveCard.vue +322 -225
  21. package/src/components/messages/AdaptiveCardRenderer.vue +260 -0
  22. package/src/components/messages/AudioMessage.vue +4 -1
  23. package/src/components/messages/DatePicker.vue +5 -27
  24. package/src/components/messages/FileMessage.vue +12 -3
  25. package/src/components/messages/Gallery.vue +96 -10
  26. package/src/components/messages/GalleryItem.vue +17 -5
  27. package/src/components/messages/ImageMessage.vue +20 -5
  28. package/src/components/messages/List.vue +56 -42
  29. package/src/components/messages/ListItem.vue +105 -68
  30. package/src/components/messages/TextMessage.vue +1 -1
  31. package/src/components/messages/TextWithButtons.vue +35 -11
  32. package/src/components/messages/VideoMessage.vue +35 -26
  33. package/src/composables/useCollation.ts +28 -45
  34. package/src/composables/useLiveRegion.ts +101 -0
  35. package/src/index.ts +4 -1
  36. package/src/types/index.ts +127 -2
  37. package/src/utils/helpers.ts +46 -24
  38. package/src/utils/matcher.ts +20 -6
  39. package/src/utils/sanitize.ts +1 -2
  40. package/src/utils/theme.ts +42 -1
@@ -0,0 +1,260 @@
1
+ <template>
2
+ <div ref="targetRef" data-testid="adaptive-card-renderer" />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ /**
7
+ * AdaptiveCardRenderer - Inner component that uses Microsoft's adaptivecards library
8
+ *
9
+ * This component handles the actual rendering of Adaptive Cards using the
10
+ * Microsoft adaptivecards library. It's designed for presentation-only mode
11
+ * (no action handling).
12
+ *
13
+ * Inspired by Microsoft's adaptivecards-react package:
14
+ * https://github.com/microsoft/AdaptiveCards/blob/5b66a52e0e0cee5074a42dcbe688d608e0327ae4/source/nodejs/adaptivecards-react/src/adaptive-card.tsx
15
+ */
16
+ import { ref, shallowRef, watch, onMounted, onBeforeUnmount } from 'vue'
17
+ import { AdaptiveCard as MSAdaptiveCard, HostConfig } from 'adaptivecards'
18
+ import MarkdownIt from 'markdown-it'
19
+ import { sanitizeHTMLWithConfig } from '../../utils/sanitize'
20
+
21
+ /**
22
+ * Shared MarkdownIt instance at module scope.
23
+ *
24
+ * Why this is safe to share across component instances:
25
+ * - MarkdownIt's render() method is a pure function (input → output, no side effects)
26
+ * - No internal state is mutated during rendering
27
+ * - Configuration is set at construction time and doesn't change
28
+ * - This pattern is recommended by MarkdownIt documentation for performance
29
+ *
30
+ * Creating a new instance per component would be wasteful since there's no
31
+ * per-instance state to isolate.
32
+ */
33
+ const md = new MarkdownIt()
34
+
35
+ /**
36
+ * Set up Markdown processing for Adaptive Cards (module-level, runs once)
37
+ *
38
+ * This sets a static callback on MSAdaptiveCard that processes markdown text.
39
+ * We intentionally do this at module scope to:
40
+ * 1. Avoid race conditions from multiple components setting the callback
41
+ * 2. Ensure consistent markdown processing across all card instances
42
+ *
43
+ * Note: We use sanitizeHTML directly (not via useSanitize composable) because
44
+ * this runs at module scope without component context. For adaptive cards,
45
+ * sanitization is always applied for security - config overrides don't apply.
46
+ */
47
+ MSAdaptiveCard.onProcessMarkdown = (text, result) => {
48
+ try {
49
+ const html = md.render(text)
50
+ // Use default sanitization (no custom allowed tags for adaptive card content)
51
+ result.outputHtml = sanitizeHTMLWithConfig(html)
52
+ result.didProcess = true
53
+ } catch (error) {
54
+ console.error('AdaptiveCardRenderer: Markdown processing failed', { error })
55
+ result.outputHtml = text
56
+ result.didProcess = true
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Host config input type - plain object that HostConfig constructor parses.
62
+ * We don't use Partial<HostConfig> because HostConfig properties are class instances,
63
+ * but the constructor accepts plain objects and converts them internally.
64
+ */
65
+ type HostConfigInput = Record<string, unknown>
66
+
67
+ interface Props {
68
+ /**
69
+ * The Adaptive Card payload to render
70
+ */
71
+ payload?: Record<string, unknown>
72
+ /**
73
+ * Host configuration for styling the card.
74
+ * Accepts a plain object matching the HostConfig structure - the library
75
+ * parses this internally when constructing the HostConfig instance.
76
+ */
77
+ hostConfig?: HostConfigInput
78
+ /**
79
+ * When true, disables all inputs and buttons after rendering
80
+ * Useful for displaying submitted cards in chat history
81
+ */
82
+ readonly?: boolean
83
+ /**
84
+ * Data to pre-fill into input fields
85
+ * Keys should match input element IDs in the card
86
+ * Used to show submitted values in chat history
87
+ */
88
+ inputData?: Record<string, unknown>
89
+ }
90
+
91
+ const props = withDefaults(defineProps<Props>(), {
92
+ readonly: false,
93
+ })
94
+
95
+ const targetRef = ref<HTMLDivElement | null>(null)
96
+
97
+ // Component-scoped card instance - using shallowRef for proper instance isolation
98
+ // Each component gets its own MSAdaptiveCard instance to prevent conflicts
99
+ const cardInstance = shallowRef<MSAdaptiveCard | null>(null)
100
+
101
+ /**
102
+ * Apply input data to card payload by setting values on input elements
103
+ * Recursively walks the card structure to find inputs and set their values
104
+ */
105
+ function applyInputData(
106
+ cardPayload: Record<string, unknown>,
107
+ inputData: Record<string, unknown>
108
+ ): Record<string, unknown> {
109
+ if (!inputData || Object.keys(inputData).length === 0) {
110
+ return cardPayload
111
+ }
112
+
113
+ // Deep clone to avoid mutating original
114
+ const payload = structuredClone(cardPayload)
115
+
116
+ function processElement(element: Record<string, unknown>): void {
117
+ // Check if this is an input element with an id
118
+ const type = element.type as string | undefined
119
+ const id = element.id as string | undefined
120
+
121
+ if (type?.startsWith('Input.') && id && id in inputData) {
122
+ // Set the value for this input
123
+ const value = inputData[id]
124
+
125
+ if (type === 'Input.Toggle') {
126
+ // Toggle inputs use valueOn/valueOff - custom values take precedence
127
+ const matchesCustomOn = element.valueOn !== undefined && value === element.valueOn
128
+ const matchesCustomOff = element.valueOff !== undefined && value === element.valueOff
129
+ const isStandardOn = value === true || value === 'true'
130
+
131
+ const isToggleOn = matchesCustomOn || (!matchesCustomOff && isStandardOn)
132
+
133
+ element.value = isToggleOn
134
+ ? (element.valueOn ?? 'true')
135
+ : (element.valueOff ?? 'false')
136
+ } else if (type === 'Input.ChoiceSet' && Array.isArray(value)) {
137
+ // Multi-select choice sets use comma-separated values
138
+ element.value = value.join(',')
139
+ } else {
140
+ element.value = value
141
+ }
142
+ }
143
+
144
+ // Recursively process child elements
145
+ if (Array.isArray(element.body)) {
146
+ element.body.forEach((child: Record<string, unknown>) => processElement(child))
147
+ }
148
+ if (Array.isArray(element.items)) {
149
+ element.items.forEach((child: Record<string, unknown>) => processElement(child))
150
+ }
151
+ if (Array.isArray(element.columns)) {
152
+ element.columns.forEach((col: Record<string, unknown>) => {
153
+ if (Array.isArray(col.items)) {
154
+ col.items.forEach((child: Record<string, unknown>) => processElement(child))
155
+ }
156
+ })
157
+ }
158
+ if (Array.isArray(element.actions)) {
159
+ element.actions.forEach((action: Record<string, unknown>) => {
160
+ if (action.card) {
161
+ processElement(action.card as Record<string, unknown>)
162
+ }
163
+ })
164
+ }
165
+ }
166
+
167
+ processElement(payload)
168
+ return payload
169
+ }
170
+
171
+ /**
172
+ * Disable all interactive elements in the rendered card
173
+ * Used for displaying submitted cards in chat history
174
+ */
175
+ function disableInteractiveElements(container: HTMLElement): void {
176
+ const interactiveElements = container.querySelectorAll(
177
+ 'input, textarea, select, button'
178
+ )
179
+ interactiveElements.forEach((el) => {
180
+ el.setAttribute('disabled', 'true')
181
+ el.setAttribute('aria-disabled', 'true')
182
+ })
183
+
184
+ // Add a class to the container for additional styling hooks
185
+ container.classList.add('ac-readonly')
186
+ }
187
+
188
+ /**
189
+ * Render the adaptive card
190
+ */
191
+ function renderCard(): void {
192
+ if (!targetRef.value || !props.payload) {
193
+ return
194
+ }
195
+
196
+ // Create card instance if needed
197
+ if (!cardInstance.value) {
198
+ cardInstance.value = new MSAdaptiveCard()
199
+ }
200
+
201
+ // Apply host config if provided
202
+ if (props.hostConfig) {
203
+ cardInstance.value.hostConfig = new HostConfig(props.hostConfig)
204
+ }
205
+
206
+ try {
207
+ // Apply input data to payload if provided
208
+ const payloadToRender = props.inputData
209
+ ? applyInputData(props.payload, props.inputData)
210
+ : props.payload
211
+
212
+ // Parse and render the card
213
+ cardInstance.value.parse(payloadToRender)
214
+ const renderedCard = cardInstance.value.render()
215
+
216
+ if (renderedCard && targetRef.value) {
217
+ // Clear previous content and append new
218
+ targetRef.value.innerHTML = ''
219
+ targetRef.value.appendChild(renderedCard)
220
+
221
+ // Accessibility: Add aria-level to heading elements without it
222
+ const headings = targetRef.value.querySelectorAll("[role='heading']")
223
+ headings.forEach((heading) => {
224
+ if (heading.getAttribute('aria-level') === null) {
225
+ heading.setAttribute('aria-level', '4')
226
+ }
227
+ })
228
+
229
+ // Disable all interactive elements when in readonly mode
230
+ if (props.readonly) {
231
+ disableInteractiveElements(targetRef.value)
232
+ }
233
+ }
234
+ } catch (error) {
235
+ console.error('AdaptiveCardRenderer: Unable to render Adaptive Card', {
236
+ error,
237
+ payload: props.payload,
238
+ })
239
+ }
240
+ }
241
+
242
+ // Render on mount (markdown processing is set up at module scope)
243
+ onMounted(() => {
244
+ renderCard()
245
+ })
246
+
247
+ // Re-render when payload, hostConfig, or inputData changes
248
+ watch(
249
+ () => [props.payload, props.hostConfig, props.inputData],
250
+ () => {
251
+ renderCard()
252
+ },
253
+ { deep: true }
254
+ )
255
+
256
+ // Cleanup on unmount
257
+ onBeforeUnmount(() => {
258
+ cardInstance.value = null
259
+ })
260
+ </script>
@@ -270,8 +270,11 @@ onMounted(() => {
270
270
  gap: 10px;
271
271
  background-color: var(--cc-white, #ffffff);
272
272
  border-radius: var(--cc-bubble-border-radius, 15px);
273
- border: 1px solid var(--cc-black-80, rgba(0, 0, 0, 0.8));
273
+ border: 1px solid var(--cc-border-media-card, var(--cc-black-80, rgba(0, 0, 0, 0.8)));
274
274
  padding: 0px 12px;
275
+ box-shadow: var(--cc-message-shadow, rgba(151, 124, 156, 0.1) 0px 5px 9px 0px,
276
+ rgba(203, 195, 212, 0.1) 0px 5px 16px 0px,
277
+ rgba(216, 212, 221, 0.1) 0px 8px 20px 0px);
275
278
  }
276
279
 
277
280
  .audioWrapper .controls {
@@ -21,16 +21,6 @@
21
21
  </Typography>
22
22
  </div>
23
23
 
24
- <!-- Event name/label (optional) -->
25
- <div
26
- v-if="eventName"
27
- :class="['webchat-date-picker-event', $style.eventName]"
28
- data-testid="datepicker-event"
29
- >
30
- <Typography variant="copy-medium" component="span" :class="$style.eventLabel">
31
- {{ eventName }}
32
- </Typography>
33
- </div>
34
24
  </div>
35
25
  </template>
36
26
 
@@ -59,16 +49,9 @@ const buttonText = computed(() => {
59
49
  return datePickerData.value?.openPickerButtonText || 'Pick date'
60
50
  })
61
51
 
62
- // Event name/label
63
- const eventName = computed(() => {
64
- return datePickerData.value?.eventName
65
- })
66
-
67
- // Check if button should be disabled (for presentation purposes)
68
- // In a real implementation, this would check conversation state
52
+ // Disable button when a date has already been selected
69
53
  const isDisabled = computed(() => {
70
- // Could be extended to check message.data.hasReply or other flags
71
- return false
54
+ return !!selectedDate.value
72
55
  })
73
56
 
74
57
  // Get selected date if available (from message text or reply)
@@ -93,6 +76,9 @@ const selectedDate = computed(() => {
93
76
  flex-direction: column;
94
77
  gap: 8px;
95
78
  align-items: flex-start;
79
+ box-shadow: var(--cc-message-shadow, rgba(151, 124, 156, 0.1) 0px 5px 9px 0px,
80
+ rgba(203, 195, 212, 0.1) 0px 5px 16px 0px,
81
+ rgba(216, 212, 221, 0.1) 0px 8px 20px 0px);
96
82
  }
97
83
 
98
84
  .button {
@@ -124,12 +110,4 @@ const selectedDate = computed(() => {
124
110
  border-radius: var(--cc-bubble-border-radius, 15px);
125
111
  }
126
112
 
127
- .eventName {
128
- padding: 4px 8px;
129
- }
130
-
131
- .eventLabel {
132
- color: var(--cc-black-40, rgba(0, 0, 0, 0.4));
133
- font-style: italic;
134
- }
135
113
  </style>
@@ -149,6 +149,9 @@ const nonImages = computed(() => {
149
149
  object-position: center center;
150
150
  animation: webchatImagePreviewPopIn 0.2s ease-out;
151
151
  transform-origin: center;
152
+ box-shadow: var(--cc-message-shadow, rgba(151, 124, 156, 0.1) 0px 5px 9px 0px,
153
+ rgba(203, 195, 212, 0.1) 0px 5px 16px 0px,
154
+ rgba(216, 212, 221, 0.1) 0px 8px 20px 0px);
152
155
  }
153
156
 
154
157
  .smallImagePreview {
@@ -159,6 +162,9 @@ const nonImages = computed(() => {
159
162
  object-position: center center;
160
163
  animation: webchatImagePreviewPopIn 0.2s ease-out;
161
164
  transform-origin: center;
165
+ box-shadow: var(--cc-message-shadow, rgba(151, 124, 156, 0.1) 0px 5px 9px 0px,
166
+ rgba(203, 195, 212, 0.1) 0px 5px 16px 0px,
167
+ rgba(216, 212, 221, 0.1) 0px 8px 20px 0px);
162
168
  }
163
169
 
164
170
  .filePreview {
@@ -167,8 +173,11 @@ const nonImages = computed(() => {
167
173
  padding: 8px 12px;
168
174
  border-radius: var(--cc-bubble-border-radius, 15px);
169
175
  height: 33px;
170
- background-color: var(--cc-black-95, rgba(0, 0, 0, 0.95));
176
+ background-color: var(--cc-file-preview-background, rgba(0, 0, 0, 0.95));
171
177
  max-width: 295px;
178
+ box-shadow: var(--cc-message-shadow, rgba(151, 124, 156, 0.1) 0px 5px 9px 0px,
179
+ rgba(203, 195, 212, 0.1) 0px 5px 16px 0px,
180
+ rgba(216, 212, 221, 0.1) 0px 8px 20px 0px);
172
181
  }
173
182
 
174
183
  .filePreview .fileNameWrapper {
@@ -176,7 +185,7 @@ const nonImages = computed(() => {
176
185
  flex-direction: row;
177
186
  align-items: center;
178
187
  max-width: 200px;
179
- color: var(--cc-black-10, rgba(0, 0, 0, 0.1));
188
+ color: var(--cc-file-preview-text-color, #ffffff);
180
189
  }
181
190
 
182
191
  .filePreview .fileName {
@@ -190,6 +199,6 @@ const nonImages = computed(() => {
190
199
  }
191
200
 
192
201
  .filePreview .fileSize {
193
- color: var(--cc-black-40, rgba(0, 0, 0, 0.4));
202
+ color: var(--cc-file-preview-secondary-text-color, rgba(255, 255, 255, 0.6));
194
203
  }
195
204
  </style>
@@ -1,9 +1,9 @@
1
1
  <template>
2
- <div v-if="elements.length > 0">
2
+ <div v-if="elements.length > 0" :class="$style.galleryRoot">
3
3
  <!-- Single card (no carousel) -->
4
4
  <div
5
5
  v-if="elements.length === 1"
6
- :class="['webchat-carousel-template-root', $style.wrapper]"
6
+ :class="['webchat-carousel-template-root', $style.wrapper, variantClass]"
7
7
  data-testid="gallery-message"
8
8
  >
9
9
  <GalleryItem :slide="elements[0]" :contentId="`${carouselContentId}-0`" />
@@ -21,22 +21,23 @@
21
21
  }"
22
22
  :pagination="{ clickable: true }"
23
23
  :a11y="{ slideLabelMessage }"
24
- :class="['webchat-carousel-template-root', $style.wrapper]"
24
+ :class="swiperClasses"
25
25
  data-testid="gallery-message"
26
+ @slideChange="handleSlideChange"
26
27
  >
27
28
  <SwiperSlide
28
29
  v-for="(element, index) in elements"
29
30
  :key="index"
30
- style="width: 206px"
31
+ :style="slideStyle"
31
32
  >
32
33
  <GalleryItem :slide="element" :contentId="`${carouselContentId}-${index}`" />
33
34
  </SwiperSlide>
34
35
 
35
- <!-- Navigation buttons -->
36
- <button class="gallery-button-prev">
36
+ <!-- Navigation buttons (click.stop prevents event bubbling to parent card) -->
37
+ <button class="gallery-button-prev" @click.stop>
37
38
  <ArrowBackIcon />
38
39
  </button>
39
- <button class="gallery-button-next">
40
+ <button class="gallery-button-next" @click.stop>
40
41
  <ArrowBackIcon />
41
42
  </button>
42
43
  </Swiper>
@@ -44,14 +45,16 @@
44
45
  </template>
45
46
 
46
47
  <script setup lang="ts">
47
- import { computed, onMounted, useCssModule } from 'vue'
48
+ import { computed, onMounted, useCssModule, nextTick } from 'vue'
48
49
  import { Swiper, SwiperSlide } from 'swiper/vue'
50
+ import type { Swiper as SwiperType } from 'swiper'
49
51
  import { Navigation, Pagination, A11y } from 'swiper/modules'
50
52
  import GalleryItem from './GalleryItem.vue'
51
53
  import { ArrowBackIcon } from '../../assets/svg'
52
54
  import { useMessageContext } from '../../composables/useMessageContext'
53
55
  import { getChannelPayload } from '../../utils/matcher'
54
56
  import { getRandomId } from '../../utils/helpers'
57
+ import { getMessageId } from '../../types'
55
58
  import type { IWebchatTemplateAttachment } from '../../types'
56
59
 
57
60
  // Import Swiper styles
@@ -66,7 +69,7 @@ const $style = useCssModule()
66
69
  const modules = [Navigation, Pagination, A11y]
67
70
 
68
71
  // Message context
69
- const { message, config } = useMessageContext()
72
+ const { message, config, onSlideChange } = useMessageContext()
70
73
 
71
74
  // Get gallery elements from message payload
72
75
  const payload = computed(() => getChannelPayload(message, config))
@@ -78,6 +81,40 @@ const elements = computed(() => {
78
81
  // Generate unique ID for content
79
82
  const carouselContentId = getRandomId('webchatCarouselContentButton')
80
83
 
84
+ // Message ID for DOM queries (aria-live removal)
85
+ const dataMessageId = computed(() => getMessageId(message))
86
+
87
+ // Gallery variant from config
88
+ const variant = computed(() => config?.settings?.layout?.galleryVariant ?? 'default')
89
+
90
+ const variantClass = computed(() => {
91
+ if (variant.value === 'compact') return $style.compact
92
+ if (variant.value === 'copilot') return $style.copilot
93
+ return undefined
94
+ })
95
+
96
+ // Swiper classes (includes variant)
97
+ const swiperClasses = computed(() => {
98
+ return ['webchat-carousel-template-root', $style.wrapper, variantClass.value].filter(Boolean)
99
+ })
100
+
101
+ // Slide width based on variant
102
+ const slideStyle = computed(() => {
103
+ if (variant.value === 'compact') return { width: '170px' }
104
+ if (variant.value === 'copilot') {
105
+ // Copilot: adaptive sizing based on card count
106
+ if (elements.value.length === 1) return { width: '260px' }
107
+ if (elements.value.length === 2) return { width: '220px' }
108
+ return { width: '190px' }
109
+ }
110
+ return { width: '206px' }
111
+ })
112
+
113
+ // Handle slide change event from Swiper
114
+ const handleSlideChange = (swiper: SwiperType) => {
115
+ onSlideChange?.(swiper.activeIndex, elements.value.length)
116
+ }
117
+
81
118
  // Slide label for accessibility
82
119
  const slideLabelMessage = computed(() => {
83
120
  const slide = config?.settings?.customTranslations?.ariaLabels?.slide
@@ -95,8 +132,17 @@ const slideLabelMessage = computed(() => {
95
132
  return `${slide}: ${customSlidePosition}`
96
133
  })
97
134
 
98
- // Auto-focus first button/card on mount
135
+ // Auto-focus first button/card on mount + remove aria-live from swiper-wrapper
99
136
  onMounted(() => {
137
+ // Remove aria-live from swiper-wrapper (matches React behavior — prevents noisy screen reader announcements)
138
+ nextTick(() => {
139
+ const messageEl = document.querySelector(`[data-message-id="${dataMessageId.value}"]`)
140
+ const swiperWrapper = messageEl?.querySelector('.swiper-wrapper')
141
+ if (swiperWrapper) {
142
+ swiperWrapper.removeAttribute('aria-live')
143
+ }
144
+ })
145
+
100
146
  if (!config?.settings?.widgetSettings?.enableAutoFocus) return
101
147
 
102
148
  const chatHistory = document.getElementById('webchatChatHistoryWrapperLiveLogPanel')
@@ -116,6 +162,10 @@ onMounted(() => {
116
162
  </script>
117
163
 
118
164
  <style module>
165
+ .galleryRoot {
166
+ width: 100%;
167
+ }
168
+
119
169
  .slideItem {
120
170
  position: relative;
121
171
  width: 206px;
@@ -293,4 +343,40 @@ onMounted(() => {
293
343
  :global(article) :global(.swiper).wrapper :global(.swiper-pagination-horizontal.swiper-pagination-bullets .swiper-pagination-bullet) {
294
344
  margin: 0 4px;
295
345
  }
346
+
347
+ /*
348
+ ** GALLERY VARIANT: COMPACT
349
+ ** Narrower slides with smaller navigation buttons
350
+ */
351
+ :global(article) :global(.swiper).compact :global(.gallery-button-prev),
352
+ :global(article) :global(.swiper).compact :global(.gallery-button-next) {
353
+ width: 24px;
354
+ height: 24px;
355
+ top: calc(150px / 2 - 6px);
356
+ }
357
+
358
+ :global(article) :global(.swiper).compact :global(.gallery-button-prev) svg,
359
+ :global(article) :global(.swiper).compact :global(.gallery-button-next) svg {
360
+ width: 10px;
361
+ height: 10px;
362
+ }
363
+
364
+ /*
365
+ ** GALLERY VARIANT: COPILOT
366
+ ** Adaptive sizing based on card count with adjusted spacing
367
+ */
368
+ :global(article) :global(.swiper).copilot {
369
+ padding-left: 12px;
370
+ padding-right: 12px;
371
+ margin-left: -12px;
372
+ margin-right: -12px;
373
+ }
374
+
375
+ :global(article) :global(.swiper).copilot :global(.gallery-button-prev) {
376
+ left: 12px;
377
+ }
378
+
379
+ :global(article) :global(.swiper).copilot :global(.gallery-button-next) {
380
+ right: 12px;
381
+ }
296
382
  </style>
@@ -16,8 +16,8 @@
16
16
  v-else
17
17
  :src="slide.image_url"
18
18
  :alt="slide.image_alt_text || ''"
19
- :class="$style.slideImage"
20
19
  @error="handleImageError"
20
+ @click="handleImageClick"
21
21
  />
22
22
  </div>
23
23
 
@@ -54,6 +54,7 @@
54
54
  :dataMessageId="dataMessageId"
55
55
  :onEmitAnalytics="onEmitAnalytics"
56
56
  :templateTextId="slide.title ? titleId : undefined"
57
+ :openXAppOverlay="openXAppOverlay"
57
58
  />
58
59
  </div>
59
60
  </div>
@@ -79,7 +80,7 @@ const props = defineProps<Props>()
79
80
  const $style = useCssModule()
80
81
 
81
82
  // Context and config
82
- const { action, config, onEmitAnalytics } = useMessageContext()
83
+ const { action, config, onEmitAnalytics, messageParams, openXAppOverlay, onImageClick } = useMessageContext()
83
84
  const dataMessageId = window.__TEST_MESSAGE_ID__ // For testing
84
85
 
85
86
  // Sanitize HTML
@@ -97,6 +98,13 @@ const handleImageError = () => {
97
98
  isImageBroken.value = true
98
99
  }
99
100
 
101
+ // Handle image click — notify consumer via onImageClick callback
102
+ const handleImageClick = () => {
103
+ if (props.slide.image_url) {
104
+ onImageClick?.(props.slide.image_url)
105
+ }
106
+ }
107
+
100
108
  // Check if card has extra info (subtitle or buttons)
101
109
  const hasExtraInfo = computed(() => {
102
110
  return !!(props.slide.subtitle || (props.slide.buttons && props.slide.buttons.length > 0))
@@ -109,8 +117,7 @@ const defaultActionUrl = computed(() => {
109
117
 
110
118
  // Should buttons be disabled
111
119
  const shouldBeDisabled = computed(() => {
112
- // TODO: Add conversation ended check when messageParams available
113
- return false
120
+ return messageParams?.isConversationEnded ?? false
114
121
  })
115
122
 
116
123
  // Translations
@@ -150,6 +157,11 @@ const handleKeyDown = (event: KeyboardEvent) => {
150
157
  position: relative;
151
158
  width: 206px;
152
159
  overflow: hidden;
160
+ border-radius: var(--cc-bubble-border-radius, 15px);
161
+ background-color: var(--cc-white, #ffffff);
162
+ box-shadow: var(--cc-message-shadow, rgba(151, 124, 156, 0.1) 0px 5px 9px 0px,
163
+ rgba(203, 195, 212, 0.1) 0px 5px 16px 0px,
164
+ rgba(216, 212, 221, 0.1) 0px 8px 20px 0px);
153
165
  }
154
166
 
155
167
  .slideItem .top {
@@ -160,7 +172,7 @@ const handleKeyDown = (event: KeyboardEvent) => {
160
172
  .slideItem .bottom {
161
173
  border-bottom-left-radius: var(--cc-bubble-border-radius, 15px);
162
174
  border-bottom-right-radius: var(--cc-bubble-border-radius, 15px);
163
- border: 1px solid var(--cc-black-80, rgba(0, 0, 0, 0.8));
175
+ border: 1px solid var(--cc-border-media-card, var(--cc-black-80, rgba(0, 0, 0, 0.8)));
164
176
  background-color: var(--cc-white, #ffffff);
165
177
  display: flex;
166
178
  flex-direction: column;
@@ -29,7 +29,7 @@
29
29
  :custom-icon="DownloadIcon"
30
30
  :position="1"
31
31
  :total="1"
32
- :class-name="$style.downloadButtonWrapper"
32
+ :class-name="$style.downloadButton"
33
33
  class="webchat-buttons-template-button"
34
34
  />
35
35
 
@@ -88,7 +88,7 @@ import ActionButton from '../common/ActionButton.vue'
88
88
  import { DownloadIcon, CloseIcon } from '../../assets/svg'
89
89
  import type { IWebchatImageAttachment, IWebchatButton } from '../../types'
90
90
 
91
- const { message, config, action, onEmitAnalytics } = useMessageContext()
91
+ const { message, config, action, onEmitAnalytics, onImageClick } = useMessageContext()
92
92
 
93
93
  const $style = useCssModule()
94
94
 
@@ -144,6 +144,11 @@ const closeLabel = computed(() =>
144
144
 
145
145
  // Handlers
146
146
  const handleExpand = () => {
147
+ // Notify consumer via onImageClick callback
148
+ if (imageData.value.url) {
149
+ onImageClick?.(imageData.value.url)
150
+ }
151
+
147
152
  if (isDownloadable.value) {
148
153
  showLightbox.value = true
149
154
  // Focus the download button after lightbox opens
@@ -215,6 +220,10 @@ onUnmounted(() => {
215
220
  max-width: 295px;
216
221
  width: 100%;
217
222
  outline: none;
223
+ background-color: var(--cc-white, #ffffff);
224
+ box-shadow: var(--cc-message-shadow, rgba(151, 124, 156, 0.1) 0px 5px 9px 0px,
225
+ rgba(203, 195, 212, 0.1) 0px 5px 16px 0px,
226
+ rgba(216, 212, 221, 0.1) 0px 8px 20px 0px);
218
227
  }
219
228
 
220
229
  .wrapper .fixedImage,
@@ -238,7 +247,9 @@ onUnmounted(() => {
238
247
  .downloadable {
239
248
  background-color: var(--cc-white, #ffffff);
240
249
  cursor: pointer;
241
- border: 1px solid var(--cc-black-80, rgba(0, 0, 0, 0.8));
250
+ border: 1px solid var(--cc-border-media-card, var(--cc-black-80, rgba(0, 0, 0, 0.8)));
251
+ border-bottom: none;
252
+ overflow: hidden;
242
253
  }
243
254
 
244
255
  .downloadable img {
@@ -265,8 +276,12 @@ onUnmounted(() => {
265
276
  /* Base class for image containers */
266
277
  }
267
278
 
268
- .downloadButtonWrapper {
269
- padding: 16px;
279
+ .wrapper .downloadButton {
280
+ width: 100%;
281
+ box-sizing: border-box;
282
+ border: 1px solid var(--cc-border-media-card, var(--cc-black-80, rgba(0, 0, 0, 0.8)));
283
+ border-top: none;
284
+ border-radius: 0 0 var(--cc-bubble-border-radius, 15px) var(--cc-bubble-border-radius, 15px);
270
285
  }
271
286
 
272
287
  .brokenImage {