Skip to content
Merged
34 changes: 32 additions & 2 deletions dev/messages-ai-chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<script type="module">
import '@vaadin/message-input';
import '@vaadin/message-list';
import { Notification } from '@vaadin/notification';

/**
* Simulates streaming text from an AI model
Expand Down Expand Up @@ -115,10 +116,39 @@

// Set initial messages
list.items = [
createItem('Hello! Can you help me with a question?'),
createItem("Of course! I'm here to help. What's your question?", true),
{
text: 'Can you help me analyze these Q3 financial documents? I need a summary of the key findings and any concerns you spot.',
time: 'Yesterday',
userName: 'User',
userColorIndex: 1,
attachments: [
{ name: 'proposal.pdf', url: '#proposal.pdf', type: 'application/pdf' },
{
name: 'budget.xlsx',
url: '#budget.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
{
name: 'chart.png',
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',
type: 'image/svg+xml',
},
],
},
{
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?",
time: 'Yesterday',
userName: 'Assistant',
userColorIndex: 2,
},
];

// Handle attachment clicks
list.addEventListener('attachment-click', (e) => {
const { attachment } = e.detail;
Notification.show(`Attachment clicked: ${attachment.name}`, { position: 'bottom-start' });
});

// Handle new messages from user
input.addEventListener('submit', async (e) => {
// Add user message to the list
Expand Down
46 changes: 46 additions & 0 deletions packages/message-list/src/styles/vaadin-message-base-styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,50 @@ export const messageStyles = css`
::slotted(vaadin-markdown) {
white-space: normal;
}

[part='attachments'] {
display: flex;
flex-wrap: wrap;
gap: 6px;
}

[part~='attachment'] {
display: inline-flex;
align-items: center;
color: inherit;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
cursor: pointer;
text-align: start;
}

[part~='attachment-image'] [part='attachment-preview'] {
display: block;
max-width: 200px;
max-height: 150px;
}

[part~='attachment-file'] {
gap: 6px;
}

[part='attachment-icon'] {
display: inline-block;
width: 1em;
height: 1em;
background: currentColor;
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>');
mask-size: contain;
mask-repeat: no-repeat;
flex-shrink: 0;
}

[part='attachment-name'] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
21 changes: 20 additions & 1 deletion packages/message-list/src/vaadin-message-list-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
*/
import type { Constructor } from '@open-wc/dedupe-mixin';
import type { KeyboardDirectionMixinClass } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js';
import type { MessageAttachment, MessageAttachmentClickEvent } from './vaadin-message-mixin.js';

export { MessageAttachment, MessageAttachmentClickEvent };

/**
* Fired when an attachment is clicked in a message list item.
*/
export type MessageListAttachmentClickEvent = CustomEvent<{ attachment: MessageAttachment; item: MessageListItem }>;

