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/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"
},
diff --git a/src/List.tsx b/src/List.tsx
index d622e671..e12bb1b6 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';
@@ -20,11 +20,38 @@ 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 { getSpinSize } from './utils/scrollbarUtil';
+import ScrollBar, { SCROLLBAR_SIZE } from './ScrollBar';
+import { getScrollbarVisibility, 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 (normalizedSize === '0') {
+ return 0;
+ }
+
+ if (
+ !/^(auto|initial|inherit|unset|revert|fit-content|max-content|min-content)$/i.test(
+ normalizedSize,
+ )
+ ) {
+ return normalizedSize;
+ }
+ }
+
+ return SCROLLBAR_SIZE;
+}
+
+function toCssSize(size: number | string) {
+ return typeof size === 'number' ? `${size}px` : size;
+}
+
const ScrollStyle: React.CSSProperties = {
overflowY: 'auto',
overflowAnchor: 'none',
@@ -153,6 +180,9 @@ 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 [containerWidth, setContainerWidth] = React.useState();
+ const viewportHeight = size.height || height;
// =============================== Item Key ===============================
@@ -242,7 +272,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 +284,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 +299,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,15 +331,30 @@ export function RawList(props: ListProps, ref: React.Ref) {
}, [scrollHeight]);
// ================================= Size =================================
- const [size, setSize] = React.useState({ width: 0, height });
+ 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) => {
+ updateContainerWidth(element);
+ };
+
+ useResizeObserver(true, () => containerRef.current!, undefined, onContainerResize);
+
// Hack on scrollbar to enable flash call
const verticalScrollBarRef = useRef(null);
const horizontalScrollBarRef = useRef(null);
@@ -324,7 +369,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 +520,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 +632,85 @@ export function RawList(props: ListProps, ref: React.Ref) {
sharedConfig,
);
+ 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,
+ containerWidth: containerWidth ?? size.width,
+ containerHeight: height ?? viewportHeight ?? 0,
+ viewportWidth: size.width || containerWidth || 0,
+ viewportHeight: viewportHeight || height || 0,
+ });
+ const shouldShowVerticalScrollBar =
+ showScrollBar !== false && !!inVirtual && hasVerticalScrollBar;
+ const shouldShowHorizontalScrollBar =
+ 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',
+ gridTemplateColumns: shouldShowVerticalScrollBar
+ ? `minmax(0, 1fr) ${toCssSize(verticalScrollbarSize)}`
+ : 'minmax(0, 1fr)',
+ gridTemplateRows: shouldShowHorizontalScrollBar
+ ? `minmax(0, 1fr) ${toCssSize(horizontalScrollbarSize)}`
+ : undefined,
+ ...(shouldShowHorizontalScrollBar && height
+ ? { [fullHeight ? 'height' : 'maxHeight']: height }
+ : {}),
+ ...(rootMinHeight === undefined ? {} : { minHeight: rootMinHeight }),
+ }
+ : {};
+
let componentStyle: React.CSSProperties = null;
if (height) {
- componentStyle = { [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle };
+ // 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) {
+ componentStyle.minWidth = 0;
+ componentStyle.minHeight = 0;
+ componentStyle.gridColumn = 1;
+ componentStyle.gridRow = 1;
+ }
if (useVirtual) {
componentStyle.overflowY = 'hidden';
@@ -614,6 +735,7 @@ export function RawList(props: ListProps, ref: React.Ref) {
ref={containerRef}
style={{
...style,
+ ...rootGridStyle,
position: 'relative',
}}
className={mergedClassName}
@@ -645,41 +767,59 @@ export function RawList(props: ListProps, ref: React.Ref) {
- {inVirtual && scrollHeight > height && (
-
+ {shouldShowVerticalScrollBar && (
+
+
+
)}
- {inVirtual && scrollWidth > size.width && (
-
+ {shouldShowHorizontalScrollBar && (
+
+
+
)}
);
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/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 3e94ccbe..1df336eb 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;
@@ -17,8 +18,10 @@ describe('List.scrollWidth', () => {
let mockMouseEvent;
let pageX: number;
- const holderHeight = 100;
+ 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,
};
},
});
@@ -65,7 +81,10 @@ describe('List.scrollWidth', () => {
});
beforeEach(() => {
+ holderHeight = 100;
holderWidth = 100;
+ containerHeight = 100;
+ containerWidth = 100;
jest.useFakeTimers();
});
@@ -83,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,
@@ -103,6 +125,295 @@ 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',
+ height: '100px',
+ });
+ expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '100%' });
+ 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: '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('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,
+ 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,
+ 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',
+ height: '100px',
+ });
+ expect(container.querySelector('.rc-virtual-list-holder')).toHaveStyle({ height: '100%' });
+ });
+
+ 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;
+
+ 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: '100%' });
+ });
+
+ 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',
+ height: '100px',
+ });
+ expect(horizontal.container.querySelector('.rc-virtual-list-holder')).toHaveStyle({
+ height: '100%',
+ });
+ 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,
+ height: 100,
+ data: genData(100),
+ scrollWidth: 1000,
+ direction: 'rtl',
+ });
+
+ expect(container.querySelector('.rc-virtual-list')).toHaveStyle({
+ 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: '2',
+ });
+ expect(container.querySelector('.rc-virtual-list-scrollbar-slot-horizontal')).toHaveStyle({
+ 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', () => {