diff --git a/e2e/testcafe-devextreme/tests/navigation/splitter/etalons/Splitter in tab content after window resize, pane_1.size=`100px` (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/navigation/splitter/etalons/Splitter in tab content after window resize, pane_1.size=`100px` (fluent.blue.light).png index 2501aa486c75..c9bd3743a923 100644 Binary files a/e2e/testcafe-devextreme/tests/navigation/splitter/etalons/Splitter in tab content after window resize, pane_1.size=`100px` (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/navigation/splitter/etalons/Splitter in tab content after window resize, pane_1.size=`100px` (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/navigation/splitter/resize.ts b/e2e/testcafe-devextreme/tests/navigation/splitter/resize.ts index 7a43afce5754..815ad3d42da2 100644 --- a/e2e/testcafe-devextreme/tests/navigation/splitter/resize.ts +++ b/e2e/testcafe-devextreme/tests/navigation/splitter/resize.ts @@ -16,7 +16,7 @@ test.meta({ browserSize: [800, 800] })('non resizable pane should not change its await t .expect(splitter.getItem(2).element.clientWidth) - .eql(145); + .eql(300); }).before(async () => createWidget('dxSplitter', { width: '100%', height: 300, diff --git a/packages/devextreme/js/__internal/ui/splitter/splitter.ts b/packages/devextreme/js/__internal/ui/splitter/splitter.ts index 2ca1c018760d..6d994b9e65b6 100644 --- a/packages/devextreme/js/__internal/ui/splitter/splitter.ts +++ b/packages/devextreme/js/__internal/ui/splitter/splitter.ts @@ -53,10 +53,12 @@ import { findIndexOfNextVisibleItem, findLastIndexOfNonCollapsedItem, findLastIndexOfVisibleItem, + findLastVisibleExpandedItemIndex, getElementSize, getNextLayout, isElementVisible, setFlexProp, + tryConvertToNumber, } from './utils/layout'; import { getDefaultLayout } from './utils/layout_default'; import { compareNumbersWithPrecision } from './utils/number_comparison'; @@ -128,12 +130,16 @@ class Splitter extends CollectionWidgetLiveUpdate { private _layout?: number[]; + private _idealLayout?: number[]; + private _currentLayout?: number[]; private _activeResizeHandleIndex?: number; private _collapseDirection?: CollapseExpandDirection; + private _initialPaneSizes: (string | number | undefined)[] = []; + private _itemRestrictions: PaneRestrictions[] = []; private _currentOnePxRatio?: number; @@ -236,13 +242,20 @@ class Splitter extends CollectionWidgetLiveUpdate { } _resizeHandler(): void { - if (this._shouldRecalculateLayout && this._isAttached() && this._isVisible()) { + if (!this._isAttached() || !this._isVisible()) { + return; + } + + if (this._shouldRecalculateLayout) { this._layout = this._getDefaultLayoutBasedOnSize(); + this._idealLayout = this._layout; this._applyStylesFromLayout(this._layout); this._updateItemSizes(); this._shouldRecalculateLayout = false; + } else { + this._dimensionChanged(); } } @@ -252,8 +265,11 @@ class Splitter extends CollectionWidgetLiveUpdate { this._updateResizeHandlesResizableState(); this._updateResizeHandlesCollapsibleState(); + this._initialPaneSizes = items.map((item: Item): string | number | undefined => item.size); + if (this._isVisible()) { this._layout = this._getDefaultLayoutBasedOnSize(); + this._idealLayout = this._layout; this._applyStylesFromLayout(this._layout); this._updateItemSizes(); @@ -537,6 +553,7 @@ class Splitter extends CollectionWidgetLiveUpdate { this._applyStylesFromLayout(newLayout); this._layout = newLayout; + this._idealLayout = newLayout; }, onResizeEnd: (e: ResizeEndEvent): void => { const { element, event } = e; @@ -663,6 +680,7 @@ class Splitter extends CollectionWidgetLiveUpdate { switch (property) { case 'size': this._layout = this._getDefaultLayoutBasedOnSize(item); + this._idealLayout = this._layout; this._applyStylesFromLayout(this.getLayout()); this._updateItemSizes(); @@ -671,6 +689,7 @@ class Splitter extends CollectionWidgetLiveUpdate { case 'minSize': case 'collapsedSize': this._layout = this._getDefaultLayoutBasedOnSize(); + this._idealLayout = this._layout; this._applyStylesFromLayout(this.getLayout()); this._updateItemSizes(); @@ -737,6 +756,7 @@ class Splitter extends CollectionWidgetLiveUpdate { paneIndex, this._itemRestrictions, ); + this._idealLayout = this._layout; this._applyStylesFromLayout(this.getLayout()); this._updateItemSizes(); @@ -859,6 +879,7 @@ class Splitter extends CollectionWidgetLiveUpdate { this._activeResizeHandleIndex, this._itemRestrictions, ); + this._idealLayout = this._layout; this._applyStylesFromLayout(this.getLayout()); this._updateItemSizes(); @@ -1100,9 +1121,169 @@ class Splitter extends CollectionWidgetLiveUpdate { } _dimensionChanged(): void { + const idealLayout = this._idealLayout; + + if (!idealLayout || idealLayout.length === 0) { + return; + } + + const { orientation, items = [] } = this.option(); + const elementSize = getElementSize(this.$element(), orientation); + const handlesSize = this._getResizeHandlesSize(); + const availableSize = Math.max(0, elementSize - handlesSize); + + if (availableSize <= 0) { + this._layout = idealLayout.map((): number => 0); + this._applyStylesFromLayout(this._layout); + this._updateItemSizes(); + return; + } + + const idealPixels = idealLayout.map((ratio: number): number => (ratio / 100) * availableSize); + + let remaining = 0; + const newPixels = idealPixels.map((px: number, index: number): number => { + const item = items[index]; + + if (!item || item.visible === false) { + remaining += px; + return 0; + } + + if (item.collapsed === true) { + const collapsedPx = tryConvertToNumber(item.collapsedSize, elementSize) ?? 0; + remaining += px - collapsedPx; + return collapsedPx; + } + + if (item.resizable === false) { + const originalSize = this._initialPaneSizes[index]; + + if (isDefined(originalSize)) { + const fixedPx = tryConvertToNumber(originalSize, elementSize) ?? px; + remaining += px - fixedPx; + return fixedPx; + } + } + + const minPx = tryConvertToNumber(item.minSize, elementSize) ?? 0; + const maxPx = tryConvertToNumber(item.maxSize, elementSize); + const clampedPx = this._getClampedPixelSize(px, minPx, maxPx); + + remaining += px - clampedPx; + return clampedPx; + }); + + this._distributeRemainingPixels(newPixels, remaining); + + this._layout = newPixels.map((px: number): number => (px / availableSize) * 100); + this._applyStylesFromLayout(this._layout); this._updateItemSizes(); + } + + _getEligiblePaneIndices( + pixels: number[], + remaining: number, + ): number[] { + const { items = [], orientation } = this.option(); + const elementSize = getElementSize(this.$element(), orientation); + const indices: number[] = []; + const direction = remaining > 0 ? 1 : -1; + + for (let index = 0; index < pixels.length; index += 1) { + const item = items[index]; + + if (!item || item.visible === false || item.collapsed === true + || (item.resizable === false && isDefined(this._initialPaneSizes[index]))) { + // skip + } else { + const minPx = tryConvertToNumber(item.minSize, elementSize) ?? 0; + const maxPx = tryConvertToNumber(item.maxSize, elementSize); + const clampedPx = this._getClampedPixelSize(pixels[index] + direction, minPx, maxPx); + + if (compareNumbersWithPrecision(clampedPx, pixels[index]) !== 0) { + indices.push(index); + } + } + } + + return indices; + } + + _distributeRemainingPixels( + pixels: number[], + initialRemaining: number, + ): void { + const { items = [] } = this.option(); + + let remaining = initialRemaining; + + while (compareNumbersWithPrecision(remaining, 0) !== 0) { + const eligiblePaneIndices = this._getEligiblePaneIndices(pixels, remaining); + + if (eligiblePaneIndices.length === 0) { + const fallback = findLastVisibleExpandedItemIndex(items); + + if (fallback !== -1) { + pixels[fallback] += remaining; + } + break; + } + + const share = remaining / eligiblePaneIndices.length; + const result = this._applyPixelShare(pixels, eligiblePaneIndices, share); + + remaining -= result.applied; + + if (!result.distributed) { + break; + } + } + } + + _applyPixelShare( + pixels: number[], + eligiblePaneIndices: number[], + share: number, + ): { applied: number; distributed: boolean } { + const { items = [], orientation } = this.option(); + const elementSize = getElementSize(this.$element(), orientation); + let applied = 0; + let distributed = false; + + eligiblePaneIndices.forEach((index: number): void => { + const item = items[index]; + const prev = pixels[index]; + const next = prev + share; + + const minPx = tryConvertToNumber(item?.minSize, elementSize) ?? 0; + const maxPx = tryConvertToNumber(item?.maxSize, elementSize); + const clampedPx = this._getClampedPixelSize(next, minPx, maxPx); + + const delta = clampedPx - prev; + + if (compareNumbersWithPrecision(delta, 0) !== 0) { + pixels[index] = clampedPx; + applied += delta; + distributed = true; + } + }); + + return { applied, distributed }; + } + + _getClampedPixelSize( + size: number, + minPx: number, + maxPx: number | undefined, + ): number { + let result = Math.max(size, minPx); + + if (isDefined(maxPx)) { + result = Math.min(result, maxPx); + } - this._layout = this._getDefaultLayoutBasedOnSize(); + return result; } _optionChanged(args: OptionChanged): void { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js index 752166f71b94..f804e165d25c 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js @@ -1963,18 +1963,18 @@ QUnit.module('Resizing', moduleConfig, () => { }); }); - QUnit.test(`non resizable panes shouldn't change their sizes after update splitter dimension, ${orientation} orientation`, function(assert) { + QUnit.test(`non resizable panes with restrictions shouldn't change their sizes after update splitter dimension, ${orientation} orientation`, function(assert) { this.reinit({ width: 1018, height: 1018, - dataSource: [{ }, { }, { }, { resizable: false, size: '300px' }], + dataSource: [{ }, { }, { }, { resizable: false, minSize: '300px', maxSize: '300px' }], orientation, }); this.instance.option(orientation === 'horizontal' ? 'width' : 'height', 700); - this.checkItemSizes([159.133, 159.133, 159.133, 204.602]); - this.assertLayout([23.3333, 23.3333, 23.3333, 30]); + this.checkItemSizes([104.602, 138.695, 138.703, 300]); + this.assertLayout(['15.3372', '20.3372', '20.3372', '43.9883']); }); }); @@ -2822,6 +2822,120 @@ QUnit.module('Resizing', moduleConfig, () => { this.assertLayout(['15', '10', '25', '50']); }); + + [{ + initialWidth: 408, + newWidth: 808, + dataSource: [{ size: '200px', maxSize: '200px' }, { }], + expectedLayout: ['25', '75'], + expectedItemSizes: [200, 600], + }, { + initialWidth: 408, + newWidth: 808, + dataSource: [{ }, { size: '200px', maxSize: '200px' }], + expectedLayout: ['75', '25'], + expectedItemSizes: [600, 200], + }, { + initialWidth: 808, + newWidth: 408, + dataSource: [{ size: '200px', minSize: '200px' }, { }], + expectedLayout: ['50', '50'], + expectedItemSizes: [200, 200], + }, { + initialWidth: 808, + newWidth: 408, + dataSource: [{ }, { size: '200px', minSize: '200px' }], + expectedLayout: ['50', '50'], + expectedItemSizes: [200, 200], + }, { + initialWidth: 416, + newWidth: 816, + dataSource: [{ size: '200px', maxSize: '200px' }, { }, { }], + expectedLayout: ['25', '37.5', '37.5'], + expectedItemSizes: [200, 300, 300], + }, { + initialWidth: 816, + newWidth: 416, + dataSource: [{ size: '200px', minSize: '200px' }, { }, { }], + expectedLayout: ['50', '25', '25'], + expectedItemSizes: [200, 100, 100], + }, { + initialWidth: 416, + newWidth: 816, + dataSource: [{ }, { size: '200px', maxSize: '200px' }, { }], + expectedLayout: ['37.5', '25', '37.5'], + expectedItemSizes: [300, 200, 300], + }, { + initialWidth: 408, + newWidth: 808, + dataSource: [{ size: '200px', minSize: '100px', maxSize: '300px' }, { }], + expectedLayout: ['37.5', '62.5'], + expectedItemSizes: [300, 500], + }, { + initialWidth: 208, + newWidth: 808, + dataSource: [{ size: '100px', maxSize: '200px' }, { }], + expectedLayout: ['25', '75'], + expectedItemSizes: [200, 600], + }].forEach(({ initialWidth, newWidth, dataSource, expectedLayout, expectedItemSizes }) => { + QUnit.test(`pane constraints should be respected after dimension change from ${initialWidth} to ${newWidth}, dataSource: ${JSON.stringify(dataSource)}`, function(assert) { + this.reinit({ + width: initialWidth, + height: 408, + dataSource, + }); + + this.instance.option('width', newWidth); + + this.checkItemSizes(expectedItemSizes); + this.assertLayout(expectedLayout); + }); + }); + + QUnit.test('layout should be restored after shrinking and expanding back', function(assert) { + this.reinit({ + width: 416, + height: 408, + dataSource: [{ }, { }, { }], + }); + + this.assertLayout(['33.3333', '33.3333', '33.3333']); + + this.instance.option('width', 40); + this.instance.option('width', 416); + + this.assertLayout(['33.3333', '33.3333', '33.3333']); + }); + + QUnit.test('layout should be restored after shrinking and expanding with minSize', function(assert) { + this.reinit({ + width: 416, + height: 408, + dataSource: [{ minSize: '100px' }, { }, { }], + }); + + this.assertLayout(['33.3333', '33.3333', '33.3333']); + + this.instance.option('width', 40); + this.instance.option('width', 416); + + this.assertLayout(['33.3333', '33.3333', '33.3333']); + }); + + QUnit.test('layout should be restored after shrinking and expanding with maxSize', function(assert) { + this.reinit({ + width: 816, + height: 408, + dataSource: [{ size: '200px', maxSize: '200px' }, { }, { }], + }); + + this.assertLayout(['25', '37.5', '37.5']); + + this.instance.option('width', 40); + this.instance.option('width', 816); + + this.assertLayout(['25', '37.5', '37.5']); + }); }); QUnit.module('Initialization', moduleConfig, () => {