diff --git a/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.4a25712f1.png b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.4a25712f1.png new file mode 100644 index 0000000000..897bcb3ece Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.4a25712f1.png differ diff --git a/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.50df2cc11.png b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.50df2cc11.png new file mode 100644 index 0000000000..20e4013f1f Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.50df2cc11.png differ diff --git a/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.5e69a5271.png b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.5e69a5271.png new file mode 100644 index 0000000000..f714f6585b Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.5e69a5271.png differ diff --git a/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.8f47d6241.png b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.8f47d6241.png new file mode 100644 index 0000000000..454afffdac Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.8f47d6241.png differ diff --git a/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.9c1d6a3a1.png b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.9c1d6a3a1.png new file mode 100644 index 0000000000..3172c292d2 Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.9c1d6a3a1.png differ diff --git a/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.fea6f8681.png b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.fea6f8681.png new file mode 100644 index 0000000000..5e5e2c34a0 Binary files /dev/null and b/integration_tests/snapshots/css/css-flexbox/flex-gap-sizing.ts.fea6f8681.png differ diff --git a/integration_tests/specs/css/css-flexbox/flex-gap-sizing.ts b/integration_tests/specs/css/css-flexbox/flex-gap-sizing.ts new file mode 100644 index 0000000000..83ab26ef80 --- /dev/null +++ b/integration_tests/specs/css/css-flexbox/flex-gap-sizing.ts @@ -0,0 +1,490 @@ +/*auto generated*/ +describe('css-flexbox gap sizing', () => { + // The bug: when flex items with minWidth overflow a container with gap, + // the scrollable content area does not include the gap space. + // Scrolling to the end clips the last item by exactly the total gap length. + + it('row-flex-1-minwidth-with-gap-scroll-to-end', async () => { + let p; + let flexbox; + p = createElement( + 'p', + { + style: { + 'box-sizing': 'border-box', + }, + }, + [ + createText( + `Test passes if scrolling to the right end shows "Item 3" fully with its right border visible.` + ), + ] + ); + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + 'flex-direction': 'row', + gap: '10px', + width: '200px', + height: '80px', + 'background-color': '#f0f0f0', + 'overflow-x': 'auto', + border: '3px solid black', + 'box-sizing': 'border-box', + }, + }, + [ + createElement('div', { + style: { + flex: '1', + 'min-width': '100px', + 'background-color': '#ffcccc', + height: '60px', + border: '3px solid red', + 'box-sizing': 'border-box', + }, + }, [createText('Item 1')]), + createElement('div', { + style: { + flex: '1', + 'min-width': '100px', + 'background-color': '#ccffcc', + height: '60px', + border: '3px solid green', + 'box-sizing': 'border-box', + }, + }, [createText('Item 2')]), + createElement('div', { + style: { + flex: '1', + 'min-width': '100px', + 'background-color': '#ccccff', + height: '60px', + border: '3px solid blue', + 'box-sizing': 'border-box', + }, + }, [createText('Item 3')]), + ] + ); + BODY.appendChild(p); + BODY.appendChild(flexbox); + + // Wait for layout to complete so the scroll controller has clients + await waitForFrame(); + + // Scroll to the very end + flexbox.scrollLeft = flexbox.scrollWidth; + + // Wait for the scroll paint to take effect + await waitForFrame(); + + await snapshot(); + }); + + it('row-flex-1-minwidth-with-gap-4-items-scroll-to-end', async () => { + let p; + let flexbox; + p = createElement( + 'p', + { + style: { + 'box-sizing': 'border-box', + }, + }, + [ + createText( + `Test passes if scrolling to the right end shows "Item 4" fully with its right border visible.` + ), + ] + ); + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + 'flex-direction': 'row', + gap: '10px', + width: '200px', + height: '80px', + 'background-color': '#f0f0f0', + 'overflow-x': 'auto', + border: '3px solid black', + 'box-sizing': 'border-box', + }, + }, + [ + createElement('div', { + style: { + flex: '1', + 'min-width': '80px', + 'background-color': '#ffcccc', + height: '60px', + border: '3px solid red', + 'box-sizing': 'border-box', + }, + }, [createText('Item 1')]), + createElement('div', { + style: { + flex: '1', + 'min-width': '80px', + 'background-color': '#ccffcc', + height: '60px', + border: '3px solid green', + 'box-sizing': 'border-box', + }, + }, [createText('Item 2')]), + createElement('div', { + style: { + flex: '1', + 'min-width': '80px', + 'background-color': '#ccccff', + height: '60px', + border: '3px solid blue', + 'box-sizing': 'border-box', + }, + }, [createText('Item 3')]), + createElement('div', { + style: { + flex: '1', + 'min-width': '80px', + 'background-color': '#ffffcc', + height: '60px', + border: '3px solid orange', + 'box-sizing': 'border-box', + }, + }, [createText('Item 4')]), + ] + ); + BODY.appendChild(p); + BODY.appendChild(flexbox); + + // Wait for layout to complete so the scroll controller has clients + await waitForFrame(); + + // Scroll to the very end + flexbox.scrollLeft = flexbox.scrollWidth; + + // Wait for the scroll paint to take effect + await waitForFrame(); + + await snapshot(); + }); + + it('row-flex-1-minwidth-with-large-gap-scroll-to-end', async () => { + let p; + let flexbox; + p = createElement( + 'p', + { + style: { + 'box-sizing': 'border-box', + }, + }, + [ + createText( + `Test passes if scrolling to the right end shows "Item 3" fully (large 30px gap).` + ), + ] + ); + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + 'flex-direction': 'row', + gap: '30px', + width: '200px', + height: '80px', + 'background-color': '#f0f0f0', + 'overflow-x': 'auto', + border: '3px solid black', + 'box-sizing': 'border-box', + }, + }, + [ + createElement('div', { + style: { + flex: '1', + 'min-width': '100px', + 'background-color': '#ffcccc', + height: '60px', + border: '3px solid red', + 'box-sizing': 'border-box', + }, + }, [createText('Item 1')]), + createElement('div', { + style: { + flex: '1', + 'min-width': '100px', + 'background-color': '#ccffcc', + height: '60px', + border: '3px solid green', + 'box-sizing': 'border-box', + }, + }, [createText('Item 2')]), + createElement('div', { + style: { + flex: '1', + 'min-width': '100px', + 'background-color': '#ccccff', + height: '60px', + border: '3px solid blue', + 'box-sizing': 'border-box', + }, + }, [createText('Item 3')]), + ] + ); + BODY.appendChild(p); + BODY.appendChild(flexbox); + + // Wait for layout to complete so the scroll controller has clients + await waitForFrame(); + + // Scroll to the very end + flexbox.scrollLeft = flexbox.scrollWidth; + + // Wait for the scroll paint to take effect + await waitForFrame(); + + await snapshot(); + }); + + it('column-flex-1-minheight-with-gap-scroll-to-end', async () => { + let p; + let flexbox; + p = createElement( + 'p', + { + style: { + 'box-sizing': 'border-box', + }, + }, + [ + createText( + `Test passes if scrolling to the bottom shows "Item 3" fully with its bottom border visible.` + ), + ] + ); + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + 'flex-direction': 'column', + gap: '10px', + width: '150px', + height: '200px', + 'background-color': '#f0f0f0', + 'overflow-y': 'auto', + border: '3px solid black', + 'box-sizing': 'border-box', + }, + }, + [ + createElement('div', { + style: { + flex: '1', + 'min-height': '100px', + 'background-color': '#ffcccc', + border: '3px solid red', + 'box-sizing': 'border-box', + }, + }, [createText('Item 1')]), + createElement('div', { + style: { + flex: '1', + 'min-height': '100px', + 'background-color': '#ccffcc', + border: '3px solid green', + 'box-sizing': 'border-box', + }, + }, [createText('Item 2')]), + createElement('div', { + style: { + flex: '1', + 'min-height': '100px', + 'background-color': '#ccccff', + border: '3px solid blue', + 'box-sizing': 'border-box', + }, + }, [createText('Item 3')]), + ] + ); + BODY.appendChild(p); + BODY.appendChild(flexbox); + + // Wait for layout to complete so the scroll controller has clients + await waitForFrame(); + + // Scroll to the very end + flexbox.scrollTop = flexbox.scrollHeight; + + // Wait for the scroll paint to take effect + await waitForFrame(); + + await snapshot(); + }); + + it('column-flex-1-minheight-with-gap-4-items-scroll-to-end', async () => { + let p; + let flexbox; + p = createElement( + 'p', + { + style: { + 'box-sizing': 'border-box', + }, + }, + [ + createText( + `Test passes if scrolling to the bottom shows "Item 4" fully with its bottom border visible.` + ), + ] + ); + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + 'flex-direction': 'column', + gap: '10px', + width: '150px', + height: '200px', + 'background-color': '#f0f0f0', + 'overflow-y': 'auto', + border: '3px solid black', + 'box-sizing': 'border-box', + }, + }, + [ + createElement('div', { + style: { + flex: '1', + 'min-height': '80px', + 'background-color': '#ffcccc', + border: '3px solid red', + 'box-sizing': 'border-box', + }, + }, [createText('Item 1')]), + createElement('div', { + style: { + flex: '1', + 'min-height': '80px', + 'background-color': '#ccffcc', + border: '3px solid green', + 'box-sizing': 'border-box', + }, + }, [createText('Item 2')]), + createElement('div', { + style: { + flex: '1', + 'min-height': '80px', + 'background-color': '#ccccff', + border: '3px solid blue', + 'box-sizing': 'border-box', + }, + }, [createText('Item 3')]), + createElement('div', { + style: { + flex: '1', + 'min-height': '80px', + 'background-color': '#ffffcc', + border: '3px solid orange', + 'box-sizing': 'border-box', + }, + }, [createText('Item 4')]), + ] + ); + BODY.appendChild(p); + BODY.appendChild(flexbox); + + // Wait for layout to complete so the scroll controller has clients + await waitForFrame(); + + // Scroll to the very end + flexbox.scrollTop = flexbox.scrollHeight; + + // Wait for the scroll paint to take effect + await waitForFrame(); + + await snapshot(); + }); + + it('column-flex-1-minheight-with-large-gap-scroll-to-end', async () => { + let p; + let flexbox; + p = createElement( + 'p', + { + style: { + 'box-sizing': 'border-box', + }, + }, + [ + createText( + `Test passes if scrolling to the bottom shows "Item 3" fully (large 30px gap).` + ), + ] + ); + flexbox = createElement( + 'div', + { + style: { + display: 'flex', + 'flex-direction': 'column', + gap: '30px', + width: '150px', + height: '200px', + 'background-color': '#f0f0f0', + 'overflow-y': 'auto', + border: '3px solid black', + 'box-sizing': 'border-box', + }, + }, + [ + createElement('div', { + style: { + flex: '1', + 'min-height': '100px', + 'background-color': '#ffcccc', + border: '3px solid red', + 'box-sizing': 'border-box', + }, + }, [createText('Item 1')]), + createElement('div', { + style: { + flex: '1', + 'min-height': '100px', + 'background-color': '#ccffcc', + border: '3px solid green', + 'box-sizing': 'border-box', + }, + }, [createText('Item 2')]), + createElement('div', { + style: { + flex: '1', + 'min-height': '100px', + 'background-color': '#ccccff', + border: '3px solid blue', + 'box-sizing': 'border-box', + }, + }, [createText('Item 3')]), + ] + ); + BODY.appendChild(p); + BODY.appendChild(flexbox); + + // Wait for layout to complete so the scroll controller has clients + await waitForFrame(); + + // Scroll to the very end + flexbox.scrollTop = flexbox.scrollHeight; + + // Wait for the scroll paint to take effect + await waitForFrame(); + + await snapshot(); + }); +}); diff --git a/webf/lib/src/rendering/flex.dart b/webf/lib/src/rendering/flex.dart index f4cd45bdab..2eacff81b0 100644 --- a/webf/lib/src/rendering/flex.dart +++ b/webf/lib/src/rendering/flex.dart @@ -4027,14 +4027,26 @@ class RenderFlexLayout extends RenderLayoutBox { } } - final double childScrollableMain = preSiblingsMainSize + - (_isHorizontalFlexDirection - ? childScrollableSize.width + childOffsetX - : childScrollableSize.height + childOffsetY); - final double childScrollableCross = _isHorizontalFlexDirection + final double childBoxMainSize = _isHorizontalFlexDirection ? child.size.width : child.size.height; + final double childBoxCrossSize = _isHorizontalFlexDirection ? child.size.height : child.size.width; + final double childMainOffset = _isHorizontalFlexDirection ? childOffsetX : childOffsetY; + final double childCrossOffset = _isHorizontalFlexDirection ? childOffsetY : childOffsetX; + final double childScrollableMainExtent = _isHorizontalFlexDirection + ? childScrollableSize.width + childOffsetX + : childScrollableSize.height + childOffsetY; + final double childScrollableCrossExtent = _isHorizontalFlexDirection ? childScrollableSize.height + childOffsetY : childScrollableSize.width + childOffsetX; + // The child's extent must cover at least its offset border-box. + // child.scrollableSize may only cover padding-box, but offsets (margin/relative/transform) + // still need to be preserved so negative offsets don't create phantom trailing scroll range. + final double childScrollableMain = preSiblingsMainSize + + math.max(childBoxMainSize + childMainOffset, childScrollableMainExtent); + final double childScrollableCross = math.max( + childBoxCrossSize + childCrossOffset, + childScrollableCrossExtent); + maxScrollableMainSizeOfLine = math.max(maxScrollableMainSizeOfLine, childScrollableMain); maxScrollableCrossSizeInLine = math.max(maxScrollableCrossSizeInLine, childScrollableCross); @@ -4048,6 +4060,10 @@ class RenderFlexLayout extends RenderLayoutBox { } } preSiblingsMainSize += childMainSize; + // Add main-axis gap between items (not after the last item). + if (runChild != runChildren.last) { + preSiblingsMainSize += _getMainAxisGap(); + } } // Max scrollable cross size of all the children in the line. @@ -4056,6 +4072,10 @@ class RenderFlexLayout extends RenderLayoutBox { scrollableMainSizeOfLines.add(maxScrollableMainSizeOfLine); scrollableCrossSizeOfLines.add(maxScrollableCrossSizeOfLine); preLinesCrossSize += runMetric.crossAxisExtent; + // Add cross-axis gap between flex lines (not after the last line). + if (runMetric != runMetrics.last) { + preLinesCrossSize += _getCrossAxisGap(); + } } // Max scrollable main size of all lines. diff --git a/webf/test/src/rendering/flex_item_width_test.dart b/webf/test/src/rendering/flex_item_width_test.dart index 6d8bbedda1..9bb33abe4f 100644 --- a/webf/test/src/rendering/flex_item_width_test.dart +++ b/webf/test/src/rendering/flex_item_width_test.dart @@ -30,10 +30,13 @@ void main() { }); group('Flex Item Width', () { - testWidgets('block elements in flex container should not stretch to parent width', (WidgetTester tester) async { + testWidgets( + 'block elements in flex container should not stretch to parent width', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'flex-item-width-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'flex-item-width-${DateTime.now().millisecondsSinceEpoch}', html: '''
@@ -53,19 +56,19 @@ void main() { final item1 = prepared.getElementById('item1'); final item2 = prepared.getElementById('item2'); final item3 = prepared.getElementById('item3'); - + // Container should be 600px wide expect(container.offsetWidth, equals(600)); - + // Items should size to their content, not stretch to 600px expect(item1.offsetWidth, lessThan(600)); expect(item2.offsetWidth, lessThan(600)); expect(item3.offsetWidth, lessThan(600)); - + // Items should have different widths based on their content expect(item1.offsetWidth, isNot(equals(item2.offsetWidth))); expect(item2.offsetWidth, isNot(equals(item3.offsetWidth))); - + // Debug output print('Container width: ${container.offsetWidth}'); print('Item 1 width: ${item1.offsetWidth}'); @@ -73,10 +76,12 @@ void main() { print('Item 3 width: ${item3.offsetWidth}'); }); - testWidgets('block elements with explicit width should respect it', (WidgetTester tester) async { + testWidgets('block elements with explicit width should respect it', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'flex-item-explicit-width-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'flex-item-explicit-width-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -93,10 +98,10 @@ void main() { final item1 = prepared.getElementById('item1'); final item2 = prepared.getElementById('item2'); - + // Item with explicit width should be 100px expect(item1.offsetWidth, equals(100)); - + // Item without width should size to content expect(item2.offsetWidth, lessThan(600)); expect(item2.offsetWidth, isNot(equals(100))); @@ -105,7 +110,8 @@ void main() { testWidgets('flex-grow should expand items', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'flex-grow-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'flex-grow-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -122,15 +128,48 @@ void main() { final item1 = prepared.getElementById('item1'); final item2 = prepared.getElementById('item2'); - + // Item 2 should size to content final item2ContentWidth = item2.offsetWidth; - + // Item 1 with flex-grow should take remaining space expect(item1.offsetWidth, equals(600 - item2ContentWidth)); - + // Total should equal container width expect(item1.offsetWidth + item2.offsetWidth, equals(600)); }); + + testWidgets( + 'negative start-side offset should not create phantom trailing scroll range', + (WidgetTester tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + controllerName: + 'flex-negative-offset-scroll-${DateTime.now().millisecondsSinceEpoch}', + html: ''' + + +