From 3b4af603254041fd6b64f5cba16539174b3fc62e Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Mon, 26 Oct 2026 15:31:39 -0400 Subject: [PATCH 1/7] fix: reserve space for virtual scrollbars Fixes #391 --- src/List.tsx | 165 +++++++++++++++++++++++++++---------- src/ScrollBar.tsx | 11 ++- tests/scrollWidth.test.tsx | 81 +++++++++++++++++- 3 files changed, 208 insertions(+), 49 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index d622e671..f639bcaf 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -20,11 +20,42 @@ import type { ScrollOffset, ScrollOffsetInfo, ScrollPos, ScrollTarget } from './ import useScrollTo from './hooks/useScrollTo'; import type { ExtraRenderInfo, GetKey, RenderFunc, SharedConfig } from './interface'; import type { ScrollBarDirectionType, ScrollBarRef } from './ScrollBar'; -import ScrollBar from './ScrollBar'; +import ScrollBar, { SCROLLBAR_SIZE } from './ScrollBar'; import { getSpinSize } from './utils/scrollbarUtil'; const EMPTY_DATA = []; +function getScrollbarSize(size: number | string | undefined) { + if (typeof size === 'number') { + return Math.max(size, 0); + } + + if (typeof size === 'string' && size.trim()) { + const normalizedSize = size.trim(); + if ( + !/^(auto|initial|inherit|unset|revert|fit-content|max-content|min-content)$/i.test( + normalizedSize, + ) + ) { + return normalizedSize; + } + } + + return SCROLLBAR_SIZE; +} + +function getReducedSize(size: number, gutter: number | string) { + if (typeof gutter === 'number') { + return Math.max(size - gutter, 0); + } + + return `calc(${size}px - ${gutter})`; +} + +function toCssSize(size: number | string) { + return typeof size === 'number' ? `${size}px` : size; +} + const ScrollStyle: React.CSSProperties = { overflowY: 'auto', overflowAnchor: 'none', @@ -153,6 +184,8 @@ export function RawList(props: ListProps, ref: React.Ref) { const componentRef = useRef(null); const fillerInnerRef = useRef(null); const containerRef = useRef(null); + const [size, setSize] = React.useState({ width: 0, height }); + const viewportHeight = size.height || height; // =============================== Item Key =============================== @@ -242,7 +275,7 @@ export function RawList(props: ListProps, ref: React.Ref) { } // Check item bottom in the range. We will render additional one item for motion usage - if (currentItemBottom > offsetTop + height && endIndex === undefined) { + if (currentItemBottom > offsetTop + viewportHeight && endIndex === undefined) { endIndex = i; } @@ -254,7 +287,7 @@ export function RawList(props: ListProps, ref: React.Ref) { startIndex = 0; startOffset = 0; - endIndex = Math.ceil(height / itemHeight); + endIndex = Math.ceil(viewportHeight / itemHeight); } if (endIndex === undefined) { endIndex = mergedData.length - 1; @@ -269,7 +302,7 @@ export function RawList(props: ListProps, ref: React.Ref) { end: endIndex, offset: startOffset, }; - }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]); + }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, viewportHeight]); rangeRef.current.start = start; rangeRef.current.end = end; @@ -301,8 +334,6 @@ export function RawList(props: ListProps, ref: React.Ref) { }, [scrollHeight]); // ================================= Size ================================= - const [size, setSize] = React.useState({ width: 0, height }); - const onHolderResize: ResizeObserverProps['onResize'] = (sizeInfo) => { setSize({ width: sizeInfo.offsetWidth, @@ -324,7 +355,7 @@ export function RawList(props: ListProps, ref: React.Ref) { ); // =============================== In Range =============================== - const maxScrollHeight = scrollHeight - height; + const maxScrollHeight = scrollHeight - viewportHeight; const maxScrollHeightRef = useRef(maxScrollHeight); maxScrollHeightRef.current = maxScrollHeight; @@ -475,7 +506,7 @@ export function RawList(props: ListProps, ref: React.Ref) { // `mousemove` path above is not fired during a native drag, and the native // drag-to-edge autoscroll is disabled by `overflow: hidden`, so drive it from // the drag events here instead. - useDragEdgeScroll(inVirtual, componentRef, height, itemHeight, (offset) => { + useDragEdgeScroll(inVirtual, componentRef, viewportHeight, itemHeight, (offset) => { syncScrollTop((top) => top + offset); }); @@ -587,9 +618,40 @@ export function RawList(props: ListProps, ref: React.Ref) { sharedConfig, ); + const hasVerticalScrollBar = !!inVirtual && scrollHeight > viewportHeight; + const hasHorizontalScrollBar = !!inVirtual && !!scrollWidth && scrollWidth > size.width; + const hasScrollBar = hasVerticalScrollBar || hasHorizontalScrollBar; + const verticalScrollbarSize = getScrollbarSize(styles?.verticalScrollBar?.width); + const horizontalScrollbarSize = getScrollbarSize(styles?.horizontalScrollBar?.height); + const contentColumn = hasVerticalScrollBar && isRTL ? 2 : 1; + + const rootGridStyle: React.CSSProperties = hasScrollBar + ? { + display: 'grid', + gridTemplateColumns: hasVerticalScrollBar + ? isRTL + ? `${toCssSize(verticalScrollbarSize)} minmax(0, 1fr)` + : `minmax(0, 1fr) ${toCssSize(verticalScrollbarSize)}` + : 'minmax(0, 1fr)', + gridTemplateRows: hasHorizontalScrollBar + ? `minmax(0, 1fr) ${toCssSize(horizontalScrollbarSize)}` + : undefined, + } + : {}; + let componentStyle: React.CSSProperties = null; if (height) { - componentStyle = { [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle }; + const holderHeight = hasHorizontalScrollBar + ? getReducedSize(height, horizontalScrollbarSize) + : height; + componentStyle = { [fullHeight ? 'height' : 'maxHeight']: holderHeight, ...ScrollStyle }; + + if (hasScrollBar) { + componentStyle.minWidth = 0; + componentStyle.minHeight = 0; + componentStyle.gridColumn = contentColumn; + componentStyle.gridRow = 1; + } if (useVirtual) { componentStyle.overflowY = 'hidden'; @@ -614,6 +676,7 @@ export function RawList(props: ListProps, ref: React.Ref) { ref={containerRef} style={{ ...style, + ...rootGridStyle, position: 'relative', }} className={mergedClassName} @@ -645,41 +708,59 @@ export function RawList(props: ListProps, ref: React.Ref) { - {inVirtual && scrollHeight > height && ( - + {hasVerticalScrollBar && ( +
+ +
)} - {inVirtual && scrollWidth > size.width && ( - + {hasHorizontalScrollBar && ( +
+ +
)} ); diff --git a/src/ScrollBar.tsx b/src/ScrollBar.tsx index ec4e5a49..688a099b 100644 --- a/src/ScrollBar.tsx +++ b/src/ScrollBar.tsx @@ -5,6 +5,8 @@ import { getPageXY } from './hooks/useScrollDrag'; export type ScrollBarDirectionType = 'ltr' | 'rtl'; +export const SCROLLBAR_SIZE = 8; + export interface ScrollBarProps { prefixCls: string; scrollOffset: number; @@ -134,10 +136,7 @@ const ScrollBar = React.forwardRef((props, ref) => nextTop = pagePosition - rect.top - spinSize / 2; } - onScroll( - getScrollOffsetByThumbTop(nextTop, enableScrollRange, enableOffsetRange), - horizontal, - ); + onScroll(getScrollOffsetByThumbTop(nextTop, enableScrollRange, enableOffsetRange), horizontal); }; const onContainerMouseDown: React.MouseEventHandler = (e) => { @@ -284,7 +283,7 @@ const ScrollBar = React.forwardRef((props, ref) => if (horizontal) { Object.assign(containerStyle, { - height: 8, + height: SCROLLBAR_SIZE, left: 0, right: 0, bottom: 0, @@ -297,7 +296,7 @@ const ScrollBar = React.forwardRef((props, ref) => }); } else { Object.assign(containerStyle, { - width: 8, + width: SCROLLBAR_SIZE, top: 0, bottom: 0, [isLTR ? 'right' : 'left']: 0, diff --git a/tests/scrollWidth.test.tsx b/tests/scrollWidth.test.tsx index 3e94ccbe..d6694096 100644 --- a/tests/scrollWidth.test.tsx +++ b/tests/scrollWidth.test.tsx @@ -17,7 +17,7 @@ describe('List.scrollWidth', () => { let mockMouseEvent; let pageX: number; - const holderHeight = 100; + let holderHeight = 100; let holderWidth = 100; beforeAll(() => { @@ -65,6 +65,7 @@ describe('List.scrollWidth', () => { }); beforeEach(() => { + holderHeight = 100; holderWidth = 100; jest.useFakeTimers(); }); @@ -103,6 +104,84 @@ describe('List.scrollWidth', () => { }); expect(container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toBeTruthy(); + expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toBeTruthy(); + expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ + gridTemplateColumns: 'minmax(0, 1fr) 8px', + gridTemplateRows: 'minmax(0, 1fr) 8px', + }); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '92px' }); + expect(container.querySelector('.rc-virtual-list-scrollbar-slot-horizontal')).toHaveStyle({ + gridRow: '2', + }); + }); + + it('reserves only the horizontal scrollbar gutter when vertical content fits', async () => { + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(2), + scrollWidth: 1000, + }); + + expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toBeNull(); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '92px' }); + expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ + gridTemplateRows: 'minmax(0, 1fr) 8px', + }); + }); + + it('matches reserved gutters to custom scrollbar dimensions', async () => { + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(100), + scrollWidth: 1000, + styles: { + verticalScrollBar: { width: 12 }, + horizontalScrollBar: { height: 6 }, + }, + }); + + expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ + gridTemplateColumns: 'minmax(0, 1fr) 12px', + gridTemplateRows: 'minmax(0, 1fr) 6px', + }); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '94px' }); + }); + + it('adds the vertical scrollbar when the horizontal gutter reduces the viewport', async () => { + holderHeight = 92; + + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(5), + scrollWidth: 1000, + }); + + expect(container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toBeTruthy(); + expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toBeTruthy(); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '92px' }); + }); + + it('reserves the vertical gutter on the left in rtl', async () => { + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(100), + scrollWidth: 1000, + direction: 'rtl', + }); + + expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ + gridTemplateColumns: '8px minmax(0, 1fr)', + }); + expect(container.querySelector('.rc-virtual-list-scrollbar-slot-vertical')).toHaveStyle({ + gridColumn: '1', + }); + expect(container.querySelector('.rc-virtual-list-scrollbar-slot-horizontal')).toHaveStyle({ + gridColumn: '2', + }); }); describe('trigger offset', () => { From ef57371452bf7ee518599303a9063158594566eb Mon Sep 17 00:00:00 2001 From: dannyward630 Date: Tue, 27 Oct 2026 15:41:56 -0400 Subject: [PATCH 2/7] fix: stabilize virtual scrollbar gutters --- src/List.tsx | 52 ++++++++++++------ src/utils/scrollbarUtil.ts | 30 ++++++++++ tests/scrollWidth.test.tsx | 109 ++++++++++++++++++++++++++++++++++--- 3 files changed, 167 insertions(+), 24 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index f639bcaf..6fa27bbf 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -1,6 +1,6 @@ import { clsx } from 'clsx'; import type { ResizeObserverProps } from '@rc-component/resize-observer'; -import ResizeObserver from '@rc-component/resize-observer'; +import ResizeObserver, { useResizeObserver } from '@rc-component/resize-observer'; import { useEvent, useLayoutEffect } from '@rc-component/util'; import * as React from 'react'; import { useRef, useState } from 'react'; @@ -21,7 +21,7 @@ import useScrollTo from './hooks/useScrollTo'; import type { ExtraRenderInfo, GetKey, RenderFunc, SharedConfig } from './interface'; import type { ScrollBarDirectionType, ScrollBarRef } from './ScrollBar'; import ScrollBar, { SCROLLBAR_SIZE } from './ScrollBar'; -import { getSpinSize } from './utils/scrollbarUtil'; +import { getScrollbarVisibility, getSpinSize } from './utils/scrollbarUtil'; const EMPTY_DATA = []; @@ -32,6 +32,10 @@ function getScrollbarSize(size: number | string | undefined) { if (typeof size === 'string' && size.trim()) { const normalizedSize = size.trim(); + if (normalizedSize === '0') { + return 0; + } + if ( !/^(auto|initial|inherit|unset|revert|fit-content|max-content|min-content)$/i.test( normalizedSize, @@ -185,6 +189,7 @@ export function RawList(props: ListProps, ref: React.Ref) { const fillerInnerRef = useRef(null); const containerRef = useRef(null); const [size, setSize] = React.useState({ width: 0, height }); + const [containerWidth, setContainerWidth] = React.useState(); const viewportHeight = size.height || height; // =============================== Item Key =============================== @@ -341,6 +346,14 @@ export function RawList(props: ListProps, ref: React.Ref) { }); }; + const onContainerResize: ResizeObserverProps['onResize'] = (_sizeInfo, element) => { + const styles = window.getComputedStyle(element); + const padding = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight); + setContainerWidth(Math.max(element.clientWidth - (Number.isFinite(padding) ? padding : 0), 0)); + }; + + useResizeObserver(true, () => containerRef.current!, undefined, onContainerResize); + // Hack on scrollbar to enable flash call const verticalScrollBarRef = useRef(null); const horizontalScrollBarRef = useRef(null); @@ -618,22 +631,27 @@ export function RawList(props: ListProps, ref: React.Ref) { sharedConfig, ); - const hasVerticalScrollBar = !!inVirtual && scrollHeight > viewportHeight; - const hasHorizontalScrollBar = !!inVirtual && !!scrollWidth && scrollWidth > size.width; - const hasScrollBar = hasVerticalScrollBar || hasHorizontalScrollBar; const verticalScrollbarSize = getScrollbarSize(styles?.verticalScrollBar?.width); const horizontalScrollbarSize = getScrollbarSize(styles?.horizontalScrollBar?.height); - const contentColumn = hasVerticalScrollBar && isRTL ? 2 : 1; + const { hasVerticalScrollBar, hasHorizontalScrollBar } = getScrollbarVisibility({ + scrollWidth, + scrollHeight, + containerWidth: containerWidth ?? size.width, + containerHeight: height ?? viewportHeight ?? 0, + viewportWidth: size.width || containerWidth || 0, + viewportHeight: viewportHeight || height || 0, + }); + const shouldShowVerticalScrollBar = !!inVirtual && hasVerticalScrollBar; + const shouldShowHorizontalScrollBar = !!inVirtual && hasHorizontalScrollBar; + const hasScrollBar = shouldShowVerticalScrollBar || shouldShowHorizontalScrollBar; const rootGridStyle: React.CSSProperties = hasScrollBar ? { display: 'grid', - gridTemplateColumns: hasVerticalScrollBar - ? isRTL - ? `${toCssSize(verticalScrollbarSize)} minmax(0, 1fr)` - : `minmax(0, 1fr) ${toCssSize(verticalScrollbarSize)}` + gridTemplateColumns: shouldShowVerticalScrollBar + ? `minmax(0, 1fr) ${toCssSize(verticalScrollbarSize)}` : 'minmax(0, 1fr)', - gridTemplateRows: hasHorizontalScrollBar + gridTemplateRows: shouldShowHorizontalScrollBar ? `minmax(0, 1fr) ${toCssSize(horizontalScrollbarSize)}` : undefined, } @@ -641,7 +659,7 @@ export function RawList(props: ListProps, ref: React.Ref) { let componentStyle: React.CSSProperties = null; if (height) { - const holderHeight = hasHorizontalScrollBar + const holderHeight = shouldShowHorizontalScrollBar ? getReducedSize(height, horizontalScrollbarSize) : height; componentStyle = { [fullHeight ? 'height' : 'maxHeight']: holderHeight, ...ScrollStyle }; @@ -649,7 +667,7 @@ export function RawList(props: ListProps, ref: React.Ref) { if (hasScrollBar) { componentStyle.minWidth = 0; componentStyle.minHeight = 0; - componentStyle.gridColumn = contentColumn; + componentStyle.gridColumn = 1; componentStyle.gridRow = 1; } @@ -708,12 +726,12 @@ export function RawList(props: ListProps, ref: React.Ref) { - {hasVerticalScrollBar && ( + {shouldShowVerticalScrollBar && (
@@ -735,12 +753,12 @@ export function RawList(props: ListProps, ref: React.Ref) {
)} - {hasHorizontalScrollBar && ( + {shouldShowHorizontalScrollBar && (
diff --git a/src/utils/scrollbarUtil.ts b/src/utils/scrollbarUtil.ts index 3ec27acc..6e7205d5 100644 --- a/src/utils/scrollbarUtil.ts +++ b/src/utils/scrollbarUtil.ts @@ -1,5 +1,35 @@ const MIN_SIZE = 20; +export function getScrollbarVisibility({ + scrollWidth, + scrollHeight, + containerWidth, + containerHeight, + viewportWidth, + viewportHeight, +}: { + scrollWidth?: number; + scrollHeight: number; + containerWidth: number; + containerHeight: number; + viewportWidth: number; + viewportHeight: number; +}) { + // Start with the stable outer viewport. Once one scrollbar is needed, its + // measured reduction to the holder can require the scrollbar on the other + // axis. The direct overflow checks never use the reduced holder dimensions, + // so the two decisions cannot toggle each other on and off. + const hasVerticalContentOverflow = scrollHeight > containerHeight; + const hasHorizontalContentOverflow = !!scrollWidth && scrollWidth > containerWidth; + + return { + hasVerticalScrollBar: + hasVerticalContentOverflow || (hasHorizontalContentOverflow && scrollHeight > viewportHeight), + hasHorizontalScrollBar: + hasHorizontalContentOverflow || (hasVerticalContentOverflow && scrollWidth > viewportWidth), + }; +} + export function getSpinSize(containerSize = 0, scrollRange = 0) { let baseSize = (containerSize / scrollRange) * containerSize; if (isNaN(baseSize)) { diff --git a/tests/scrollWidth.test.tsx b/tests/scrollWidth.test.tsx index d6694096..909d695f 100644 --- a/tests/scrollWidth.test.tsx +++ b/tests/scrollWidth.test.tsx @@ -5,6 +5,7 @@ import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; import React from 'react'; import type { ListRef } from '../src'; import List, { type ListProps } from '../src'; +import { getScrollbarVisibility } from '../src/utils/scrollbarUtil'; const ITEM_HEIGHT = 20; @@ -19,6 +20,8 @@ describe('List.scrollWidth', () => { let holderHeight = 100; let holderWidth = 100; + let containerHeight = 100; + let containerWidth = 100; beforeAll(() => { mockElement = spyElementPrototypes(HTMLElement, { @@ -27,11 +30,17 @@ describe('List.scrollWidth', () => { if (this.classList.contains('rc-virtual-list-holder')) { return holderHeight; } + if (this.classList.contains('rc-virtual-list')) { + return containerHeight; + } return ITEM_HEIGHT; }, }, offsetWidth: { get() { + if (this.classList.contains('rc-virtual-list')) { + return containerWidth; + } return holderWidth; }, }, @@ -40,14 +49,21 @@ describe('List.scrollWidth', () => { return holderHeight; }, }, + clientWidth: { + get() { + return this.classList.contains('rc-virtual-list') ? containerWidth : holderWidth; + }, + }, getBoundingClientRect() { + const width = this.classList.contains('rc-virtual-list') ? containerWidth : holderWidth; + const height = this.classList.contains('rc-virtual-list') ? containerHeight : holderHeight; return { top: 0, - bottom: holderHeight, + bottom: height, left: 0, - right: holderWidth, - width: holderWidth, - height: holderHeight, + right: width, + width, + height, }; }, }); @@ -67,6 +83,8 @@ describe('List.scrollWidth', () => { beforeEach(() => { holderHeight = 100; holderWidth = 100; + containerHeight = 100; + containerWidth = 100; jest.useFakeTimers(); }); @@ -84,6 +102,9 @@ describe('List.scrollWidth', () => { await act(async () => { onLibResize([ + { + target: ret.container.querySelector('.rc-virtual-list')!, + } as ResizeObserverEntry, { target: ret.container.querySelector('.rc-virtual-list-holder')!, } as ResizeObserverEntry, @@ -164,6 +185,54 @@ describe('List.scrollWidth', () => { expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '92px' }); }); + it('keeps scrollbar decisions stable when the holder was measured after gutters', async () => { + containerWidth = 200; + containerHeight = 100; + holderWidth = 192; + holderHeight = 92; + + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(5), + scrollWidth: 200, + style: { width: 200 }, + }); + + expect(container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toBeNull(); + expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toBeNull(); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '100px' }); + }); + + it('normalizes string zero scrollbar dimensions', async () => { + const horizontal = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(100), + scrollWidth: 1000, + styles: { horizontalScrollBar: { height: '0' } }, + }); + + expect(horizontal.container.querySelector('.rc-virtual-list')).toHaveStyle({ + gridTemplateRows: 'minmax(0, 1fr) 0px', + }); + expect(horizontal.container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ + height: '100px', + }); + horizontal.unmount(); + + const vertical = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(100), + styles: { verticalScrollBar: { width: '0' } }, + }); + + expect(vertical.container.querySelector('.rc-virtual-list')).toHaveStyle({ + gridTemplateColumns: 'minmax(0, 1fr) 0px', + }); + }); + it('reserves the vertical gutter on the left in rtl', async () => { const { container } = await genList({ itemHeight: ITEM_HEIGHT, @@ -174,16 +243,42 @@ describe('List.scrollWidth', () => { }); expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ - gridTemplateColumns: '8px minmax(0, 1fr)', + gridTemplateColumns: 'minmax(0, 1fr) 8px', }); + expect(container.querySelector('.rc-virtual-list')).toHaveAttribute('dir', 'rtl'); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ gridColumn: '1' }); expect(container.querySelector('.rc-virtual-list-scrollbar-slot-vertical')).toHaveStyle({ - gridColumn: '1', + gridColumn: '2', }); expect(container.querySelector('.rc-virtual-list-scrollbar-slot-horizontal')).toHaveStyle({ - gridColumn: '2', + gridColumn: '1', }); }); + it('resolves mutually dependent gutters from the outer viewport', () => { + expect( + getScrollbarVisibility({ + containerHeight: 100, + containerWidth: 200, + scrollHeight: 100, + scrollWidth: 200, + viewportHeight: 92, + viewportWidth: 192, + }), + ).toEqual({ hasHorizontalScrollBar: false, hasVerticalScrollBar: false }); + + expect( + getScrollbarVisibility({ + containerHeight: 100, + containerWidth: 200, + scrollHeight: 100, + scrollWidth: 201, + viewportHeight: 92, + viewportWidth: 192, + }), + ).toEqual({ hasHorizontalScrollBar: true, hasVerticalScrollBar: true }); + }); + describe('trigger offset', () => { it('click horizontal track to scroll', async () => { const listRef = React.createRef(); From 17e4d22e729747d7d07d46a5d0ac35107480d2b5 Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Tue, 27 Oct 2026 23:03:14 -0400 Subject: [PATCH 3/7] fix: honor scrollbar settings and percentage sizing --- src/List.tsx | 36 +++++++++++++---- tests/scrollWidth.test.tsx | 80 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index 6fa27bbf..d73c4ede 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -339,17 +339,26 @@ export function RawList(props: ListProps, ref: React.Ref) { }, [scrollHeight]); // ================================= Size ================================= + const updateContainerWidth = (element: HTMLElement) => { + const computedStyle = window.getComputedStyle(element); + const padding = parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight); + const nextWidth = Math.max(element.clientWidth - (Number.isFinite(padding) ? padding : 0), 0); + setContainerWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth)); + }; + const onHolderResize: ResizeObserverProps['onResize'] = (sizeInfo) => { setSize({ width: sizeInfo.offsetWidth, height: sizeInfo.offsetHeight, }); + + if (containerRef.current) { + updateContainerWidth(containerRef.current); + } }; const onContainerResize: ResizeObserverProps['onResize'] = (_sizeInfo, element) => { - const styles = window.getComputedStyle(element); - const padding = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight); - setContainerWidth(Math.max(element.clientWidth - (Number.isFinite(padding) ? padding : 0), 0)); + updateContainerWidth(element); }; useResizeObserver(true, () => containerRef.current!, undefined, onContainerResize); @@ -633,6 +642,17 @@ export function RawList(props: ListProps, ref: React.Ref) { const verticalScrollbarSize = getScrollbarSize(styles?.verticalScrollBar?.width); const horizontalScrollbarSize = getScrollbarSize(styles?.horizontalScrollBar?.height); + const verticalScrollbarStyle = styles?.verticalScrollBar; + const horizontalScrollbarStyle = styles?.horizontalScrollBar; + const verticalScrollbarStyleInSlot = + typeof verticalScrollbarStyle?.width === 'string' && verticalScrollbarStyle.width.includes('%') + ? { ...verticalScrollbarStyle, width: '100%' } + : verticalScrollbarStyle; + const horizontalScrollbarStyleInSlot = + typeof horizontalScrollbarStyle?.height === 'string' && + horizontalScrollbarStyle.height.includes('%') + ? { ...horizontalScrollbarStyle, height: '100%' } + : horizontalScrollbarStyle; const { hasVerticalScrollBar, hasHorizontalScrollBar } = getScrollbarVisibility({ scrollWidth, scrollHeight, @@ -641,8 +661,10 @@ export function RawList(props: ListProps, ref: React.Ref) { viewportWidth: size.width || containerWidth || 0, viewportHeight: viewportHeight || height || 0, }); - const shouldShowVerticalScrollBar = !!inVirtual && hasVerticalScrollBar; - const shouldShowHorizontalScrollBar = !!inVirtual && hasHorizontalScrollBar; + const shouldShowVerticalScrollBar = + showScrollBar !== false && !!inVirtual && hasVerticalScrollBar; + const shouldShowHorizontalScrollBar = + showScrollBar !== false && !!inVirtual && hasHorizontalScrollBar; const hasScrollBar = shouldShowVerticalScrollBar || shouldShowHorizontalScrollBar; const rootGridStyle: React.CSSProperties = hasScrollBar @@ -746,7 +768,7 @@ export function RawList(props: ListProps, ref: React.Ref) { onStopMove={onScrollbarStopMove} spinSize={verticalScrollBarSpinSize} containerSize={size.height} - style={styles?.verticalScrollBar} + style={verticalScrollbarStyleInSlot} thumbStyle={styles?.verticalScrollBarThumb} showScrollBar={showScrollBar} /> @@ -774,7 +796,7 @@ export function RawList(props: ListProps, ref: React.Ref) { spinSize={horizontalScrollBarSpinSize} containerSize={size.width} horizontal - style={styles?.horizontalScrollBar} + style={horizontalScrollbarStyleInSlot} thumbStyle={styles?.horizontalScrollBarThumb} showScrollBar={showScrollBar} /> diff --git a/tests/scrollWidth.test.tsx b/tests/scrollWidth.test.tsx index 909d695f..72c52997 100644 --- a/tests/scrollWidth.test.tsx +++ b/tests/scrollWidth.test.tsx @@ -151,6 +151,19 @@ describe('List.scrollWidth', () => { }); }); + it('does not reserve scrollbar gutters when scrollbars are disabled', async () => { + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(100), + scrollWidth: 1000, + showScrollBar: false, + }); + + expect(container.querySelector('.rc-virtual-list-scrollbar-slot')).toBeNull(); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '100px' }); + }); + it('matches reserved gutters to custom scrollbar dimensions', async () => { const { container } = await genList({ itemHeight: ITEM_HEIGHT, @@ -170,6 +183,73 @@ describe('List.scrollWidth', () => { expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '94px' }); }); + it('fills percentage-sized scrollbar gutters without applying the percentage twice', async () => { + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(100), + scrollWidth: 1000, + styles: { + verticalScrollBar: { width: '50%' }, + horizontalScrollBar: { height: '50%' }, + }, + }); + + expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ + gridTemplateColumns: 'minmax(0, 1fr) 50%', + gridTemplateRows: 'minmax(0, 1fr) 50%', + }); + expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toHaveStyle({ + width: '100%', + }); + expect(container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toHaveStyle({ + height: '100%', + }); + }); + + it('refreshes available container width when only the holder size changes', async () => { + containerWidth = 200; + holderWidth = 200; + + const props = { + itemHeight: ITEM_HEIGHT, + height: 100, + data: genData(2), + scrollWidth: 190, + style: { width: 200 }, + }; + const result = await genList(props); + + expect(result.container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toBeNull(); + + result.rerender( + + {({ id }) =>
  • {id}
  • } +
    , + ); + holderWidth = 180; + + await act(async () => { + onLibResize([ + { + target: result.container.querySelector('.rc-virtual-list')!, + } as ResizeObserverEntry, + { + target: result.container.querySelector('.rc-virtual-list-holder')!, + } as ResizeObserverEntry, + ]); + + await Promise.resolve(); + }); + + expect(result.container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toBeTruthy(); + }); + it('adds the vertical scrollbar when the horizontal gutter reduces the viewport', async () => { holderHeight = 92; From db9ba362a4f5b1db931330c3456dbc66c0193d6b Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Tue, 27 Oct 2026 23:20:57 -0400 Subject: [PATCH 4/7] fix: require resize observer hook version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 370479a8..3a8883b1 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ }, "dependencies": { "@babel/runtime": "^8.0.0", - "@rc-component/resize-observer": "^1.0.1", + "@rc-component/resize-observer": "^1.1.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, From 47f874e0d574816327d97a1779b0dd8dbc38face Mon Sep 17 00:00:00 2001 From: Danny Ward Date: Tue, 27 Oct 2026 23:51:17 -0400 Subject: [PATCH 5/7] fix: size holder to scrollbar grid track --- src/List.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index d73c4ede..97c10a5e 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -48,14 +48,6 @@ function getScrollbarSize(size: number | string | undefined) { return SCROLLBAR_SIZE; } -function getReducedSize(size: number, gutter: number | string) { - if (typeof gutter === 'number') { - return Math.max(size - gutter, 0); - } - - return `calc(${size}px - ${gutter})`; -} - function toCssSize(size: number | string) { return typeof size === 'number' ? `${size}px` : size; } @@ -681,9 +673,9 @@ export function RawList(props: ListProps, ref: React.Ref) { let componentStyle: React.CSSProperties = null; if (height) { - const holderHeight = shouldShowHorizontalScrollBar - ? getReducedSize(height, horizontalScrollbarSize) - : height; + // The grid track already excludes the horizontal scrollbar gutter. Size + // the holder against that track so percentage gutters use the same basis. + const holderHeight = shouldShowHorizontalScrollBar ? '100%' : height; componentStyle = { [fullHeight ? 'height' : 'maxHeight']: holderHeight, ...ScrollStyle }; if (hasScrollBar) { From fcdbad138f0fbfa6904bb79e25e1b8918dc98747 Mon Sep 17 00:00:00 2001 From: dannyward630 Date: Wed, 28 Oct 2026 04:18:13 -0400 Subject: [PATCH 6/7] fix: constrain virtual scrollbar grid height --- src/List.tsx | 3 +++ tests/scrollWidth.test.tsx | 28 +++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/List.tsx b/src/List.tsx index 97c10a5e..941e7a77 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -668,6 +668,9 @@ export function RawList(props: ListProps, ref: React.Ref) { gridTemplateRows: shouldShowHorizontalScrollBar ? `minmax(0, 1fr) ${toCssSize(horizontalScrollbarSize)}` : undefined, + ...(shouldShowHorizontalScrollBar && height + ? { [fullHeight ? 'height' : 'maxHeight']: height } + : {}), } : {}; diff --git a/tests/scrollWidth.test.tsx b/tests/scrollWidth.test.tsx index 72c52997..6ca634e8 100644 --- a/tests/scrollWidth.test.tsx +++ b/tests/scrollWidth.test.tsx @@ -129,8 +129,9 @@ describe('List.scrollWidth', () => { expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ gridTemplateColumns: 'minmax(0, 1fr) 8px', gridTemplateRows: 'minmax(0, 1fr) 8px', + height: '100px', }); - expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '92px' }); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '100%' }); expect(container.querySelector('.rc-virtual-list-scrollbar-slot-horizontal')).toHaveStyle({ gridRow: '2', }); @@ -145,12 +146,27 @@ describe('List.scrollWidth', () => { }); expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toBeNull(); - expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '92px' }); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '100%' }); expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ gridTemplateRows: 'minmax(0, 1fr) 8px', + height: '100px', }); }); + it('constrains the grid without changing fullHeight false into fixed height', async () => { + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + fullHeight: false, + data: genData(100), + scrollWidth: 1000, + }); + + expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ maxHeight: '100px' }); + expect(container.querySelector('.rc-virtual-list')).not.toHaveStyle({ height: '100px' }); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ maxHeight: '100%' }); + }); + it('does not reserve scrollbar gutters when scrollbars are disabled', async () => { const { container } = await genList({ itemHeight: ITEM_HEIGHT, @@ -179,8 +195,9 @@ describe('List.scrollWidth', () => { expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ gridTemplateColumns: 'minmax(0, 1fr) 12px', gridTemplateRows: 'minmax(0, 1fr) 6px', + height: '100px', }); - expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '94px' }); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '100%' }); }); it('fills percentage-sized scrollbar gutters without applying the percentage twice', async () => { @@ -262,7 +279,7 @@ describe('List.scrollWidth', () => { expect(container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toBeTruthy(); expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toBeTruthy(); - expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '92px' }); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '100%' }); }); it('keeps scrollbar decisions stable when the holder was measured after gutters', async () => { @@ -295,9 +312,10 @@ describe('List.scrollWidth', () => { expect(horizontal.container.querySelector('.rc-virtual-list')).toHaveStyle({ gridTemplateRows: 'minmax(0, 1fr) 0px', + height: '100px', }); expect(horizontal.container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ - height: '100px', + height: '100%', }); horizontal.unmount(); From 2f266705e103a4090269fa6e07dc57dfe93fdf31 Mon Sep 17 00:00:00 2001 From: dannyward630 Date: Wed, 28 Oct 2026 06:00:49 -0400 Subject: [PATCH 7/7] fix: size percentage gutter when list shrinks --- docs/demo/scrollbar-percentage-regression.md | 8 ++ examples/scrollbar-percentage-regression.tsx | 80 ++++++++++++++++++++ src/List.tsx | 24 ++++++ tests/scrollWidth.test.tsx | 39 ++++++++++ 4 files changed, 151 insertions(+) create mode 100644 docs/demo/scrollbar-percentage-regression.md create mode 100644 examples/scrollbar-percentage-regression.tsx diff --git a/docs/demo/scrollbar-percentage-regression.md b/docs/demo/scrollbar-percentage-regression.md new file mode 100644 index 00000000..e7be7403 --- /dev/null +++ b/docs/demo/scrollbar-percentage-regression.md @@ -0,0 +1,8 @@ +--- +title: Scrollbar percentage regression +nav: + title: Demo + path: /demo +--- + + diff --git a/examples/scrollbar-percentage-regression.tsx b/examples/scrollbar-percentage-regression.tsx new file mode 100644 index 00000000..fa600834 --- /dev/null +++ b/examples/scrollbar-percentage-regression.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import List from '../src/List'; + +const data = [{ id: 'first' }, { id: 'second' }]; + +export default function ScrollbarPercentageRegression() { + const [layoutResult, setLayoutResult] = React.useState('Checking layout…'); + + React.useLayoutEffect(() => { + const root = document.querySelector('#percentage-gutter-list'); + const holder = root?.querySelector('.rc-virtual-list-holder'); + const horizontalTrack = root?.querySelector( + '.rc-virtual-list-scrollbar-slot-horizontal', + ); + + if (!root || !holder || !horizontalTrack) { + setLayoutResult('FAIL: expected list, holder, and horizontal track'); + return undefined; + } + + let frame = 0; + const verifyLayout = () => { + const holderBounds = holder.getBoundingClientRect(); + const rows = Array.from(holder.querySelectorAll('[data-regression-row]')); + const bothRowsVisible = + rows.length === 2 && + rows.every((row) => { + const bounds = row.getBoundingClientRect(); + return bounds.top >= holderBounds.top && bounds.bottom <= holderBounds.bottom; + }); + const gutterStartsAfterHolder = + Math.abs(holderBounds.bottom - horizontalTrack.getBoundingClientRect().top) < 1; + const hasNoVerticalGutter = !root.querySelector('.rc-virtual-list-scrollbar-slot-vertical'); + + setLayoutResult( + bothRowsVisible && gutterStartsAfterHolder && hasNoVerticalGutter + ? 'PASS: both rows fit above the horizontal gutter, with no vertical gutter' + : 'FAIL: percentage gutter reduces or overlaps the short list viewport', + ); + }; + + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(verifyLayout); + }); + observer.observe(root); + observer.observe(holder); + frame = requestAnimationFrame(verifyLayout); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); + + return ( + <> + + {({ id }) => ( +
    + {id} +
    + )} +
    +

    + {layoutResult} +

    + + ); +} diff --git a/src/List.tsx b/src/List.tsx index 941e7a77..e12bb1b6 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -659,6 +659,29 @@ export function RawList(props: ListProps, ref: React.Ref) { showScrollBar !== false && !!inVirtual && hasHorizontalScrollBar; const hasScrollBar = shouldShowVerticalScrollBar || shouldShowHorizontalScrollBar; + const horizontalScrollbarPercentMatch = + typeof horizontalScrollbarSize === 'string' + ? /^(\d+(?:\.\d+)?|\.\d+)%$/.exec(horizontalScrollbarSize.trim()) + : null; + const horizontalScrollbarPercent = horizontalScrollbarPercentMatch + ? Number(horizontalScrollbarPercentMatch[1]) / 100 + : null; + // When the root can shrink under maxHeight, a percentage gutter also shrinks + // its content track. Keep enough intrinsic height for the content to fit, + // while leaving the height prop as the maximum. + const rootMinHeight = + !fullHeight && + shouldShowHorizontalScrollBar && + typeof height === 'number' && + typeof scrollHeight === 'number' && + horizontalScrollbarPercent !== null && + horizontalScrollbarPercent > 0 && + horizontalScrollbarPercent < 1 && + style?.height === undefined && + style?.minHeight === undefined + ? Math.min(height, scrollHeight / (1 - horizontalScrollbarPercent)) + : undefined; + const rootGridStyle: React.CSSProperties = hasScrollBar ? { display: 'grid', @@ -671,6 +694,7 @@ export function RawList(props: ListProps, ref: React.Ref) { ...(shouldShowHorizontalScrollBar && height ? { [fullHeight ? 'height' : 'maxHeight']: height } : {}), + ...(rootMinHeight === undefined ? {} : { minHeight: rootMinHeight }), } : {}; diff --git a/tests/scrollWidth.test.tsx b/tests/scrollWidth.test.tsx index 6ca634e8..1df336eb 100644 --- a/tests/scrollWidth.test.tsx +++ b/tests/scrollWidth.test.tsx @@ -167,6 +167,45 @@ describe('List.scrollWidth', () => { expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ maxHeight: '100%' }); }); + it('keeps short content visible with a percentage gutter when fullHeight is false', async () => { + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + fullHeight: false, + data: genData(2), + scrollWidth: 1000, + style: { width: 200 }, + styles: { horizontalScrollBar: { height: '50%' } }, + }); + + expect(container.querySelector('.rc-virtual-list-scrollbar-horizontal')).toBeTruthy(); + expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toBeNull(); + expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ + maxHeight: '100px', + minHeight: '80px', + }); + expect(container.querySelector('.rc-virtual-list')).not.toHaveStyle({ height: '100px' }); + expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ maxHeight: '100%' }); + }); + + it('caps the percentage gutter minimum at the requested height', async () => { + const { container } = await genList({ + itemHeight: ITEM_HEIGHT, + height: 100, + fullHeight: false, + data: genData(10), + scrollWidth: 1000, + style: { width: 200 }, + styles: { horizontalScrollBar: { height: '50%' } }, + }); + + expect(container.querySelector('.rc-virtual-list-scrollbar-vertical')).toBeTruthy(); + expect(container.querySelector('.rc-virtual-list')).toHaveStyle({ + maxHeight: '100px', + minHeight: '100px', + }); + }); + it('does not reserve scrollbar gutters when scrollbars are disabled', async () => { const { container } = await genList({ itemHeight: ITEM_HEIGHT,