🔧 fix(data-grid): 修复虚拟表格滚动条遮挡并统一横向同步链路

- 修复数据视图横向滚动条遮挡最后一行内容的问题
- 为虚拟表格接入外部横向滚动条,移除内部重复横向滚动轨道
- 统一拖拽滚动条与鼠标滑轮的横向同步逻辑,修复内容移动但滚动条不跟随
- 调整横向滚动条底部停靠间距,避免继续压住表格内容
- 提升纵向滚动条 thumb 对比度并增加弱轨道底色,改善深色主题下可见性
- 新增 DataGrid 布局计算辅助函数与最小测试用例
- refs #220
This commit is contained in:
Syngnat
2026-03-13 15:27:18 +08:00
parent eb36dcc5a2
commit 9b02720169
3 changed files with 214 additions and 119 deletions

View File

@@ -31,6 +31,7 @@ import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { calculateTableBodyBottomPadding } from './dataGridLayout';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -919,12 +920,14 @@ const DataGrid: React.FC<DataGridProps> = ({
const toolbarBottomPadding = 6;
const filterTopPadding = 2;
const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)';
const floatingScrollbarGap = 6;
const floatingScrollbarGap = 8;
const floatingScrollbarBottomOffset = 0;
const floatingScrollbarInset = 10;
const floatingScrollbarHeight = 10;
const floatingScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.34)' : 'rgba(0,0,0,0.22)';
const floatingScrollbarThumbBorderColor = darkMode ? 'rgba(255,255,255,0.10)' : 'rgba(255,255,255,0.32)';
const floatingScrollbarThumbShadow = darkMode ? '0 4px 12px rgba(0,0,0,0.28)' : '0 4px 10px rgba(0,0,0,0.12)';
const floatingScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(0,0,0,0.44)';
const floatingScrollbarThumbBorderColor = darkMode ? 'rgba(255,255,255,0.26)' : 'rgba(255,255,255,0.52)';
const floatingScrollbarThumbShadow = darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)';
const verticalScrollbarTrackBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const horizontalScrollbarTrackBg = 'transparent';
const horizontalScrollbarTrackBorderColor = 'transparent';
const horizontalScrollbarTrackShadow = 'none';
@@ -1310,19 +1313,33 @@ const DataGrid: React.FC<DataGridProps> = ({
const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN;
const headerHeight =
Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42;
const paginationEl = target.querySelector('.data-grid-pagination-wrap') as HTMLElement | null;
const rawPaginationHeight = paginationEl ? paginationEl.getBoundingClientRect().height : 0;
const paginationHeight =
Number.isFinite(rawPaginationHeight) && rawPaginationHeight > 0 ? rawPaginationHeight : 0;
const bodyEl = target.querySelector('.ant-table-body') as HTMLElement | null;
const virtualHolderEl = target.querySelector('.rc-virtual-list-holder') as HTMLElement | null;
const scrollableEl = virtualHolderEl || bodyEl;
const virtualBodyEl = target.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
const rcVirtualHolderEl = target.querySelector('.rc-virtual-list-holder') as HTMLElement | null;
const virtualScrollbarEl = target.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null;
const scrollableEl = virtualBodyEl || rcVirtualHolderEl || bodyEl;
const hasHorizontalOverflow = !!scrollableEl && (scrollableEl.scrollWidth - scrollableEl.clientWidth > 1);
// 外部横向滚动条采用悬浮覆盖,不再通过压缩表格高度制造独立底部空白层
// 只给 body 增加底部内边距,确保最后一行可以完整滚到胶囊条上方
const nextBodyBottomPadding = hasHorizontalOverflow
? floatingScrollbarHeight + floatingScrollbarGap + 4
: 0;
// 普通表格可通过 body 底部内边距避开悬浮横向滚动条
// 但虚拟表格的内部横向滚动轨道会直接覆盖在可视区底部,需要同时从 y 高度里扣掉安全区
const nextBodyBottomPadding = calculateTableBodyBottomPadding({
hasHorizontalOverflow,
floatingScrollbarHeight,
floatingScrollbarGap,
});
setTableBodyBottomPadding(nextBodyBottomPadding);
const extraBottom = 2;
const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom));
const virtualScrollbarViewportReserve = hasHorizontalOverflow && !!virtualScrollbarEl
? Math.ceil(virtualScrollbarEl.getBoundingClientRect().height || (floatingScrollbarHeight + floatingScrollbarGap + 4))
: 0;
const nextHeight = Math.max(
100,
Math.floor(height - headerHeight - paginationHeight - extraBottom - virtualScrollbarViewportReserve)
);
setTableHeight(nextHeight);
}, [floatingScrollbarGap, floatingScrollbarHeight]);
@@ -3242,7 +3259,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。
return Math.max(baseWidth, tableViewportWidth + 2);
}, [totalWidth, isMacLike, tableViewportWidth]);
const horizontalScrollVisible = viewMode === 'table' && !enableVirtual && tableScrollX > tableViewportWidth + 1;
const horizontalScrollVisible = viewMode === 'table' && tableScrollX > tableViewportWidth + 1;
const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX);
const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]);
const tableComponents = useMemo(() => {
@@ -3259,11 +3276,41 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [enableInlineEditableCell, useContextMenuRow]);
const tableOnRow = useMemo(() => (useContextMenuRow ? rowPropsFactory : undefined), [useContextMenuRow, rowPropsFactory]);
const resolveVirtualHorizontalElements = useCallback((tableContainer: HTMLElement) => {
const holderEl = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null;
const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null;
return { holderEl, innerEl, headerEl };
}, []);
const readVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement): number => {
const { innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
const marginLeft = innerEl ? Math.abs(parseFloat(innerEl.style.marginLeft) || 0) : 0;
const headerLeft = headerEl ? Math.max(0, headerEl.scrollLeft) : 0;
return Math.max(marginLeft, headerLeft);
}, [resolveVirtualHorizontalElements]);
const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
const { holderEl, innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
if (!(holderEl instanceof HTMLElement) || !(innerEl instanceof HTMLElement)) {
return false;
}
const maxScroll = Math.max(0, tableScrollX - holderEl.clientWidth);
const clampedOffset = Math.max(0, Math.min(maxScroll, nextOffset));
innerEl.style.marginLeft = `${-clampedOffset}px`;
if (headerEl) {
headerEl.scrollLeft = clampedOffset;
}
return true;
}, [resolveVirtualHorizontalElements, tableScrollX]);
const pickHorizontalScrollTargets = useCallback((tableContainer: HTMLElement): HTMLElement[] => {
const virtualBody = tableContainer.querySelector('.ant-table-tbody-virtual-holder');
const body = tableContainer.querySelector('.ant-table-body');
const content = tableContainer.querySelector('.ant-table-content');
const virtualHolder = tableContainer.querySelector('.rc-virtual-list-holder');
const candidates = [virtualHolder, body, content].filter((node): node is HTMLElement => node instanceof HTMLElement);
const candidates = [virtualBody, virtualHolder, body, content].filter((node): node is HTMLElement => node instanceof HTMLElement);
if (candidates.length === 0) {
return [];
}
@@ -3283,6 +3330,19 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') {
return;
}
const tableContainer = tableContainerRef.current;
if (enableVirtual && tableContainer instanceof HTMLElement) {
const nextScrollLeft = readVirtualHorizontalOffset(tableContainer);
if (Math.abs(lastTableScrollLeftRef.current - nextScrollLeft) < 1 && Math.abs(externalScroll.scrollLeft - nextScrollLeft) < 1) {
return;
}
lastTableScrollLeftRef.current = nextScrollLeft;
if (Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) {
externalScroll.scrollLeft = nextScrollLeft;
lastExternalScrollLeftRef.current = nextScrollLeft;
}
return;
}
const nextTargets = targets && targets.length > 0 ? targets : tableScrollTargetsRef.current;
if (!nextTargets || nextTargets.length === 0) {
return;
@@ -3300,7 +3360,7 @@ const DataGrid: React.FC<DataGridProps> = ({
externalScroll.scrollLeft = nextScrollLeft;
lastExternalScrollLeftRef.current = nextScrollLeft;
}
}, []);
}, [enableVirtual, readVirtualHorizontalOffset]);
const applyExternalScrollToTableTargets = useCallback(() => {
const externalScroll = externalHorizontalScrollRef.current;
@@ -3322,6 +3382,14 @@ const DataGrid: React.FC<DataGridProps> = ({
lastExternalScrollLeftRef.current = externalScroll.scrollLeft;
horizontalSyncSourceRef.current = 'external';
const tableContainer = tableContainerRef.current;
if (enableVirtual && tableContainer instanceof HTMLElement) {
if (applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft)) {
lastTableScrollLeftRef.current = externalScroll.scrollLeft;
}
horizontalSyncSourceRef.current = '';
return;
}
liveTargets.forEach((target) => {
if (target.scrollWidth <= target.clientWidth + 1) {
return;
@@ -3332,9 +3400,9 @@ const DataGrid: React.FC<DataGridProps> = ({
});
lastTableScrollLeftRef.current = externalScroll.scrollLeft;
horizontalSyncSourceRef.current = '';
}, []);
}, [applyVirtualHorizontalOffset, enableVirtual]);
// 非虚拟模式:外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效)
// 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效)
useEffect(() => {
const externalScroll = externalHorizontalScrollRef.current;
if (!externalScroll || !horizontalScrollVisible) return;
@@ -3359,10 +3427,10 @@ const DataGrid: React.FC<DataGridProps> = ({
};
}, [horizontalScrollVisible]);
// 非虚拟模式:支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。
// 某些平台在表格内容未铺满一页时,不会把水平手势正确路由到表格 body导致只能在表头/底部滚动条区域滚动
// 支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。
// 虚拟表格与普通表格统一走外部横向滚动条,避免内部轨道覆盖最后一行
useEffect(() => {
if (viewMode !== 'table' || enableVirtual) return;
if (viewMode !== 'table') return;
const container = tableContainerRef.current;
if (!(container instanceof HTMLElement)) return;
@@ -3389,20 +3457,47 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!isTableDataAreaTarget(event.target)) return;
const targets = pickHorizontalScrollTargets(container);
const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0];
if (!(activeTarget instanceof HTMLElement)) return;
const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth);
if (maxScrollLeft <= 0) return;
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta));
if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) return;
event.preventDefault();
event.stopPropagation();
horizontalSyncSourceRef.current = 'table';
activeTarget.scrollLeft = nextScrollLeft;
let nextScrollLeft = 0;
if (enableVirtual) {
const currentOffset = readVirtualHorizontalOffset(container);
const { holderEl } = resolveVirtualHorizontalElements(container);
if (!(holderEl instanceof HTMLElement)) {
horizontalSyncSourceRef.current = '';
return;
}
const maxScrollLeft = Math.max(0, tableScrollX - holderEl.clientWidth);
if (maxScrollLeft <= 0) {
horizontalSyncSourceRef.current = '';
return;
}
nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, currentOffset + horizontalDelta));
if (Math.abs(nextScrollLeft - currentOffset) < 1) {
horizontalSyncSourceRef.current = '';
return;
}
applyVirtualHorizontalOffset(container, nextScrollLeft);
} else {
const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0];
if (!(activeTarget instanceof HTMLElement)) {
horizontalSyncSourceRef.current = '';
return;
}
const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth);
if (maxScrollLeft <= 0) {
horizontalSyncSourceRef.current = '';
return;
}
nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta));
if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) {
horizontalSyncSourceRef.current = '';
return;
}
activeTarget.scrollLeft = nextScrollLeft;
}
lastTableScrollLeftRef.current = nextScrollLeft;
const externalScroll = externalHorizontalScrollRef.current;
@@ -3417,13 +3512,13 @@ const DataGrid: React.FC<DataGridProps> = ({
return () => {
container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions);
};
}, [viewMode, enableVirtual, pickHorizontalScrollTargets]);
}, [applyVirtualHorizontalOffset, enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, resolveVirtualHorizontalElements, tableScrollX, viewMode]);
useEffect(() => {
if (viewMode !== 'table') return;
const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current));
return () => cancelAnimationFrame(rafId);
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
}, [viewMode, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]);
useEffect(() => {
if (viewMode !== 'table' || !onScrollSnapshotChange) return;
@@ -3515,71 +3610,6 @@ const DataGrid: React.FC<DataGridProps> = ({
return () => cancelAnimationFrame(rafId);
}, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]);
// 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动
useEffect(() => {
if (viewMode !== 'table' || !enableVirtual) return;
const container = tableContainerRef.current;
if (!container) return;
// 滚动条区域高度:滚动条高度 + 间距 + 容错
const scrollbarZoneHeight = floatingScrollbarHeight + floatingScrollbarGap + 8;
const handleContainerWheel = (e: WheelEvent) => {
// 判断鼠标是否在底部滚动条区域
const containerRect = container.getBoundingClientRect();
if (e.clientY < containerRect.bottom - scrollbarZoneHeight) return;
// 适配 antd 的虚拟列表类名
const holderEl = container.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null;
if (!innerEl || !holderEl) return;
const dominantDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (Math.abs(dominantDelta) < 0.5) return;
e.preventDefault();
e.stopPropagation();
// 读取当前 marginLeft负值表示向右偏移
const currentMarginLeft = parseFloat(innerEl.style.marginLeft) || 0;
const contentWidth = tableScrollX;
const viewportWidth = holderEl.clientWidth;
const maxScroll = Math.max(0, contentWidth - viewportWidth);
const currentOffset = Math.abs(currentMarginLeft);
const newOffset = Math.min(maxScroll, Math.max(0, currentOffset + dominantDelta));
// 直接更新内容位置
innerEl.style.marginLeft = `${-newOffset}px`;
// 同步 scrollbar thumb 位置
const scrollbarEl = container.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null;
if (scrollbarEl && maxScroll > 0) {
const thumbEl = scrollbarEl.querySelector('[class*="scrollbar-thumb"]') as HTMLElement | null;
if (thumbEl) {
const ratio = newOffset / maxScroll;
const thumbWidth = parseFloat(thumbEl.style.width) || thumbEl.offsetWidth;
const trackWidth = scrollbarEl.clientWidth;
const thumbMaxOffset = trackWidth - thumbWidth;
thumbEl.style.left = `${ratio * thumbMaxOffset}px`;
}
}
// 同步表头水平位置
const headerEl = container.querySelector('.ant-table-header') as HTMLElement | null;
if (headerEl) {
headerEl.scrollLeft = newOffset;
}
};
container.addEventListener('wheel', handleContainerWheel, { passive: false, capture: true });
return () => {
container.removeEventListener('wheel', handleContainerWheel, { capture: true } as EventListenerOptions);
};
}, [viewMode, enableVirtual, tableScrollX, floatingScrollbarHeight, floatingScrollbarGap]);
useEffect(() => {
if (viewMode !== 'table') return;
const tableContainer = tableContainerRef.current;
@@ -3965,7 +3995,7 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
</div>
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, background: bgContent, borderRadius: panelRadius, border: `1px solid ${panelFrameColor}`, boxSizing: 'border-box' }}>
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, display: 'flex', flexDirection: 'column', background: bgContent, borderRadius: panelRadius, border: `1px solid ${panelFrameColor}`, boxSizing: 'border-box' }}>
{contextHolder}
<Modal
title="编辑行"
@@ -4116,7 +4146,13 @@ const DataGrid: React.FC<DataGridProps> = ({
<div
ref={tableContainerRef}
className={`data-grid-table-wrap${horizontalScrollVisible ? ' data-grid-table-wrap-external-active' : ''}`}
style={{ height: '100%', minHeight: 0, position: 'relative' }}
style={{
flex: '1 1 auto',
minHeight: 0,
position: 'relative',
boxSizing: 'border-box',
paddingBottom: enableVirtual ? tableBodyBottomPadding : 0,
}}
>
<Form component={false} form={form}>
<DataContext.Provider value={dataContextValue}>
@@ -4443,7 +4479,7 @@ const DataGrid: React.FC<DataGridProps> = ({
</div>
{pagination && (
<div style={{ padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
<div className="data-grid-pagination-wrap" style={{ padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
<div className="data-grid-pagination-shell">
<div className="data-grid-pagination-summary" aria-live="polite">
<span className="data-grid-pagination-kicker"></span>
@@ -4556,6 +4592,16 @@ const DataGrid: React.FC<DataGridProps> = ({
box-sizing: border-box;
scroll-padding-bottom: ${tableBodyBottomPadding}px;
}
.${gridId} .ant-table-tbody-virtual-holder,
.${gridId} .rc-virtual-list-holder {
padding-bottom: ${tableBodyBottomPadding}px;
box-sizing: border-box;
scroll-padding-bottom: ${tableBodyBottomPadding}px;
}
.${gridId} .ant-table-tbody-virtual-holder-inner {
padding-bottom: ${tableBodyBottomPadding}px;
box-sizing: border-box;
}
.${gridId} .data-grid-table-wrap {
width: 100%;
max-width: 100%;
@@ -4565,22 +4611,7 @@ const DataGrid: React.FC<DataGridProps> = ({
display: none !important;
}
.${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal {
height: ${floatingScrollbarHeight + 4}px !important;
bottom: ${floatingScrollbarGap}px !important;
left: ${floatingScrollbarInset}px !important;
right: ${floatingScrollbarInset}px !important;
background: transparent !important;
visibility: visible !important;
pointer-events: auto !important;
z-index: 24;
}
.${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal .ant-table-tbody-virtual-scrollbar-thumb {
background: ${horizontalScrollbarThumbBg} !important;
border: 1px solid ${horizontalScrollbarThumbBorderColor} !important;
border-radius: 999px !important;
box-shadow: ${horizontalScrollbarThumbShadow} !important;
height: ${floatingScrollbarHeight}px !important;
margin-top: 2px;
display: none !important;
}
.${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-content {
overflow-x: hidden !important;
@@ -4589,6 +4620,10 @@ const DataGrid: React.FC<DataGridProps> = ({
overflow-x: hidden !important;
overflow-y: auto !important;
}
.${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-tbody-virtual-holder,
.${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .rc-virtual-list-holder {
overflow-x: hidden !important;
}
.${gridId} .ant-table-body {
scrollbar-width: thin;
scrollbar-color: ${floatingScrollbarThumbBg} transparent;
@@ -4598,8 +4633,9 @@ const DataGrid: React.FC<DataGridProps> = ({
height: 0;
}
.${gridId} .ant-table-body::-webkit-scrollbar-track {
background: transparent;
background: ${verticalScrollbarTrackBg};
margin: 8px 0;
border-radius: 999px;
}
.${gridId} .ant-table-body::-webkit-scrollbar-thumb {
background: ${floatingScrollbarThumbBg};
@@ -4616,8 +4652,9 @@ const DataGrid: React.FC<DataGridProps> = ({
height: 0;
}
.${gridId} .rc-virtual-list-holder::-webkit-scrollbar-track {
background: transparent;
background: ${verticalScrollbarTrackBg};
margin: 8px 0;
border-radius: 999px;
}
.${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb {
background: ${floatingScrollbarThumbBg};
@@ -4629,7 +4666,7 @@ const DataGrid: React.FC<DataGridProps> = ({
position: absolute;
left: ${floatingScrollbarInset}px;
right: ${floatingScrollbarInset}px;
bottom: ${floatingScrollbarGap}px;
bottom: ${floatingScrollbarBottomOffset}px;
height: ${floatingScrollbarHeight + 4}px;
overflow-x: auto;
overflow-y: hidden;

View File

@@ -0,0 +1,35 @@
import { strict as assert } from 'node:assert';
import { calculateTableBodyBottomPadding } from './dataGridLayout';
assert.equal(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: false,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
}),
0,
'无横向滚动条时不应增加底部间距'
);
assert.equal(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
}),
28,
'默认悬浮滚动条应预留滚动条高度、间距和额外安全区'
);
assert.equal(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 14,
floatingScrollbarGap: 4,
}),
30,
'较粗滚动条场景下应同步放大底部安全区'
);
console.log('dataGridLayout tests passed');

View File

@@ -0,0 +1,23 @@
export interface TableBodyBottomPaddingOptions {
hasHorizontalOverflow: boolean;
floatingScrollbarHeight: number;
floatingScrollbarGap: number;
}
const MIN_SCROLLBAR_CLEARANCE = 8;
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
export const calculateTableBodyBottomPadding = ({
hasHorizontalOverflow,
floatingScrollbarHeight,
floatingScrollbarGap,
}: TableBodyBottomPaddingOptions): number => {
if (!hasHorizontalOverflow) {
return 0;
}
const safeScrollbarHeight = Math.max(0, Math.ceil(floatingScrollbarHeight));
const safeScrollbarGap = Math.max(0, Math.ceil(floatingScrollbarGap));
return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE;
};