@miso.ai/client-sdk-ui 1.14.0-beta.2 → 1.14.0-beta.3

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.
Files changed (44) hide show
  1. package/dist/cjs/asset/svgs.js +4 -2
  2. package/dist/cjs/constants.js +2 -0
  3. package/dist/cjs/defaults.js +100 -10
  4. package/dist/cjs/element/container/index.js +14 -0
  5. package/dist/cjs/element/container/miso-message-item.js +91 -0
  6. package/dist/cjs/element/container/miso-thread-item.js +91 -0
  7. package/dist/cjs/element/role/index.js +7 -0
  8. package/dist/cjs/element/role/miso-delete.js +20 -0
  9. package/dist/cjs/layout/button/button.js +45 -5
  10. package/dist/cjs/layout/button/checkbox.js +2 -1
  11. package/dist/cjs/layout/container/item-container.js +99 -0
  12. package/dist/cjs/layout/index.js +14 -0
  13. package/dist/cjs/layout/input/search-box.js +9 -4
  14. package/dist/cjs/layout/list/collection.js +10 -3
  15. package/dist/cjs/layout/list/messages.js +239 -226
  16. package/dist/cjs/layout/list/threads.js +97 -155
  17. package/dist/cjs/layout/templates.js +65 -38
  18. package/dist/cjs/layout/text/question.js +71 -0
  19. package/dist/cjs/layout/text/typewriter/index.js +14 -21
  20. package/dist/cjs/util/dom.js +21 -0
  21. package/dist/cjs/version.js +1 -1
  22. package/package.json +3 -3
  23. package/src/CLAUDE.md +6 -2
  24. package/src/asset/svgs.js +3 -1
  25. package/src/constants.js +2 -0
  26. package/src/defaults.js +71 -4
  27. package/src/element/container/index.js +2 -0
  28. package/src/element/container/miso-message-item.js +94 -0
  29. package/src/element/container/miso-thread-item.js +94 -0
  30. package/src/element/role/index.js +1 -0
  31. package/src/element/role/miso-delete.js +16 -0
  32. package/src/layout/button/button.js +40 -4
  33. package/src/layout/button/checkbox.js +2 -1
  34. package/src/layout/container/item-container.js +99 -0
  35. package/src/layout/index.js +2 -0
  36. package/src/layout/input/search-box.js +4 -2
  37. package/src/layout/list/collection.js +12 -4
  38. package/src/layout/list/messages.js +202 -204
  39. package/src/layout/list/threads.js +90 -130
  40. package/src/layout/templates.js +74 -39
  41. package/src/layout/text/question.js +58 -0
  42. package/src/layout/text/typewriter/index.js +10 -15
  43. package/src/util/dom.js +16 -0
  44. package/src/version.js +1 -1
@@ -362,8 +362,10 @@ export default class SearchBoxLayout extends TemplateBasedLayout {
362
362
  return;
363
363
  }
364
364
  this.close();
