diff --git a/packages/devextreme/js/__internal/ui/splitter/splitter.ts b/packages/devextreme/js/__internal/ui/splitter/splitter.ts index 0c9f18d32cb9..1f37c2c9c8fe 100644 --- a/packages/devextreme/js/__internal/ui/splitter/splitter.ts +++ b/packages/devextreme/js/__internal/ui/splitter/splitter.ts @@ -60,7 +60,7 @@ import { setFlexProp, tryConvertToNumber, } from './utils/layout'; -import { getDefaultLayout } from './utils/layout_default'; +import { fitAutoSizesIntoLayout, getDefaultLayout } from './utils/layout_default'; import { compareNumbersWithPrecision } from './utils/number_comparison'; import { CollapseExpandDirection, @@ -141,7 +141,11 @@ class Splitter extends CollectionWidgetLiveUpdate { private _collapseDirection?: CollapseExpandDirection; - private _initialPaneSizes: (string | number | undefined)[] = []; + // @ts-expect-error ts-error + private _initialPaneSizes: (string | number | undefined)[]; + + // @ts-expect-error ts-error + private _measuredPaneSizes: (number | undefined)[]; private _itemRestrictions: PaneRestrictions[] = []; @@ -184,6 +188,12 @@ class Splitter extends CollectionWidgetLiveUpdate { super._init(); this._initializeRenderQueue(); + + // initialized here and not in the field declarations: field initializers run + // after the base constructor has already rendered the widget, wiping the + // state captured during the initial render + this._initialPaneSizes = []; + this._measuredPaneSizes = []; } _initializeRenderQueue(): void { @@ -254,7 +264,7 @@ class Splitter extends CollectionWidgetLiveUpdate { const { _ignoreSizeConstraints } = this.option(); if (this._shouldRecalculateLayout) { - this._layout = this._getDefaultLayoutBasedOnSize(); + this._layout = this._getDefaultLayoutBasedOnSize(undefined, true); this._idealLayout = this._layout; this._applyStylesFromLayout(this._layout); @@ -273,10 +283,20 @@ class Splitter extends CollectionWidgetLiveUpdate { this._updateResizeHandlesResizableState(); this._updateResizeHandlesCollapsibleState(); - this._initialPaneSizes = items.map((item: Item): string | number | undefined => item.size); + this._initialPaneSizes = items.map( + (item: Item, index: number): string | number | undefined => { + // a size equal to the one measured on the previous render was written back + // by _updateItemSizes, not declared by the user — the pane stays auto-sized + if (isDefined(item.size) && item.size === this._measuredPaneSizes[index]) { + return this._initialPaneSizes[index]; + } + + return item.size; + }, + ); if (this._isVisible()) { - this._layout = this._getDefaultLayoutBasedOnSize(); + this._layout = this._getDefaultLayoutBasedOnSize(undefined, true); this._idealLayout = this._layout; this._applyStylesFromLayout(this._layout); this._setPanesCacheSize(); @@ -1047,10 +1067,16 @@ class Splitter extends CollectionWidgetLiveUpdate { this._itemEventHandler($item, eventName, actionArgs); } - _getDefaultLayoutBasedOnSize(item?: Item): number[] { + _getDefaultLayoutBasedOnSize(item?: Item, shouldFitAutoSizes = false): number[] { this._updateItemsRestrictions(item); - return getDefaultLayout(this._itemRestrictions); + // on render the stored sizes may have been measured for another container size; + // option-change recalculations keep resolving conflicts positionally + const layoutRestrictions = shouldFitAutoSizes + ? fitAutoSizesIntoLayout(this._itemRestrictions) + : this._itemRestrictions; + + return getDefaultLayout(layoutRestrictions); } _updateItemsRestrictions(currentItem?: Item): void { @@ -1072,7 +1098,7 @@ class Splitter extends CollectionWidgetLiveUpdate { }); } - items.forEach((item) => { + items.forEach((item, index) => { const sizeRatio = convertSizeToRatio(item.size, elementSize, handlesSizeSum); const minSizeRatio = convertSizeToRatio(item.minSize, elementSize, handlesSizeSum); const userMaxSize = convertSizeToRatio(item.maxSize, elementSize, handlesSizeSum); @@ -1095,6 +1121,7 @@ class Splitter extends CollectionWidgetLiveUpdate { size: sizeRatio, maxSize: effectiveMaxSize, minSize: minSizeRatio, + isSizeAuto: !isDefined(this._initialPaneSizes[index]), }); }); } @@ -1112,7 +1139,10 @@ class Splitter extends CollectionWidgetLiveUpdate { _updateItemSizes(): void { this._iterateItems((index, itemElement) => { - this._updateItemData('size', index, this._getItemDimension(itemElement)); + const size = this._getItemDimension(itemElement); + + this._measuredPaneSizes[index] = size; + this._updateItemData('size', index, size); }); } diff --git a/packages/devextreme/js/__internal/ui/splitter/utils/__tests__/layout_default.test.ts b/packages/devextreme/js/__internal/ui/splitter/utils/__tests__/layout_default.test.ts new file mode 100644 index 000000000000..0986d48e0ff4 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/splitter/utils/__tests__/layout_default.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from '@jest/globals'; +import { isDefined } from '@js/core/utils/type'; + +import { convertSizeToRatio } from '../layout'; +import { fitAutoSizesIntoLayout, getDefaultLayout } from '../layout_default'; +import type { PaneRestrictions } from '../types'; + +// mirrors Splitter._getDefaultLayoutBasedOnSize(undefined, true) — the render-time +// recalculation, where the stored sizes may come from another container size +function getRenderLayout(restrictions: PaneRestrictions[]): number[] { + return getDefaultLayout(fitAutoSizesIntoLayout(restrictions)); +} + +interface TestItem { + size?: string | number; + minSize?: string | number; + maxSize?: string | number; + collapsedSize?: string | number; + resizable?: boolean; + visible?: boolean; + collapsed?: boolean; +} + +// mirrors Splitter._updateItemsRestrictions() called without a current item +function getItemRestrictions( + items: TestItem[], + elementSize: number, + handlesSizeSum: number, + declaredSizes: (string | number | undefined)[] = items.map((item) => item.size), +): PaneRestrictions[] { + return items.map((item, index) => ({ + resizable: item.resizable !== false, + visible: item.visible !== false, + collapsed: item.collapsed === true, + collapsedSize: convertSizeToRatio(item.collapsedSize, elementSize, handlesSizeSum), + size: convertSizeToRatio(item.size, elementSize, handlesSizeSum), + maxSize: convertSizeToRatio(item.maxSize, elementSize, handlesSizeSum), + minSize: convertSizeToRatio(item.minSize, elementSize, handlesSizeSum), + isSizeAuto: !isDefined(declaredSizes[index]), + })); +} + +// the layout is applied as flex-grow on panes with flex-basis: 0, flex-shrink: 0 and +// overflow: hidden, so their rendered size is their share of the whole available space +function getPaneSizes(layout: number[], availableSize: number): number[] { + const totalGrow = layout.reduce((total, grow) => total + grow, 0); + + return layout.map((grow) => (availableSize * grow) / totalGrow); +} + +function expectSizes(actual: number[], expected: number[]): void { + expect(actual.map((size) => Number(size.toFixed(3)))).toEqual(expected); +} + +describe('getDefaultLayout', () => { + describe('pane sizes requested by the user', () => { + // the Splitter Overview demo: two 140px panes around a pane with no size of its own, + // one resize handle (8px) and one inactive handle next to the non-resizable pane (2px) + const demoItems: TestItem[] = [ + { size: '140px', minSize: '70px' }, + {}, + { size: '140px', resizable: false }, + ]; + const demoHandles = 8 + 2; + const demoElementSize = 982; + + it('keeps sizes declared in pixels when another pane limits itself with a percentage maxSize', () => { + const items: TestItem[] = [ + { size: '140px', minSize: '70px' }, + { maxSize: '75%' }, + { size: '140px', resizable: false }, + ]; + + const layout = getDefaultLayout( + getItemRestrictions(items, demoElementSize, demoHandles), + ); + + expectSizes( + getPaneSizes(layout, demoElementSize - demoHandles), + [140, 692, 140], + ); + }); + + it('takes the space a shrunk container lost from the pane the widget sized itself', () => { + // the first layout pass runs before the page is resized to its final width + const wideElementSize = 1018; + const wideLayout = getDefaultLayout( + getItemRestrictions(demoItems, wideElementSize, demoHandles), + ); + const measuredSizes = getPaneSizes(wideLayout, wideElementSize - demoHandles); + + expectSizes(measuredSizes, [140, 728, 140]); + + // _updateItemSizes() writes the measured sizes back into items[].size, so a layout + // recalculation after the container has shrunk gets sizes that no longer fit into it + const resizedItems: TestItem[] = demoItems.map((item, index) => ({ + ...item, + size: measuredSizes[index], + })); + + const layout = getRenderLayout(getItemRestrictions( + resizedItems, + demoElementSize, + demoHandles, + demoItems.map((item) => item.size), + )); + + expectSizes( + getPaneSizes(layout, demoElementSize - demoHandles), + [140, 692, 140], + ); + }); + + it('is not affected by the container size the previous layout pass was based on', () => { + const layoutFromScratch = getRenderLayout( + getItemRestrictions(demoItems, demoElementSize, demoHandles), + ); + + [1018, 982, 900].forEach((previousElementSize) => { + const previousLayout = getDefaultLayout( + getItemRestrictions(demoItems, previousElementSize, demoHandles), + ); + const measuredSizes = getPaneSizes( + previousLayout, + previousElementSize - demoHandles, + ); + + const layout = getRenderLayout(getItemRestrictions( + demoItems.map((item, index) => ({ ...item, size: measuredSizes[index] })), + demoElementSize, + demoHandles, + demoItems.map((item) => item.size), + )); + + expect(layout).toEqual(layoutFromScratch); + }); + }); + + it('keeps sizes the widget measured itself when they still fit into the container', () => { + const layout = getRenderLayout(getItemRestrictions( + [{ size: 200 }, { size: 100 }, { size: 100 }], + 408, + 8, + [undefined, undefined, undefined], + )); + + expectSizes(getPaneSizes(layout, 400), [200, 100, 100]); + }); + + it('without the render-time fit oversubscribed sizes keep resolving positionally', () => { + // option-change recalculations (collapsedSize, minSize, maxSize) call getDefaultLayout + // directly: sizes are fresh there and conflicts are resolved in pane order + const layout = getDefaultLayout(getItemRestrictions( + [{ size: 200 }, { size: 200 }, { size: 100 }], + 408, + 8, + [undefined, undefined, undefined], + )); + + expectSizes(getPaneSizes(layout, 400), [200, 200, 0]); + }); + }); + + // the layouts asserted by the 'Pane sizing' QUnit module, all of them rendered into a + // 408px container: sizes requested by the user must keep resolving the same way + describe('layouts covered by the QUnit tests', () => { + const cases: { items: TestItem[]; expected: number[] }[] = [ + { items: [{ minSize: '30%' }], expected: [100] }, + // rounding dust makes the pre-normalization total 99.9999999999 here — the layout + // must not be rescaled because of it + { items: [{ minSize: '30%' }, {}, {}], expected: [33.3333, 33.3333, 33.3333] }, + { items: [{ size: '40%', minSize: '30%' }, {}], expected: [40.8, 59.2] }, + { items: [{ minSize: '40%' }, {}, {}], expected: [41.6327, 25.034, 33.3333] }, + { items: [{ size: '30%' }, {}, { minSize: '30%' }], expected: [31.2245, 34.3878, 34.3878] }, + { items: [{ size: '30%' }, {}, { minSize: '30%', size: '40%' }], expected: [31.2245, 27.1429, 41.6327] }, + { items: [{}, {}, { minSize: '30%', size: '20%' }], expected: [29.1837, 39.5918, 31.2245] }, + { items: [{ size: '50%' }, { minSize: '40%' }, { minSize: '40%' }], expected: [16.7347, 41.6327, 41.6327] }, + { items: [{ size: '200px', minSize: '30%' }, {}], expected: [50, 50] }, + { items: [{ size: 200, minSize: 300 }, {}], expected: [75, 25] }, + { items: [{ minSize: '70%' }, { minSize: 100 }, { minSize: 100 }], expected: [72.8571, 25.5102, 25.5102] }, + { items: [{ maxSize: '30%' }], expected: [100] }, + { items: [{ size: '40%', maxSize: '30%' }, {}], expected: [30.6, 69.4] }, + { items: [{ size: '20%', maxSize: '30%' }, {}], expected: [20.4, 79.6] }, + { items: [{ size: '40%' }, { maxSize: '30%' }], expected: [69.4, 30.6] }, + { items: [{}, { maxSize: '20%' }, {}], expected: [39.5918, 20.8163, 39.5918] }, + { items: [{}, {}, { maxSize: '20%' }, { maxSize: '20%' }], expected: [28.75, 28.75, 21.25, 21.25] }, + { items: [{}, { maxSize: '20%' }, {}, { maxSize: '10%' }], expected: [34.0625, 21.25, 34.0625, 10.625] }, + { items: [{}, { maxSize: '20%' }, {}, { maxSize: '40%' }], expected: [26.25, 21.25, 26.25, 26.25] }, + { items: [{ maxSize: '20%' }, { size: '10%' }, {}], expected: [20.8163, 10.4082, 68.7755] }, + { items: [{ maxSize: '10%' }, { maxSize: '10%' }, { maxSize: '10%' }], expected: [10.4082, 10.4082, 79.1837] }, + ]; + + cases.forEach(({ items, expected }) => { + it(`items: ${JSON.stringify(items)}`, () => { + const layout = getDefaultLayout( + getItemRestrictions(items, 408, (items.length - 1) * 8), + ); + + layout.forEach((grow, index) => { + expect(grow).toBeCloseTo(expected[index], 3); + }); + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/splitter/utils/layout_default.ts b/packages/devextreme/js/__internal/ui/splitter/utils/layout_default.ts index 7c76f9a728c5..8f2e74c13713 100644 --- a/packages/devextreme/js/__internal/ui/splitter/utils/layout_default.ts +++ b/packages/devextreme/js/__internal/ui/splitter/utils/layout_default.ts @@ -9,6 +9,69 @@ import { import { compareNumbersWithPrecision, PRECISION } from './number_comparison'; import type { PaneRestrictions } from './types'; +function getRequestedSize(paneRestrictions: PaneRestrictions): number { + const { + size, visible, collapsed, collapsedSize = 0, + } = paneRestrictions; + + if (visible === false) { + return 0; + } + + if (collapsed === true) { + return collapsedSize; + } + + return size ?? 0; +} + +function isPaneInLayout(paneRestrictions: PaneRestrictions): boolean { + return paneRestrictions.visible !== false && paneRestrictions.collapsed !== true; +} + +function isSizeAdjustable(paneRestrictions: PaneRestrictions): boolean { + return paneRestrictions.isSizeAuto === true + && isDefined(paneRestrictions.size) + && isPaneInLayout(paneRestrictions); +} + +// Sizes the widget measured itself are relative to the container size of the previous layout +// pass. Once the container has been resized, they no longer add up to the space available now, +// and the difference would otherwise be taken from (or given to) panes whose size the user +// did request, making the layout depend on the container size it was first calculated for. +// Applies only to render-time recalculations: option-change recalculations work with sizes +// measured for the current container, where the pinned behavior resolves conflicts by order. +export function fitAutoSizesIntoLayout(layoutRestrictions: PaneRestrictions[]): PaneRestrictions[] { + let requestedSize = 0; + let adjustableSize = 0; + let hasPanesWithoutSize = false; + + layoutRestrictions.forEach((paneRestrictions) => { + requestedSize += getRequestedSize(paneRestrictions); + + if (isSizeAdjustable(paneRestrictions)) { + adjustableSize += paneRestrictions.size ?? 0; + } else if (isPaneInLayout(paneRestrictions) && !isDefined(paneRestrictions.size)) { + hasPanesWithoutSize = true; + } + }); + + const excessSize = requestedSize - 100; + const excessSign = compareNumbersWithPrecision(excessSize, 0); + // free space belongs to the panes that have no size of their own, if there are any + const shouldFit = excessSign > 0 || (excessSign < 0 && !hasPanesWithoutSize); + + if (adjustableSize <= 0 || !shouldFit) { + return layoutRestrictions; + } + + const ratio = Math.max(0, adjustableSize - excessSize) / adjustableSize; + + return layoutRestrictions.map((paneRestrictions) => (isSizeAdjustable(paneRestrictions) + ? { ...paneRestrictions, size: (paneRestrictions.size ?? 0) * ratio } + : paneRestrictions)); +} + export function getDefaultLayout(layoutRestrictions: PaneRestrictions[]): number[] { let layout: number[] = new Array(layoutRestrictions.length).fill(null); @@ -61,9 +124,9 @@ export function getDefaultLayout(layoutRestrictions: PaneRestrictions[]): number layoutRestrictions.forEach((paneRestrictions, index) => { if (layout[index] === null) { if (isDefined(paneRestrictions.maxSize) && panelsToDistribute === 1) { - layout[index] = remainingSize > paneRestrictions.maxSize - ? remainingSize - : paneRestrictions.maxSize; + // the only pane left without a size takes all the space the sized panes did not + // claim; a larger maxSize must not push the layout over 100% and squeeze them + layout[index] = remainingSize; remainingSize -= layout[index]; numPanelsWithDefinedSize += 1; } else if (isDefined(paneRestrictions.maxSize) @@ -94,25 +157,9 @@ export function getDefaultLayout(layoutRestrictions: PaneRestrictions[]): number return layout; } - let nextLayout = [...layout]; - - const nextLayoutTotalSize = nextLayout.reduce( - (accumulated, current) => accumulated + current, - 0, - ); - - if (!(compareNumbersWithPrecision(nextLayoutTotalSize, 100) === 0)) { - for (let index = 0; index < layoutRestrictions.length; index += 1) { - const unsafeSize = nextLayout[index]; - - const safeSize = (100 / nextLayoutTotalSize) * unsafeSize; - nextLayout[index] = safeSize; - } - } - remainingSize = 0; - nextLayout = layout.map((panelSize, index) => { + const nextLayout = layout.map((panelSize, index) => { const restriction = layoutRestrictions[index]; const adjustedSize = normalizePanelSize(restriction, panelSize); diff --git a/packages/devextreme/js/__internal/ui/splitter/utils/types.ts b/packages/devextreme/js/__internal/ui/splitter/utils/types.ts index 47f8e08987d7..1dc23512e8d5 100644 --- a/packages/devextreme/js/__internal/ui/splitter/utils/types.ts +++ b/packages/devextreme/js/__internal/ui/splitter/utils/types.ts @@ -11,6 +11,8 @@ export interface PaneRestrictions { size?: number; maxSize?: number; minSize?: number; + // size was measured by the widget itself, not requested by the user + isSizeAuto?: boolean; } export interface ResizeOffset { 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 1d967ea5757b..6a72ebde5bbb 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js @@ -270,6 +270,35 @@ QUnit.module('Pane sizing', moduleConfig, () => { }); }); + QUnit.test('pixel pane sizes should not be squeezed by another pane maxSize on initial render', function(assert) { + this.reinit({ + width: 982, + height: 408, + dataSource: [{ size: '140px', minSize: '70px' }, { maxSize: '75%' }, { size: '140px', resizable: false }], + orientation: 'horizontal', + }); + + this.checkItemSizes([140, 692, 140]); + this.assertLayout(['14.4033', '71.1934', '14.4033']); + }); + + QUnit.test('pixel pane sizes should be preserved when splitter is repainted after its container was resized', function(assert) { + $('#splitterParentContainer').css('width', 1018); + this.reinit({ + height: 408, + dataSource: [{ size: '140px', minSize: '70px' }, { }, { size: '140px', resizable: false }], + orientation: 'horizontal', + }, '#splitterInContainer'); + + this.checkItemSizes([140, 728, 140]); + + $('#splitterParentContainer').css('width', 982); + this.instance.repaint(); + + this.checkItemSizes([140, 692, 140]); + this.assertLayout(['14.4033', '71.1934', '14.4033']); + }); + [{ resizeDistance: 100, dataSource: [{ size: '50%', minSize: '30%' }, { }],