@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.
- package/dist/chat-components-vue.css +1 -1
- package/dist/chat-components-vue.js +12386 -5741
- package/dist/components/Message.vue.d.ts +4 -0
- package/dist/components/common/Typography.vue.d.ts +1 -1
- package/dist/components/messages/AdaptiveCardRenderer.vue.d.ts +35 -0
- package/dist/components/messages/ListItem.vue.d.ts +1 -1
- package/dist/composables/useLiveRegion.d.ts +30 -0
- package/dist/index.d.ts +3 -2
- package/dist/types/index.d.ts +105 -1
- package/dist/utils/helpers.d.ts +3 -2
- package/dist/utils/matcher.d.ts +3 -3
- package/dist/utils/theme.d.ts +12 -1
- package/package.json +8 -3
- package/src/components/Message.vue +98 -55
- package/src/components/common/ActionButton.vue +16 -7
- package/src/components/common/ChatBubble.vue +8 -6
- package/src/components/common/ChatEvent.vue +5 -2
- package/src/components/common/TypingIndicator.vue +4 -1
- package/src/components/common/Typography.vue +56 -67
- package/src/components/messages/AdaptiveCard.vue +322 -225
- package/src/components/messages/AdaptiveCardRenderer.vue +260 -0
- package/src/components/messages/AudioMessage.vue +4 -1
- package/src/components/messages/DatePicker.vue +5 -27
- package/src/components/messages/FileMessage.vue +12 -3
- package/src/components/messages/Gallery.vue +96 -10
- package/src/components/messages/GalleryItem.vue +17 -5
- package/src/components/messages/ImageMessage.vue +20 -5
- package/src/components/messages/List.vue +56 -42
- package/src/components/messages/ListItem.vue +105 -68
- package/src/components/messages/TextMessage.vue +1 -1
- package/src/components/messages/TextWithButtons.vue +35 -11
- package/src/components/messages/VideoMessage.vue +35 -26
- package/src/composables/useCollation.ts +28 -45
- package/src/composables/useLiveRegion.ts +101 -0
- package/src/index.ts +4 -1
- package/src/types/index.ts +127 -2
- package/src/utils/helpers.ts +46 -24
- package/src/utils/matcher.ts +20 -6
- package/src/utils/sanitize.ts +1 -2
- 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
|
-
//
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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.
|
|
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
|
-
.
|
|
269
|
-
|
|
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 {
|