365
- // TODO: q -> value
366
- this._view.hub.update(fields.query(), { q: value });
365
+ // a view event, like the buttons: the workflow routes it into its query
366
+ // flow (or its own submission semantics), the layout stays hands-off
367
+ const { session } = this._view._state || {};
368
+ this._view._emit('submit', Object.freeze({ session, value }));
367
369
  if (this.options.clearOnSubmit) {
368
370
  const inputElement = this._element && this._element.querySelector(`[data-role="input"]`);
369
371
  if (inputElement) {
@@ -2,7 +2,7 @@ import { STATUS } from '../../constants.js';
2
2
  import TemplateBasedLayout from '../template.js';
3
3
  import { makeTrackable } from '../mixin/trackable.js';
4
4
  import { makeTriggerable } from '../mixin/triggerable.js';
5
- import { product, article, compactArticle, image, question, thread, threadMenuBlock, message, messageQuestionBlock, messageAnswerBlock, productInfoBlock, articleInfoBlock, compactArticleInfoBlock, imageInfoBlock, titleBlock, brandBlock, descriptionBlock, dateBlock, authorsBlock, authorsAndDateBlock, priceBlock, discountRateText, ctaBlock, cta, imageBlock, indexBlock } from '../templates.js';
5
+ import { product, article, compactArticle, image, question, thread, threadBody, threadMenuBlock, message, messageBody, productInfoBlock, articleInfoBlock, compactArticleInfoBlock, imageInfoBlock, titleBlock, brandBlock, descriptionBlock, dateBlock, authorsBlock, authorsAndDateBlock, priceBlock, discountRateText, ctaBlock, cta, imageBlock, indexBlock } from '../templates.js';
6
6
 
7
7
  function root(layout, state) {
8
8
  const { className, role, templates, options } = layout;
@@ -67,10 +67,10 @@ const DEFAULT_TEMPLATES = Object.freeze({
67
67
  image,
68
68
  question,
69
69
  thread,
70
+ threadBody,
70
71
  threadMenuBlock,
71
72
  message,
72
- messageQuestionBlock,
73
- messageAnswerBlock,
73
+ messageBody,
74
74
  root,
75
75
  [STATUS.READY]: ready,
76
76
  empty: () => ``,
@@ -181,7 +181,15 @@ export default class CollectionLayout extends TemplateBasedLayout {
181
181
  }
182
182
 
183
183
  _getItemElements(element) {
184
- return element ? Array.from(element.querySelectorAll(`[data-role="item"]`)) : [];
184
+ if (!element) {
185
+ return [];
186
+ }
187
+ // an item may host a nested collection with items of its own (a
188
+ // conversation message and its source cards): ours are only those whose
189
+ // closest list is this layout's own list
190
+ const listElement = this._getListElement(element);
191
+ return Array.from((listElement || element).querySelectorAll(`[data-role="item"]`))
192
+ .filter(item => item.closest(`[data-role="list"]`) === listElement);
185
193
  }
186
194
 
187
195
  _onClick(event) {
@@ -1,29 +1,47 @@
1
- import { Resolution, pacer, requestAnimationFrame as raf } from '@miso.ai/commons';
2
- import { hasAnswer, getGeneratedBy } from '@miso.ai/client-sdk-workflow';
3
- import { LAYOUT_TYPE } from '../../constants.js';
1
+ import { STATUS, LAYOUT_TYPE } from '../../constants.js';
4
2
  import CollectionLayout from './collection.js';
5
- import { cursorClassName } from '../text/typewriter/utils.js';
6
- import { messageAuthor } from '../templates.js';
7
- import { setOrRemoveAttribute } from '../../util/dom.js';
8
3
 
9
4
  const TYPE = LAYOUT_TYPE.MESSAGES;
10
5
  const DEFAULT_CLASSNAME = 'miso-messages';
11
- const TYPEWRITER_CLASSNAME = 'miso-typewriter';
6
+
7
+ // the infinite scroll trigger sits ABOVE the list — older messages load as
8
+ // the user scrolls up, unlike the stock collection root's bottom trigger
9
+ function root(layout, state) {
10
+ const { className, role, templates, options } = layout;
11
+ const { status } = state;
12
+ const roleAttr = role ? ` data-role="${role}"` : '';
13
+ const itemTypeAttr = options.itemType ? ` data-item-type="${options.itemType}"` : '';
14
+ return `<div class="${className} ${status}"${roleAttr}${itemTypeAttr}>${templates.trigger(layout, state)}${status === STATUS.READY ? templates[status](layout, state) : ''}${templates.loading(layout, state)}</div>`;
15
+ }
12
16
 
13
17
  /**
14
- * The conversation panel of the chat history interface: a list of `message`
15
- * items (question bubble + answer body), rendered incrementally — appended
16
- * messages (e.g. a posted follow-up question) render as new items, while
17
- * in-place changes are applied to existing items by a post-render pass
18
- * (`_syncMessages`):
18
+ * The conversation panel of the chat history interface: a shell that renders
19
+ * one <miso-message-item> container element per message item, incrementally —
20
+ * fresh items land at either end of the list: appended messages (a posted
21
+ * follow-up question) render as new items at the bottom, while an older
22
+ * page fetched by the more flow (infinite scroll: the trigger, placed above
23
+ * the list, fires the `more` hub field as the user scrolls up) renders as
24
+ * prepended items — told apart by which boundary the already-rendered
25
+ * records line up with.
26
+ * The content of a message is not rendered here: each <miso-message-item> is
27
+ * assigned its item subworkflow (workflow._getMessageWorkflow, off the item
28
+ * binding) in the post-render sync pass, and the role elements inside
29
+ * (question, answer, ...) render through that workflow's own layouts.
19
30
  *
20
- * - question texts are filled in when they arrive (the head request carries
21
- * question ids only)
22
- * - a finished answer body is transformed from markdown to HTML in one shot
23
- * through the `std:ui-markdown` plugin
24
- * - a streaming answer (a follow-up being generated, `finished: false`) is
25
- * driven by a per-message typewriter: a progressive markdown renderer with
26
- * a paced cursor, like the typewriter layout of the ask workflow
31
+ * All message-level presentation is the message workflow's own: a record
32
+ * without its answer body presents as status `loading`, stamped on the
33
+ * <miso-message-item> element by its container layout — there is deliberately no
34
+ * per-message loading icon; the answer typewriter's blinking caret is the
35
+ * loading indication — and the question bubble (text, authorship
36
+ * attributes) renders through the `question` layout.
37
+ *
38
+ * The shell keeps the panel-level behaviors: scroll pinning (the view sticks
39
+ * to the bottom while the user has not scrolled up, following content growth
40
+ * as answers type out), scroll anchoring while scrolled up (prepended items
41
+ * — and their contents, rendering in later through their own workflows —
42
+ * must not shift the reading position), and the `ongoing` view state read
43
+ * off the data — the last message's answer absent or unfinished — which
44
+ * e.g. disables the search box while an answer is on its way.
27
45
  */
28
46
  export default class MessagesLayout extends CollectionLayout {
29
47
 
@@ -35,162 +53,134 @@ export default class MessagesLayout extends CollectionLayout {
35
53
  return DEFAULT_CLASSNAME;
36
54
  }
37
55
 
38
- constructor({ className = DEFAULT_CLASSNAME, ...options } = {}) {
39
- super({ className, ...options });
40
- this._renderedAnswers = new WeakMap(); // answer element -> rendered markdown
41
- this._typewriters = new WeakMap(); // answer element -> MessageTypewriter
42
- this._readiness = new Resolution();
56
+ constructor({ className = DEFAULT_CLASSNAME, templates, ...options } = {}) {
57
+ super({ className, templates: { root, ...templates }, ...options });
43
58
  this._pinned = true; // whether the view sticks to the bottom on updates
44
- this._displaying = false; // whether a typewriter is still displaying an answer
45
- // kick off sooner
46
- MessagesLayout.MisoClient.plugins.install('std:ui-markdown');
47
- }
48
-
49
- initialize(view) {
50
- super.initialize(view);
51
- this._setup();
59
+ this._anchor = undefined; // the scroll anchor while prepended content settles
60
+ this._ongoing = undefined;
52
61
  }
53
62
 
54
- // setup //
55
- async _setup() {
56
- try {
57
- const plugin = await MessagesLayout.MisoClient.plugins.install('std:ui-markdown');
58
- if (!this._view) {
59
- return; // destroyed
60
- }
61
- this._markdown = plugin.getContext(this._view.workflow._client);
62
- this._readiness.resolve();
63
- } catch (e) {
64
- this._readiness.reject(e);
65
- }
66
- }
67
-
68
- async _ready() {
69
- return this._readiness.promise;
63
+ // item identity: the question id, or the local placeholder id standing in
64
+ // for it while a posted question awaits its response — the placeholder id
65
+ // survives the settle, so the element and its workflow do too
66
+ _getItemKey(message) {
67
+ return message.placeholder_id || message.question_id || message;
70
68
  }
71
69
 
72
70
  // render //
73
71
  async render(element, state, controls = {}) {
74
- // capture the view state callback, so the typewriter can report the
75
- // `ongoing` state outside the render cycle
72
+ // capture the view state callback for the ongoing report
76
73
  this._notifyUpdate = controls.notifyUpdate;
77
74
  await super.render(element, state, controls);
78
75
  }
79
76
 
80
- _afterRender(element, state) {
81
- super._afterRender(element, state); // syncs bindings to the latest values
82
- if (!state.incremental) {
83
- this._displaying = false; // a fresh render (thread load) drops any ongoing typewriter
77
+ // incremental renders cover a pure append (a posted follow-up at the
78
+ // bottom) or a pure prepend (an older page above) — anything else
79
+ // re-renders in full
80
+ _shallRenderIncrementally(state, rendered) {
81
+ if (!super._shallRenderIncrementally(state, rendered)) {
82
+ return false;
84
83
  }
85
- if (!state.incremental || state.html) {
86
- // a fresh render (thread load) or appended items (posted follow-up):
87
- // jump to the bottom
88
- this._scrollToBottom({ force: true });
84
+ const values = this._getItems(state) || [];
85
+ const renderedValues = rendered.value;
86
+ const count = values.length - renderedValues.length;
87
+ return count === 0 || // in-place changes only
88
+ this._getItemKey(values[0]) === this._getItemKey(renderedValues[0]) || // appended
89
+ this._getItemKey(values[count]) === this._getItemKey(renderedValues[0]); // prepended
90
+ }
91
+
92
+ // the fresh items land at the tail (a posted follow-up) or at the head
93
+ // (an older page fetched by the more flow): told apart by which boundary
94
+ // the already-rendered records line up with
95
+ _html(state, rendered, incremental) {
96
+ if (!incremental) {
97
+ return this.templates.root(this, state);
98
+ }
99
+ const values = this._getItems(state) || [];
100
+ const renderedValues = rendered.value;
101
+ const count = values.length - renderedValues.length;
102
+ if (count === 0) {
103
+ return '';
89
104
  }
90
- this._syncMessages(element).catch(error => console.error(error));
105
+ if (this._getItemKey(values[0]) === this._getItemKey(renderedValues[0])) {
106
+ const fresh = values.slice(renderedValues.length);
107
+ return {
108
+ position: 'beforeend',
109
+ items: this.templates.items(this, state, fresh, { offset: renderedValues.length }),
110
+ };
111
+ }
112
+ const fresh = values.slice(0, count);
113
+ return {
114
+ position: 'afterbegin',
115
+ items: this.templates.items(this, state, fresh, { offset: 0 }),
116
+ };
91
117
  }
92
118
 
93
- async _syncMessages(element) {
94
- await this._ready();
95
- const items = this._getItemElements(element);
96
- let displaying = false;
97
- for (let i = 0; i < items.length; i++) {
98
- const item = items[i];
99
- const binding = this._bindings.get(item);
100
- if (!binding) {
101
- continue;
102
- }
103
- const message = binding.value;
104
- const last = i === items.length - 1;
105
- this._syncQuestion(item, message);
106
- this._syncAnswer(item, message, { last });
107
- if (last) {
108
- displaying = this._isDisplaying(item, message);
119
+ _render(element, { state }, { notifyUpdate }) {
120
+ const { incremental, html } = state;
121
+ if (incremental) {
122
+ if (html) {
123
+ const listElement = this._getListElement(element);
124
+ if (html.position === 'afterbegin') {
125
+ this._prependAnchored(listElement, html.items);
126
+ } else {
127
+ listElement.insertAdjacentHTML('beforeend', html.items);
128
+ }
129
+ } else {
130
+ notifyUpdate(false);
109
131
  }
132
+ } else {
133
+ element.innerHTML = html;
110
134
  }
111
- this._setDisplaying(displaying);
112
135
  }
113
136
 
114
- // whether the (last) message pair is still being displayed: its answer is
115
- // pending, streaming, or still being typed — live (posted in this session)
116
- // or not: an answer still generating is picked up by the answers polling
117
- // even after switching away from the thread and back, and must keep
118
- // blocking submission all the same
119
- _isDisplaying(item, message) {
120
- if (!hasAnswer(message)) {
121
- return true; // waiting for the answer body
137
+ _afterRender(element, state) {
138
+ super._afterRender(element, state); // syncs bindings and (re)arms the trigger
139
+ if (!state.incremental) {
140
+ // a fresh render (thread load): jump to the bottom
141
+ this._dropAnchor();
142
+ this._scrollToBottom({ force: true });
143
+ } else if (state.html && state.html.position === 'beforeend') {
144
+ // appended items (posted follow-up): jump to the bottom
145
+ this._scrollToBottom({ force: true });
122
146
  }
123
- const answerElement = item.querySelector('[data-role="answer"]');
124
- const typewriter = answerElement && this._typewriters.get(answerElement);
125
- return typewriter ? !typewriter.done : message.finished === false;
147
+ this._watchGrowth(this._getListElement(element) || element);
148
+ this._syncWorkflows(element);
149
+ this._syncOngoing(state);
126
150
  }
127
151
 
128
- _syncQuestion(item, message) {
129
- const questionElement = item.querySelector('[data-role="question"]');
130
- if (!questionElement) {
131
- return;
132
- }
133
- // the authorship arrives with the answers response, after the stub render
134
- setOrRemoveAttribute(questionElement, 'data-author', messageAuthor(message));
135
- setOrRemoveAttribute(questionElement, 'data-generated-by', getGeneratedBy(message));
136
- const { question } = message;
137
- if (!question || questionElement.textContent === question) {
138
- return;
139
- }
140
- questionElement.textContent = question;
141
- questionElement.hidden = false;
152
+ _unrender() {
153
+ super._unrender();
154
+ this._dropAnchor();
142
155
  }
143
156
 
144
- _syncAnswer(item, message, { last = false } = {}) {
145
- const answerElement = item.querySelector('[data-role="answer"]');
146
- if (!answerElement) {
157
+ // assign each <miso-message-item> its item subworkflow, off the item binding
158
+ _syncWorkflows(element) {
159
+ const workflow = this._view && this._view.workflow;
160
+ if (!workflow || typeof workflow._getMessageWorkflow !== 'function') {
147
161
  return;
148
162
  }
149
- // an ongoing typewriter keeps consuming updates until it finishes typing
150
- const typewriter = this._typewriters.get(answerElement);
151
- if (typewriter) {
152
- typewriter.update(message);
153
- return;
154
- }
155
- if (!hasAnswer(message)) {
156
- return; // still loading; the spinner stays
157
- }
158
- if (message.live || message.finished === false) {
159
- // an answer being generated live in this session: drive it with a
160
- // typewriter (even if the data arrived complete in one shot)
161
- const typewriter = new MessageTypewriter(this._markdown, answerElement, {
162
- onUpdate: () => this._scrollToBottom(), // keep pinned to the bottom while typing
163
- onDone: () => this._setDisplaying(false),
164
- });
165
- this._typewriters.set(answerElement, typewriter);
166
- typewriter.update(message);
167
- return;
168
- }
169
- // a finished answer: transform in one shot
170
- if (this._renderedAnswers.get(answerElement) === message.answer) {
171
- return; // already rendered
163
+ for (const item of this._getItemElements(element)) {
164
+ const binding = this._bindings.get(item);
165
+ if (binding && item.isContainer) {
166
+ item.workflow = workflow._getMessageWorkflow(binding.value);
167
+ }
172
168
  }
173
- this._renderedAnswers.set(answerElement, message.answer);
174
- this._transform(answerElement, message).then(() => {
175
- last && this._scrollToBottom();
176
- }).catch(error => {
177
- this._renderedAnswers.delete(answerElement);
178
- console.error(error);
179
- });
180
- }
181
-
182
- async _transform(answerElement, { answer, sources }) {
183
- answerElement.innerHTML = await this._markdown.transform(answer, sources);
184
169
  }
185
170
 
186
- // whether the last answer is still being displayed (typed); reported as
187
- // the `ongoing` view state, e.g. for the search box to disable submission
188
- _setDisplaying(displaying) {
189
- if (this._displaying === displaying) {
171
+ // whether an answer is on its way — pending, or still being generated —
172
+ // reported as the `ongoing` view state, e.g. for the search box to disable
173
+ // submission
174
+ _syncOngoing(state) {
175
+ const messages = state.value || [];
176
+ const last = messages[messages.length - 1];
177
+ // a message settled by a failed content fetch is not on its way anymore
178
+ const ongoing = !!last && !last.error && (last.answer === undefined || last.finished === false);
179
+ if (this._ongoing === ongoing) {
190
180
  return;
191
181
  }
192
- this._displaying = displaying;
193
- this._notifyUpdate && this._notifyUpdate({ ongoing: displaying });
182
+ this._ongoing = ongoing;
183
+ this._notifyUpdate && this._notifyUpdate({ ongoing });
194
184
  }
195
185
 
196
186
  // scrolling //
@@ -230,6 +220,14 @@ export default class MessagesLayout extends CollectionLayout {
230
220
  this._unwatchScroll && this._unwatchScroll();
231
221
  const onScroll = () => {
232
222
  this._pinned = scrollable.scrollHeight - scrollable.scrollTop - scrollable.clientHeight < 40;
223
+ if (this._pinned) {
224
+ // back at the bottom: the reading position needs no anchor
225
+ this._anchor = undefined;
226
+ } else if (this._anchor && this._anchor.element.isConnected) {
227
+ // follow the user: the anchor holds the position they scrolled to,
228
+ // not the one where the prepend happened
229
+ this._anchor.top = this._relativeTop(this._anchor.element, scrollable);
230
+ }
233
231
  };
234
232
  scrollable.addEventListener('scroll', onScroll, { passive: true });
235
233
  this._watchedScrollable = scrollable;
@@ -241,78 +239,78 @@ export default class MessagesLayout extends CollectionLayout {
241
239
  this._unsubscribes.push(() => this._unwatchScroll === unwatch && unwatch());
242
240
  }
243
241
 
244
- }
245
-
246
- // TODO: can't we just use Controller?
247
- /**
248
- * Types a streaming answer into an element: a progressive markdown renderer
249
- * fed by data updates, advanced by a paced cursor on animation frames — the
250
- * typewriting effect, without the full typewriter layout machinery.
251
- */
252
- class MessageTypewriter {
253
-
254
- constructor(markdown, element, { onUpdate, onDone } = {}) {
255
- this._element = element;
256
- this._onUpdate = onUpdate;
257
- this._onDone = onDone;
258
- // the element acts as a typewriter container: it starts as the caret ref
259
- // (the cursor class moves into the content as it types), and the preset
260
- // stamps the `done` class on finish, which hides the caret via CSS
261
- element.classList.add(TYPEWRITER_CLASSNAME, cursorClassName(TYPEWRITER_CLASSNAME));
262
- this._renderer = markdown.createRenderer({
263
- cursorClass: cursorClassName(TYPEWRITER_CLASSNAME),
264
- getSource: index => (this._sources || [])[index],
265
- });
266
- this._getNextCursor = pacer();
267
- this._rendered = this._renderer.clear(element);
268
- this._timestamp = undefined;
269
- this._value = '';
270
- this._sources = undefined;
271
- this._dataDone = false;
272
- this._doneAt = undefined;
273
- this._requested = false;
242
+ // the message contents render (and type out) through their own workflows,
243
+ // outside this layout's render cycle: follow the content growth — pinned,
244
+ // the view sticks to the bottom; scrolled up with an anchor active (a
245
+ // prepended page still rendering its contents in), the anchor is held
246
+ // stationary instead
247
+ _watchGrowth(element) {
248
+ if (!element || this._watchedGrowth === element || typeof ResizeObserver === 'undefined') {
249
+ return;
250
+ }
251
+ this._unwatchGrowth && this._unwatchGrowth();
252
+ const observer = new ResizeObserver(() => this._onContentGrowth());
253
+ observer.observe(element);
254
+ this._watchedGrowth = element;
255
+ const unwatch = this._unwatchGrowth = () => {
256
+ observer.disconnect();
257
+ this._watchedGrowth = undefined;
258
+ this._unwatchGrowth = undefined;
259
+ };
260
+ this._unsubscribes.push(() => this._unwatchGrowth === unwatch && unwatch());
274
261
  }
275
262
 
276
- get done() {
277
- return this._rendered.done;
263
+ _onContentGrowth() {
264
+ if (this._pinned) {
265
+ this._scrollToBottom();
266
+ } else {
267
+ this._restoreAnchor();
268
+ }
278
269
  }
279
270
 
280
- update({ answer = '', finished, sources }) {
281
- this._value = answer;
282
- this._sources = sources;
283
- this._dataDone = finished !== false;
284
- this._requestFrame();
271
+ // scroll anchoring for prepended content //
272
+ // The anchor is the previously-first item: everything prepended — and
273
+ // its content, rendering in later through the item workflows — sits
274
+ // above it, so holding the anchor stationary holds the reading position,
275
+ // while growth below it (a streaming answer at the bottom) moves nothing
276
+ // above and needs no compensation. Not the browser's own scroll
277
+ // anchoring, which not every engine provides.
278
+ _prependAnchored(listElement, items) {
279
+ this._setAnchor();
280
+ listElement.insertAdjacentHTML('afterbegin', items);
281
+ this._restoreAnchor();
285
282
  }
286
283
 
287
- _requestFrame() {
288
- if (this._requested || this._rendered.done) {
289
- return;
290
- }
291
- this._requested = true;
292
- raf(timestamp => this._frame(timestamp));
284
+ _setAnchor() {
285
+ const scrollable = this._getScrollable();
286
+ const element = this._getItemElements(this._element)[0];
287
+ this._anchor = scrollable && element
288
+ ? { element, scrollable, top: this._relativeTop(element, scrollable) }
289
+ : undefined;
293
290
  }
294
291
 
295
- _frame(timestamp) {
296
- this._requested = false;
297
- if (!this._element.isConnected) {
298
- // the element is gone (re-render, thread switch); stop typing
299
- this._onDone && this._onDone();
292
+ _restoreAnchor() {
293
+ const anchor = this._anchor;
294
+ if (!anchor) {
300
295
  return;
301
296
  }
302
- const prev = this._rendered;
303
- const prevTimestamp = this._timestamp !== undefined ? this._timestamp : timestamp;
304
- if (this._dataDone && this._doneAt === undefined) {
305
- this._doneAt = timestamp;
297
+ const { element, scrollable, top } = anchor;
298
+ if (!element.isConnected) {
299
+ this._anchor = undefined;
300
+ return;
306
301
  }
307
- const cursor = this._getNextCursor(prev.cursor, this._doneAt, prevTimestamp, timestamp);
308
- this._rendered = this._renderer.update(this._element, prev, { value: this._value, cursor, timestamp, done: this._dataDone });
309
- this._timestamp = timestamp;
310
- this._onUpdate && this._onUpdate();
311
- if (!this._rendered.done) {
312
- this._requestFrame();
313
- } else {
314
- this._onDone && this._onDone();
302
+ const delta = this._relativeTop(element, scrollable) - top;
303
+ if (delta) {
304
+ scrollable.scrollTop += delta;
315
305
  }
316
306
  }
317
307
 
308
+ _relativeTop(element, scrollable) {
309
+ return element.getBoundingClientRect().top - scrollable.getBoundingClientRect().top;
310
+ }
311
+
312
+ _dropAnchor() {
313
+ this._anchor = undefined;
314
+ }
315
+
318
316
  }