From e63cafab2745ef7c3458f26fa2728ab5c45c7bd4 Mon Sep 17 00:00:00 2001 From: Tomi Virkki Date: Thu, 27 Nov 2025 11:16:13 +0200 Subject: [PATCH] feat: snap message-list scroll to bottom on items change --- dev/messages-ai-chat.html | 22 ++------- .../src/vaadin-message-list-mixin.js | 13 +++++ .../message-list/src/vaadin-message-list.js | 10 +++- .../message-list/test/message-list.test.js | 49 +++++++++++++++++++ 4 files changed, 75 insertions(+), 19 deletions(-) diff --git a/dev/messages-ai-chat.html b/dev/messages-ai-chat.html index a8e92a54b29..f40871539d1 100644 --- a/dev/messages-ai-chat.html +++ b/dev/messages-ai-chat.html @@ -20,23 +20,14 @@ height: 100%; } - vaadin-scroller { - flex: 1; - scroll-snap-type: y proximity; - } - - vaadin-scroller::after { - display: block; - content: ''; - scroll-snap-align: end; - min-height: 1px; + vaadin-message-list { + flex: 1 1 auto; }
- - - +
diff --git a/packages/message-list/src/vaadin-message-list-mixin.js b/packages/message-list/src/vaadin-message-list-mixin.js index 9d05324e5a4..812a82f6b7a 100644 --- a/packages/message-list/src/vaadin-message-list-mixin.js +++ b/packages/message-list/src/vaadin-message-list-mixin.js @@ -6,6 +6,8 @@ import { html, render } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; import { KeyboardDirectionMixin } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js'; +import { timeOut } from '@vaadin/component-base/src/async.js'; +import { Debouncer } from '@vaadin/component-base/src/debounce.js'; /** * @polymerMixin @@ -99,6 +101,9 @@ export const MessageListMixin = (superClass) => this._renderMessages(items); this._setTabIndexesByIndex(focusedIndex); + if (oldItems.length) { + this.__debounceAppending(); + } requestAnimationFrame(() => { if (items.length > oldItems.length && closeToBottom) { @@ -160,6 +165,14 @@ export const MessageListMixin = (superClass) => } } + /** @private */ + __debounceAppending() { + this.$.list.style.setProperty('--_vaadin-message-list-scroll-snap-align', 'end'); + this.__debounceAnimation = Debouncer.debounce(this.__debounceAnimation, timeOut.after(500), () => { + this.$.list.style.removeProperty('--_vaadin-message-list-scroll-snap-align'); + }); + } + /** @private */ _onMessageFocusIn(e) { const target = e.composedPath().find((node) => node instanceof customElements.get('vaadin-message')); diff --git a/packages/message-list/src/vaadin-message-list.js b/packages/message-list/src/vaadin-message-list.js index 239edb12d0b..868c67e9d40 100644 --- a/packages/message-list/src/vaadin-message-list.js +++ b/packages/message-list/src/vaadin-message-list.js @@ -63,18 +63,26 @@ class MessageList extends SlotStylesMixin(MessageListMixin(ElementMixin(Themable display: block; overflow: auto; padding: var(--vaadin-message-list-padding, var(--vaadin-padding-xs) 0); + scroll-padding: var(--vaadin-message-list-padding, var(--vaadin-padding-xs) 0); + scroll-snap-type: y proximity; } :host([hidden]) { display: none !important; } + + [part='list']::after { + content: ''; + display: block; + scroll-snap-align: var(--_vaadin-message-list-scroll-snap-align, none); + } `; } /** @protected */ render() { return html` -
+
`; diff --git a/packages/message-list/test/message-list.test.js b/packages/message-list/test/message-list.test.js index 51acac20e9f..4e9365034a4 100644 --- a/packages/message-list/test/message-list.test.js +++ b/packages/message-list/test/message-list.test.js @@ -3,6 +3,7 @@ import { arrowDown, arrowRight, arrowUp, + aTimeout, end, fixtureSync, focusin, @@ -205,6 +206,54 @@ describe('message-list', () => { await nextRender(); expect(messageList.scrollTop).to.be.equal(0); }); + + it('should scroll to bottom on appending message text', async () => { + // Scroll to end + messageList.scrollBy(0, 1000); + await nextRender(); + + // Append text to last message + messageList.items.at(-1).text += '\nfoo'; + messageList.items = [...messageList.items]; + await nextRender(); + await nextRender(); + + // Verify scrolled to bottom + expect(messageList.scrollTop).to.be.closeTo(messageList.scrollHeight - messageList.clientHeight, 1); + }); + + it('should not scroll if not at the bottom on appending message text', async () => { + const scrollTopBeforeAppend = messageList.scrollTop; + + // Append text to last message while still at top + messageList.items.at(-1).text += '\nfoo'; + messageList.items = [...messageList.items]; + await nextRender(); + await nextRender(); + + // Verify scroll position unchanged + expect(messageList.scrollTop).to.be.equal(scrollTopBeforeAppend); + }); + + it('should not snap to bottom after snap duration expires', async () => { + // Scroll to end + messageList.scrollBy(0, 1000); + await nextRender(); + + // Append text to last message + messageList.items.at(-1).text += '\nfoo'; + messageList.items = [...messageList.items]; + + // Wait for snap behavior to expire (500ms + buffer) + await aTimeout(600); + + // Now scroll up manually + const scrollTopBefore = messageList.scrollTop; + messageList.scrollTop -= 20; + + // Should be able to scroll up freely + expect(messageList.scrollTop).to.be.equal(scrollTopBefore - 20); + }); }); describe('tabindex', () => {