Skip to content

Commit 5cdcefe

Browse files
authored
feat: snap message-list scroll to bottom on items change (#10549)
1 parent ef27fca commit 5cdcefe

File tree

4 files changed

+81
-25
lines changed

4 files changed

+81
-25
lines changed

dev/messages-ai-chat.html

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,14 @@
2020
height: 100%;
2121
}
2222

23-
vaadin-scroller {
24-
flex: 1;
25-
scroll-snap-type: y proximity;
26-
}
27-
28-
vaadin-scroller::after {
29-
display: block;
30-
content: '';
31-
scroll-snap-align: end;
32-
min-height: 1px;
23+
vaadin-message-list {
24+
flex: 1 1 auto;
3325
}
3426
</style>
3527

3628
<script type="module">
3729
import '@vaadin/message-input';
3830
import '@vaadin/message-list';
39-
import '@vaadin/scroller';
4031

4132
/**
4233
* Simulates streaming text from an AI model
@@ -132,30 +123,23 @@
132123
input.addEventListener('submit', async (e) => {
133124
// Add user message to the list
134125
list.items = [...list.items, createItem(e.detail.value)];
135-
input.disabled = true;
136126

137127
// Create empty assistant message that will be populated gradually
138128
const newAssistantItem = createItem('', true);
139129

140130
// Simulate AI typing response token by token
141-
simulateMessageStream()
142-
.onNext((token) => {
143-
newAssistantItem.text += token;
144-
// Force UI update by creating a new array
145-
list.items = list.items.includes(newAssistantItem) ? [...list.items] : [...list.items, newAssistantItem];
146-
})
147-
.onComplete(() => {
148-
input.disabled = false;
149-
});
131+
simulateMessageStream().onNext((token) => {
132+
newAssistantItem.text += token;
133+
// Force UI update by creating a new array
134+
list.items = list.items.includes(newAssistantItem) ? [...list.items] : [...list.items, newAssistantItem];
135+
});
150136
});
151137
</script>
152138
</head>
153139

154140
<body>
155141
<div id="chat">
156-
<vaadin-scroller>
157-
<vaadin-message-list markdown></vaadin-message-list>
158-
</vaadin-scroller>
142+
<vaadin-message-list markdown></vaadin-message-list>
159143
<vaadin-message-input></vaadin-message-input>
160144
</div>
161145
</body>

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import { html, render } from 'lit';
77
import { ifDefined } from 'lit/directives/if-defined.js';
88
import { KeyboardDirectionMixin } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js';
9+
import { timeOut } from '@vaadin/component-base/src/async.js';
10+
import { Debouncer } from '@vaadin/component-base/src/debounce.js';
911

1012
/**
1113
* @polymerMixin
@@ -99,6 +101,9 @@ export const MessageListMixin = (superClass) =>
99101

100102
this._renderMessages(items);
101103
this._setTabIndexesByIndex(focusedIndex);
104+
if (oldItems.length) {
105+
this.__enableScrollSnapping();
106+
}
102107

103108
requestAnimationFrame(() => {
104109
if (items.length > oldItems.length && closeToBottom) {
@@ -160,6 +165,16 @@ export const MessageListMixin = (superClass) =>
160165
}
161166
}
162167

168+
/** @private */
169+
__enableScrollSnapping() {
170+
this.$.list.style.setProperty('--_vaadin-message-list-scroll-snap-align', 'end');
171+
// Disable scroll-snapping after a delay to allow the user to freely scroll
172+
// without being snapped back to the bottom.
173+
this.__debounceScrollSnapping = Debouncer.debounce(this.__debounceScrollSnapping, timeOut.after(500), () => {
174+
this.$.list.style.removeProperty('--_vaadin-message-list-scroll-snap-align');
175+
});
176+
}
177+
163178
/** @private */
164179
_onMessageFocusIn(e) {
165180
const target = e.composedPath().find((node) => node instanceof customElements.get('vaadin-message'));

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,26 @@ class MessageList extends SlotStylesMixin(MessageListMixin(ElementMixin(Themable
6363
display: block;
6464
overflow: auto;
6565
padding: var(--vaadin-message-list-padding, var(--vaadin-padding-xs) 0);
66+
scroll-padding: var(--vaadin-message-list-padding, var(--vaadin-padding-xs) 0);
67+
scroll-snap-type: y proximity;
6668
}
6769
6870
:host([hidden]) {
6971
display: none !important;
7072
}
73+
74+
[part='list']::after {
75+
content: '';
76+
display: block;
77+
scroll-snap-align: var(--_vaadin-message-list-scroll-snap-align, none);
78+
}
7179
`;
7280
}
7381

7482
/** @protected */
7583
render() {
7684
return html`
77-
<div part="list" role="list">
85+
<div part="list" role="list" id="list">
7886
<slot></slot>
7987
</div>
8088
`;

packages/message-list/test/message-list.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
arrowDown,
44
arrowRight,
55
arrowUp,
6+
aTimeout,
67
end,
78
fixtureSync,
89
focusin,
@@ -205,6 +206,54 @@ describe('message-list', () => {
205206
await nextRender();
206207
expect(messageList.scrollTop).to.be.equal(0);
207208
});
209+
210+
it('should scroll to bottom on appending message text', async () => {
211+
// Scroll to end
212+
messageList.scrollBy(0, 1000);
213+
await nextRender();
214+
215+
// Append text to last message
216+
messageList.items.at(-1).text += '\nfoo';
217+
messageList.items = [...messageList.items];
218+
await nextRender();
219+
await nextRender();
220+
221+
// Verify scrolled to bottom
222+
expect(messageList.scrollTop).to.be.closeTo(messageList.scrollHeight - messageList.clientHeight, 1);
223+
});
224+
225+
it('should not scroll if not at the bottom on appending message text', async () => {
226+
const scrollTopBeforeAppend = messageList.scrollTop;
227+
228+
// Append text to last message while still at top
229+
messageList.items.at(-1).text += '\nfoo';
230+
messageList.items = [...messageList.items];
231+
await nextRender();
232+
await nextRender();
233+
234+
// Verify scroll position unchanged
235+
expect(messageList.scrollTop).to.be.equal(scrollTopBeforeAppend);
236+
});
237+
238+
it('should not snap to bottom after snap duration expires', async () => {
239+
// Scroll to end
240+
messageList.scrollBy(0, 1000);
241+
await nextRender();
242+
243+
// Append text to last message
244+
messageList.items.at(-1).text += '\nfoo';
245+
messageList.items = [...messageList.items];
246+
247+
// Wait for snap behavior to expire (500ms + buffer)
248+
await aTimeout(600);
249+
250+
// Now scroll up manually
251+
const scrollTopBefore = messageList.scrollTop;
252+
messageList.scrollTop -= 20;
253+
254+
// Should be able to scroll up freely
255+
expect(messageList.scrollTop).to.be.equal(scrollTopBefore - 20);
256+
});
208257
});
209258

210259
describe('tabindex', () => {

0 commit comments

Comments
 (0)