export interface MessageListItem {
text?: string;
Expand All @@ -15,6 +23,7 @@ export interface MessageListItem {
userColorIndex?: number;
theme?: string;
className?: string;
attachments?: MessageAttachment[];
}

export declare function MessageListMixin<T extends Constructor<HTMLElement>>(
Expand All @@ -34,9 +43,19 @@ export declare class MessageListMixinClass {
* userImg: string,
* userColorIndex: number,
* className: string,
* theme: string
* theme: string,
* attachments: Array<{
* name: string,
* url: string,
* type: string
* }>
* }>
* ```
*
* When a message has attachments, they are rendered in the message's shadow DOM.
* Image attachments (type starting with "image/") show a thumbnail preview,
* while other attachments show a document icon with the file name.
* Clicking an attachment dispatches an `attachment-click` event.
*/
items: MessageListItem[] | null | undefined;

Expand Down
32 changes: 31 additions & 1 deletion packages/message-list/src/vaadin-message-list-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,19 @@ export const MessageListMixin = (superClass) =>
* userImg: string,
* userColorIndex: number,
* className: string,
* theme: string
* theme: string,
* attachments: Array<{
* name: string,
* url: string,
* type: string
* }>
* }>
* ```
*
* When a message has attachments, they are rendered in the message's shadow DOM.
* Image attachments (type starting with "image/") show a thumbnail preview,
* while other attachments show a document icon with the file name.
* Clicking an attachment dispatches an `attachment-click` event.
*/
items: {
type: Array,
Expand Down Expand Up @@ -78,6 +88,24 @@ export const MessageListMixin = (superClass) =>
this.setAttribute('role', 'region');
}

/**
* Handles attachment-click events from child messages and dispatches
* a new event enriched with the item.
* @param {CustomEvent} e
* @param {Object} item
* @private
*/
__onAttachmentClick(e, item) {
this.dispatchEvent(
new CustomEvent('attachment-click', {
detail: {
...e.detail,
item,
},
}),
);
}

/**
* Override method inherited from `KeyboardDirectionMixin`
* to use the list of message elements as items.
Expand Down Expand Up @@ -142,9 +170,11 @@ export const MessageListMixin = (superClass) =>
.userName="${item.userName}"
.userImg="${item.userImg}"
.userColorIndex="${item.userColorIndex}"
.attachments="${item.attachments}"
theme="${ifDefined(item.theme)}"
class="${ifDefined(item.className)}"
@focusin="${this._onMessageFocusIn}"
@attachment-click="${(e) => this.__onAttachmentClick(e, item)}"
style="${ifDefined(loadingMarkdown ? 'visibility: hidden' : undefined)}"
>${this.markdown
? html`<vaadin-markdown .content=${item.text}></vaadin-markdown>`
Expand Down
29 changes: 26 additions & 3 deletions packages/message-list/src/vaadin-message-list.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { SlotStylesMixin } from '@vaadin/component-base/src/slot-styles-mixin.js';
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
import { MessageListMixin } from './vaadin-message-list-mixin.js';
import { type MessageListAttachmentClickEvent, MessageListMixin } from './vaadin-message-list-mixin.js';

export { MessageListItem } from './vaadin-message-list-mixin.js';
export {
MessageAttachment,
MessageAttachmentClickEvent,
MessageListAttachmentClickEvent,
MessageListItem,
} from './vaadin-message-list-mixin.js';

export type MessageListEventMap = HTMLElementEventMap & {
'attachment-click': MessageListAttachmentClickEvent;
};

/**
* `<vaadin-message-list>` is a Web Component for showing an ordered list of messages. The messages are rendered as <vaadin-message>
Expand Down Expand Up @@ -42,8 +51,22 @@ export { MessageListItem } from './vaadin-message-list-mixin.js';
* state attributes and stylable shadow parts of message elements.
*
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
*
* @fires {CustomEvent} attachment-click - Fired when an attachment is clicked.
*/
declare class MessageList extends SlotStylesMixin(MessageListMixin(ThemableMixin(ElementMixin(HTMLElement)))) {}
declare class MessageList extends SlotStylesMixin(MessageListMixin(ThemableMixin(ElementMixin(HTMLElement)))) {
addEventListener<K extends keyof MessageListEventMap>(
type: K,
listener: (this: MessageList, ev: MessageListEventMap[K]) => void,
options?: AddEventListenerOptions | boolean,
): void;

removeEventListener<K extends keyof MessageListEventMap>(
type: K,
listener: (this: MessageList, ev: MessageListEventMap[K]) => void,
options?: EventListenerOptions | boolean,
): void;
}

declare global {
interface HTMLElementTagNameMap {
Expand Down
2 changes: 2 additions & 0 deletions packages/message-list/src/vaadin-message-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import { MessageListMixin } from './vaadin-message-list-mixin.js';
*
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
*
* @fires {CustomEvent} attachment-click - Fired when an attachment is clicked.
*
* @customElement
* @extends HTMLElement
* @mixes ThemableMixin
Expand Down
23 changes: 23 additions & 0 deletions packages/message-list/src/vaadin-message-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@
import type { Constructor } from '@open-wc/dedupe-mixin';
import type { FocusMixinClass } from '@vaadin/a11y-base/src/focus-mixin.js';

export interface MessageAttachment {
name?: string;
url?: string;
type?: string;
}

/**
* Fired when an attachment is clicked.
*/
export type MessageAttachmentClickEvent = CustomEvent<{ attachment: MessageAttachment }>;

export declare function MessageMixin<T extends Constructor<HTMLElement>>(
base: T,
): Constructor<FocusMixinClass> & Constructor<MessageMixinClass> & T;
Expand Down Expand Up @@ -64,4 +75,16 @@ export declare class MessageMixinClass {
* @attr {number} user-color-index
*/
userColorIndex: number | null | undefined;

/**
* An array of attachment objects to display with the message.
* Each attachment object can have the following properties:
* - `name`: The name of the attachment file
* - `url`: The URL of the attachment
* - `type`: The MIME type of the attachment (e.g., 'image/png', 'application/pdf')
*
* Image attachments (type starting with "image/") show a thumbnail preview,
* while other attachments show a document icon with the file name.
*/
attachments: MessageAttachment[] | null | undefined;
}
75 changes: 75 additions & 0 deletions packages/message-list/src/vaadin-message-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Copyright (c) 2021 - 2026 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js';
import { SlotController } from '@vaadin/component-base/src/slot-controller.js';

Expand Down Expand Up @@ -78,6 +80,22 @@ export const MessageMixin = (superClass) =>
type: Number,
},

/**
* An array of attachment objects to display with the message.
* Each attachment object can have the following properties:
* - `name`: The name of the attachment file
* - `url`: The URL of the attachment
* - `type`: The MIME type of the attachment (e.g., 'image/png', 'application/pdf')
*
* Image attachments (type starting with "image/") show a thumbnail preview,
* while other attachments show a document icon with the file name.
*
* @type {Array<{name?: string, url?: string, type?: string}>}
*/
attachments: {
type: Array,
},

/** @private */
_avatar: {
type: Object,
Expand Down Expand Up @@ -113,4 +131,61 @@ export const MessageMixin = (superClass) =>
});
}
}

/**
* Renders attachments for the message.
* @private
*/
__renderAttachments() {
const attachments = this.attachments;
if (!attachments || attachments.length === 0) {
return '';
}

return html`
<div part="attachments">${attachments.map((attachment) => this.__renderAttachment(attachment))}</div>
`;
}

/**
* Renders a single attachment.
* @param {Object} attachment - The attachment object with name, url, and type properties
* @private
*/
__renderAttachment(attachment) {
const isImage = attachment.type && attachment.type.startsWith('image/');

if (isImage) {
return html`
<button
type="button"
part="attachment attachment-image"
aria-label="${attachment.name || ''}"
@click="${() => this.__onAttachmentClick(attachment)}"
>
<img part="attachment-preview" src="${ifDefined(attachment.url)}" alt="" />
</button>
`;
}

return html`
<button type="button" part="attachment attachment-file" @click="${() => this.__onAttachmentClick(attachment)}">
<span part="attachment-icon" aria-hidden="true"></span>
<span part="attachment-name">${attachment.name || ''}</span>
</button>
`;
}

/**
* Dispatches an event when an attachment is clicked.
* @param {Object} attachment - The attachment that was clicked
* @private
*/
__onAttachmentClick(attachment) {
this.dispatchEvent(
new CustomEvent('attachment-click', {
detail: { attachment },
}),
);
}
};
Loading