Skip to content

Commit 4afe890

Browse files
authored
feat: message-list item attachments (#10611)
1 parent 9681e63 commit 4afe890

13 files changed

+668
-21
lines changed

dev/messages-ai-chat.html

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<script type="module">
2929
import '@vaadin/message-input';
3030
import '@vaadin/message-list';
31+
import { Notification } from '@vaadin/notification';
3132

3233
/**
3334
* Simulates streaming text from an AI model
@@ -115,10 +116,39 @@
115116

116117
// Set initial messages
117118
list.items = [
118-
createItem('Hello! Can you help me with a question?'),
119-
createItem("Of course! I'm here to help. What's your question?", true),
119+
{
120+
text: 'Can you help me analyze these Q3 financial documents? I need a summary of the key findings and any concerns you spot.',
121+
time: 'Yesterday',
122+
userName: 'User',
123+
userColorIndex: 1,
124+
attachments: [
125+
{ name: 'proposal.pdf', url: '#proposal.pdf', type: 'application/pdf' },
126+
{
127+
name: 'budget.xlsx',
128+
url: '#budget.xlsx',
129+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
130+
},
131+
{
132+
name: 'chart.png',
133+
url: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150"%3E%3Crect fill="%23f0f0f0" width="200" height="150"/%3E%3Crect fill="%234CAF50" x="20" y="100" width="30" height="40"/%3E%3Crect fill="%232196F3" x="60" y="70" width="30" height="70"/%3E%3Crect fill="%23FF9800" x="100" y="50" width="30" height="90"/%3E%3Crect fill="%239C27B0" x="140" y="30" width="30" height="110"/%3E%3Ctext x="100" y="20" text-anchor="middle" font-size="12" fill="%23333"%3EQ3 Growth%3C/text%3E%3C/svg%3E',
134+
type: 'image/svg+xml',
135+
},
136+
],
137+
},
138+
{
139+
text: "I've reviewed your Q3 financial documents. Here's a summary:\n\n**Key Findings:**\n- Revenue increased 12% compared to Q2\n- Operating costs remained stable\n- The chart shows positive growth trends\n\n**Concerns:**\n- Marketing spend is 15% over budget\n- Cash flow projections need revision\n\nWould you like me to elaborate on any of these points?",
140+
time: 'Yesterday',
141+
userName: 'Assistant',
142+
userColorIndex: 2,
143+
},
120144
];
121145

146+
// Handle attachment clicks
147+
list.addEventListener('attachment-click', (e) => {
148+
const { attachment } = e.detail;
149+
Notification.show(`Attachment clicked: ${attachment.name}`, { position: 'bottom-start' });
150+
});
151+
122152
// Handle new messages from user
123153
input.addEventListener('submit', async (e) => {
124154
// Add user message to the list

packages/message-list/src/styles/vaadin-message-base-styles.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,50 @@ export const messageStyles = css`
6666
::slotted(vaadin-markdown) {
6767
white-space: normal;
6868
}
69+
70+
[part='attachments'] {
71+
display: flex;
72+
flex-wrap: wrap;
73+
gap: 6px;
74+
}
75+
76+
[part~='attachment'] {
77+
display: inline-flex;
78+
align-items: center;
79+
color: inherit;
80+
background: none;
81+
border: none;
82+
padding: 0;
83+
margin: 0;
84+
font: inherit;
85+
cursor: pointer;
86+
text-align: start;
87+
}
88+
89+
[part~='attachment-image'] [part='attachment-preview'] {
90+
display: block;
91+
max-width: 200px;
92+
max-height: 150px;
93+
}
94+
95+
[part~='attachment-file'] {
96+
gap: 6px;
97+
}
98+
99+
[part='attachment-icon'] {
100+
display: inline-block;
101+
width: 1em;
102+
height: 1em;
103+
background: currentColor;
104+
mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>');
105+
mask-size: contain;
106+
mask-repeat: no-repeat;
107+
flex-shrink: 0;
108+
}
109+
110+
[part='attachment-name'] {
111+
overflow: hidden;
112+
text-overflow: ellipsis;
113+
white-space: nowrap;
114+
}
69115
`;

packages/message-list/src/vaadin-message-list-mixin.d.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
*/
66
import type { Constructor } from '@open-wc/dedupe-mixin';
77
import type { KeyboardDirectionMixinClass } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js';
8+
import type { MessageAttachment, MessageAttachmentClickEvent } from './vaadin-message-mixin.js';
9+
10+
export { MessageAttachment, MessageAttachmentClickEvent };
11+
12+
/**
13+
* Fired when an attachment is clicked in a message list item.
14+
*/
15+
export type MessageListAttachmentClickEvent = CustomEvent<{ attachment: MessageAttachment; item: MessageListItem }>;
816

917
export interface MessageListItem {
1018
text?: string;
@@ -15,6 +23,7 @@ export interface MessageListItem {
1523
userColorIndex?: number;
1624
theme?: string;
1725
className?: string;
26+
attachments?: MessageAttachment[];
1827
}
1928

2029
export declare function MessageListMixin<T extends Constructor<HTMLElement>>(
@@ -34,9 +43,19 @@ export declare class MessageListMixinClass {
3443
* userImg: string,
3544
* userColorIndex: number,
3645
* className: string,
37-
* theme: string
46+
* theme: string,
47+
* attachments: Array<{
48+
* name: string,
49+
* url: string,
50+
* type: string
51+
* }>
3852
* }>
3953
* ```
54+
*
55+
* When a message has attachments, they are rendered in the message's shadow DOM.
56+
* Image attachments (type starting with "image/") show a thumbnail preview,
57+
* while other attachments show a document icon with the file name.
58+
* Clicking an attachment dispatches an `attachment-click` event.
4059
*/
4160
items: MessageListItem[] | null | undefined;
4261

packages/message-list/src/vaadin-message-list-mixin.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,19 @@ export const MessageListMixin = (superClass) =>
2929
* userImg: string,
3030
* userColorIndex: number,
3131
* className: string,
32-
* theme: string
32+
* theme: string,
33+
* attachments: Array<{
34+
* name: string,
35+
* url: string,
36+
* type: string
37+
* }>
3338
* }>
3439
* ```
40+
*
41+
* When a message has attachments, they are rendered in the message's shadow DOM.
42+
* Image attachments (type starting with "image/") show a thumbnail preview,
43+
* while other attachments show a document icon with the file name.
44+
* Clicking an attachment dispatches an `attachment-click` event.
3545
*/
3646
items: {
3747
type: Array,
@@ -78,6 +88,24 @@ export const MessageListMixin = (superClass) =>
7888
this.setAttribute('role', 'region');
7989
}
8090

91+
/**
92+
* Handles attachment-click events from child messages and dispatches
93+
* a new event enriched with the item.
94+
* @param {CustomEvent} e
95+
* @param {Object} item
96+
* @private
97+
*/
98+
__onAttachmentClick(e, item) {
99+
this.dispatchEvent(
100+
new CustomEvent('attachment-click', {
101+
detail: {
102+
...e.detail,
103+
item,
104+
},
105+
}),
106+
);
107+
}
108+
81109
/**
82110
* Override method inherited from `KeyboardDirectionMixin`
83111
* to use the list of message elements as items.
@@ -142,9 +170,11 @@ export const MessageListMixin = (superClass) =>
142170
.userName="${item.userName}"
143171
.userImg="${item.userImg}"
144172
.userColorIndex="${item.userColorIndex}"
173+
.attachments="${item.attachments}"
145174
theme="${ifDefined(item.theme)}"
146175
class="${ifDefined(item.className)}"
147176
@focusin="${this._onMessageFocusIn}"
177+
@attachment-click="${(e) => this.__onAttachmentClick(e, item)}"
148178
style="${ifDefined(loadingMarkdown ? 'visibility: hidden' : undefined)}"
149179
>${this.markdown
150180
? html`<vaadin-markdown .content=${item.text}></vaadin-markdown>`

packages/message-list/src/vaadin-message-list.d.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@
66
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
77
import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js';
88
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
9-
import { MessageListMixin } from './vaadin-message-list-mixin.js';
9+
import { type MessageListAttachmentClickEvent, MessageListMixin } from './vaadin-message-list-mixin.js';
1010

11-
export { MessageListItem } from './vaadin-message-list-mixin.js';
11+
export {
12+
MessageAttachment,
13+
MessageAttachmentClickEvent,
14+
MessageListAttachmentClickEvent,
15+
MessageListItem,
16+
} from './vaadin-message-list-mixin.js';
17+
18+
export type MessageListEventMap = HTMLElementEventMap & {
19+
'attachment-click': MessageListAttachmentClickEvent;
20+
};
1221

1322
/**
1423
* `<vaadin-message-list>` is a Web Component for showing an ordered list of messages. The messages are rendered as <vaadin-message>
@@ -42,8 +51,22 @@ export { MessageListItem } from './vaadin-message-list-mixin.js';
4251
* state attributes and stylable shadow parts of message elements.
4352
*
4453
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
54+
*
55+
* @fires {CustomEvent} attachment-click - Fired when an attachment is clicked.
4556
*/
46-
declare class MessageList extends SlotStylesMixin(MessageListMixin(ThemableMixin(ElementMixin(HTMLElement)))) {}
57+
declare class MessageList extends SlotStylesMixin(MessageListMixin(ThemableMixin(ElementMixin(HTMLElement)))) {
58+
addEventListener<K extends keyof MessageListEventMap>(
59+
type: K,
60+
listener: (this: MessageList, ev: MessageListEventMap[K]) => void,
61+
options?: AddEventListenerOptions | boolean,
62+
): void;
63+
64+
removeEventListener<K extends keyof MessageListEventMap>(
65+
type: K,
66+
listener: (this: MessageList, ev: MessageListEventMap[K]) => void,
67+
options?: EventListenerOptions | boolean,
68+
): void;
69+
}
4770

4871
declare global {
4972
interface HTMLElementTagNameMap {

packages/message-list/src/vaadin-message-list.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import { MessageListMixin } from './vaadin-message-list-mixin.js';
4545
*
4646
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
4747
*
48+
* @fires {CustomEvent} attachment-click - Fired when an attachment is clicked.
49+
*
4850
* @customElement
4951
* @extends HTMLElement
5052
* @mixes ThemableMixin

packages/message-list/src/vaadin-message-mixin.d.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66
import type { Constructor } from '@open-wc/dedupe-mixin';
77
import type { FocusMixinClass } from '@vaadin/a11y-base/src/focus-mixin.js';
88

9+
export interface MessageAttachment {
10+
name?: string;
11+
url?: string;
12+
type?: string;
13+
}
14+
15+
/**
16+
* Fired when an attachment is clicked.
17+
*/
18+
export type MessageAttachmentClickEvent = CustomEvent<{ attachment: MessageAttachment }>;
19+
920
export declare function MessageMixin<T extends Constructor<HTMLElement>>(
1021
base: T,
1122
): Constructor<FocusMixinClass> & Constructor<MessageMixinClass> & T;
@@ -64,4 +75,16 @@ export declare class MessageMixinClass {
6475
* @attr {number} user-color-index
6576
*/
6677
userColorIndex: number | null | undefined;
78+
79+
/**
80+
* An array of attachment objects to display with the message.
81+
* Each attachment object can have the following properties:
82+
* - `name`: The name of the attachment file
83+
* - `url`: The URL of the attachment
84+
* - `type`: The MIME type of the attachment (e.g., 'image/png', 'application/pdf')
85+
*
86+
* Image attachments (type starting with "image/") show a thumbnail preview,
87+
* while other attachments show a document icon with the file name.
88+
*/
89+
attachments: MessageAttachment[] | null | undefined;
6790
}

packages/message-list/src/vaadin-message-mixin.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Copyright (c) 2021 - 2026 Vaadin Ltd.
44
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
55
*/
6+
import { html } from 'lit';
7+
import { ifDefined } from 'lit/directives/if-defined.js';
68
import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js';
79
import { SlotController } from '@vaadin/component-base/src/slot-controller.js';
810

@@ -78,6 +80,22 @@ export const MessageMixin = (superClass) =>
7880
type: Number,
7981
},
8082

83+
/**
84+
* An array of attachment objects to display with the message.
85+
* Each attachment object can have the following properties:
86+
* - `name`: The name of the attachment file
87+
* - `url`: The URL of the attachment
88+
* - `type`: The MIME type of the attachment (e.g., 'image/png', 'application/pdf')
89+
*
90+
* Image attachments (type starting with "image/") show a thumbnail preview,
91+
* while other attachments show a document icon with the file name.
92+
*
93+
* @type {Array<{name?: string, url?: string, type?: string}>}
94+
*/
95+
attachments: {
96+
type: Array,
97+
},
98+
8199
/** @private */
82100
_avatar: {
83101
type: Object,
@@ -113,4 +131,61 @@ export const MessageMixin = (superClass) =>
113131
});
114132
}
115133
}
134+
135+
/**
136+
* Renders attachments for the message.
137+
* @private
138+
*/
139+
__renderAttachments() {
140+
const attachments = this.attachments;
141+
if (!attachments || attachments.length === 0) {
142+
return '';
143+
}
144+
145+
return html`
146+
<div part="attachments">${attachments.map((attachment) => this.__renderAttachment(attachment))}</div>
147+
`;
148+
}
149+
150+
/**
151+
* Renders a single attachment.
152+
* @param {Object} attachment - The attachment object with name, url, and type properties
153+
* @private
154+
*/
155+
__renderAttachment(attachment) {
156+
const isImage = attachment.type && attachment.type.startsWith('image/');
157+
158+
if (isImage) {
159+
return html`
160+
<button
161+
type="button"
162+
part="attachment attachment-image"
163+
aria-label="${attachment.name || ''}"
164+
@click="${() => this.__onAttachmentClick(attachment)}"
165+
>
166+
<img part="attachment-preview" src="${ifDefined(attachment.url)}" alt="" />
167+
</button>
168+
`;
169+
}
170+
171+
return html`
172+
<button type="button" part="attachment attachment-file" @click="${() => this.__onAttachmentClick(attachment)}">
173+
<span part="attachment-icon" aria-hidden="true"></span>
174+
<span part="attachment-name">${attachment.name || ''}</span>
175+
</button>
176+
`;
177+
}
178+
179+
/**
180+
* Dispatches an event when an attachment is clicked.
181+
* @param {Object} attachment - The attachment that was clicked
182+
* @private
183+
*/
184+
__onAttachmentClick(attachment) {
185+
this.dispatchEvent(
186+
new CustomEvent('attachment-click', {
187+
detail: { attachment },
188+
}),
189+
);
190+
}
116191
};

0 commit comments

Comments
 (0)