diff --git a/packages/devextreme/js/__internal/ui/chat/messagelist.ts b/packages/devextreme/js/__internal/ui/chat/messagelist.ts index 51e7a94247fb..5e7389e281ca 100644 --- a/packages/devextreme/js/__internal/ui/chat/messagelist.ts +++ b/packages/devextreme/js/__internal/ui/chat/messagelist.ts @@ -1,13 +1,9 @@ -import domAdapter from '@js/core/dom_adapter'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import resizeObserverSingleton from '@js/core/resize_observer'; -import { contains } from '@js/core/utils/dom'; -import { hasWindow } from '@js/core/utils/window'; +import { isElementInDom } from '@js/core/utils/dom'; +import { isDefined } from '@js/core/utils/type'; import messageLocalization from '@js/localization/message'; -import { - isReachedBottom, -} from '@js/renovation/ui/scroll_view/utils/get_boundary_props'; import { getScrollTopMax } from '@js/renovation/ui/scroll_view/utils/get_scroll_top_max'; import type { Message } from '@js/ui/chat'; import Scrollable from '@js/ui/scroll_view/ui.scrollable'; @@ -26,6 +22,8 @@ const CHAT_MESSAGELIST_EMPTY_IMAGE_CLASS = 'dx-chat-messagelist-empty-image'; const CHAT_MESSAGELIST_EMPTY_MESSAGE_CLASS = 'dx-chat-messagelist-empty-message'; const CHAT_MESSAGELIST_EMPTY_PROMPT_CLASS = 'dx-chat-messagelist-empty-prompt'; +const SCROLLABLE_CONTAINER_CLASS = 'dx-scrollable-container'; + export interface Properties extends WidgetOptions { items: Message[]; currentUserId: number | string | undefined; @@ -34,9 +32,7 @@ export interface Properties extends WidgetOptions { class MessageList extends Widget { private _messageGroups?: MessageGroup[]; - private _containerClientHeight = 0; - - private _suppressResizeHandling?: boolean; + private _containerClientHeight?: number; private _scrollable!: Scrollable; @@ -60,47 +56,42 @@ class MessageList extends Widget { super._initMarkup(); this._renderScrollable(); - this._renderMessageListContent(); + } - this._attachResizeObserverSubscription(); + _renderContentImpl(): void { + super._renderContentImpl(); - this._suppressResizeHandling = true; + this._attachResizeObserverSubscription(); } _attachResizeObserverSubscription(): void { - if (hasWindow()) { - const element = this._getScrollContainer(); - - resizeObserverSingleton.unobserve(element); - resizeObserverSingleton.observe(element, (entry) => this._resizeHandler(entry)); - } - } + const element = this.$element().get(0); - _isAttached(element: Element): boolean { - return !!contains(domAdapter.getBody(), element); + resizeObserverSingleton.unobserve(element); + resizeObserverSingleton.observe(element, (entry) => this._resizeHandler(entry)); } _resizeHandler({ contentRect, target }: ResizeObserverEntry): void { const newHeight = contentRect.height; - if (this._suppressResizeHandling - && this._isAttached(target) - && isElementVisible(target as HTMLElement) - ) { - this._scrollContentToLastMessage(); + if (!isElementInDom($(target)) || !isElementVisible(target as HTMLElement)) { + return; + } - this._suppressResizeHandling = false; + if (!isDefined(this._containerClientHeight)) { + this._scrollContentToLastMessage(); } else { const heightChange = this._containerClientHeight - newHeight; + const isHeightDecreasing = heightChange > 0; - let { scrollTop } = target; + let scrollTop = this._scrollable.scrollTop(); - if (heightChange >= 1 || !isReachedBottom(target as HTMLDivElement, target.scrollTop, 0, 1)) { + if (isHeightDecreasing) { scrollTop += heightChange; - } - this._scrollable.scrollTo({ top: scrollTop }); + this._scrollable.scrollTo({ top: scrollTop }); + } } this._containerClientHeight = newHeight; @@ -231,14 +222,13 @@ class MessageList extends Widget { } _scrollContentToLastMessage(): void { - const scrollOffsetTopMax = getScrollTopMax(this._getScrollContainer()); - - this._scrollable.scrollTo({ top: scrollOffsetTopMax }); + this._scrollable.scrollTo({ + top: getScrollTopMax(this._scrollableContainer()), + }); } - _getScrollContainer(): HTMLElement { - // @ts-expect-error - return $(this._scrollable.container()).get(0); + _scrollableContainer(): Element { + return $(this._scrollable.element()).find(`.${SCROLLABLE_CONTAINER_CLASS}`).get(0); } _clean(): void { diff --git a/packages/devextreme/js/renovation/ui/scroll_view/utils/get_scroll_top_max.ts b/packages/devextreme/js/renovation/ui/scroll_view/utils/get_scroll_top_max.ts index 013ff9ce85f7..fb32dc17a25c 100644 --- a/packages/devextreme/js/renovation/ui/scroll_view/utils/get_scroll_top_max.ts +++ b/packages/devextreme/js/renovation/ui/scroll_view/utils/get_scroll_top_max.ts @@ -1,3 +1,3 @@ -export function getScrollTopMax(element: HTMLElement): number { +export function getScrollTopMax(element: HTMLElement | Element): number { return element.scrollHeight - element.clientHeight; } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js index 29804c5100ec..a417470120c4 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js @@ -399,8 +399,8 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.scrollable.scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should not be 0 after initialization'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after initialization'); done(); }, this._resizeTimeout); }); @@ -417,17 +417,16 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should not be 0 after items are updated at runtime'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after items are updated at runtime'); done(); }); }); [MOCK_CURRENT_USER_ID, MOCK_COMPANION_USER_ID].forEach(id => { const isCurrentUser = id === MOCK_CURRENT_USER_ID; - const textName = `Scrollable should be scrolled to last message after render ${isCurrentUser ? 'current user' : 'companion'} message`; - QUnit.test(textName, function(assert) { + QUnit.test(`Scrollable should be scrolled to last message after render ${isCurrentUser ? 'current user' : 'companion'} message`, function(assert) { const done = assert.async(); assert.expect(2); const items = generateMessages(31); @@ -450,14 +449,14 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should not be 0 after a new message is rendered'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after rendering the new message'); done(); }); }); }); - QUnit.test('should be scrolled to the last message after being rendered inside an invisible element and display correctly when shown', function(assert) { + QUnit.test('should be scrolled to the last message after showing if was initially rendered inside an invisible element', function(assert) { const done = assert.async(); $('#qunit-fixture').css('display', 'none'); @@ -474,15 +473,15 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.strictEqual(scrollTop, 0); + assert.strictEqual(scrollTop, 0, 'scroll position should be 0 when the element is hidden'); $('#qunit-fixture').css('display', 'block'); setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should change after the element is made visible'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after element becomes visible'); done(); }, this._resizeTimeout); @@ -506,20 +505,162 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.strictEqual(scrollTop, 0); + assert.strictEqual(scrollTop, 0, 'scroll position should be 0 while the element is detached'); $messageList.appendTo('#qunit-fixture'); setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should change after the element is attached to the DOM'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after attachment'); done(); }, this._resizeTimeout); }); }); + + QUnit.test('should save last scroll position after reducing height', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.strictEqual(scrollTop, this.getScrollOffsetMax(), 'scroll position should be at the bottom after initialization'); + + this.instance.option('height', 300); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'max scroll position should be saved after reducing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); + + QUnit.test('should save last scroll position after increasing height', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.strictEqual(scrollTop, this.getScrollOffsetMax(), 'scroll position should be at the bottom after initialization'); + + this.instance.option('height', 700); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'max scroll position should be saved after increasing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); + + QUnit.test('should save last scroll position after reducing height from middle scrolltop', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.strictEqual(scrollTop, this.getScrollOffsetMax(), 'scroll position should be at the bottom after initialization'); + + this.getScrollable().scrollTo({ top: this.getScrollOffsetMax() - 200 }); + this.instance.option('height', 300); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax() - 200, 1, 'scroll position should be set correctly after reducing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); + + QUnit.test('should save its scroll position after increasing height', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.strictEqual(scrollTop, this.getScrollOffsetMax(), 'scroll position should be at the bottom after initialization'); + + const newScrollTop = this.getScrollOffsetMax() - 200; + this.getScrollable().scrollTo({ top: newScrollTop }); + this.instance.option('height', 600); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, newScrollTop, 1, 'scroll position should be saved correctly after increasing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); + + QUnit.test('should limit scroll position after increasing height more than scroll offset allows', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const newScrollTop = this.getScrollOffsetMax() - 200; + + this.getScrollable().scrollTo({ top: newScrollTop }); + this.instance.option('height', 800); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be limited to the max scrollable offset after increasing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); }); QUnit.module('localization', moduleConfig, () => {